mirror of
https://github.com/itsmemac/Packwright.git
synced 2026-10-17 18:02:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c8675416 |
No files matched your search
@@ -49,9 +49,6 @@ jobs:
|
||||
run: |
|
||||
cp LICENSE THIRD_PARTY_NOTICES.md publish/
|
||||
mkdir -p publish/licenses
|
||||
cp third-party/ProsperoPkgTool/LICENSE publish/licenses/ProsperoPkgTool-LICENSE
|
||||
cp third-party/LibProsperoPkg12/LICENSE publish/licenses/LibProsperoPkg-LICENSE
|
||||
cp third-party/LibProsperoPkg12/NOTICE publish/licenses/LibProsperoPkg-NOTICE
|
||||
cp src/Packwright.Ufs2/LICENSE publish/licenses/UFS2Tool-LICENSE
|
||||
cp third-party/licenses/* publish/licenses/
|
||||
- name: Build the .deb and .rpm
|
||||
@@ -80,9 +77,6 @@ jobs:
|
||||
run: |
|
||||
cp LICENSE THIRD_PARTY_NOTICES.md publish/
|
||||
mkdir -p publish/licenses
|
||||
cp third-party/ProsperoPkgTool/LICENSE publish/licenses/ProsperoPkgTool-LICENSE
|
||||
cp third-party/LibProsperoPkg12/LICENSE publish/licenses/LibProsperoPkg-LICENSE
|
||||
cp third-party/LibProsperoPkg12/NOTICE publish/licenses/LibProsperoPkg-NOTICE
|
||||
cp src/Packwright.Ufs2/LICENSE publish/licenses/UFS2Tool-LICENSE
|
||||
cp third-party/licenses/* publish/licenses/
|
||||
- name: Install Inno Setup
|
||||
@@ -124,7 +118,6 @@ jobs:
|
||||
chmod +x "$bundle/Contents/MacOS/$APP"
|
||||
cp LICENSE THIRD_PARTY_NOTICES.md "$bundle/Contents/Resources/"
|
||||
mkdir -p "$bundle/Contents/Resources/licenses"
|
||||
cp third-party/ProsperoPkgTool/LICENSE "$bundle/Contents/Resources/licenses/ProsperoPkgTool-LICENSE"
|
||||
cp src/Packwright.Ufs2/LICENSE "$bundle/Contents/Resources/licenses/UFS2Tool-LICENSE"
|
||||
cp third-party/licenses/* "$bundle/Contents/Resources/licenses/"
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
<Project Path="src/Packwright.Core/Packwright.Core.csproj" />
|
||||
<Project Path="src/Packwright.Containers/Packwright.Containers.csproj" />
|
||||
<Project Path="src/Packwright.Ufs2/Packwright.Ufs2.csproj" />
|
||||
<Project Path="src/Packwright.Pkg/Packwright.Pkg.csproj" />
|
||||
<Project Path="tests/Packwright.SmokeTests/Packwright.SmokeTests.csproj" />
|
||||
</Solution>
|
||||
@@ -5,12 +5,13 @@ One app to manage your PS5 dump and image collection, read PS5 packages, fix the
|
||||

|
||||
|
||||
> Packwright grew out of [PS5 PKG Tool](https://github.com/pearlxcore/PS5PkgTool) by [pearlxcore](https://github.com/pearlxcore)
|
||||
> (GPL-3.0), and its image and package engines still power parts of this app. Thank you to pearlxcore.
|
||||
> (GPL-3.0), and its exFAT, FFPKG and FFPFSC image code, library scanner and task queue still come from that project.
|
||||
> Thank you to pearlxcore.
|
||||
> Details are in [License and credits](#license-and-credits).
|
||||
|
||||
**This is not software for obtaining free PS5 games.**
|
||||
|
||||
*PlayStation and PS5 are trademarks of Sony Interactive Entertainment Inc. This project is independent and is not affiliated with, authorized or endorsed by Sony. No Sony software or SDK files are included.*
|
||||
*PlayStation and PS5 are trademarks of Sony Interactive Entertainment Inc. This project is independent and is not affiliated with, authorized or endorsed by Sony. No Sony software, SDK files or keys are included.*
|
||||
|
||||
> **Experimental:** Images and packages generated by this app have been validated and compared on PC, but they have not been tested on a jailbroken PS5. A successful build or validation does not guarantee that an image will mount, or that a package will install, launch, or play correctly on real hardware. The same goes for **Send to console**, which has only been tested against a simulated console. Keep backups and use generated output at your own risk.
|
||||
|
||||
@@ -34,7 +35,7 @@ One app to manage your PS5 dump and image collection, read PS5 packages, fix the
|
||||
|
||||
- **Library** of dumps, packages and images as artwork cards or a sortable list, with a search language, grouping, saved views and a details panel.
|
||||
- **Fix missing details:** autofill what it can from the files, look titles up by Title ID, and edit names, IDs, firmware and icons, in the library or inside the file.
|
||||
- **Image tools:** convert between exFAT, FFPKG, FFPFSC and ZArchive, build debug packages (FPKG), extract, verify, repair and edit files inside images, with presets.
|
||||
- **Image and package tools:** convert between folders, exFAT, FFPKG, FFPFSC, ZArchive and debug packages (FPKG) in every direction, verify, extract, edit and rebuild with new details, repair and edit files inside images, with presets.
|
||||
- **Library health** report: moved or unreadable files, duplicates, superseded updates, wasted space and more.
|
||||
- **Send to console** over FTP, or ask a console's installer to download a package from your PC.
|
||||
- **Export** the library as CSV, JSON or a web page with artwork.
|
||||
@@ -130,7 +131,7 @@ Packwright runs as one window: starting it again brings the open window to the f
|
||||
|
||||
### Library
|
||||
|
||||
- Scans folders for unpacked dumps, Sony `.pkg` files, `.ffpfsc`, `.ffpkg` and `.exfat` images, and ZArchive (`.zar`) files.
|
||||
- Scans folders for unpacked dumps, PS5 `.pkg` files, `.ffpfsc`, `.ffpkg` and `.exfat` images, and ZArchive (`.zar`) files.
|
||||
- **Cards** with game artwork, or a **list**. In the list, click a column name to sort (click again to reverse) and right-click a column header to choose which columns are shown.
|
||||
- Group by family (base game, updates and DLC together), Title ID, category, region, format or firmware. Updates that a newer patch replaces are marked.
|
||||
- A [search language](#searching-your-library) and **saved views** that remember the search, filters, grouping, sort and columns.
|
||||
@@ -164,11 +165,17 @@ The **Health** page checks the whole library at a glance: files that were moved
|
||||
|
||||
### Debug packages (FPKG)
|
||||
|
||||
- Reads, verifies and extracts debug PS5 packages, including inner PFS browsing, trophies and PlayGo data.
|
||||
- Builds a package from a dump or directly from an image. The Content ID is read from the source, and the passcode is configurable.
|
||||
- Build options include inner compression (Auto, Kraken, uncompressed), Kraken level and threads, PlayGo chunks, DRM type, SDK version, a workspace folder, reproducible output, fake-signing and right.sprx injection. Free disk space is checked before a build starts.
|
||||
- Retail packages are recognized but cannot be decoded without matching key material. Only debug packages built with the default or a known passcode are fully readable.
|
||||
Packwright has its own package engine (`src/Packwright.Pkg`). It uses no third-party engine, ships no keys and bundles no Sony files.
|
||||
|
||||
- **Read, verify, extract:** debug packages built with the default passcode (all zeros) or a passcode you enter. Includes inner file browsing, trophies and PlayGo data. Extracting also writes the title's `sce_sys` entries, so the result is a complete folder.
|
||||
- **Convert:** a package becomes a folder, exFAT, FFPKG, FFPFSC or ZArchive, and any of those becomes a package.
|
||||
- **Build from a folder or an image:** the Content ID is read from the source. Options: inner compression (Auto, Kraken, uncompressed), compression threads, DRM type, SDK version (written into `param.json`), PlayGo chunks (only for a title without PlayGo files), reproducible output. Files are streamed into the package as it is written, so a build needs room only for the package; free space is checked first.
|
||||
- **Edit and rebuild:** details and icon of a package are changed by rewriting only its metadata, which takes seconds even for a large title. If the Content ID is changed, the package is rebuilt in full.
|
||||
- **Files are taken as they are.** Executables are not re-signed. The title's own `keystone` and `sce_sys/about/right.sprx` are copied when present. When one is missing, Packwright does not make one: it tells you (in the log) that the file is not present and builds the package without it.
|
||||
- **Missing icon textures** (`icon0.dds`, `pic*.dds`) are generated from the PNG pictures.
|
||||
- **Optional keys:** a few fields of a package are normally sealed with public RSA values. If you have them, import them under Settings > Compatibility keys (see [docs/KEYS.md](docs/KEYS.md)). Packwright ships none; without them a package is complete and readable on a PC.
|
||||
- **Retail packages** are recognized but not decoded: their filesystem is encrypted with a key that is not in the file. Retail keys are for you to supply, and entering one is not available yet.
|
||||
- **Not supported:** the install metadata table `naps_meta_18`, signing of raw ELF modules, and PlayGo files for titles that bring their own (those are copied unchanged).
|
||||
### Send to console
|
||||
|
||||
Right-click a game, image or package and choose **Send to console...** to put it on a console on your own network.
|
||||
@@ -185,7 +192,7 @@ Packwright can check GitHub for a newer version. It asks once, **About > Check f
|
||||
|
||||
### Settings and everyday comfort
|
||||
|
||||
- Light or dark theme (the moon button at the top of the sidebar), library layout, artwork on or off, output and rename defaults, preview limits, default passcode and builder, confirmation prompts.
|
||||
- Light or dark theme (the moon button at the top of the sidebar), library layout, artwork on or off, output and rename defaults, preview limits, default passcode, optional compatibility keys, confirmation prompts.
|
||||
- Import and export settings (the passcode is left out unless you ask for it).
|
||||
- On Windows, an optional "Open with Packwright" entry for Explorer's right-click menu (per user, no administrator rights).
|
||||
|
||||
@@ -233,7 +240,7 @@ Example: `id:PPSA01 size:>20GB -dlc | category:patch`.
|
||||
| Settings (including consoles and presets), library cache, task history | Windows `%LOCALAPPDATA%\Packwright`, Linux and macOS `~/.local/share/Packwright` |
|
||||
| Library-only edits (`metadata-overrides.json`, `overrides/` for custom icons) | the same folder |
|
||||
| Logs | `logs/` inside that folder (also shown on the **Log** page) |
|
||||
| Temporary work files | your system temporary folder, or the workspace folder you choose |
|
||||
| Temporary work files | your system temporary folder (only used when an image has to be unpacked first) |
|
||||
|
||||
Delete the folder to reset the app. Setting the `PACKWRIGHT_DATA` environment variable to a folder makes a separate copy of the app use that folder instead (handy for testing).
|
||||
|
||||
@@ -243,15 +250,34 @@ Delete the folder to reset the app. Setting the `PACKWRIGHT_DATA` environment va
|
||||
| --- | :---: | :---: | :---: |
|
||||
| Library, details, file preview | yes | yes | yes |
|
||||
| exFAT, FFPKG, FFPFSC, ZArchive convert, extract, verify, edit | yes | yes | yes |
|
||||
| FPKG build with ProsperoPkgTool | yes | yes | yes |
|
||||
| FPKG build with LibProsperoPkg | yes | - | - |
|
||||
| Kraken-compressed package content | yes | yes | yes |
|
||||
| FPKG build (built-in engine), with Kraken compression | yes | yes | yes |
|
||||
| Send to console, health report, export, command line | yes | yes | yes |
|
||||
| One-button update install | yes | - | - |
|
||||
| Explorer context menu | yes | - | - |
|
||||
|
||||
The filesystem, image, ZArchive and package code (including Kraken compression, which ProsperoPkgTool implements itself) is managed C# and behaves the same everywhere. LibProsperoPkg is a Windows-only library, so the other systems use ProsperoPkgTool for package builds. Linux and macOS builds are produced by the release workflow, but the Windows build has had the most testing.
|
||||
The filesystem, image, ZArchive and package code (including Kraken compression) is managed C# and behaves the same everywhere. Linux and macOS builds are produced by the release workflow, but the Windows build has had the most testing.
|
||||
|
||||
## What works and what does not
|
||||
|
||||
Tested on real titles (a 1.1 GB and an 8 GB one): every file comes out identical, and every package digest and seal is recomputed and matches. Nothing has been tested on a console, so a successful check on a PC does not promise that a loader will mount an image or install a package.
|
||||
|
||||
| Operation | Folder | exFAT | FFPKG | FFPFSC | ZArchive | Debug package |
|
||||
| --- | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| Read, browse, verify | yes | yes | yes | yes | yes | yes |
|
||||
| Extract to a folder | - | yes | yes | yes | yes | yes |
|
||||
| Convert to exFAT / FFPKG / FFPFSC / ZArchive | yes | yes | yes | yes | yes | yes |
|
||||
| Build a debug package from it | yes | yes | yes | yes | yes | - |
|
||||
| Edit details and icon (rebuilds with the change) | yes | yes | yes | yes | yes | yes (metadata only, fast) |
|
||||
| Edit files inside | - | yes | yes | - | - | - |
|
||||
| Repair / rebuild metadata | - | yes | yes | - | - | - |
|
||||
|
||||
Every pair of formats converts in both directions (20 pairs tested).
|
||||
|
||||
**Cannot do (yet):**
|
||||
- Decode retail packages (needs a key you supply).
|
||||
- Generate a missing `keystone` or `right.sprx`; the package is built without it and you are told.
|
||||
- Write `naps_meta_18`, sign raw ELF modules.
|
||||
- Guarantee that a console accepts an image or package.
|
||||
## Build from source
|
||||
|
||||
Requirements: the [.NET 10 SDK](https://dotnet.microsoft.com/download). No other tools or sibling repositories are needed.
|
||||
@@ -273,7 +299,7 @@ dotnet publish src/Packwright.App -c Release -r osx-arm64 --self-contained true
|
||||
dotnet publish src/Packwright.App -c Release -r osx-x64 --self-contained true -o publish/osx-x64
|
||||
```
|
||||
|
||||
The Windows publish also copies the Windows-only libraries from `third-party/`. Any system can publish for any target.
|
||||
Any system can publish for any target.
|
||||
|
||||
The installers are built from a publish folder: `installer/Packwright.iss` (Inno Setup 6, Windows), `installer/linux/build-packages.sh` (`.deb` and `.rpm`, needs `dpkg-deb` and `rpmbuild`), and the macOS disk image step in `.github/workflows/release.yml`.
|
||||
|
||||
@@ -294,10 +320,12 @@ The workflows live in `.github/workflows/`: `release.yml` and `build.yml`.
|
||||
src/
|
||||
Packwright.App Avalonia application: pages, styles, queue glue, command line
|
||||
Packwright.Core Packages, parsers, library scanner, builders, FTP client, update checker, task queue
|
||||
Packwright.Pkg The package engine: reads, verifies, extracts and builds PS5 packages
|
||||
Packwright.Containers exFAT, PFS/PFSC (FFPFSC) and ZArchive images
|
||||
Packwright.Ufs2 UFS2 / FFPKG filesystem
|
||||
installer/ Windows installer script and Linux package builder
|
||||
third-party/ Vendored binaries (ProsperoPkgTool, LibProsperoPkg) and license texts
|
||||
third-party/ License texts of bundled components
|
||||
docs/KEYS.md The optional compatibility key file
|
||||
docs/ Screenshots
|
||||
.github/workflows/ Build and release automation
|
||||
Packwright.slnx Solution
|
||||
@@ -307,15 +335,20 @@ Packwright.slnx Solution
|
||||
|
||||
Copyright (c) 2026 itsmemac. Packwright is licensed under the [GNU General Public License v3.0](LICENSE), with no warranty.
|
||||
|
||||
It started from [PS5 PKG Tool](https://github.com/pearlxcore/PS5PkgTool) by pearlxcore (Copyright (c) pearlxcore, GPL-3.0) and has since grown into a different application, with a new cross-platform interface and many new features. Parts of its image and package engines are still used, so its copyright and license notices are kept. The changes made in 2026 are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), which also lists every bundled component, its license and its copyright. The source of each release is the tag with the same version in this repository, and the license texts of the bundled components ship in the `licenses` folder of the installed app.
|
||||
Packwright started from [PS5 PKG Tool](https://github.com/pearlxcore/PS5PkgTool) by pearlxcore (Copyright (c) pearlxcore, GPL-3.0) and has since grown into a different application, with a new cross-platform interface and many new features. Its exFAT, FFPKG and FFPFSC image code, the library scanner and the task queue still come from that project, so its copyright and license notices are kept.
|
||||
|
||||
- **[pearlxcore](https://github.com/pearlxcore/PS5PkgTool)**: author of PS5 PKG Tool, the project Packwright grew out of, and of the ProsperoPkgTool engine.
|
||||
- **[SvenGDK](https://github.com/SvenGDK/LibProsperoPKG)**: LibProsperoPkg, and the UFS2Tool filesystem code the FFPKG support is based on.
|
||||
The PS5 package engine (`src/Packwright.Pkg`) is Packwright's own: it reads, verifies, extracts and builds packages and uses no third-party engine and no third-party binary. It builds on the published format work of the projects below, and the source files taken from them keep their copyright notices.
|
||||
|
||||
The changes made in 2026 are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), which also lists every bundled component, its license and its copyright. The source of each release is the tag with the same version in this repository, and the license texts of the bundled components ship in the `licenses` folder of the installed app.
|
||||
|
||||
- **[pearlxcore](https://github.com/pearlxcore/PS5PkgTool)**: author of PS5 PKG Tool, the project Packwright grew out of.
|
||||
- **[SvenGDK](https://github.com/SvenGDK/LibProsperoPKG)**: LibProsperoPKG (GPL-3.0-or-later), whose package format work the Packwright package engine builds on, and the UFS2Tool filesystem code the FFPKG support is based on.
|
||||
- **[maxton](https://github.com/maxton/LibOrbisPkg)**: LibOrbisPkg (GPL-3.0), from which parts of LibProsperoPKG, and so of the package engine, descend.
|
||||
- **[Powzix](https://github.com/powzix/ooz)**: ooz (GPL-3.0), the Kraken decoder that the package engine's Kraken code is translated from.
|
||||
- **[PSBrew / Renan Barreto](https://github.com/PSBrew/MkPFS)**: MkPFS.
|
||||
- **[kerrdec97](https://github.com/kerrdec97/ps5-exfat-builder)**: ps5-exfat-builder.
|
||||
- **[strongt1me](https://github.com/strongt1me/PS5-Dump-Image-Converter)**: PS5-Dump-Image-Converter.
|
||||
- **[Exzap](https://github.com/Exzap/ZArchive)**: the ZArchive format.
|
||||
- **[andshrew](https://github.com/andshrew/PlayStation-Titles)**: the PS5 title list that Title ID lookups use (MIT License).
|
||||
- **[Avalonia UI](https://avaloniaui.net)**, **[ZstdSharp](https://github.com/oleg-st/ZstdSharp)**, **[CommunityToolkit](https://github.com/CommunityToolkit/dotnet)** (HighPerformance, used by LibProsperoPkg), **[ImageSharp](https://github.com/SixLabors/ImageSharp)** and **[BCnEncoder.Net](https://github.com/Nominom/BCnEncoder.NET)**.
|
||||
- Robin Perris for [DarkUI](https://github.com/RobinPerris/DarkUI), which the original Windows interface was built on.
|
||||
- Sony <3
|
||||
- **[Avalonia UI](https://avaloniaui.net)**, **[ZstdSharp](https://github.com/oleg-st/ZstdSharp)**, **[ImageSharp](https://github.com/SixLabors/ImageSharp)** and **[BCnEncoder.Net](https://github.com/Nominom/BCnEncoder.NET)**.
|
||||
- Robin Perris for [DarkUI](https://github.com/RobinPerris/DarkUI), which the original Windows interface was built on.
|
||||
+25
-42
@@ -20,6 +20,8 @@ itsmemac:
|
||||
- the Windows Forms interface was replaced by a cross-platform Avalonia interface (Windows, Linux,
|
||||
macOS), with a new design, name and application icon;
|
||||
- ZArchive (`.zar`) support was added: writing, reading, library scanning, browsing and conversion;
|
||||
- the package engine was replaced by one of its own (`src/Packwright.Pkg`), which reads, verifies, extracts and builds
|
||||
packages, including Kraken compression, with no third-party engine and no keys (see below);
|
||||
- the library gained artwork cards, grouping, saved views, a search syntax, a file explorer with
|
||||
preview, rename/move/delete tools, task persistence and a task history with game icons;
|
||||
- editing of a title's details (name, IDs, region, versions, firmware, SDK, DRM, language, features and icon), in the
|
||||
@@ -38,42 +40,28 @@ project (Copyright (c) 2026 itsmemac, GPL-3.0).
|
||||
## Source code
|
||||
|
||||
The complete corresponding source of each release is the tag with the same version in
|
||||
https://github.com/itsmemac/Packwright. The bundled LibProsperoPkg 1.2.0 files are binaries from
|
||||
https://github.com/SvenGDK/LibProsperoPkg (GPL-3.0-or-later); their source is available from that
|
||||
project.
|
||||
https://github.com/itsmemac/Packwright.
|
||||
|
||||
## ProsperoPkgTool
|
||||
## Packwright.Pkg (the package engine)
|
||||
|
||||
The PS5 PKG (FIH/CNT/PFS/NAPS) reading, verification, extraction and debug-package creation
|
||||
implementation is provided by the clean-room MIT engine ProsperoPkgTool, consumed as a vendored
|
||||
managed library at `third-party/ProsperoPkgTool/ProsperoPkgTool.dll`.
|
||||
`src/Packwright.Pkg` is Packwright's own package engine: it reads, validates and extracts PS5 packages and builds
|
||||
them (inner image, outer filesystem, metadata container, install metadata, Kraken compression). It contains no
|
||||
Sony code, no Sony keys and no third-party binaries.
|
||||
|
||||
MIT License. Copyright (c) 2026 pearlxcore. No GPL, SDK, or decompiled code is included; the
|
||||
engine is a clean-room reimplementation validated against independent oracles.
|
||||
The engine builds on the format work of LibProsperoPKG by SvenGDK (https://github.com/SvenGDK/LibProsperoPKG,
|
||||
Copyright (c) 2026 SvenGDK, GPL-3.0-or-later): source files of that project (the PFS and container structures, the
|
||||
utility code, the Kraken encoder and decoder) are included in adapted form with their original copyright headers, and
|
||||
the layout and digest rules of the writer follow it. LibProsperoPKG in turn contains code derived from LibOrbisPkg by
|
||||
maxton (https://github.com/maxton/LibOrbisPkg, GPL-3.0) and an index-based translation of the Kraken decoder "ooz" by
|
||||
Powzix (https://github.com/powzix/ooz, GPL-3.0). Their notices are kept in the headers of the files concerned. Every
|
||||
part of the engine was checked against real packages on disk: file for file, digest for digest.
|
||||
|
||||
## LibProsperoPkg
|
||||
The engine ships no key. A few fields of a package are normally sealed with public RSA values; the user may import them
|
||||
from a file of their own (see `docs/KEYS.md`). Packwright only checks the file against fingerprints that do not reveal
|
||||
the values, and refuses a private key.
|
||||
|
||||
The alternative package build/validate/extract backend is provided by LibProsperoPkg, loaded from a
|
||||
vendored payload at `third-party/LibProsperoPkg12/` (version **1.2.0** - the version the
|
||||
PPR-PKG / fpkg-gui builder ships; the newer 2.6.0 regressed the NAPS layout). It is loaded in an
|
||||
isolated assembly-load context together with its bundled dependencies: `BCnEncoder.Net 2.3.0`,
|
||||
`CommunityToolkit.HighPerformance 8.4.0` and `Magick.NET 14.15.0` (managed + its
|
||||
`runtimes/win-x64/native/Magick.Native-Q8-x64.dll`).
|
||||
|
||||
LibProsperoPkg can optionally use Sony's proprietary Publishing Tools library (`libScePubTools.dll`)
|
||||
if it finds one from a Sony SDK installation. **That library is Sony's property and is not part of
|
||||
this project or of any download.** Everything here works without it.
|
||||
|
||||
LibProsperoPkg itself contains code derived from LibOrbisPkg by maxton
|
||||
(https://github.com/maxton/LibOrbisPkg, GPL-3.0) and a translation of the "ooz" Kraken decoder by
|
||||
Powzix (https://github.com/powzix/ooz, GPL-3.0); see `third-party/LibProsperoPkg12/NOTICE`.
|
||||
|
||||
GNU General Public License v3.0 or later (GPL-3.0-or-later).
|
||||
Copyright (c) SvenGDK 2026. https://github.com/SvenGDK/LibProsperoPkg
|
||||
|
||||
Because LibProsperoPkg is GPL-3.0-or-later and Packwright loads it, the combined work is
|
||||
distributed under the GPL-3.0 (see `LICENSE`). The full license text ships at
|
||||
`third-party/LibProsperoPkg12/LICENSE` and, in a download, in the `licenses` folder.
|
||||
GNU General Public License v3.0 or later. Because these parts are GPL-3.0-or-later, the combined work is distributed
|
||||
under the GPL-3.0 (see `LICENSE`).
|
||||
|
||||
## UFS2Tool
|
||||
|
||||
@@ -89,10 +77,6 @@ Avalonia.Themes.Fluent, Avalonia.Fonts.Inter), licensed under the MIT License.
|
||||
Copyright (c) AvaloniaUI OÜ and contributors. https://github.com/AvaloniaUI/Avalonia
|
||||
The Inter font is licensed under the SIL Open Font License 1.1.
|
||||
|
||||
## CommunityToolkit.HighPerformance
|
||||
|
||||
Bundled with LibProsperoPkg (see above). MIT License. Copyright (c) .NET Foundation and Contributors. https://github.com/CommunityToolkit/dotnet
|
||||
|
||||
## ZstdSharp.Port
|
||||
|
||||
MIT License. Copyright (c) Oleg Stepanischev and contributors. https://github.com/oleg-st/ZstdSharp
|
||||
@@ -123,12 +107,11 @@ format that follow the reference implementation, and were checked against archiv
|
||||
Authors, licensed under the SIL Open Font License 1.1 (`third-party/licenses/Inter-OFL-1.1.txt`).
|
||||
- The application icon is original to this project.
|
||||
|
||||
## SixLabors ImageSharp and Magick.NET
|
||||
## SixLabors ImageSharp
|
||||
|
||||
ImageSharp (used through BCnEncoder.Net.ImageSharp) is licensed under the Six Labors Split License.
|
||||
This project is open source, and ImageSharp is also a transitive dependency here, so ImageSharp is used
|
||||
under the Apache License 2.0 (`third-party/licenses/Apache-2.0.txt`). Magick.NET is licensed under the
|
||||
Apache License 2.0 and ships with the Windows-only LibProsperoPkg payload.
|
||||
under the Apache License 2.0 (`third-party/licenses/Apache-2.0.txt`).
|
||||
|
||||
## DarkUI
|
||||
|
||||
@@ -139,7 +122,7 @@ this project.
|
||||
## BCnEncoder.Net
|
||||
|
||||
BCnEncoder.Net (and BCnEncoder.Net.ImageSharp) is distributed under the MIT License. It is used to
|
||||
encode BC7 textures for PS5 CNT media. https://github.com/Nominom/BCnEncoder.NET
|
||||
decode the BC7 textures of PS5 artwork for display. https://github.com/Nominom/BCnEncoder.NET
|
||||
|
||||
## PlayStation-Titles (title list, downloaded on request)
|
||||
|
||||
@@ -155,14 +138,14 @@ documentation and validated against independent tools. Credit to the authors of
|
||||
document and handle these formats:
|
||||
|
||||
- MkPFS, PSBrew / Renan Barreto (https://github.com/PSBrew/MkPFS), PFS and PFSC/FFPFSC format work.
|
||||
- UFS2Tool and LibProsperoPkg, SvenGDK (https://github.com/SvenGDK/UFS2Tool), UFS2/FFPKG work. UFS2Tool is used under the BSD 2-Clause License.
|
||||
- UFS2Tool and LibProsperoPKG, SvenGDK (https://github.com/SvenGDK/UFS2Tool), UFS2/FFPKG work. UFS2Tool is used under the BSD 2-Clause License.
|
||||
- ps5-exfat-builder, kerrdec97 (https://github.com/PSBrew/ps5-exfat-builder), exFAT format work.
|
||||
- PS5 Dump and Image Converter, strongt1me (https://github.com/strongt1me/PS5-Dump-Image-Converter).
|
||||
|
||||
## MIT License text
|
||||
|
||||
This text applies to every component above that is marked "MIT License" (ProsperoPkgTool, Avalonia
|
||||
UI, CommunityToolkit.HighPerformance, ZstdSharp.Port, BCnEncoder.Net, DarkUI and the PlayStation-Titles list), each with the
|
||||
This text applies to every component above that is marked "MIT License" (Avalonia
|
||||
UI, ZstdSharp.Port, BCnEncoder.Net, DarkUI and the PlayStation-Titles list), each with the
|
||||
copyright notice given for it in its own section.
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Compatibility keys (optional)
|
||||
|
||||
Packwright ships **no keys** of any kind. A package it builds is complete and readable without any: the inner image and
|
||||
the outer image use the public passcode scheme (the passcode is `00000000000000000000000000000000` unless you choose
|
||||
another), and Packwright reads, verifies and extracts its own packages with no key at all.
|
||||
|
||||
A few fields of a package are normally sealed by the original tools with public RSA values (the moduli of the public
|
||||
keys), or encrypted under a fixed key. A console may check them. If you have those values you can import them, and
|
||||
Packwright uses them when it builds a package. Without them those fields are filled with random bytes of the right
|
||||
length, which is fine for reading the package on a PC and may or may not be accepted by a console.
|
||||
|
||||
You are responsible for where the values come from. Packwright never downloads, extracts or generates them, and it
|
||||
never accepts a private key: a file that contains one is refused.
|
||||
|
||||
## Importing
|
||||
|
||||
Settings > Compatibility keys > **Import key file...** and pick the file. Packwright checks it, keeps a copy in its data
|
||||
folder (`keys.txt`) and uses it for every package it builds. **Remove** deletes the copy.
|
||||
|
||||
## File format
|
||||
|
||||
UTF-8 text, one `name=value` per line, `#` starts a comment. Values are hexadecimal.
|
||||
|
||||
| Name | Value | Used for |
|
||||
| --- | --- | --- |
|
||||
| `passcode0` ... `passcode6` | seven 384-byte RSA-3072 public moduli, big-endian | the wrapped keys in the container's entry-key table |
|
||||
| `mount-image` | a 384-byte RSA-3072 public modulus, big-endian | the wrapped image key |
|
||||
| `metadata` | a 384-byte RSA-3072 public modulus, big-endian | the seal over the container header |
|
||||
| `naps-meta-xts` | three 16-byte values (data key, tweak key, tweak), comma separated | the install metadata table (`naps_meta_18.dat`); not written by this version |
|
||||
|
||||
Every value is checked against a fingerprint (its SHA-256) that is part of Packwright's source. The fingerprints do not
|
||||
reveal the values; they only tell Packwright whether the file you picked is the expected one. A wrong or corrupt value
|
||||
is refused with a message, and nothing is imported.
|
||||
|
||||
The seven `passcode` lines have to be given together or not at all. Each line is optional otherwise: what is missing is
|
||||
filled with random bytes.
|
||||
@@ -1,10 +1,10 @@
|
||||
; Packwright installer (Inno Setup 6). Built by the release workflow:
|
||||
; iscc /DAppVersion=1.0.1 /DSourceDir=<publish folder> /O<output folder> installer\Packwright.iss
|
||||
; iscc /DAppVersion=1.1.0 /DSourceDir=<publish folder> /O<output folder> installer\Packwright.iss
|
||||
; Installs for the current user by default (no administrator prompt), so a silent update is smooth. The "for all
|
||||
; users" choice is offered when the installer is started as administrator.
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "1.0.1"
|
||||
#define AppVersion "1.1.0"
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "..\publish"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
### What is new
|
||||
|
||||
- **Packwright now has its own package engine.** It reads, verifies, extracts and builds PS5 packages with no third-party engine and no bundled binary, and ships no keys. Builds stream the files into the package, so they need no workspace folder, only room for the package.
|
||||
- **Kraken compression** for package builds (Auto, Kraken or uncompressed), with the size of the original packages.
|
||||
- **Editing a package's details and icon takes seconds.** Only the metadata is rewritten; the rest of the package is copied. Changing the Content ID still rebuilds it in full.
|
||||
- **Extracting a package also writes its `sce_sys` entries**, so the result is a complete folder that can be built into a package again.
|
||||
- **Missing icon textures** (`icon0.dds`, `pic*.dds`) are generated, and a replaced icon updates its texture.
|
||||
- **Missing `keystone` or `right.sprx`:** Packwright never makes one. The package is built without it and the log says which files are not present.
|
||||
- **Compatibility keys (optional):** import public values from a file of your own under Settings; see `docs/KEYS.md`. Nothing is shipped.
|
||||
- **Build options:** play-go chunks, DRM type, SDK version, reproducible output. The builder choice and the workspace, fake-sign and right.sprx options are gone with the old engines.
|
||||
- **Patch packages** are described from their headers.
|
||||
- A note on the build options says packages are verified on a PC but not tested on a console.
|
||||
|
||||
If you already have Packwright 1.0.1, open **About** and choose **Check for updates**.
|
||||
@@ -29,7 +29,7 @@ internal static class Cli
|
||||
private static readonly HashSet<string> ValueOptions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"to", "o", "output", "preset", "passcode", "level", "gain", "cluster", "block", "fragment", "density", "min-free",
|
||||
"compression", "kraken-level", "threads", "playgo", "drm", "builder", "workspace", "format", "folder", "host", "port", "user", "password"
|
||||
"compression", "kraken-level", "threads", "playgo", "drm", "format", "folder", "host", "port", "user", "password"
|
||||
};
|
||||
|
||||
public static bool IsCommand(string[] args) =>
|
||||
@@ -169,8 +169,8 @@ internal static class Cli
|
||||
--level 1-9 --gain 0-100 FFPFSC compression
|
||||
--cluster 32|64 --no-ampr exFAT
|
||||
--block 32|64 --fragment 4|64 --density 256|512|1024 --min-free 0-50 FFPKG
|
||||
--builder <id> --compression auto|kraken|stored --kraken-level N --threads N --playgo N
|
||||
--deterministic --no-fake-sign --no-right-sprx --drm upgradable|free|standard|keep --workspace <folder>
|
||||
--compression auto|kraken|stored --kraken-level N --threads N --playgo N
|
||||
--deterministic --drm upgradable|free|standard|keep
|
||||
extract/verify on a package: --passcode <32 characters> (default: the debug passcode)
|
||||
|
||||
Exit codes: 0 done, 1 failed, 2 wrong usage, 130 cancelled.
|
||||
@@ -433,9 +433,10 @@ internal static class Cli
|
||||
{
|
||||
string contentId = Jobs.ReadContentId(spec.Source, format);
|
||||
if (contentId.Length == 0) return "The source has no content ID (sce_sys/param.json), so a package cannot be built from it.";
|
||||
Console.Error.WriteLine("Note: packages are experimental. They are verified on this PC but not tested on a console; exFAT and FFPKG images are the better-tested route.");
|
||||
spec.Kind = JobSpec.BuildPackage;
|
||||
spec.ContentId = contentId;
|
||||
spec.Backend = (options.Get("builder") ?? (basis.Backend.Length > 0 ? basis.Backend : AppSettingsDefaultBackend())).Trim();
|
||||
spec.Backend = BackendRegistry.DefaultId;
|
||||
spec.Compression = (Choice("compression") switch
|
||||
{
|
||||
"kraken" => Ps5InnerCompression.Kraken,
|
||||
@@ -445,14 +446,11 @@ internal static class Cli
|
||||
}).ToString();
|
||||
spec.KrakenLevel = Math.Clamp(Pick("kraken-level", basis.KrakenLevel), -4, 9);
|
||||
spec.KrakenThreads = Math.Clamp(Pick("threads", basis.KrakenThreads), 0, 64);
|
||||
spec.PlayGoChunks = Math.Clamp(Pick("playgo", basis.PlayGoChunks), 1, 64);
|
||||
spec.PlayGoChunks = Math.Clamp(Pick("playgo", 1), 1, 64);
|
||||
spec.Deterministic = options.Has("deterministic") || basis.Deterministic;
|
||||
spec.FakeSign = !options.Has("no-fake-sign") && basis.FakeSign;
|
||||
spec.RightSprx = !options.Has("no-right-sprx") && basis.RightSprx;
|
||||
spec.Drm = (Choice("drm") ?? basis.DrmIndex switch { 1 => "free", 2 => "standard", 3 => "keep", _ => "upgradable" }) switch
|
||||
{ "free" => "free", "standard" => "standard", "keep" => null, _ => "upgradable" };
|
||||
spec.Sdk = basis.Sdk.Length > 0 ? SdkFromVersion(basis.Sdk) : null;
|
||||
spec.TempDirectory = options.Get("workspace") ?? (basis.TempDirectory.Length > 0 ? basis.TempDirectory : null);
|
||||
return null;
|
||||
}
|
||||
spec.Kind = JobSpec.Convert;
|
||||
@@ -460,8 +458,6 @@ internal static class Cli
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string AppSettingsDefaultBackend() => new AppStateStore().LoadSettings().BuildBackend;
|
||||
|
||||
private static ulong? SdkFromVersion(string version)
|
||||
{
|
||||
int index = Ps5SdkVersions.Releases.ToList().FindIndex(release => release.Version == version);
|
||||
|
||||
@@ -128,9 +128,12 @@ public sealed class AppSettings
|
||||
// Build defaults
|
||||
public string DebugPasscode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Selected package backend id ("lpp" or "ppt"). Empty/unknown falls back to the default.</summary>
|
||||
/// <summary>Kept so settings written by older versions still load; the package engine is no longer selectable.</summary>
|
||||
public string BuildBackend { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Path of the imported compatibility key file (a copy kept in the app data folder), or empty.</summary>
|
||||
public string KeyFile { get; set; } = string.Empty;
|
||||
|
||||
// Updates (nothing is checked until the user allows it)
|
||||
public bool CheckForUpdates { get; set; }
|
||||
public bool UpdateCheckAsked { get; set; }
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<RootNamespace>Packwright.App</RootNamespace>
|
||||
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<Version>1.0.1</Version>
|
||||
<FileVersion>1.0.1.0</FileVersion>
|
||||
<Version>1.1.0</Version>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<Product>Packwright</Product>
|
||||
<Authors>itsmemac</Authors>
|
||||
<Company>itsmemac</Company>
|
||||
@@ -45,11 +45,4 @@
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\third-party\LibProsperoPkg12\**\*" Condition="'$(RuntimeIdentifier)' == 'win-x64' Or ('$(RuntimeIdentifier)' == '' And $([MSBuild]::IsOSPlatform('Windows')))">
|
||||
<Link>LibProsperoPkg12\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -61,13 +61,10 @@ public sealed class JobSpec
|
||||
public string Compression { get; set; } = nameof(Ps5InnerCompression.Auto);
|
||||
public int KrakenLevel { get; set; } = 7;
|
||||
public int KrakenThreads { get; set; }
|
||||
public int PlayGoChunks { get; set; } = 1;
|
||||
public bool Deterministic { get; set; }
|
||||
public bool FakeSign { get; set; } = true;
|
||||
public bool RightSprx { get; set; } = true;
|
||||
public string? Drm { get; set; } = "upgradable";
|
||||
public ulong? Sdk { get; set; }
|
||||
public string? TempDirectory { get; set; }
|
||||
public int PlayGoChunks { get; set; } = 1;
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this);
|
||||
|
||||
@@ -89,7 +86,7 @@ public sealed class JobSpec
|
||||
public PackageTaskStage[] Plan() => Kind switch
|
||||
{
|
||||
Convert => FromPackage ? PackageTaskPlans.ConvertPackage : PackageTaskPlans.ConvertImage,
|
||||
BuildPackage => PackageTaskPlans.BuildPackageFor(Source, Backend == BackendRegistry.LppId && !Directory.Exists(Source)),
|
||||
BuildPackage => PackageTaskPlans.BuildPackageFor(Source),
|
||||
Extract => PackageTaskPlans.Extract,
|
||||
Verify => PackageTaskPlans.Verify,
|
||||
RefreshAmpr => PackageTaskPlans.Ampr,
|
||||
@@ -142,16 +139,16 @@ public sealed class JobSpec
|
||||
if (Output.Length == 0 || ContentId.Length == 0) return null;
|
||||
var settings = new PackageBuildSettings(
|
||||
Enum.TryParse(Compression, out Ps5InnerCompression compression) ? compression : Ps5InnerCompression.Auto,
|
||||
KrakenLevel, KrakenThreads, PlayGoChunks, Deterministic, FakeSign, RightSprx, Drm, Sdk, TempDirectory);
|
||||
KrakenLevel, KrakenThreads, Deterministic, Drm, Sdk, PlayGoChunks);
|
||||
IPackageBackend backend = Jobs.ResolveBackend(Backend);
|
||||
string passcode = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
||||
string passcode = Passcode.Length > 0 ? Passcode : Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
return (progress, token) => Jobs.BuildPackageAsync(Source, Output, ContentId, passcode, Overwrite, settings,
|
||||
backend, progress, token);
|
||||
case Extract:
|
||||
if (Output.Length == 0) return null;
|
||||
if (FromPackage)
|
||||
{
|
||||
string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
||||
string code = Passcode.Length > 0 ? Passcode : Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
return async (progress, token) =>
|
||||
await Jobs.ExtractPackageAsync(Source, Output, code, progress, token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -160,17 +157,17 @@ public sealed class JobSpec
|
||||
case Verify:
|
||||
if (FromPackage)
|
||||
{
|
||||
string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
||||
string code = Passcode.Length > 0 ? Passcode : Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
return (progress, token) =>
|
||||
{
|
||||
var watch = System.Diagnostics.Stopwatch.StartNew();
|
||||
SonyDebugPackageValidationResult result = Jobs.VerifyPackage(Source, code);
|
||||
Ps5DebugPackageValidationResult result = Jobs.VerifyPackage(Source, code);
|
||||
if (!result.IsValid)
|
||||
throw new InvalidDataException("This package did not pass verification: " + result.Message.Trim().TrimEnd('.') +
|
||||
". The file may be damaged or incomplete (compare its size and checksum with where you got it).");
|
||||
Logger.Info($"Package verified: {result.IndexedFiles:N0} indexed file(s).");
|
||||
progress.Report(new PackageTaskProgress("Verified",
|
||||
Result: $"Verified: no problems found. {result.IndexedFiles:N0} file(s) checked in {Elapsed(watch)}."));
|
||||
Result: $"Verified: structure, metadata and file decoding are intact. {result.IndexedFiles:N0} file(s) checked in {Elapsed(watch)}."));
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
@@ -211,7 +208,7 @@ public sealed class JobSpec
|
||||
.ConfigureAwait(false);
|
||||
case EditDetails:
|
||||
if (Edit is not { IsEmpty: false } edit) return null;
|
||||
string editCode = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
||||
string editCode = Passcode.Length > 0 ? Passcode : Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
return (progress, token) => MetadataEditor.ApplyAsync(Source, Output, FromPackage, edit, editCode,
|
||||
Backend, progress, token);
|
||||
case SendFtp:
|
||||
|
||||
@@ -13,13 +13,10 @@ public sealed record PackageBuildSettings(
|
||||
Ps5InnerCompression Compression = Ps5InnerCompression.Auto,
|
||||
int KrakenLevel = 7,
|
||||
int KrakenThreads = 0,
|
||||
int PlayGoChunks = 1,
|
||||
bool Deterministic = false,
|
||||
bool FakeSignModules = true,
|
||||
bool InjectRightSprx = true,
|
||||
string? DrmType = "upgradable",
|
||||
ulong? SdkVersionOverride = null,
|
||||
string? TempDirectory = null);
|
||||
int PlayGoChunks = 1);
|
||||
|
||||
/// <summary>
|
||||
/// The long-running operations behind the Tools tab. Each method is UI-free and reports progress
|
||||
@@ -43,22 +40,25 @@ public static class Jobs
|
||||
new Progress<Ps5ImageConversionProgress>(value => target.Report(new PackageTaskProgress(
|
||||
value.Stage, 0, 0, value.Completed, value.Total, 0, 0, string.Empty)));
|
||||
|
||||
public static IProgress<SonyPackageExtractProgress> AdaptExtract(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<SonyPackageExtractProgress>(value => target.Report(new PackageTaskProgress(
|
||||
public static IProgress<Ps5PackageExtractProgress> AdaptExtract(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<Ps5PackageExtractProgress>(value => target.Report(new PackageTaskProgress(
|
||||
"Extracting", 0, 0, value.CompletedBytes, value.TotalBytes, 0, 0, value.CurrentPath)));
|
||||
|
||||
/// <summary>The builder to use: the saved choice, falling back when LibProsperoPkg is not usable on this OS.</summary>
|
||||
public static IPackageBackend ResolveBackend(string? id)
|
||||
private static bool _keysLoaded;
|
||||
|
||||
/// <summary>Loads the imported compatibility key file (once) so package builds use it.</summary>
|
||||
public static void EnsureKeys()
|
||||
{
|
||||
IPackageBackend backend = BackendRegistry.Get(id);
|
||||
if (backend.Id == BackendRegistry.LppId && !PlatformSupportsLpp)
|
||||
backend = BackendRegistry.Get(BackendRegistry.PptId);
|
||||
return backend;
|
||||
if (_keysLoaded) return;
|
||||
_keysLoaded = true;
|
||||
string path = new AppStateStore().LoadSettings().KeyFile;
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
string? problem = Ps5BuildKeys.TryConfigure(path);
|
||||
if (problem is not null) Logger.Warn("The imported key file was not used: " + problem);
|
||||
}
|
||||
|
||||
public static bool PlatformSupportsLpp => OperatingSystem.IsWindows() &&
|
||||
File.Exists(Path.Combine(AppContext.BaseDirectory, "LibProsperoPkg12", "LibProsperoPkg.dll"));
|
||||
|
||||
/// <summary>The package engine. There is one.</summary>
|
||||
public static IPackageBackend ResolveBackend(string? id) => BackendRegistry.Get(id);
|
||||
public static Task ConvertAsync(string source, string output, Ps5ImageConversionTarget target, bool overwrite,
|
||||
bool fromPackage, IProgress<PackageTaskProgress> progress, CancellationToken token,
|
||||
ExfatBuildOptions? exfatOptions = null, FfpfscBuildOptions? ffpfscOptions = null,
|
||||
@@ -66,7 +66,7 @@ public static class Jobs
|
||||
{
|
||||
IProgress<Ps5ImageConversionProgress> bridge = AdaptConversion(progress);
|
||||
return fromPackage
|
||||
? SonyPackageImageConversion.ConvertAsync(source, output, target, overwrite, bridge, token,
|
||||
? Ps5PackageImageConversion.ConvertAsync(source, output, target, overwrite, bridge, token,
|
||||
exfatOptions, ffpfscOptions, ffpkgOptions)
|
||||
: Ps5ImageConversionService.ConvertAsync(source, output, target, overwrite, bridge, token,
|
||||
exfatOptions, ffpfscOptions, ffpkgOptions);
|
||||
@@ -125,13 +125,13 @@ public static class Jobs
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<SonyPackageExtractResult> ExtractPackageAsync(string source, string output,
|
||||
public static async Task<Ps5PackageExtractResult> ExtractPackageAsync(string source, string output,
|
||||
string passcode, IProgress<PackageTaskProgress> progress, CancellationToken token) =>
|
||||
await SonyPackageExtraction.ExtractAsync(source, output, passcode, AdaptExtract(progress), token)
|
||||
await Ps5PackageExtraction.ExtractAsync(source, output, passcode, AdaptExtract(progress), token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public static SonyDebugPackageValidationResult VerifyPackage(string source, string passcode) =>
|
||||
SonyDebugPackageBuilder.Validate(source, passcode);
|
||||
public static Ps5DebugPackageValidationResult VerifyPackage(string source, string passcode) =>
|
||||
Ps5DebugPackageBuilder.Validate(source, passcode);
|
||||
|
||||
/// <summary>Reads the content ID from a dump folder or image so a package can be built from it.</summary>
|
||||
public static string ReadContentId(string source, Ps5ImageFormat format)
|
||||
@@ -207,72 +207,30 @@ public static class Jobs
|
||||
string jobDirectory = Path.Combine(outputDirectory, ".packwright-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(jobDirectory);
|
||||
string partial = Path.Combine(jobDirectory, Path.GetFileName(output) + ".partial");
|
||||
var options = new SonyDebugPackageBuildOptions
|
||||
var options = new Ps5DebugPackageBuildOptions
|
||||
{
|
||||
ContentId = contentId,
|
||||
Passcode = passcode,
|
||||
SdkVersionOverride = settings.SdkVersionOverride,
|
||||
TempDirectory = settings.TempDirectory,
|
||||
DrmTypeOverride = settings.DrmType,
|
||||
Log = Logger.Info,
|
||||
Compression = settings.Compression,
|
||||
KrakenLevel = settings.KrakenLevel,
|
||||
KrakenThreads = settings.KrakenThreads,
|
||||
PlayGoChunkCount = settings.PlayGoChunks,
|
||||
Seed = settings.Deterministic ? DeterministicSeed(contentId, passcode) : null,
|
||||
FakeSignModules = settings.FakeSignModules,
|
||||
InjectRightSprx = settings.InjectRightSprx
|
||||
Seed = settings.Deterministic ? DeterministicSeed(contentId, passcode) : null
|
||||
};
|
||||
var bridge = new Progress<SonyDebugPackageProgress>(value =>
|
||||
var bridge = new Progress<Ps5DebugPackageProgress>(value =>
|
||||
progress.Report(new PackageTaskProgress(value.Stage, 0, 0, value.CompletedBytes, value.TotalBytes,
|
||||
0, 0, value.CurrentPath)));
|
||||
EnsureKeys();
|
||||
bool isDirectory = Directory.Exists(source);
|
||||
Ps5ImageFormat sourceFormat = isDirectory ? Ps5ImageFormat.Unknown : Ps5ImageFormatProbe.Detect(source);
|
||||
// A ZArchive, and any image built with LibProsperoPkg, is unpacked to a staging folder first.
|
||||
bool stageFromImage = !isDirectory && (backend.Id == BackendRegistry.LppId || sourceFormat == Ps5ImageFormat.ZArchive);
|
||||
IPackageBackend effective = isDirectory || stageFromImage ? backend : BackendRegistry.Get(BackendRegistry.PptId);
|
||||
|
||||
string? staging = null;
|
||||
try
|
||||
{
|
||||
string buildSource = source;
|
||||
if (stageFromImage)
|
||||
{
|
||||
long rawBytes = sourceFormat == Ps5ImageFormat.ZArchive
|
||||
? ZArchiveLogicalBytes(source)
|
||||
: VolumeDebugPackageBuilder.EstimatePayloadBytes(source);
|
||||
// The extractors create the folder themselves (some refuse one that already exists).
|
||||
staging = Path.Combine(Path.GetTempPath(), "Packwright-image-" + Guid.NewGuid().ToString("N"));
|
||||
progress.Report(new PackageTaskProgress("Extract image", 0, 0, 0, rawBytes, 0, 0,
|
||||
Path.GetFileName(source)));
|
||||
await ExtractImageAsync(source, Ps5ImageFormatProbe.Detect(source), staging, progress, token)
|
||||
.ConfigureAwait(false);
|
||||
buildSource = staging;
|
||||
}
|
||||
|
||||
async Task BuildAsync()
|
||||
{
|
||||
if (isDirectory || staging is not null)
|
||||
await effective.BuildFromDirectoryAsync(buildSource, partial, options, bridge, token)
|
||||
.ConfigureAwait(false);
|
||||
else
|
||||
await effective.BuildFromImageAsync(source, partial, options, bridge, token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await BuildAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (BackendNotSupportedException ex) when (effective.Id == BackendRegistry.LppId)
|
||||
{
|
||||
Logger.Info($"Builder {effective.DisplayName} could not lay out this title ({ex.Message}); " +
|
||||
"retrying with ProsperoPkgTool.");
|
||||
effective = BackendRegistry.Get(BackendRegistry.PptId);
|
||||
TryDeleteFile(partial);
|
||||
await BuildAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isDirectory)
|
||||
await backend.BuildFromDirectoryAsync(source, partial, options, bridge, token).ConfigureAwait(false);
|
||||
else
|
||||
await backend.BuildFromImageAsync(source, partial, options, bridge, token).ConfigureAwait(false);
|
||||
PackageReaderVerificationResult result = PackageReaderVerification.Inspect(partial, passcode);
|
||||
Logger.Info($"Verified built package: {result.FileCount:N0} file(s), eboot.bin " +
|
||||
(result.HasEboot ? "present" : "missing") + ".");
|
||||
@@ -290,7 +248,6 @@ public static class Jobs
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(jobDirectory);
|
||||
if (staging is not null) TryDeleteDirectory(staging);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ public static class LibraryActions
|
||||
{
|
||||
var plans = new List<RenamePlan>();
|
||||
foreach (IGrouping<string, Ps5GameInfo> group in games
|
||||
.Where(game => game.SourceKind == Ps5SourceKind.SonyPackage && !string.IsNullOrWhiteSpace(game.TitleId))
|
||||
.Where(game => game.SourceKind == Ps5SourceKind.Ps5Package && !string.IsNullOrWhiteSpace(game.TitleId))
|
||||
.GroupBy(game => game.TitleId, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@ using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Packwright.Core.Backends;
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Core.Tasks;
|
||||
using Packwright.Containers;
|
||||
using Packwright.Infrastructure;
|
||||
@@ -61,6 +63,7 @@ public static class MetadataEditor
|
||||
{
|
||||
private const string ParamPath = "sce_sys/param.json";
|
||||
private const string IconPath = "sce_sys/icon0.png";
|
||||
private const string IconTexturePath = "sce_sys/icon0.dds";
|
||||
private const int MaximumParamBytes = 4 * 1024 * 1024;
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOptions = new()
|
||||
@@ -206,6 +209,17 @@ public static class MetadataEditor
|
||||
return "0x" + head.PadRight(Math.Max(total, 4), '0');
|
||||
}
|
||||
|
||||
|
||||
// The texture (icon0.dds) a title carries next to its icon, made from the new picture.
|
||||
private static byte[] IconTextureFor(byte[] png)
|
||||
{
|
||||
try { return Ps5DdsEncoder.EncodePng(png); }
|
||||
catch (Exception ex) when (ex is SixLabors.ImageSharp.ImageFormatException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException("The icon is not a picture Packwright can read.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- entry point
|
||||
|
||||
/// <summary>
|
||||
@@ -290,7 +304,11 @@ public static class MetadataEditor
|
||||
byte[] patched = PatchParam(existing, edit);
|
||||
await WriteFileAsync(param, patched, token).ConfigureAwait(false);
|
||||
if (icon is not null)
|
||||
{
|
||||
await WriteFileAsync(Path.Combine(root, "sce_sys", "icon0.png"), icon, token).ConfigureAwait(false);
|
||||
string texture = Path.Combine(root, "sce_sys", "icon0.dds");
|
||||
if (File.Exists(texture)) await WriteFileAsync(texture, IconTextureFor(icon), token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch when (!inPlace)
|
||||
{
|
||||
@@ -344,12 +362,13 @@ public static class MetadataEditor
|
||||
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||
{
|
||||
byte[]? param;
|
||||
bool hasIcon, hasSys;
|
||||
bool hasIcon, hasSys, hasTexture;
|
||||
using (var stream = File.OpenRead(image))
|
||||
using (var volume = new ExfatVolume(stream))
|
||||
{
|
||||
param = volume.Find(ParamPath) is null ? null : volume.ReadAllBytes(ParamPath, MaximumParamBytes);
|
||||
hasIcon = volume.Find(IconPath) is not null;
|
||||
hasTexture = volume.Find(IconTexturePath) is not null;
|
||||
hasSys = volume.Find("sce_sys") is not null;
|
||||
}
|
||||
string paramFile = Path.Combine(work, "param.json");
|
||||
@@ -362,6 +381,12 @@ public static class MetadataEditor
|
||||
string iconFile = Path.Combine(work, "icon0.png");
|
||||
await File.WriteAllBytesAsync(iconFile, icon, token).ConfigureAwait(false);
|
||||
operations.Add(hasIcon ? ExfatEditOperation.Replace(IconPath, iconFile) : ExfatEditOperation.AddFile(IconPath, iconFile));
|
||||
if (hasTexture)
|
||||
{
|
||||
string textureFile = Path.Combine(work, "icon0.dds");
|
||||
await File.WriteAllBytesAsync(textureFile, IconTextureFor(icon), token).ConfigureAwait(false);
|
||||
operations.Add(ExfatEditOperation.Replace(IconTexturePath, textureFile));
|
||||
}
|
||||
}
|
||||
await ExfatImageMaintenance.ApplyEditsAsync(image, operations, Jobs.Adapt(progress), token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -370,13 +395,14 @@ public static class MetadataEditor
|
||||
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||
{
|
||||
byte[]? param;
|
||||
bool hasIcon, hasSys;
|
||||
bool hasIcon, hasSys, hasTexture;
|
||||
using (var volume = new Ufs2Volume(image))
|
||||
{
|
||||
bool Has(string path, bool directory) => volume.Entries.Any(entry => entry.IsDirectory == directory &&
|
||||
entry.Path.Trim('/').Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
param = Has(ParamPath, false) ? volume.ReadAllBytes(ParamPath, MaximumParamBytes) : null;
|
||||
hasIcon = Has(IconPath, false);
|
||||
hasTexture = Has(IconTexturePath, false);
|
||||
hasSys = Has("sce_sys", true);
|
||||
}
|
||||
string paramFile = Path.Combine(work, "param.json");
|
||||
@@ -389,6 +415,12 @@ public static class MetadataEditor
|
||||
string iconFile = Path.Combine(work, "icon0.png");
|
||||
await File.WriteAllBytesAsync(iconFile, icon, token).ConfigureAwait(false);
|
||||
operations.Add(hasIcon ? Ufs2EditOperation.Replace(IconPath, iconFile) : Ufs2EditOperation.AddFile(IconPath, iconFile));
|
||||
if (hasTexture)
|
||||
{
|
||||
string textureFile = Path.Combine(work, "icon0.dds");
|
||||
await File.WriteAllBytesAsync(textureFile, IconTextureFor(icon), token).ConfigureAwait(false);
|
||||
operations.Add(Ufs2EditOperation.Replace(IconTexturePath, textureFile));
|
||||
}
|
||||
}
|
||||
await Ufs2Operations.ApplyEditsAsync(image, operations, Jobs.AdaptUfs2(progress), token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -453,7 +485,11 @@ public static class MetadataEditor
|
||||
prefix = sys is null ? string.Empty : sys[..^"sce_sys".Length];
|
||||
}
|
||||
replacements[prefix + ParamPath] = PatchParam(existing, edit);
|
||||
if (icon is not null) replacements[prefix + IconPath] = icon;
|
||||
if (icon is not null)
|
||||
{
|
||||
replacements[prefix + IconPath] = icon;
|
||||
if (volume.FileExists(prefix + IconTexturePath)) replacements[prefix + IconTexturePath] = IconTextureFor(icon);
|
||||
}
|
||||
}
|
||||
|
||||
string partial = target + ".packwright-tmp";
|
||||
@@ -477,6 +513,15 @@ public static class MetadataEditor
|
||||
private static async Task EditPackageAsync(string source, string target, MetadataEdit edit, byte[]? icon,
|
||||
string passcode, string backendId, string work, IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||
{
|
||||
// The quick way: copy the package and rewrite only its metadata. It applies while the content ID is unchanged.
|
||||
string code = passcode.Length > 0 ? passcode : Packwright.Core.Builders.Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
Stage(progress, "Rewriting package metadata");
|
||||
var relay = new Progress<Ps5DebugPackageProgress>(value =>
|
||||
progress.Report(new PackageTaskProgress(value.Stage, 0, 0, value.CompletedBytes, value.TotalBytes, 0, 0, value.CurrentPath)));
|
||||
if (await Ps5PackageMetadataEditor.TryEditAsync(source, target, existing => PatchParam(existing, edit), icon, code, relay, token)
|
||||
.ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
string temporary = Path.Combine(work, "package.exfat");
|
||||
Stage(progress, "Unpacking package");
|
||||
await Jobs.ConvertAsync(source, temporary, Ps5ImageConversionTarget.Exfat, false, true, progress, token,
|
||||
@@ -486,7 +531,6 @@ public static class MetadataEditor
|
||||
string contentId = Jobs.ReadContentId(temporary, Ps5ImageFormat.Exfat);
|
||||
if (contentId.Length == 0) throw new InvalidDataException("The package has no content ID, so it cannot be rebuilt.");
|
||||
IPackageBackend backend = Jobs.ResolveBackend(backendId);
|
||||
string code = passcode.Length > 0 ? passcode : Packwright.Core.Builders.SonyDebugPackageCredentials.DefaultPasscode;
|
||||
Stage(progress, "Rebuilding package");
|
||||
await Jobs.BuildPackageAsync(temporary, target, contentId, code, true, new PackageBuildSettings(), backend,
|
||||
progress, token).ConfigureAwait(false);
|
||||
|
||||
@@ -51,7 +51,7 @@ public static class SourceInfo
|
||||
return File.Exists(param) ? new Ps5ParamReader().Read(param) : null;
|
||||
}
|
||||
if (!File.Exists(path)) return null;
|
||||
if (path.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) return new SonyPkgGameReader().Read(path);
|
||||
if (path.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) return new Ps5PkgGameReader().Read(path);
|
||||
return Ps5ImageFormatProbeLookup(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,12 @@
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="BUILT WITH" Classes="section" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Packwright grew out of PS5 PKG Tool by pearlxcore (GPL-3.0). Its image and package engines still power parts of this app. Thank you, pearlxcore." />
|
||||
<TextBlock TextWrapping="Wrap" Text="ProsperoPkgTool by pearlxcore (MIT), the clean room package engine" />
|
||||
<TextBlock TextWrapping="Wrap" Text="LibProsperoPkg and UFS2Tool by SvenGDK (GPL-3.0-or-later / BSD 2-Clause)" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Packwright grew out of PS5 PKG Tool by pearlxcore (GPL-3.0). Its exFAT, FFPKG and FFPFSC image code, library scanner and task queue still come from that project. Thank you, pearlxcore." />
|
||||
<TextBlock TextWrapping="Wrap" Text="LibProsperoPKG and UFS2Tool by SvenGDK (GPL-3.0-or-later / BSD 2-Clause); the package engine builds on LibProsperoPKG" />
|
||||
<TextBlock TextWrapping="Wrap" Text="MkPFS by PSBrew / Renan Barreto, ps5-exfat-builder by kerrdec97, PS5-Dump-Image-Converter by strongt1me" />
|
||||
<TextBlock TextWrapping="Wrap" Text="ZArchive format by Exzap (MIT), zstd via ZstdSharp (MIT)" />
|
||||
<TextBlock TextWrapping="Wrap" Text="PS5 title list by andshrew (MIT), used for Title ID lookups" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Avalonia UI (MIT), ImageSharp, BCnEncoder.Net, Magick.NET" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Avalonia UI (MIT), ImageSharp, BCnEncoder.Net" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -598,7 +598,7 @@ public sealed class EditDetailsWindow : Window
|
||||
};
|
||||
_modeNote.Text = _mode.SelectedIndex switch
|
||||
{
|
||||
1 => _item.Game.SourceKind == Ps5SourceKind.SonyPackage
|
||||
1 => _item.Game.SourceKind == Ps5SourceKind.Ps5Package
|
||||
? "The package is unpacked, changed and rebuilt as a debug package (FPKG), as a queued task. This needs free " +
|
||||
"space for a temporary copy and works only for packages Packwright can read (not retail or encrypted ones). " +
|
||||
"The builder sets the required firmware from the game's SDK, so a firmware edit does not stick on packages."
|
||||
|
||||
@@ -296,7 +296,7 @@ public partial class LibraryView
|
||||
Kind = JobSpec.EditDetails,
|
||||
Title = item.Title,
|
||||
Source = item.Path,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.SonyPackage,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.Ps5Package,
|
||||
Passcode = _services.Settings.DebugPasscode,
|
||||
Backend = _services.Settings.BuildBackend,
|
||||
Edit = write,
|
||||
|
||||
@@ -484,7 +484,7 @@ public partial class LibraryView
|
||||
Title = item.Title,
|
||||
Source = item.Path,
|
||||
Output = outcome.Output,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.SonyPackage,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.Ps5Package,
|
||||
Passcode = _services.Settings.DebugPasscode,
|
||||
Backend = _services.Settings.BuildBackend,
|
||||
Edit = outcome.Edit,
|
||||
@@ -527,7 +527,7 @@ public partial class LibraryView
|
||||
|
||||
private async Task RenameByInstallOrderAsync()
|
||||
{
|
||||
List<Ps5GameInfo> packages = SelectedGames().Where(game => game.SourceKind == Ps5SourceKind.SonyPackage).ToList();
|
||||
List<Ps5GameInfo> packages = SelectedGames().Where(game => game.SourceKind == Ps5SourceKind.Ps5Package).ToList();
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Rename by install order", "Select one or more package (.pkg) files first.");
|
||||
|
||||
@@ -74,8 +74,19 @@
|
||||
<TextBlock Text="PACKAGE DEFAULTS" Classes="section" />
|
||||
<TextBlock Text="Default passcode" Classes="h" />
|
||||
<TextBox x:Name="PasscodeBox" Watermark="32 characters, blank = all zeros" MaxLength="32" />
|
||||
<TextBlock Text="Preferred builder" Classes="h" Margin="0,6,0,6" />
|
||||
<ComboBox x:Name="BackendBox" MinWidth="320" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="COMPATIBILITY KEYS (OPTIONAL)" Classes="section" />
|
||||
<TextBlock Classes="muted" TextWrapping="Wrap"
|
||||
Text="Packwright ships no keys, and a package it builds is complete and readable without any. A few fields of a package are normally sealed with public RSA values by the original tools; if you have those values, import them here and Packwright uses them when it builds a package. Only public values are accepted; a file with a private key is refused." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="ImportKeysButton" Content="Import key file..." />
|
||||
<Button x:Name="RemoveKeysButton" Content="Remove" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="KeysStatus" Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -23,14 +23,12 @@ public partial class SettingsView : UserControl
|
||||
InitializeComponent();
|
||||
FolderList.ItemsSource = _folders;
|
||||
ViewBox.ItemsSource = Layouts;
|
||||
BackendBox.ItemsSource = BackendRegistry.All.Select(backend =>
|
||||
backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp
|
||||
? backend.DisplayName + " (Windows only)"
|
||||
: backend.DisplayName).ToList();
|
||||
RenameTokens.Text = "Tokens: " + string.Join(" ", Ps5RenameFormatter.TokenNames);
|
||||
Load();
|
||||
ApplyTheme(_services.Settings.Theme);
|
||||
_services.LibraryChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(Load);
|
||||
ImportKeysButton.Click += async (_, _) => await ImportKeysAsync();
|
||||
RemoveKeysButton.Click += (_, _) => RemoveKeys();
|
||||
AddFolderButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Add a library folder") is { } folder &&
|
||||
@@ -115,10 +113,45 @@ public partial class SettingsView : UserControl
|
||||
PasscodeBox.Text = settings.DebugPasscode;
|
||||
ConfirmDeleteBox.IsChecked = settings.ConfirmDelete;
|
||||
ConfirmMoveBox.IsChecked = settings.ConfirmMove;
|
||||
BackendBox.SelectedIndex = Math.Max(0, BackendRegistry.All.ToList()
|
||||
.FindIndex(backend => backend.Id == Jobs.ResolveBackend(settings.BuildBackend).Id));
|
||||
ShowKeysStatus();
|
||||
}
|
||||
|
||||
private void ShowKeysStatus()
|
||||
{
|
||||
Jobs.EnsureKeys();
|
||||
var keys = Packwright.Core.Builders.Ps5BuildKeys.Current;
|
||||
KeysStatus.Text = keys.IsEmpty ? "No key file imported. Packages are built without those fields filled in." : "Imported: " + keys.Describe() + ".";
|
||||
RemoveKeysButton.IsEnabled = !keys.IsEmpty;
|
||||
}
|
||||
|
||||
private async Task ImportKeysAsync()
|
||||
{
|
||||
string? file = await Dialogs.PickFileAsync(this, "Choose a key file", ["*.txt", "*.keys", "*"]);
|
||||
if (file is null) return;
|
||||
string? problem = Packwright.Core.Builders.Ps5BuildKeys.TryConfigure(file);
|
||||
if (problem is not null)
|
||||
{
|
||||
KeysStatus.Text = "Not imported: " + problem;
|
||||
return;
|
||||
}
|
||||
string target = System.IO.Path.Combine(_services.Store.AppDataDirectory, "keys.txt");
|
||||
File.Copy(file, target, overwrite: true);
|
||||
Packwright.Core.Builders.Ps5BuildKeys.TryConfigure(target);
|
||||
var settings = _services.Settings;
|
||||
settings.KeyFile = target;
|
||||
_services.ReplaceSettings(settings);
|
||||
ShowKeysStatus();
|
||||
}
|
||||
|
||||
private void RemoveKeys()
|
||||
{
|
||||
Packwright.Core.Builders.Ps5BuildKeys.TryConfigure(null);
|
||||
var settings = _services.Settings;
|
||||
settings.KeyFile = string.Empty;
|
||||
_services.ReplaceSettings(settings);
|
||||
try { File.Delete(System.IO.Path.Combine(_services.Store.AppDataDirectory, "keys.txt")); } catch (IOException) { }
|
||||
ShowKeysStatus();
|
||||
}
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
string passcode = PasscodeBox.Text ?? string.Empty;
|
||||
@@ -147,7 +180,6 @@ public partial class SettingsView : UserControl
|
||||
settings.DebugPasscode = passcode;
|
||||
settings.ConfirmDelete = ConfirmDeleteBox.IsChecked == true;
|
||||
settings.ConfirmMove = ConfirmMoveBox.IsChecked == true;
|
||||
settings.BuildBackend = BackendRegistry.All[Math.Max(0, BackendBox.SelectedIndex)].Id;
|
||||
_services.ReplaceSettings(settings);
|
||||
ApplyTheme(_services.Settings.Theme);
|
||||
_services.NotifySettingsChanged();
|
||||
|
||||
@@ -72,17 +72,14 @@ public partial class ToolsView
|
||||
FragmentIndex = Math.Max(0, FragmentBox.SelectedIndex),
|
||||
DensityIndex = Math.Max(0, DensityBox.SelectedIndex),
|
||||
MinFree = (int)(MinFreeBox.Value ?? 0),
|
||||
Backend = BackendRegistry.All.ElementAtOrDefault(Math.Max(0, BackendBox.SelectedIndex))?.Id ?? string.Empty,
|
||||
Backend = BackendRegistry.DefaultId,
|
||||
CompressionIndex = Math.Max(0, CompressionBox.SelectedIndex),
|
||||
KrakenLevel = KrakenLevels[krakenIndex].Value,
|
||||
KrakenThreads = (int)(KrakenThreadsBox.Value ?? 0),
|
||||
PlayGoChunks = (int)(PlayGoBox.Value ?? 1),
|
||||
Deterministic = DeterministicBox.IsChecked == true,
|
||||
FakeSign = FakeSignBox.IsChecked == true,
|
||||
RightSprx = RightSprxBox.IsChecked == true,
|
||||
DrmIndex = Math.Max(0, DrmBox.SelectedIndex),
|
||||
Sdk = SdkBox.SelectedIndex > 0 ? SdkBox.SelectedItem as string ?? string.Empty : string.Empty,
|
||||
TempDirectory = TempBox.Text?.Trim() ?? string.Empty
|
||||
Sdk = SdkBox.SelectedIndex > 0 ? SdkBox.SelectedItem as string ?? string.Empty : string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,20 +98,15 @@ public partial class ToolsView
|
||||
Select(FragmentBox, preset.FragmentIndex);
|
||||
Select(DensityBox, preset.DensityIndex);
|
||||
MinFreeBox.Value = Math.Clamp(preset.MinFree, 0, 50);
|
||||
int backend = BackendRegistry.All.ToList().FindIndex(item => item.Id == preset.Backend);
|
||||
if (backend >= 0) BackendBox.SelectedIndex = backend;
|
||||
Select(CompressionBox, preset.CompressionIndex);
|
||||
int kraken = Array.FindIndex(KrakenLevels, level => level.Value == preset.KrakenLevel);
|
||||
KrakenLevelBox.SelectedIndex = kraken >= 0 ? kraken : Array.FindIndex(KrakenLevels, level => level.Value == 7);
|
||||
KrakenThreadsBox.Value = Math.Clamp(preset.KrakenThreads, 0, 64);
|
||||
PlayGoBox.Value = Math.Clamp(preset.PlayGoChunks, 1, 64);
|
||||
DeterministicBox.IsChecked = preset.Deterministic;
|
||||
FakeSignBox.IsChecked = preset.FakeSign;
|
||||
RightSprxBox.IsChecked = preset.RightSprx;
|
||||
Select(DrmBox, preset.DrmIndex);
|
||||
int sdk = preset.Sdk.Length == 0 ? 0 : SdkBox.Items.OfType<string>().ToList().IndexOf(preset.Sdk);
|
||||
SdkBox.SelectedIndex = Math.Max(0, sdk);
|
||||
TempBox.Text = preset.TempDirectory.Length > 0 && Directory.Exists(preset.TempDirectory) ? preset.TempDirectory : string.Empty;
|
||||
_suppress = wasSuppressed;
|
||||
|
||||
if (Enum.TryParse(preset.Target, out Target target))
|
||||
|
||||
@@ -107,34 +107,34 @@
|
||||
<Border x:Name="BuildOptions" Classes="card" IsVisible="False">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="PACKAGE BUILD OPTIONS" Classes="section" />
|
||||
<Grid ColumnDefinitions="*,16,*" RowDefinitions="Auto,Auto">
|
||||
<Border Background="#33E0A030" BorderBrush="#E0A030" BorderThickness="1" CornerRadius="6" Padding="10">
|
||||
<TextBlock TextWrapping="Wrap" Text="Experimental: a package built here is a debug package. It is checked and verified on this PC, but it has not been tested on a console, and a successful build does not guarantee a loader will install it. exFAT and FFPKG images are the better-tested route." />
|
||||
</Border>
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Builder" Classes="h" />
|
||||
<ComboBox x:Name="BackendBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="Inner compression" Classes="h" />
|
||||
<ComboBox x:Name="CompressionBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="PlayGo chunks" Classes="h" />
|
||||
<NumericUpDown x:Name="PlayGoBox" Minimum="1" Maximum="64" Value="1" FormatString="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="2" Margin="0,12,0,0" Spacing="8" VerticalAlignment="Bottom">
|
||||
<StackPanel Grid.Column="2" Spacing="8" VerticalAlignment="Bottom">
|
||||
<CheckBox x:Name="DeterministicBox" Content="Reproducible output (fixed seed)" />
|
||||
<CheckBox x:Name="FakeSignBox" Content="Fake-sign modules" IsChecked="True" />
|
||||
<CheckBox x:Name="RightSprxBox" Content="Inject right.sprx" IsChecked="True" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Play-go chunks" Classes="h" />
|
||||
<NumericUpDown x:Name="PlayGoBox" Minimum="1" Maximum="64" Value="1" FormatString="0"
|
||||
ToolTip.Tip="Used only when the title has no play-go files of its own." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Expander Header="Advanced" Margin="0,4,0,0">
|
||||
<Grid ColumnDefinitions="*,16,*" RowDefinitions="Auto,Auto,Auto" Margin="0,10,0,0">
|
||||
<Grid ColumnDefinitions="*,16,*" RowDefinitions="Auto,Auto" Margin="0,10,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="DRM type" Classes="h" />
|
||||
<ComboBox x:Name="DrmBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="SDK version" Classes="h" />
|
||||
<ComboBox x:Name="SdkBox" HorizontalAlignment="Stretch" />
|
||||
<ComboBox x:Name="SdkBox" HorizontalAlignment="Stretch" ToolTip.Tip="Written into param.json (SDK and required firmware). The executables are packed as they are." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="Kraken level" Classes="h" />
|
||||
@@ -145,13 +145,6 @@
|
||||
<TextBlock Text="Kraken threads (0 = automatic)" Classes="h" />
|
||||
<NumericUpDown x:Name="KrakenThreadsBox" Minimum="0" Maximum="64" Value="0" FormatString="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2" Grid.ColumnSpan="3" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="Workspace folder (temporary files)" Classes="h" />
|
||||
<DockPanel>
|
||||
<Button x:Name="BrowseTempButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
|
||||
<TextBox x:Name="TempBox" Watermark="System temporary folder when empty" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
|
||||
@@ -37,17 +37,11 @@ public partial class ToolsView : UserControl
|
||||
private Ps5ImageFormat _format;
|
||||
private bool _isPackage;
|
||||
private bool _isDirectory;
|
||||
private SonyPkgKind? _packageKind;
|
||||
private Ps5PkgKind? _packageKind;
|
||||
|
||||
public ToolsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
BackendBox.ItemsSource = BackendRegistry.All.Select(backend =>
|
||||
backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp
|
||||
? backend.DisplayName + " (Windows only)"
|
||||
: backend.DisplayName).ToList();
|
||||
BackendBox.SelectedIndex = Math.Max(0, BackendRegistry.All.ToList()
|
||||
.FindIndex(backend => backend.Id == Jobs.ResolveBackend(_services.Settings.BuildBackend).Id));
|
||||
CompressionBox.ItemsSource = new[] { "Auto (Kraken where it helps)", "Kraken (force)", "Uncompressed" };
|
||||
CompressionBox.SelectedIndex = 0;
|
||||
DrmBox.ItemsSource = new[] { "Upgradable", "Free", "Standard", "Keep source" };
|
||||
@@ -56,10 +50,6 @@ public partial class ToolsView : UserControl
|
||||
SdkBox.SelectedIndex = 0;
|
||||
KrakenLevelBox.ItemsSource = KrakenLevels.Select(level => level.Name).ToList();
|
||||
KrakenLevelBox.SelectedIndex = Array.FindIndex(KrakenLevels, level => level.Value == 7);
|
||||
BrowseTempButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Workspace folder") is { } folder) TempBox.Text = folder;
|
||||
};
|
||||
ClusterBox.ItemsSource = new[] { "Auto", "32 KB", "64 KB" };
|
||||
ClusterBox.SelectedIndex = 0;
|
||||
BlockBox.ItemsSource = new[] { "32 KB", "64 KB" };
|
||||
@@ -147,7 +137,7 @@ public partial class ToolsView : UserControl
|
||||
_packageKind = null;
|
||||
if (_isPackage)
|
||||
{
|
||||
try { _packageKind = new SonyPkgReader().Read(_source!).Kind; }
|
||||
try { _packageKind = new Ps5PkgReader().Read(_source!).Kind; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
@@ -159,8 +149,8 @@ public partial class ToolsView : UserControl
|
||||
SourceTitle.IsVisible = SourceIconHost.IsVisible = false;
|
||||
SourceIcon.Source = null;
|
||||
if (_source is not null) _ = LoadSummaryAsync(_source);
|
||||
if (_isPackage && _packageKind is not null and not SonyPkgKind.FinalizedDebug)
|
||||
ResultText.Text = _packageKind == SonyPkgKind.FinalizedPatch
|
||||
if (_isPackage && _packageKind is not null and not Ps5PkgKind.FinalizedDebug)
|
||||
ResultText.Text = _packageKind == Ps5PkgKind.FinalizedPatch
|
||||
? "Patch package: Convert, Extract and Verify need a finalized debug (FPKG) package."
|
||||
: "Retail package: Convert, Extract and Verify need a debug (FPKG) package.";
|
||||
UpdateActions();
|
||||
@@ -172,10 +162,10 @@ public partial class ToolsView : UserControl
|
||||
if (_isPackage)
|
||||
return _packageKind switch
|
||||
{
|
||||
SonyPkgKind.FinalizedDebug => "debug package (FPKG)",
|
||||
SonyPkgKind.FinalizedPatch => "patch package (LIH)",
|
||||
SonyPkgKind.FinalizedRetail => "retail package",
|
||||
SonyPkgKind.MetadataContainer => "CNT metadata",
|
||||
Ps5PkgKind.FinalizedDebug => "debug package (FPKG)",
|
||||
Ps5PkgKind.FinalizedPatch => "patch package (LIH)",
|
||||
Ps5PkgKind.FinalizedRetail => "retail package",
|
||||
Ps5PkgKind.MetadataContainer => "CNT metadata",
|
||||
_ => "package"
|
||||
};
|
||||
return _format switch
|
||||
@@ -196,7 +186,7 @@ public partial class ToolsView : UserControl
|
||||
if (_isDirectory) actions.Add(ActionConvert);
|
||||
else if (_isPackage)
|
||||
{
|
||||
if (_packageKind == SonyPkgKind.FinalizedDebug) actions.AddRange([ActionConvert, ActionExtract, ActionVerify]);
|
||||
if (_packageKind == Ps5PkgKind.FinalizedDebug) actions.AddRange([ActionConvert, ActionExtract, ActionVerify]);
|
||||
}
|
||||
else if (_format is Ps5ImageFormat.Exfat or Ps5ImageFormat.Ufs2 or Ps5ImageFormat.Pfs or Ps5ImageFormat.ZArchive)
|
||||
{
|
||||
@@ -340,7 +330,7 @@ public partial class ToolsView : UserControl
|
||||
private string Passcode() =>
|
||||
!string.IsNullOrEmpty(PasscodeBox.Text) ? PasscodeBox.Text!
|
||||
: !string.IsNullOrEmpty(_services.Settings.DebugPasscode) ? _services.Settings.DebugPasscode
|
||||
: SonyDebugPackageCredentials.DefaultPasscode;
|
||||
: Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
@@ -463,15 +453,9 @@ public partial class ToolsView : UserControl
|
||||
"The source does not contain a content ID (sce_sys/param.json), so a package cannot be built from it.");
|
||||
return;
|
||||
}
|
||||
IPackageBackend backend = BackendRegistry.All[Math.Max(0, BackendBox.SelectedIndex)];
|
||||
if (backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp)
|
||||
{
|
||||
backend = BackendRegistry.Get(BackendRegistry.PptId);
|
||||
StatusChanged?.Invoke("LibProsperoPkg is Windows only; using ProsperoPkgTool.");
|
||||
}
|
||||
spec.Kind = JobSpec.BuildPackage;
|
||||
spec.ContentId = contentId;
|
||||
spec.Backend = backend.Id;
|
||||
spec.Backend = BackendRegistry.DefaultId;
|
||||
spec.Compression = (CompressionBox.SelectedIndex switch
|
||||
{
|
||||
1 => Ps5InnerCompression.Kraken,
|
||||
@@ -480,27 +464,24 @@ public partial class ToolsView : UserControl
|
||||
}).ToString();
|
||||
spec.KrakenLevel = KrakenLevels[Math.Max(0, KrakenLevelBox.SelectedIndex)].Value;
|
||||
spec.KrakenThreads = (int)(KrakenThreadsBox.Value ?? 0);
|
||||
spec.PlayGoChunks = (int)(PlayGoBox.Value ?? 1);
|
||||
spec.Deterministic = DeterministicBox.IsChecked == true;
|
||||
spec.FakeSign = FakeSignBox.IsChecked == true;
|
||||
spec.RightSprx = RightSprxBox.IsChecked == true;
|
||||
spec.PlayGoChunks = (int)(PlayGoBox.Value ?? 1);
|
||||
spec.Drm = DrmBox.SelectedIndex switch { 0 => "upgradable", 1 => "free", 2 => "standard", _ => null };
|
||||
spec.Sdk = SdkBox.SelectedIndex > 0 ? Ps5SdkVersions.ExecutableVersionAt(SdkBox.SelectedIndex - 1) : null;
|
||||
spec.TempDirectory = string.IsNullOrWhiteSpace(TempBox.Text) ? null : TempBox.Text!.Trim();
|
||||
|
||||
// Free-space preflight before a long build; the engine re-checks exactly while it runs.
|
||||
Ps5DiskSpaceCheck space = Ps5DiskSpace.Check(EstimatePayload(spec.Source), spec.Output, spec.TempDirectory);
|
||||
Ps5DiskSpaceCheck space = Ps5DiskSpace.Check(EstimatePayload(spec.Source), spec.Output, null);
|
||||
if (space.Status == Ps5DiskSpaceStatus.Insufficient)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Build package",
|
||||
"Not enough free disk space to build this package.\n\n" + space.Message +
|
||||
"\n\nFree space, or choose a different workspace or output volume.");
|
||||
"\n\nFree space, or choose a different output volume.");
|
||||
return;
|
||||
}
|
||||
if (space.Status == Ps5DiskSpaceStatus.NearLimit && !await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this),
|
||||
"Low disk space", "Low free disk space for this build.\n\n" + space.Message, "Build anyway"))
|
||||
return;
|
||||
Enqueue(spec, $"Build package from {fileName}", "Build package", sourceLabel, "FPKG", backend.DisplayName);
|
||||
Enqueue(spec, $"Build package from {fileName}", "Build package", sourceLabel, "FPKG", BackendRegistry.Default.DisplayName);
|
||||
}
|
||||
|
||||
private static long EstimatePayload(string source)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// What a <see cref="IPackageBackend"/> can do. The UI uses this to gate actions and options instead
|
||||
/// of assuming every backend supports everything (for example LibProsperoPkg cannot build from an
|
||||
/// image and cannot build very large titles).
|
||||
/// </summary>
|
||||
/// <summary>What a <see cref="IPackageBackend"/> can do. The UI uses this to gate actions and options.</summary>
|
||||
public sealed record BackendCapabilities(
|
||||
bool BuildFromDirectory,
|
||||
bool BuildFromImage,
|
||||
bool Validate,
|
||||
bool Extract,
|
||||
bool SupportsLargeImages,
|
||||
bool SupportsSeed,
|
||||
bool SupportsCompressionKnobs,
|
||||
bool SupportsRightSprxToggle,
|
||||
bool SupportsPlayGoChunks);
|
||||
bool SupportsSeed);
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a backend is asked to do something it cannot do (for example LibProsperoPkg building
|
||||
/// from an image, or its NAPS single-byte layout limit on a very large title). The message is
|
||||
/// user-facing and names the backend; callers may offer to retry with another backend.
|
||||
/// </summary>
|
||||
public sealed class BackendNotSupportedException : NotSupportedException
|
||||
{
|
||||
public BackendNotSupportedException(string backendId, string message)
|
||||
: base(message)
|
||||
{
|
||||
BackendId = backendId;
|
||||
}
|
||||
|
||||
/// <summary>The id of the backend that could not handle the request.</summary>
|
||||
public string BackendId { get; }
|
||||
}
|
||||
@@ -1,38 +1,19 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// The set of package backends the app can select. Defaults to LibProsperoPkg per the product plan;
|
||||
/// ProsperoPkgTool remains available (and is required for image sources and very large titles).
|
||||
/// </summary>
|
||||
/// <summary>The package engine the app uses. There is one: the engine built into Packwright.</summary>
|
||||
public static class BackendRegistry
|
||||
{
|
||||
public const string PptId = PptBackend.BackendId;
|
||||
public const string LppId = LppBackend.BackendId;
|
||||
public const string DefaultId = LppId;
|
||||
public const string DefaultId = PackwrightBackend.BackendId;
|
||||
|
||||
public static IReadOnlyList<IPackageBackend> All { get; } =
|
||||
[
|
||||
new LppBackend(),
|
||||
new PptBackend(),
|
||||
];
|
||||
public static IReadOnlyList<IPackageBackend> All { get; } = [new PackwrightBackend()];
|
||||
|
||||
/// <summary>The default backend (LibProsperoPkg).</summary>
|
||||
public static IPackageBackend Default => GetById(DefaultId);
|
||||
/// <summary>The package engine.</summary>
|
||||
public static IPackageBackend Default => All[0];
|
||||
|
||||
/// <summary>Resolves a backend by id, falling back to the default for an unknown or null id.</summary>
|
||||
public static IPackageBackend Get(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return Default;
|
||||
return All.FirstOrDefault(backend =>
|
||||
string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase)) ?? Default;
|
||||
}
|
||||
/// <summary>Resolves a backend by id. Settings written by older versions name engines that no longer exist; they resolve to the built-in one.</summary>
|
||||
public static IPackageBackend Get(string? id) => Default;
|
||||
|
||||
/// <summary>True when <paramref name="id"/> names a known backend.</summary>
|
||||
/// <summary>True when <paramref name="id"/> names the built-in engine.</summary>
|
||||
public static bool IsKnown(string? id) =>
|
||||
!string.IsNullOrWhiteSpace(id) &&
|
||||
All.Any(backend => string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IPackageBackend GetById(string id) =>
|
||||
All.First(backend => string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
string.Equals(id, PackwrightBackend.BackendId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -4,14 +4,12 @@ using Packwright.Core.Services;
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// A selectable PS5 package engine that can build, validate and extract packages. Two
|
||||
/// implementations ship: <c>PptBackend</c> (the vendored clean-room ProsperoPkgTool engine) and
|
||||
/// <c>LppBackend</c> (the vendored GPL LibProsperoPkg library). The selected backend is the one that
|
||||
/// both builds a package and later validates/extracts it, so the reader always matches the writer.
|
||||
/// The PS5 package engine: builds, validates and extracts packages. The engine that builds a package is also the one
|
||||
/// that later validates and extracts it, so the reader always matches the writer.
|
||||
/// </summary>
|
||||
public interface IPackageBackend
|
||||
{
|
||||
/// <summary>Stable id used in settings and the UI (e.g. <c>ppt</c>, <c>lpp</c>).</summary>
|
||||
/// <summary>Stable id used in settings.</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Human-readable name shown in the backend selector.</summary>
|
||||
@@ -21,21 +19,21 @@ public interface IPackageBackend
|
||||
BackendCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>Builds a package from an unpacked folder (a dump/app tree).</summary>
|
||||
Task<SonyDebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
Task<Ps5DebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Builds a package from an image source (exFAT / FFPKG / FFPFSC). Not all backends support this.</summary>
|
||||
Task<SonyDebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
/// <summary>Builds a package from an image source (exFAT / FFPKG / FFPFSC).</summary>
|
||||
Task<Ps5DebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates a package with this backend's own reader.</summary>
|
||||
SonyDebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode);
|
||||
Ps5DebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode);
|
||||
|
||||
/// <summary>Extracts a package's filesystem to <paramref name="destination"/>.</summary>
|
||||
Task<SonyPackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<SonyPackageExtractProgress>? progress = null, CancellationToken cancellationToken = default);
|
||||
Task<Ps5PackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<Ps5PackageExtractProgress>? progress = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>Inputs for a LibProsperoPkg 1.2.0 directory build.</summary>
|
||||
internal sealed class LibProsperoPkg12BuildRequest
|
||||
{
|
||||
public required string SourceFolder { get; init; }
|
||||
public required string OutputFolder { get; init; }
|
||||
public required string ContentId { get; init; }
|
||||
public required string Passcode { get; init; }
|
||||
public ulong? SdkVersion { get; init; }
|
||||
public int PlayGoChunks { get; init; } = 1;
|
||||
public bool Deterministic { get; init; }
|
||||
public byte[]? OuterSeed { get; init; }
|
||||
/// <summary>0 = none (stored), 1 = zlib, 2 = kraken.</summary>
|
||||
public int Compression { get; init; } = 2;
|
||||
public int KrakenLevel { get; init; } = 7;
|
||||
public int KrakenThreads { get; init; }
|
||||
public string? TempDirectory { get; init; }
|
||||
public Action<string>? Log { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the vendored LibProsperoPkg 1.2.0 (the version the working PPR-PKG/fpkg-gui ships) in an
|
||||
/// isolated <see cref="AssemblyLoadContext"/> and calls it by reflection. Isolation is required
|
||||
/// because 1.2.0 depends on BCnEncoder.Net 2.3.0 and Magick.NET, which would clash with the engine
|
||||
/// assemblies already loaded in the default context. The public surface is deliberately tiny.
|
||||
/// </summary>
|
||||
internal static class LibProsperoPkg12
|
||||
{
|
||||
private static readonly Lazy<Api?> Shared = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>True when the vendored 1.2.0 payload is present and loadable.</summary>
|
||||
public static bool Available => Shared.Value is not null;
|
||||
|
||||
private static string Folder => Path.Combine(AppContext.BaseDirectory, "LibProsperoPkg12");
|
||||
|
||||
/// <summary>Builds a package from a folder and returns the produced package path.</summary>
|
||||
public static string Build(LibProsperoPkg12BuildRequest request)
|
||||
{
|
||||
Api api = Shared.Value ?? throw NotInstalled();
|
||||
object options = Activator.CreateInstance(api.OptionsType)
|
||||
?? throw NotInstalled();
|
||||
|
||||
Set(options, "Mode", Enum.Parse(api.ModeType, "Application"));
|
||||
Set(options, "OutputFormat", Enum.Parse(api.FormatType, "DebugImage"));
|
||||
Set(options, "SourceFolder", request.SourceFolder);
|
||||
Set(options, "OutputFolder", request.OutputFolder);
|
||||
Set(options, "ContentId", request.ContentId);
|
||||
Set(options, "Passcode", request.Passcode);
|
||||
Set(options, "TemporaryDirectory", request.TempDirectory);
|
||||
Set(options, "SdkVersionOverride", request.SdkVersion);
|
||||
Set(options, "PlayGoChunkCount", request.PlayGoChunks);
|
||||
Set(options, "DeterministicBuild", request.Deterministic);
|
||||
Set(options, "OuterPfsSeed", request.OuterSeed);
|
||||
Set(options, "CompressInnerImage", request.Compression != 0);
|
||||
Set(options, "InnerCompression", Enum.Parse(api.CompressionType, request.Compression switch
|
||||
{
|
||||
2 => "Kraken",
|
||||
1 => "Zlib",
|
||||
_ => "None",
|
||||
}));
|
||||
Set(options, "KrakenCompressionLevel", request.KrakenLevel);
|
||||
Set(options, "KrakenMaxDegreeOfParallelism", request.KrakenThreads);
|
||||
|
||||
Action<string> log = message => request.Log?.Invoke(message);
|
||||
object result;
|
||||
try
|
||||
{
|
||||
result = api.BuildMethod.Invoke(null, [options, log])
|
||||
?? throw new InvalidOperationException("LibProsperoPkg returned no build result.");
|
||||
}
|
||||
catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
// Unwrap reflection so callers can recognise the library's real failure (for example an
|
||||
// unsupported layout) instead of seeing a generic TargetInvocationException.
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
return (string?)result.GetType().GetProperty("OutputPath")?.GetValue(result)
|
||||
?? throw new InvalidOperationException("LibProsperoPkg returned no output path.");
|
||||
}
|
||||
|
||||
/// <summary>Validates a package with LibProsperoPkg 1.2.0 (structure + CNT metadata signature).</summary>
|
||||
public static (bool Valid, string Message) Validate(string packagePath)
|
||||
{
|
||||
if (Shared.Value is not { } api)
|
||||
return (false, NotInstalled().Message);
|
||||
try
|
||||
{
|
||||
_ = api.ReaderRead.Invoke(null, [packagePath]);
|
||||
bool signature = (bool)(api.VerifySignature.Invoke(null, [packagePath]) ?? false);
|
||||
return signature
|
||||
? (true, "LibProsperoPkg 1.2.0 read the structure and verified the CNT metadata signature.")
|
||||
: (false, "LibProsperoPkg 1.2.0 could not verify the CNT metadata signature.");
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
return (false, ex.InnerException?.Message ?? ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extracts a package's inner filesystem with LibProsperoPkg 1.2.0 and returns the file count.</summary>
|
||||
public static int Extract(string packagePath, string outputDirectory, string passcode)
|
||||
{
|
||||
Api api = Shared.Value ?? throw NotInstalled();
|
||||
try
|
||||
{
|
||||
object list = api.ExtractInnerFiles.Invoke(null, [packagePath, outputDirectory, passcode, true])
|
||||
?? throw new InvalidOperationException("LibProsperoPkg returned no extraction result.");
|
||||
return list is ICollection collection
|
||||
? collection.Count
|
||||
: ((IEnumerable)list).Cast<object>().Count();
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
throw ex.InnerException ?? ex;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Set(object target, string property, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
PropertyInfo? info = target.GetType().GetProperty(property);
|
||||
info?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static BackendNotSupportedException NotInstalled() =>
|
||||
new(LppBackend.BackendId, "The vendored LibProsperoPkg 1.2.0 payload (LibProsperoPkg12) is missing from the application folder.");
|
||||
|
||||
private static Api? Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
string folder = Folder;
|
||||
string library = Path.Combine(folder, "LibProsperoPkg.dll");
|
||||
if (!File.Exists(library))
|
||||
return null;
|
||||
|
||||
var context = new IsolatedLoadContext(folder);
|
||||
Assembly assembly = context.LoadFromAssemblyPath(library);
|
||||
|
||||
return new Api
|
||||
{
|
||||
OptionsType = Require(assembly, "LibProsperoPkg.ProsperoBuildOptions"),
|
||||
ModeType = Require(assembly, "LibProsperoPkg.ProsperoPackageMode"),
|
||||
FormatType = Require(assembly, "LibProsperoPkg.ProsperoOutputFormat"),
|
||||
CompressionType = Require(assembly, "LibProsperoPkg.PKG.ProsperoInnerCompression"),
|
||||
BuildMethod = Require(assembly, "LibProsperoPkg.ProsperoPackageBuilder").GetMethod("Build", [OptionsType(assembly), typeof(Action<string>)])
|
||||
?? throw new MissingMethodException("ProsperoPackageBuilder.Build"),
|
||||
ArchiveType = Require(assembly, "LibProsperoPkg.PKG.ProsperoPackageArchive"),
|
||||
ReaderRead = Require(assembly, "LibProsperoPkg.PKG.ProsperoPkgReader").GetMethod("Read", [typeof(string)])
|
||||
?? throw new MissingMethodException("ProsperoPkgReader.Read"),
|
||||
VerifySignature = Require(assembly, "LibProsperoPkg.PKG.ProsperoPackageArchive").GetMethod("VerifyCntMetadataSignature", [typeof(string)])
|
||||
?? throw new MissingMethodException("VerifyCntMetadataSignature"),
|
||||
ExtractInnerFiles = Require(assembly, "LibProsperoPkg.PKG.ProsperoPackageArchive")
|
||||
.GetMethod("ExtractInnerFiles", [typeof(string), typeof(string), typeof(string), typeof(bool)])
|
||||
?? throw new MissingMethodException("ExtractInnerFiles"),
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Type OptionsType(Assembly assembly) => Require(assembly, "LibProsperoPkg.ProsperoBuildOptions");
|
||||
|
||||
private static Type Require(Assembly assembly, string typeName) =>
|
||||
assembly.GetType(typeName, throwOnError: true)
|
||||
?? throw new TypeLoadException(typeName);
|
||||
|
||||
private sealed class Api
|
||||
{
|
||||
public required Type OptionsType { get; init; }
|
||||
public required Type ModeType { get; init; }
|
||||
public required Type FormatType { get; init; }
|
||||
public required Type CompressionType { get; init; }
|
||||
public required MethodInfo BuildMethod { get; init; }
|
||||
public required Type ArchiveType { get; init; }
|
||||
public required MethodInfo ReaderRead { get; init; }
|
||||
public required MethodInfo VerifySignature { get; init; }
|
||||
public required MethodInfo ExtractInnerFiles { get; init; }
|
||||
}
|
||||
|
||||
private sealed class IsolatedLoadContext(string folder) : AssemblyLoadContext("LibProsperoPkg12")
|
||||
{
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
string path = Path.Combine(folder, assemblyName.Name + ".dll");
|
||||
return File.Exists(path) ? LoadFromAssemblyPath(path) : null;
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
foreach (string candidate in new[]
|
||||
{
|
||||
Path.Combine(folder, unmanagedDllName + ".dll"),
|
||||
Path.Combine(folder, "runtimes", "win-x64", "native", unmanagedDllName + ".dll"),
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
return LoadUnmanagedDllFromPath(candidate);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Services;
|
||||
using ProsperoPkgTool.Containers;
|
||||
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// The LibProsperoPkg backend, using the vendored <b>1.2.0</b> build (the version the working
|
||||
/// PPR-PKG/fpkg-gui ships) loaded in an isolated context by <see cref="LibProsperoPkg12"/>. Unlike
|
||||
/// the newer 2.6.0, 1.2.0 lays out large titles correctly and exposes SDK, compression, Kraken,
|
||||
/// PlayGo, deterministic and workspace options. Builds run from an unpacked folder; image sources
|
||||
/// are not supported. The DRM type is applied by patching the source sce_sys/param.json (1.2.0 has
|
||||
/// no DRM option), restored afterwards.
|
||||
/// </summary>
|
||||
public sealed class LppBackend : IPackageBackend
|
||||
{
|
||||
public const string BackendId = "lpp";
|
||||
|
||||
public string Id => BackendId;
|
||||
|
||||
public string DisplayName => "LibProsperoPkg 1.2.0";
|
||||
|
||||
public BackendCapabilities Capabilities { get; } = new(
|
||||
BuildFromDirectory: true,
|
||||
BuildFromImage: false,
|
||||
Validate: true,
|
||||
Extract: true,
|
||||
SupportsLargeImages: true,
|
||||
SupportsSeed: true,
|
||||
SupportsCompressionKnobs: true,
|
||||
SupportsRightSprxToggle: false,
|
||||
SupportsPlayGoChunks: true);
|
||||
|
||||
public Task<SonyDebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() => BuildFromDirectory(sourceDirectory, outputPath, options, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
|
||||
public Task<SonyDebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new BackendNotSupportedException(BackendId,
|
||||
"LibProsperoPkg cannot build a package from an image source. " +
|
||||
"Use ProsperoPkgTool for exFAT/FFPKG/FFPFSC sources.");
|
||||
|
||||
public SonyDebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode)
|
||||
{
|
||||
(bool valid, string message) = LibProsperoPkg12.Validate(packagePath);
|
||||
return new SonyDebugPackageValidationResult
|
||||
{
|
||||
IsValid = valid,
|
||||
Message = message,
|
||||
IndexedFiles = 0,
|
||||
PackageSize = File.Exists(packagePath) ? new FileInfo(packagePath).Length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
public Task<SonyPackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<SonyPackageExtractProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int files = LibProsperoPkg12.Extract(packagePath, destination, passcode);
|
||||
return new SonyPackageExtractResult
|
||||
{
|
||||
Destination = Path.GetFullPath(destination),
|
||||
FileCount = files,
|
||||
ExtractedBytes = 0,
|
||||
};
|
||||
}, cancellationToken);
|
||||
|
||||
private static SonyDebugPackageBuildResult BuildFromDirectory(string sourceDirectory, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string source = Path.GetFullPath(sourceDirectory);
|
||||
string destination = Path.GetFullPath(outputPath);
|
||||
if (!Directory.Exists(source)) throw new DirectoryNotFoundException(source);
|
||||
|
||||
SonyDebugPackageCredentials credentials =
|
||||
SonyDebugPackageCredentials.Create(options.ContentId, options.Passcode);
|
||||
(long sourceBytes, int sourceFiles) = MeasureTree(source);
|
||||
|
||||
progress?.Report(new SonyDebugPackageProgress(
|
||||
"Building (LibProsperoPkg)", 0, 0, Path.GetFileName(source)));
|
||||
|
||||
// LibProsperoPkg 1.2.0 uses an existing param.json verbatim, so a DRM override is applied by
|
||||
// patching sce_sys/param.json. To keep the original dump untouched the build runs from a
|
||||
// same-volume hardlink overlay whose param.json is a real patched copy; only if that overlay
|
||||
// cannot be created do we fall back to a crash-recoverable in-place patch.
|
||||
string paramPath = Path.Combine(source, "sce_sys", "param.json");
|
||||
string buildSource = source;
|
||||
string? drmOverlay = TryCreateDrmOverlay(source, paramPath, options);
|
||||
byte[]? originalParam = null;
|
||||
if (drmOverlay is not null)
|
||||
buildSource = drmOverlay;
|
||||
else
|
||||
originalParam = ApplyDrmPatch(paramPath, options);
|
||||
|
||||
string produced;
|
||||
try
|
||||
{
|
||||
var translator = new LppProgressTranslator(progress);
|
||||
produced = LibProsperoPkg12.Build(new LibProsperoPkg12BuildRequest
|
||||
{
|
||||
SourceFolder = buildSource,
|
||||
OutputFolder = Path.GetDirectoryName(destination) ?? Directory.GetCurrentDirectory(),
|
||||
ContentId = credentials.ContentId,
|
||||
Passcode = credentials.Passcode,
|
||||
SdkVersion = options.SdkVersionOverride,
|
||||
PlayGoChunks = options.PlayGoChunkCount,
|
||||
Deterministic = options.Seed is not null,
|
||||
OuterSeed = options.Seed,
|
||||
Compression = options.Compression == Ps5InnerCompression.Stored ? 0 : 2,
|
||||
KrakenLevel = options.KrakenLevel,
|
||||
KrakenThreads = options.KrakenThreads,
|
||||
TempDirectory = options.TempDirectory,
|
||||
Log = message =>
|
||||
{
|
||||
options.Log?.Invoke(message);
|
||||
translator.OnLog(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (IsUnsupportedLayout(ex))
|
||||
{
|
||||
throw new BackendNotSupportedException(BackendId,
|
||||
"LibProsperoPkg cannot lay out this title ('" + ex.Message + "'). " +
|
||||
"Switch the builder to ProsperoPkgTool and retry.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (drmOverlay is not null)
|
||||
TryDeleteDirectory(drmOverlay);
|
||||
if (originalParam is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
RestoreDrmPatch(paramPath, originalParam);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Never swallow this: keep the backup on disk so the next build restores the dump.
|
||||
options.Log?.Invoke("Warning: could not restore " + paramPath + " (" + ex.Message +
|
||||
"). The original is kept as " + Path.GetFileName(paramPath) + ParamBackupSuffix +
|
||||
" and will be restored automatically on the next build.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetFullPath(produced), destination, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(destination)) File.Delete(destination);
|
||||
File.Move(produced, destination);
|
||||
}
|
||||
|
||||
return new SonyDebugPackageBuildResult
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
KeyFingerprint = credentials.Fingerprint,
|
||||
PackageSize = new FileInfo(destination).Length,
|
||||
SourceBytes = sourceBytes,
|
||||
SourceFiles = sourceFiles,
|
||||
UsesDefaultPasscode = credentials.UsesDefaultPasscode,
|
||||
};
|
||||
}
|
||||
|
||||
private static (long Bytes, int Files) MeasureTree(string root)
|
||||
{
|
||||
long bytes = 0;
|
||||
int files = 0;
|
||||
foreach (string path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
bytes += new FileInfo(path).Length;
|
||||
files++;
|
||||
}
|
||||
return (bytes, files);
|
||||
}
|
||||
|
||||
private const string ParamBackupSuffix = ".packwright-backup";
|
||||
|
||||
/// <summary>
|
||||
/// LibProsperoPkg 1.2.0 has no DRM option and uses the source <c>sce_sys/param.json</c>
|
||||
/// verbatim, so a DRM override is applied by patching that file. Before patching, the original
|
||||
/// bytes are written to a sibling <c>param.json.packwright-backup</c> so an interrupted or
|
||||
/// crashed build can be recovered: any leftover backup is restored before the next patch.
|
||||
/// Returns the original bytes when a patch was applied, otherwise null.
|
||||
/// </summary>
|
||||
private static byte[]? ApplyDrmPatch(string paramPath, SonyDebugPackageBuildOptions options)
|
||||
{
|
||||
if (!File.Exists(paramPath))
|
||||
return null;
|
||||
|
||||
// Recover a patch left behind by a build that was killed before its finally block ran.
|
||||
RecoverLeftoverPatch(paramPath);
|
||||
if (string.IsNullOrWhiteSpace(options.DrmTypeOverride))
|
||||
return null;
|
||||
|
||||
byte[] original = File.ReadAllBytes(paramPath);
|
||||
byte[] updated = ProsperoPkgTool.Containers.ProsperoPublisherParamJson.ApplyDrmType(original, options.DrmTypeOverride);
|
||||
if (updated.AsSpan().SequenceEqual(original))
|
||||
return null;
|
||||
|
||||
File.WriteAllBytes(paramPath + ParamBackupSuffix, original);
|
||||
File.WriteAllBytes(paramPath, updated);
|
||||
return original;
|
||||
}
|
||||
|
||||
private static void RestoreDrmPatch(string paramPath, byte[]? original)
|
||||
{
|
||||
if (original is null)
|
||||
return;
|
||||
File.WriteAllBytes(paramPath, original);
|
||||
string backup = paramPath + ParamBackupSuffix;
|
||||
if (File.Exists(backup)) File.Delete(backup);
|
||||
}
|
||||
|
||||
private static void RecoverLeftoverPatch(string paramPath)
|
||||
{
|
||||
string backup = paramPath + ParamBackupSuffix;
|
||||
if (!File.Exists(backup))
|
||||
return;
|
||||
File.Copy(backup, paramPath, overwrite: true);
|
||||
File.Delete(backup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a same-volume hardlink mirror of the source with a real, patched <c>param.json</c>, so a
|
||||
/// DRM override never modifies the original dump. Returns the overlay root, or null when no patch
|
||||
/// is needed or the overlay cannot be created (the caller then patches in place with recovery).
|
||||
/// </summary>
|
||||
private static string? TryCreateDrmOverlay(string source, string paramPath, SonyDebugPackageBuildOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.DrmTypeOverride) || !File.Exists(paramPath))
|
||||
return null;
|
||||
|
||||
byte[] original = File.ReadAllBytes(paramPath);
|
||||
byte[] updated = ProsperoPkgTool.Containers.ProsperoPublisherParamJson.ApplyDrmType(original, options.DrmTypeOverride);
|
||||
if (updated.AsSpan().SequenceEqual(original))
|
||||
return null;
|
||||
|
||||
string trimmed = source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string parent = Path.GetDirectoryName(trimmed) is { Length: > 0 } directory
|
||||
? directory
|
||||
: Path.GetPathRoot(trimmed)!;
|
||||
string overlay = Path.Combine(parent, ".packwright-drm-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
MirrorTree(source, overlay);
|
||||
string overlayParam = Path.Combine(overlay, Path.GetRelativePath(source, paramPath));
|
||||
File.Delete(overlayParam);
|
||||
File.WriteAllBytes(overlayParam, updated);
|
||||
options.Log?.Invoke(
|
||||
"Applying the DRM override through a linked build overlay; the source dump is left untouched.");
|
||||
return overlay;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
|
||||
{
|
||||
options.Log?.Invoke("Could not create a linked build overlay (" + ex.Message +
|
||||
"); applying the DRM override in place with crash recovery instead.");
|
||||
TryDeleteDirectory(overlay);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MirrorTree(string source, string target)
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
foreach (string directory in Directory.EnumerateDirectories(source))
|
||||
MirrorTree(directory, Path.Combine(target, Path.GetFileName(directory)));
|
||||
foreach (string file in Directory.EnumerateFiles(source))
|
||||
{
|
||||
string destination = Path.Combine(target, Path.GetFileName(file));
|
||||
if (CreateHardLink(destination, file, IntPtr.Zero))
|
||||
continue;
|
||||
File.Copy(file, destination); // different volume or a filesystem without hardlinks
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll",
|
||||
CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool CreateHardLink(string newLink, string existingFile, IntPtr securityAttributes);
|
||||
|
||||
private static bool IsUnsupportedLayout(Exception ex)
|
||||
{
|
||||
while (ex is System.Reflection.TargetInvocationException { InnerException: { } inner })
|
||||
ex = inner;
|
||||
// Prefer the engine's own classification; keep the legacy message checks only as a fallback
|
||||
// for older vendored builds that predate ProsperoErrorInfo.
|
||||
if (ProsperoErrorInfo.IsUnsupported(ex))
|
||||
return true;
|
||||
return ex is NotSupportedException or OverflowException ||
|
||||
ex.Message.Contains("single-byte field", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("this layout size is not supported", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the LibProsperoPkg build log into stage/percent progress reports so the task queue's
|
||||
/// steps and progress bar advance. Each line is matched for a <c>[stage N/5]</c> marker (which
|
||||
/// maps to the task step) and a <c>data NN%</c> / <c>: NN%</c> percentage; reports are emitted
|
||||
/// only when the stage or percentage changes.
|
||||
/// </summary>
|
||||
private sealed class LppProgressTranslator
|
||||
{
|
||||
// Stage strings chosen so they contain the task plan's keywords.
|
||||
private static readonly string[] StageNames =
|
||||
[
|
||||
"Reading source folder", // 0 - before the first stage marker
|
||||
"Inner image", // stage 1/5
|
||||
"NAPS", // stage 2/5
|
||||
"Outer PFS", // stage 3/5
|
||||
"CNT", // stage 4/5
|
||||
"Finalize", // stage 5/5
|
||||
];
|
||||
|
||||
private static readonly Regex StageRegex = new(@"\[stage\s+(\d+)/\d+\]", RegexOptions.Compiled);
|
||||
private static readonly Regex PercentRegex = new(@"(?:data\s+|:\s+)(\d{1,3})%", RegexOptions.Compiled);
|
||||
|
||||
private readonly IProgress<SonyDebugPackageProgress>? _progress;
|
||||
private string _stage = StageNames[0];
|
||||
private int _percent = -1;
|
||||
|
||||
public LppProgressTranslator(IProgress<SonyDebugPackageProgress>? progress)
|
||||
{
|
||||
_progress = progress;
|
||||
_progress?.Report(new SonyDebugPackageProgress(_stage, 0, 100, string.Empty));
|
||||
}
|
||||
|
||||
public void OnLog(string line)
|
||||
{
|
||||
if (_progress is null || string.IsNullOrEmpty(line))
|
||||
return;
|
||||
|
||||
bool stageChanged = false;
|
||||
Match stage = StageRegex.Match(line);
|
||||
if (stage.Success && int.TryParse(stage.Groups[1].Value, out int number) &&
|
||||
number >= 0 && number < StageNames.Length)
|
||||
{
|
||||
stageChanged = !string.Equals(_stage, StageNames[number], StringComparison.Ordinal);
|
||||
_stage = StageNames[number];
|
||||
}
|
||||
else if (line.Contains("source tree scan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
stageChanged = !string.Equals(_stage, StageNames[0], StringComparison.Ordinal);
|
||||
_stage = StageNames[0];
|
||||
}
|
||||
|
||||
int percent = _percent < 0 ? 0 : _percent;
|
||||
Match match = PercentRegex.Match(line);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out int parsed))
|
||||
percent = Math.Clamp(parsed, 0, 100);
|
||||
else if (line.Contains("complete", StringComparison.OrdinalIgnoreCase))
|
||||
percent = 100;
|
||||
|
||||
if (!stageChanged && percent == _percent)
|
||||
return;
|
||||
_percent = percent;
|
||||
_progress.Report(new SonyDebugPackageProgress(_stage, percent, 100, string.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Services;
|
||||
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// The package engine built into Packwright: builds packages from a folder or an image, validates them with the same
|
||||
/// reader it extracts with, and extracts them. It carries no third-party engine and no keys.
|
||||
/// </summary>
|
||||
public sealed class PackwrightBackend : IPackageBackend
|
||||
{
|
||||
public const string BackendId = "packwright";
|
||||
|
||||
public string Id => BackendId;
|
||||
|
||||
public string DisplayName => "Packwright";
|
||||
|
||||
public BackendCapabilities Capabilities { get; } = new(
|
||||
BuildFromDirectory: true,
|
||||
BuildFromImage: true,
|
||||
Validate: true,
|
||||
Extract: true,
|
||||
SupportsLargeImages: true,
|
||||
SupportsSeed: true);
|
||||
|
||||
public Task<Ps5DebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Ps5DebugPackageBuilder.CreateFromDirectoryAsync(sourceDirectory, outputPath, options, progress, cancellationToken);
|
||||
|
||||
public Task<Ps5DebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
VolumeDebugPackageBuilder.CreateFromImageAsync(imagePath, outputPath, options, progress, cancellationToken);
|
||||
|
||||
public Ps5DebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode) =>
|
||||
Ps5DebugPackageBuilder.Validate(packagePath, passcode);
|
||||
|
||||
public Task<Ps5PackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<Ps5PackageExtractProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
Ps5PackageExtraction.ExtractAsync(packagePath, destination, passcode, progress, cancellationToken);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Services;
|
||||
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// The clean-room ProsperoPkgTool engine backend. Supports directory and image builds (including
|
||||
/// the multi-GiB file-backed path), and is the fallback when LibProsperoPkg cannot handle a source.
|
||||
/// </summary>
|
||||
public sealed class PptBackend : IPackageBackend
|
||||
{
|
||||
public const string BackendId = "ppt";
|
||||
|
||||
public string Id => BackendId;
|
||||
|
||||
public string DisplayName => "ProsperoPkgTool";
|
||||
|
||||
public BackendCapabilities Capabilities { get; } = new(
|
||||
BuildFromDirectory: true,
|
||||
BuildFromImage: true,
|
||||
Validate: true,
|
||||
Extract: true,
|
||||
SupportsLargeImages: true,
|
||||
SupportsSeed: true,
|
||||
SupportsCompressionKnobs: true,
|
||||
SupportsRightSprxToggle: true,
|
||||
SupportsPlayGoChunks: true);
|
||||
|
||||
public Task<SonyDebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
SonyDebugPackageBuilder.CreateFromDirectoryAsync(sourceDirectory, outputPath, options, progress, cancellationToken);
|
||||
|
||||
public Task<SonyDebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
VolumeDebugPackageBuilder.CreateFromImageAsync(imagePath, outputPath, options, progress, cancellationToken);
|
||||
|
||||
public SonyDebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode) =>
|
||||
SonyDebugPackageBuilder.Validate(packagePath, passcode);
|
||||
|
||||
public Task<SonyPackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<SonyPackageExtractProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
SonyPackageExtraction.ExtractAsync(packagePath, destination, passcode, progress, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Pkg.Writer;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// The compatibility key material the user imported, if any. Packwright ships no keys; a package it builds is complete
|
||||
/// and readable without them. With them, the few fields that are RSA-wrapped by the original tooling are filled in
|
||||
/// properly. See <see cref="PkgKeySet"/> for the file format.
|
||||
/// </summary>
|
||||
public static class Ps5BuildKeys
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static PkgKeySet _current = PkgKeySet.None;
|
||||
private static string? _path;
|
||||
|
||||
public static PkgKeySet Current { get { lock (Gate) return _current; } }
|
||||
public static string? Path { get { lock (Gate) return _path; } }
|
||||
|
||||
/// <summary>Loads the key file at <paramref name="path"/>; null or an empty path clears the imported keys. Throws <see cref="InvalidDataException"/> for an unacceptable file.</summary>
|
||||
public static PkgKeySet Configure(string? path)
|
||||
{
|
||||
PkgKeySet keys = string.IsNullOrWhiteSpace(path) ? PkgKeySet.None : PkgKeySet.Load(path);
|
||||
lock (Gate) { _current = keys; _path = string.IsNullOrWhiteSpace(path) ? null : path; }
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>Like <see cref="Configure"/> but never throws; returns the problem instead.</summary>
|
||||
public static string? TryConfigure(string? path)
|
||||
{
|
||||
try { Configure(path); return null; }
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lock (Gate) { _current = PkgKeySet.None; _path = null; }
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The shared part of every package build: overrides, repairs, key import, progress and the result.</summary>
|
||||
internal static class PackwrightEngine
|
||||
{
|
||||
public static Ps5DebugPackageBuildResult Run(IReadOnlyList<PkgSourceFile> sourceFiles, IEnumerable<string>? directories,
|
||||
string outputPath, Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress,
|
||||
long sourceBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
string destination = Path.GetFullPath(outputPath);
|
||||
Ps5DebugPackageCredentials credentials = Ps5DebugPackageCredentials.Create(options.ContentId, options.Passcode);
|
||||
|
||||
List<PkgSourceFile> files = Prepare(sourceFiles, options);
|
||||
var relay = progress is null ? null : new Progress<PkgBuildProgress>(value =>
|
||||
progress.Report(new Ps5DebugPackageProgress(value.Stage, value.Done, value.Total, value.Item)));
|
||||
|
||||
PkgBuildResult result = PkgBuilder.Build(files, directories, destination, new PkgBuildOptions
|
||||
{
|
||||
ContentId = credentials.ContentId,
|
||||
Passcode = credentials.Passcode,
|
||||
Keys = Ps5BuildKeys.Current,
|
||||
Seed = options.Seed,
|
||||
Compression = options.Compression == Ps5InnerCompression.Stored ? PkgCompression.Stored : PkgCompression.Kraken,
|
||||
CompressionThreads = options.KrakenThreads,
|
||||
PlayGoChunks = Math.Clamp(options.PlayGoChunkCount, 1, 64),
|
||||
Log = options.Log,
|
||||
Progress = relay,
|
||||
}, cancellationToken);
|
||||
|
||||
foreach (string warning in result.Warnings) options.Log?.Invoke("warning: " + warning);
|
||||
return new Ps5DebugPackageBuildResult
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
KeyFingerprint = credentials.Fingerprint,
|
||||
PackageSize = result.PackageSize,
|
||||
SourceBytes = sourceBytes,
|
||||
SourceFiles = sourceFiles.Count,
|
||||
UsesDefaultPasscode = credentials.UsesDefaultPasscode,
|
||||
};
|
||||
}
|
||||
|
||||
// Applies the DRM and SDK overrides to param.json and refreshes the digest of trophy/activity archives.
|
||||
private static List<PkgSourceFile> Prepare(IReadOnlyList<PkgSourceFile> input, Ps5DebugPackageBuildOptions options)
|
||||
{
|
||||
var files = new List<PkgSourceFile>(input.Count);
|
||||
foreach (PkgSourceFile file in input)
|
||||
{
|
||||
string path = file.Path.Replace('\\', '/').TrimStart('/');
|
||||
if (path.Equals("sce_sys/param.json", StringComparison.Ordinal)
|
||||
&& (!string.IsNullOrWhiteSpace(options.DrmTypeOverride) || options.SdkVersionOverride is not null))
|
||||
{
|
||||
byte[] data = ReadAll(file);
|
||||
if (!string.IsNullOrWhiteSpace(options.DrmTypeOverride)) data = Ps5ParamJson.ApplyDrmType(data, options.DrmTypeOverride);
|
||||
if (options.SdkVersionOverride is { } sdk) data = Ps5ParamJson.ApplySdkOverride(data, sdk);
|
||||
files.Add(file with { Path = path, Length = data.Length, Open = () => new MemoryStream(data, writable: false) });
|
||||
}
|
||||
else if (Ps5UcpArchive.IsRepairablePath(path) && file.Length <= 256 * 1024 * 1024)
|
||||
{
|
||||
byte[] data = Ps5UcpArchive.RepairIfNeeded(ReadAll(file));
|
||||
files.Add(file with { Path = path, Length = data.Length, Open = () => new MemoryStream(data, writable: false) });
|
||||
}
|
||||
else
|
||||
files.Add(file with { Path = path });
|
||||
}
|
||||
AddMissingTextures(files, options);
|
||||
return files;
|
||||
}
|
||||
|
||||
// Every artwork PNG has a BC7 texture next to it in a package. A title that has the picture but no texture gets one.
|
||||
private static void AddMissingTextures(List<PkgSourceFile> files, Ps5DebugPackageBuildOptions options)
|
||||
{
|
||||
var paths = new HashSet<string>(files.Select(file => file.Path), StringComparer.Ordinal);
|
||||
foreach (string name in new[] { "icon0", "pic0", "pic1", "pic2" })
|
||||
{
|
||||
string png = $"sce_sys/{name}.png", dds = $"sce_sys/{name}.dds";
|
||||
if (!paths.Contains(png) || paths.Contains(dds)) continue;
|
||||
PkgSourceFile source = files.First(file => file.Path == png);
|
||||
if (source.Length > 64 * 1024 * 1024) continue;
|
||||
try
|
||||
{
|
||||
byte[] texture = Ps5DdsEncoder.EncodePng(ReadAll(source));
|
||||
files.Add(new PkgSourceFile(dds, texture.Length, () => new MemoryStream(texture, writable: false)));
|
||||
options.Log?.Invoke($"Generated {dds} from {png}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is SixLabors.ImageSharp.ImageFormatException or NotSupportedException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
options.Log?.Invoke($"warning: {png} could not be converted to a texture ({ex.Message}); the package carries no {name}.dds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadAll(PkgSourceFile file)
|
||||
{
|
||||
using Stream stream = file.Open();
|
||||
byte[] data = new byte[file.Length];
|
||||
stream.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trophy and activity archives (<c>.ucp</c>) carry a SHA-1 of the whole file; an edited archive needs it refreshed.</summary>
|
||||
internal static class Ps5UcpArchive
|
||||
{
|
||||
private const uint Magic = 0xB228C60A; // big-endian
|
||||
private const int HeaderSize = 96;
|
||||
private const int DigestOffset = 28;
|
||||
private const int DigestSize = 20;
|
||||
|
||||
public static bool IsRepairablePath(string path) =>
|
||||
path.EndsWith(".ucp", StringComparison.OrdinalIgnoreCase)
|
||||
&& (path.StartsWith("sce_sys/trophy2/", StringComparison.Ordinal) || path.StartsWith("sce_sys/uds/", StringComparison.Ordinal));
|
||||
|
||||
public static byte[] RepairIfNeeded(byte[] data)
|
||||
{
|
||||
if (data.Length < HeaderSize || System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(data) != Magic) return data;
|
||||
byte[] zeroed = (byte[])data.Clone();
|
||||
Array.Clear(zeroed, DigestOffset, DigestSize);
|
||||
byte[] digest = SHA1.HashData(zeroed);
|
||||
if (digest.AsSpan().SequenceEqual(data.AsSpan(DigestOffset, DigestSize))) return data;
|
||||
digest.CopyTo(zeroed, DigestOffset);
|
||||
return zeroed;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using ProsperoPkgTool;
|
||||
using ProsperoPkgTool.Content;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using ProsperoPkgTool.Gp5;
|
||||
using EngineBuilder = ProsperoPkgTool.Containers.ProsperoDebugPackageBuilder;
|
||||
using EngineInnerFile = ProsperoPkgTool.Containers.ProsperoInnerImageAssembler.InnerFile;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
public sealed class ProsperoDebugPackageBuildOptions
|
||||
{
|
||||
public required string ContentId { get; init; }
|
||||
public string Passcode { get; init; } = SonyDebugPackageCredentials.DefaultPasscode;
|
||||
|
||||
/// <summary>
|
||||
/// Optional 16-byte outer-PFS seed. When supplied the build is byte-reproducible
|
||||
/// (deterministic CNT entry keys, fixed outer PFS seed); otherwise the engine randomizes.
|
||||
/// </summary>
|
||||
public byte[]? Seed { get; init; }
|
||||
|
||||
/// <summary>Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
|
||||
/// Null preserves the source metadata.</summary>
|
||||
public ulong? SdkVersionOverride { get; init; }
|
||||
|
||||
/// <summary>Optional workspace folder for the multi-GiB file-backed path. Null uses the system temp folder.</summary>
|
||||
public string? TempDirectory { get; init; }
|
||||
|
||||
/// <summary>Optional sink for engine log lines (free-space warnings, stale workspace sweep).</summary>
|
||||
public Action<string>? Log { get; init; }
|
||||
|
||||
/// <summary>Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).</summary>
|
||||
public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
|
||||
|
||||
/// <summary>Kraken level recorded in the compressed header (Oodle naming, -4..9). Header-only here.</summary>
|
||||
public int KrakenLevel { get; init; } = 7;
|
||||
|
||||
/// <summary>Blocks encoded concurrently; 0 selects the processor count.</summary>
|
||||
public int KrakenThreads { get; init; }
|
||||
|
||||
/// <summary>PlayGo chunk count for the generated project; 1 matches the debug/nwonly profile.</summary>
|
||||
public int PlayGoChunkCount { get; init; } = 1;
|
||||
|
||||
/// <summary>Optional DRM token to force in param.json. Null preserves the source token;
|
||||
/// "standard" is the opt-in override surfaced by the Advanced build setting.</summary>
|
||||
public string? DrmTypeOverride { get; init; }
|
||||
|
||||
/// <summary>Convert raw ELF modules to debug fake-SELF containers. Default true.</summary>
|
||||
public bool FakeSignModules { get; init; } = true;
|
||||
|
||||
/// <summary>Inject the built-in debug sce_sys/about/right.sprx when the source lacks one. Default true.</summary>
|
||||
public bool InjectRightSprx { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PS5 debug package from an unpacked dump folder using the vendored, validated
|
||||
/// <c>ProsperoPkgTool</c> engine (MIT) as a managed library. Files are passed to the engine's
|
||||
/// <c>Build</c> API directly (disk-backed by path).
|
||||
/// </summary>
|
||||
public static class ProsperoDebugPackageBuilder
|
||||
{
|
||||
public static Task<SonyDebugPackageBuildResult> CreateFromDirectoryAsync(string sourceDirectory,
|
||||
string outputPath, ProsperoDebugPackageBuildOptions options,
|
||||
IProgress<SonyDebugPackageProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Task.Run(() => CreateFromDirectory(sourceDirectory, outputPath, options, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static SonyDebugPackageBuildResult CreateFromDirectory(string sourceDirectory, string outputPath,
|
||||
ProsperoDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string source = Path.GetFullPath(sourceDirectory);
|
||||
string destination = Path.GetFullPath(outputPath);
|
||||
if (!Directory.Exists(source)) throw new DirectoryNotFoundException(source);
|
||||
|
||||
SonyDebugPackageCredentials credentials = SonyDebugPackageCredentials.Create(options.ContentId, options.Passcode);
|
||||
progress?.Report(new SonyDebugPackageProgress("Reading source folder", 0, 0, Path.GetFileName(source)));
|
||||
|
||||
var files = new List<EngineInnerFile>();
|
||||
var sceSys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
long sourceBytes = 0;
|
||||
int sourceFiles = 0;
|
||||
|
||||
string[] sourcePaths = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
int processedFiles = 0;
|
||||
progress?.Report(new SonyDebugPackageProgress("Reading source folder", 0, sourcePaths.Length,
|
||||
Path.GetFileName(source)));
|
||||
|
||||
foreach (string path in sourcePaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = Path.GetRelativePath(source, path).Replace('\\', '/');
|
||||
sourceBytes += new FileInfo(path).Length;
|
||||
sourceFiles++;
|
||||
progress?.Report(new SonyDebugPackageProgress("Reading source folder", ++processedFiles,
|
||||
sourcePaths.Length, relative));
|
||||
|
||||
bool publishing = SourceFolderValidator.IsPublishingArtifact(relative) ||
|
||||
Path.GetExtension(relative).Equals(".gp4", StringComparison.OrdinalIgnoreCase);
|
||||
bool underSceSys = relative.StartsWith("sce_sys/", StringComparison.OrdinalIgnoreCase);
|
||||
string sceRelative = underSceSys ? relative["sce_sys/".Length..] : string.Empty;
|
||||
|
||||
if (underSceSys)
|
||||
{
|
||||
byte[] data = File.ReadAllBytes(path);
|
||||
sceSys[sceRelative] = ProsperoUcpArchive.IsRepairableSceSysPath(sceRelative)
|
||||
? ProsperoUcpArchive.RepairIfNeeded(data)
|
||||
: data;
|
||||
}
|
||||
|
||||
if (publishing) continue;
|
||||
if (underSceSys && ProsperoSceSysMedia.IsOuterCntFile(sceRelative)) continue;
|
||||
|
||||
if (underSceSys && ProsperoUcpArchive.IsRepairableSceSysPath(sceRelative))
|
||||
{
|
||||
files.Add(new EngineInnerFile
|
||||
{
|
||||
Path = "/" + relative,
|
||||
Data = sceSys[sceRelative],
|
||||
PlayGoChunkId = 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(new EngineInnerFile
|
||||
{
|
||||
Path = "/" + relative,
|
||||
SourcePath = path,
|
||||
Length = new FileInfo(path).Length,
|
||||
PlayGoChunkId = 0
|
||||
});
|
||||
}
|
||||
|
||||
if (!sceSys.TryGetValue("param.json", out byte[]? paramJson) || paramJson.Length == 0)
|
||||
throw new InvalidDataException(
|
||||
"The dump does not contain sce_sys/param.json, which is required to build a package.");
|
||||
if (files.Count == 0)
|
||||
throw new InvalidDataException("The dump does not contain any package files.");
|
||||
|
||||
var log = new DebugPackageBuildLog
|
||||
{
|
||||
Log = options.Log ?? (_ => { }),
|
||||
Progress = new RelayProgress<ProsperoBuildProgress>(value =>
|
||||
progress?.Report(new SonyDebugPackageProgress(
|
||||
value.StageId is { } stage ? ProsperoBuildStages.Name(stage) : value.Stage,
|
||||
value.BytesTotal > 0 ? value.BytesDone : value.Done,
|
||||
value.BytesTotal > 0 ? value.BytesTotal : value.Total,
|
||||
value.CurrentPath ?? string.Empty)))
|
||||
};
|
||||
|
||||
DebugPackageBuildResult result = EngineBuilder.Build(files, new DebugPackageBuildOptions
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
Passcode = credentials.Passcode,
|
||||
ParamJson = paramJson,
|
||||
Compression = options.Compression.ToEngine(),
|
||||
KrakenLevel = options.KrakenLevel,
|
||||
KrakenThreads = options.KrakenThreads,
|
||||
PlayGoChunkCount = options.PlayGoChunkCount,
|
||||
DrmTypeOverride = options.DrmTypeOverride,
|
||||
OuterSeed = options.Seed,
|
||||
DeterministicEntryKeys = options.Seed is { Length: 16 },
|
||||
SdkVersionOverride = options.SdkVersionOverride,
|
||||
TempDirectory = options.TempDirectory,
|
||||
SceSysFiles = sceSys,
|
||||
FakeSignModules = options.FakeSignModules,
|
||||
InjectRightSprx = options.InjectRightSprx,
|
||||
Log = log
|
||||
}, cancellationToken);
|
||||
|
||||
return new SonyDebugPackageBuildResult
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
KeyFingerprint = credentials.Fingerprint,
|
||||
PackageSize = new FileInfo(destination).Length,
|
||||
SourceBytes = sourceBytes,
|
||||
SourceFiles = sourceFiles,
|
||||
UsesDefaultPasscode = credentials.UsesDefaultPasscode
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class RelayProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Packwright.Core.Parsers;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Pkg.Reader;
|
||||
using Packwright.Pkg.Writer;
|
||||
using Packwright.Pkg.Writer;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
public sealed class Ps5DebugPackageBuildOptions
|
||||
{
|
||||
public required string ContentId { get; init; }
|
||||
public string Passcode { get; init; } = Ps5DebugPackageCredentials.DefaultPasscode;
|
||||
|
||||
/// <summary>Optional 16-byte outer-PFS seed for a byte-reproducible package.</summary>
|
||||
public byte[]? Seed { get; init; }
|
||||
|
||||
/// <summary>Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
|
||||
/// Null preserves the source metadata.</summary>
|
||||
public ulong? SdkVersionOverride { get; init; }
|
||||
|
||||
/// <summary>Optional sink for engine log lines.</summary>
|
||||
public Action<string>? Log { get; init; }
|
||||
|
||||
/// <summary>Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).</summary>
|
||||
public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
|
||||
|
||||
/// <summary>Kraken level (Oodle naming, -4..9). The encoder uses one fixed strategy, so the level does not change the output.</summary>
|
||||
public int KrakenLevel { get; init; } = 7;
|
||||
|
||||
/// <summary>Spans compressed at the same time; 0 selects the processor count.</summary>
|
||||
public int KrakenThreads { get; init; }
|
||||
|
||||
/// <summary>Play-go chunks, used only when the title brings no play-go files of its own.</summary>
|
||||
public int PlayGoChunkCount { get; init; } = 1;
|
||||
|
||||
/// <summary>Optional DRM token to force in param.json. Null preserves the source token.</summary>
|
||||
public string? DrmTypeOverride { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct Ps5DebugPackageProgress(string Stage, long CompletedBytes,
|
||||
long TotalBytes, string CurrentPath);
|
||||
|
||||
public sealed class Ps5DebugPackageBuildResult
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public required string ContentId { get; init; }
|
||||
public required string KeyFingerprint { get; init; }
|
||||
public required long PackageSize { get; init; }
|
||||
public required long SourceBytes { get; init; }
|
||||
public required int SourceFiles { get; init; }
|
||||
public required bool UsesDefaultPasscode { get; init; }
|
||||
}
|
||||
|
||||
public sealed class Ps5DebugPackageValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public required int IndexedFiles { get; init; }
|
||||
public required long PackageSize { get; init; }
|
||||
}
|
||||
|
||||
public static class Ps5DebugPackageBuilder
|
||||
{
|
||||
public static Task<Ps5DebugPackageBuildResult> CreateFromDirectoryAsync(string sourceDirectory,
|
||||
string outputPath, Ps5DebugPackageBuildOptions options,
|
||||
IProgress<Ps5DebugPackageProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Task.Run(() => CreateFromDirectory(sourceDirectory, outputPath, options, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Ps5DebugPackageBuildResult CreateFromDirectory(string sourceDirectory, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string source = Path.GetFullPath(sourceDirectory);
|
||||
if (!Directory.Exists(source)) throw new DirectoryNotFoundException(source);
|
||||
progress?.Report(new Ps5DebugPackageProgress("Reading source folder", 0, 0, Path.GetFileName(source)));
|
||||
|
||||
var files = new List<PkgSourceFile>();
|
||||
var directories = new List<string>();
|
||||
long sourceBytes = 0;
|
||||
foreach (string directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
directories.Add(Path.GetRelativePath(source, directory).Replace('\\', '/'));
|
||||
string[] paths = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)
|
||||
.OrderBy(item => item, StringComparer.Ordinal).ToArray();
|
||||
int processed = 0;
|
||||
foreach (string path in paths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = Path.GetRelativePath(source, path).Replace('\\', '/');
|
||||
progress?.Report(new Ps5DebugPackageProgress("Reading source folder", ++processed, paths.Length, relative));
|
||||
if (SourceFolderValidator.IsPublishingArtifact(relative)) continue;
|
||||
long length = new FileInfo(path).Length;
|
||||
sourceBytes += length;
|
||||
string full = path;
|
||||
files.Add(new PkgSourceFile(relative, length,
|
||||
() => new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 20, FileOptions.SequentialScan)));
|
||||
}
|
||||
|
||||
if (!files.Any(file => file.Path.Equals("sce_sys/param.json", StringComparison.Ordinal)))
|
||||
throw new InvalidDataException("The dump does not contain sce_sys/param.json, which is required to build a package.");
|
||||
if (files.Count == 0) throw new InvalidDataException("The dump does not contain any package files.");
|
||||
return PackwrightEngine.Run(files, directories, outputPath, options, progress, sourceBytes, cancellationToken);
|
||||
}
|
||||
public static Ps5DebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fullPath = Path.GetFullPath(packagePath);
|
||||
using PkgPackage package = PkgPackage.Open(fullPath, passcode);
|
||||
PkgValidationReport report = PkgValidator.Validate(package);
|
||||
int files = package.IsMetadataOnly ? 0 : package.Files.Count;
|
||||
PkgCheck? content = report.Checks.FirstOrDefault(check => check.Name == "Content");
|
||||
bool valid = report.Passed && !package.IsRetail && content is { Status: PkgCheckStatus.Pass };
|
||||
string message;
|
||||
if (valid)
|
||||
message = $"Structure, metadata digests and file decoding verified ({files:N0} files).";
|
||||
else if (!report.Passed)
|
||||
message = string.Join("; ", report.Failures.Select(check => check.Name + ": " + check.Detail));
|
||||
else
|
||||
message = string.Join("; ", report.Checks.Where(check => check.Status == PkgCheckStatus.Warning)
|
||||
.Select(check => check.Detail));
|
||||
if (string.IsNullOrWhiteSpace(message)) message = "The package structure is incomplete.";
|
||||
return new Ps5DebugPackageValidationResult
|
||||
{
|
||||
IsValid = valid,
|
||||
Message = message,
|
||||
IndexedFiles = files,
|
||||
PackageSize = new FileInfo(fullPath).Length
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new Ps5DebugPackageValidationResult { IsValid = false, Message = ex.Message, IndexedFiles = 0,
|
||||
PackageSize = File.Exists(packagePath) ? new FileInfo(packagePath).Length : 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -4,11 +4,11 @@ using Packwright.Core.Parsers;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
public sealed class SonyDebugPackageCredentials
|
||||
public sealed class Ps5DebugPackageCredentials
|
||||
{
|
||||
public const string DefaultPasscode = "00000000000000000000000000000000";
|
||||
|
||||
private SonyDebugPackageCredentials(string contentId, string passcode, byte[] imageKey)
|
||||
private Ps5DebugPackageCredentials(string contentId, string passcode, byte[] imageKey)
|
||||
{
|
||||
ContentId = contentId;
|
||||
Passcode = passcode;
|
||||
@@ -22,13 +22,13 @@ public sealed class SonyDebugPackageCredentials
|
||||
public string Fingerprint { get; }
|
||||
public bool UsesDefaultPasscode => Passcode == DefaultPasscode;
|
||||
|
||||
public static SonyDebugPackageCredentials Create(string contentId, string? passcode = null)
|
||||
public static Ps5DebugPackageCredentials Create(string contentId, string? passcode = null)
|
||||
{
|
||||
string id = (contentId ?? string.Empty).Trim().ToUpperInvariant();
|
||||
string secret = passcode ?? DefaultPasscode;
|
||||
ValidateContentId(id);
|
||||
ValidatePasscode(secret);
|
||||
return new SonyDebugPackageCredentials(id, secret, SonyPfsCrypto.DeriveEkpfs(id, secret));
|
||||
return new Ps5DebugPackageCredentials(id, secret, Ps5PfsCrypto.DeriveEkpfs(id, secret));
|
||||
}
|
||||
|
||||
public static void ValidatePasscode(string passcode)
|
||||
@@ -44,6 +44,6 @@ public sealed class SonyDebugPackageCredentials
|
||||
{
|
||||
if (contentId.Length != 36 || contentId[6] != '-' || contentId[16] != '_' || contentId[19] != '-' ||
|
||||
contentId.Any(character => !(character is >= 'A' and <= 'Z' or >= '0' and <= '9' or '-' or '_')))
|
||||
throw new ArgumentException("Content ID must use the 36-character Sony package format.", nameof(contentId));
|
||||
throw new ArgumentException("Content ID must use the 36-character PS5 package format.", nameof(contentId));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using ProsperoPkgTool.Containers;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
/// <summary>Inner-image compression choice for a package build.</summary>
|
||||
@@ -14,13 +12,3 @@ public enum Ps5InnerCompression
|
||||
/// <summary>Kraken where it helps, stored otherwise (engine default).</summary>
|
||||
Auto
|
||||
}
|
||||
|
||||
internal static class Ps5InnerCompressionExtensions
|
||||
{
|
||||
public static ProsperoInnerCompressionMode ToEngine(this Ps5InnerCompression value) => value switch
|
||||
{
|
||||
Ps5InnerCompression.Stored => ProsperoInnerCompressionMode.Stored,
|
||||
Ps5InnerCompression.Kraken => ProsperoInnerCompressionMode.Kraken,
|
||||
_ => ProsperoInnerCompressionMode.Auto
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
using Packwright.Core.Parsers;
|
||||
using Packwright.Core.Services;
|
||||
using ProsperoPkgTool.Containers;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
public sealed class SonyDebugPackageBuildOptions
|
||||
{
|
||||
public required string ContentId { get; init; }
|
||||
public string Passcode { get; init; } = SonyDebugPackageCredentials.DefaultPasscode;
|
||||
|
||||
/// <summary>Optional 16-byte outer-PFS seed for a byte-reproducible package.</summary>
|
||||
public byte[]? Seed { get; init; }
|
||||
|
||||
/// <summary>Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
|
||||
/// Null preserves the source metadata.</summary>
|
||||
public ulong? SdkVersionOverride { get; init; }
|
||||
|
||||
/// <summary>Optional workspace folder for the multi-GiB file-backed path. Null uses the system temp folder.</summary>
|
||||
public string? TempDirectory { get; init; }
|
||||
|
||||
/// <summary>Optional sink for engine log lines (free-space warnings, stale workspace sweep).</summary>
|
||||
public Action<string>? Log { get; init; }
|
||||
|
||||
/// <summary>Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).</summary>
|
||||
public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
|
||||
|
||||
/// <summary>Kraken level recorded in the compressed header (Oodle naming, -4..9). Header-only here.</summary>
|
||||
public int KrakenLevel { get; init; } = 7;
|
||||
|
||||
/// <summary>Blocks encoded concurrently; 0 selects the processor count.</summary>
|
||||
public int KrakenThreads { get; init; }
|
||||
|
||||
/// <summary>PlayGo chunk count for the generated project; 1 matches the debug/nwonly profile.</summary>
|
||||
public int PlayGoChunkCount { get; init; } = 1;
|
||||
|
||||
/// <summary>Optional DRM token to force in param.json. Null preserves the source token;
|
||||
/// "standard" is the opt-in override surfaced by the Advanced build setting.</summary>
|
||||
public string? DrmTypeOverride { get; init; }
|
||||
|
||||
/// <summary>Convert raw ELF modules to debug fake-SELF containers. Default true.</summary>
|
||||
public bool FakeSignModules { get; init; } = true;
|
||||
|
||||
/// <summary>Inject the built-in debug sce_sys/about/right.sprx when the source lacks one. Default true.</summary>
|
||||
public bool InjectRightSprx { get; init; } = true;
|
||||
}
|
||||
|
||||
public readonly record struct SonyDebugPackageProgress(string Stage, long CompletedBytes,
|
||||
long TotalBytes, string CurrentPath);
|
||||
|
||||
public sealed class SonyDebugPackageBuildResult
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public required string ContentId { get; init; }
|
||||
public required string KeyFingerprint { get; init; }
|
||||
public required long PackageSize { get; init; }
|
||||
public required long SourceBytes { get; init; }
|
||||
public required int SourceFiles { get; init; }
|
||||
public required bool UsesDefaultPasscode { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SonyDebugPackageValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public required int IndexedFiles { get; init; }
|
||||
public required long PackageSize { get; init; }
|
||||
}
|
||||
|
||||
public static class SonyDebugPackageBuilder
|
||||
{
|
||||
public static async Task<SonyDebugPackageBuildResult> CreateFromDirectoryAsync(string sourceDirectory,
|
||||
string outputPath, SonyDebugPackageBuildOptions options,
|
||||
IProgress<SonyDebugPackageProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return await ProsperoDebugPackageBuilder.CreateFromDirectoryAsync(sourceDirectory, outputPath,
|
||||
new ProsperoDebugPackageBuildOptions
|
||||
{
|
||||
ContentId = options.ContentId,
|
||||
Passcode = options.Passcode,
|
||||
Seed = options.Seed,
|
||||
SdkVersionOverride = options.SdkVersionOverride,
|
||||
TempDirectory = options.TempDirectory,
|
||||
Log = options.Log,
|
||||
Compression = options.Compression,
|
||||
KrakenLevel = options.KrakenLevel,
|
||||
KrakenThreads = options.KrakenThreads,
|
||||
PlayGoChunkCount = options.PlayGoChunkCount,
|
||||
DrmTypeOverride = options.DrmTypeOverride,
|
||||
FakeSignModules = options.FakeSignModules,
|
||||
InjectRightSprx = options.InjectRightSprx
|
||||
},
|
||||
progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static SonyDebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fullPath = Path.GetFullPath(packagePath);
|
||||
ProsperoPackageInspection inspection = ProsperoPackageReader.Read(fullPath);
|
||||
ProsperoPackageVerificationReport report = ProsperoPackageVerifier.Verify(fullPath, passcode);
|
||||
using var access = SonyEnginePackageAccess.Open(fullPath, passcode);
|
||||
bool isDebug = inspection.Kind is ProsperoPackageKind.FinalizedDebug or ProsperoPackageKind.FinalizedPatchDebug;
|
||||
bool decodes = access.DecodeError is null && access.Files.Any();
|
||||
bool valid = isDebug && !report.HasFailure && decodes;
|
||||
string message = valid
|
||||
? "The engine verified FIH/CNT structure, digests, outer PFS ICV, NAPS, reconstruction and inner PFS integrity."
|
||||
: string.Join("; ", report.Checks.Where(check => check.State == VerificationState.Fail)
|
||||
.Select(check => check.Name + ": " + check.Detail));
|
||||
if (!decodes && string.IsNullOrWhiteSpace(message))
|
||||
message = access.DecodeError is { } decodeError
|
||||
? (access.DecodeUnsupported
|
||||
? $"This package uses a format this build does not support yet: {decodeError}"
|
||||
: decodeError)
|
||||
: "The package could not be decoded with the supplied passcode.";
|
||||
if (string.IsNullOrWhiteSpace(message)) message = "The package structure is incomplete.";
|
||||
return new SonyDebugPackageValidationResult
|
||||
{
|
||||
IsValid = valid,
|
||||
Message = message,
|
||||
IndexedFiles = access.Files.Count(),
|
||||
PackageSize = new FileInfo(fullPath).Length
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SonyDebugPackageValidationResult { IsValid = false, Message = ex.Message, IndexedFiles = 0,
|
||||
PackageSize = File.Exists(packagePath) ? new FileInfo(packagePath).Length : 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
/// <summary>Decides which files of a source folder or image are build artefacts rather than part of the title.</summary>
|
||||
public static class SourceFolderValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// True for files a package must not carry: publishing project descriptors (<c>.gp4</c>, <c>.gp5</c>) and the
|
||||
/// <c>sce_suppl</c> folder, which a package build generates itself. Everything else is packed as it is.
|
||||
/// </summary>
|
||||
public static bool IsPublishingArtifact(string relativePath)
|
||||
{
|
||||
string path = relativePath.Replace('\\', '/').TrimStart('/');
|
||||
if (path.StartsWith("sce_suppl/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
string extension = Path.GetExtension(path);
|
||||
return extension.Equals(".gp4", StringComparison.OrdinalIgnoreCase) || extension.Equals(".gp5", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
using Packwright.Containers;
|
||||
using ProsperoPkgTool;
|
||||
using ProsperoPkgTool.Content;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using ProsperoPkgTool.Gp5;
|
||||
using Packwright.Pkg.Writer;
|
||||
using UFS2Tool;
|
||||
using EngineBuildResult = ProsperoPkgTool.Containers.DebugPackageBuildResult;
|
||||
using EngineBuilder = ProsperoPkgTool.Containers.ProsperoDebugPackageBuilder;
|
||||
using EngineInnerFile = ProsperoPkgTool.Containers.ProsperoInnerImageAssembler.InnerFile;
|
||||
|
||||
namespace Packwright.Core.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PS5 debug package directly from a PS5 filesystem image (exFAT, UFS2/FFPKG or FFPFSC)
|
||||
/// without extracting it first. Each inner file is streamed to the engine through
|
||||
/// <see cref="IProsperoFileSource"/>, so only the small <c>sce_sys</c> metadata set and the
|
||||
/// fake-sign/UCP-repair targets are materialized.
|
||||
/// Builds a PS5 debug package directly from a PS5 filesystem image (exFAT, UFS2/FFPKG or FFPFSC) without extracting
|
||||
/// it first. Each file is streamed from the image into the package as it is written.
|
||||
/// </summary>
|
||||
public static class VolumeDebugPackageBuilder
|
||||
{
|
||||
public static Task<SonyDebugPackageBuildResult> CreateFromImageAsync(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
public static Task<Ps5DebugPackageBuildResult> CreateFromImageAsync(string imagePath, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
@@ -29,127 +21,41 @@ public static class VolumeDebugPackageBuilder
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static SonyDebugPackageBuildResult CreateFromImage(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress,
|
||||
private static Ps5DebugPackageBuildResult CreateFromImage(string imagePath, string outputPath,
|
||||
Ps5DebugPackageBuildOptions options, IProgress<Ps5DebugPackageProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string source = Path.GetFullPath(imagePath);
|
||||
string destination = Path.GetFullPath(outputPath);
|
||||
if (!File.Exists(source)) throw new FileNotFoundException("The source image was not found.", source);
|
||||
|
||||
SonyDebugPackageCredentials credentials = SonyDebugPackageCredentials.Create(options.ContentId, options.Passcode);
|
||||
progress?.Report(new SonyDebugPackageProgress("Detecting image format", 0, 0, Path.GetFileName(source)));
|
||||
progress?.Report(new Ps5DebugPackageProgress("Detecting image format", 0, 0, Path.GetFileName(source)));
|
||||
|
||||
using IDisposable volume = OpenVolume(source, out IReadOnlyList<VolumeFile> entries,
|
||||
out Func<string, Stream> open, out string formatName);
|
||||
string readStage = $"Reading {formatName} filesystem";
|
||||
progress?.Report(new Ps5DebugPackageProgress(readStage, 0, entries.Count, Path.GetFileName(source)));
|
||||
|
||||
int totalEntries = entries.Count;
|
||||
progress?.Report(new SonyDebugPackageProgress(readStage, 0, totalEntries,
|
||||
Path.GetFileName(source)));
|
||||
|
||||
var files = new List<EngineInnerFile>();
|
||||
var sceSys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = new List<PkgSourceFile>();
|
||||
var directories = new List<string>();
|
||||
long sourceBytes = 0;
|
||||
int sourceFiles = 0;
|
||||
int processedEntries = 0;
|
||||
|
||||
int processed = 0;
|
||||
foreach (VolumeFile entry in entries.OrderBy(item => item.Path, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
progress?.Report(new SonyDebugPackageProgress(readStage, ++processedEntries,
|
||||
totalEntries, entry.Path));
|
||||
if (entry.IsDirectory || entry.IsSymlink) continue;
|
||||
sourceBytes += entry.Size;
|
||||
sourceFiles++;
|
||||
|
||||
progress?.Report(new Ps5DebugPackageProgress(readStage, ++processed, entries.Count, entry.Path));
|
||||
if (entry.IsDirectory) { directories.Add(entry.Path); continue; }
|
||||
if (entry.IsSymlink) continue;
|
||||
string relative = entry.Path;
|
||||
bool publishing = SourceFolderValidator.IsPublishingArtifact(relative) ||
|
||||
Path.GetExtension(relative).Equals(".gp4", StringComparison.OrdinalIgnoreCase);
|
||||
bool underSceSys = relative.StartsWith("sce_sys/", StringComparison.OrdinalIgnoreCase);
|
||||
string sceRelative = underSceSys ? relative["sce_sys/".Length..] : string.Empty;
|
||||
|
||||
if (underSceSys)
|
||||
{
|
||||
byte[] data = ReadEntry(open, relative, entry.Size);
|
||||
sceSys[sceRelative] = ProsperoUcpArchive.IsRepairableSceSysPath(sceRelative)
|
||||
? ProsperoUcpArchive.RepairIfNeeded(data)
|
||||
: data;
|
||||
}
|
||||
|
||||
if (publishing) continue;
|
||||
if (underSceSys && ProsperoSceSysMedia.IsOuterCntFile(sceRelative)) continue;
|
||||
|
||||
if (underSceSys && ProsperoUcpArchive.IsRepairableSceSysPath(sceRelative))
|
||||
{
|
||||
files.Add(new EngineInnerFile
|
||||
{
|
||||
Path = "/" + relative,
|
||||
Data = sceSys[sceRelative],
|
||||
PlayGoChunkId = 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(new EngineInnerFile
|
||||
{
|
||||
Path = "/" + relative,
|
||||
Source = new VolumeFileSource(entry.Size, () => open(relative)),
|
||||
PlayGoChunkId = 0
|
||||
});
|
||||
if (SourceFolderValidator.IsPublishingArtifact(relative)) continue;
|
||||
sourceBytes += entry.Size;
|
||||
files.Add(new PkgSourceFile(relative, entry.Size, () => open(relative)));
|
||||
}
|
||||
|
||||
if (!sceSys.TryGetValue("param.json", out byte[]? paramJson) || paramJson.Length == 0)
|
||||
throw new InvalidDataException(
|
||||
"The image does not contain sce_sys/param.json, which is required to build a package.");
|
||||
if (files.Count == 0)
|
||||
throw new InvalidDataException("The image does not contain any package files.");
|
||||
|
||||
var log = new DebugPackageBuildLog
|
||||
{
|
||||
Log = options.Log ?? (_ => { }),
|
||||
Progress = new RelayProgress<ProsperoBuildProgress>(value =>
|
||||
progress?.Report(new SonyDebugPackageProgress(
|
||||
value.StageId is { } stage ? ProsperoBuildStages.Name(stage) : value.Stage,
|
||||
value.BytesTotal > 0 ? value.BytesDone : value.Done,
|
||||
value.BytesTotal > 0 ? value.BytesTotal : value.Total,
|
||||
value.CurrentPath ?? string.Empty)))
|
||||
};
|
||||
|
||||
EngineBuildResult result = EngineBuilder.Build(files, new DebugPackageBuildOptions
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
Passcode = credentials.Passcode,
|
||||
ParamJson = paramJson,
|
||||
Compression = options.Compression.ToEngine(),
|
||||
KrakenLevel = options.KrakenLevel,
|
||||
KrakenThreads = options.KrakenThreads,
|
||||
PlayGoChunkCount = options.PlayGoChunkCount,
|
||||
DrmTypeOverride = options.DrmTypeOverride,
|
||||
OuterSeed = options.Seed,
|
||||
DeterministicEntryKeys = options.Seed is { Length: 16 },
|
||||
SdkVersionOverride = options.SdkVersionOverride,
|
||||
TempDirectory = options.TempDirectory,
|
||||
SceSysFiles = sceSys,
|
||||
FakeSignModules = options.FakeSignModules,
|
||||
InjectRightSprx = options.InjectRightSprx,
|
||||
Log = log
|
||||
}, cancellationToken);
|
||||
|
||||
return new SonyDebugPackageBuildResult
|
||||
{
|
||||
OutputPath = destination,
|
||||
ContentId = credentials.ContentId,
|
||||
KeyFingerprint = credentials.Fingerprint,
|
||||
PackageSize = new FileInfo(destination).Length,
|
||||
SourceBytes = sourceBytes,
|
||||
SourceFiles = sourceFiles,
|
||||
UsesDefaultPasscode = credentials.UsesDefaultPasscode
|
||||
};
|
||||
if (!files.Any(file => file.Path.Equals("sce_sys/param.json", StringComparison.Ordinal)))
|
||||
throw new InvalidDataException("The image does not contain sce_sys/param.json, which is required to build a package.");
|
||||
if (files.Count == 0) throw new InvalidDataException("The image does not contain any package files.");
|
||||
return PackwrightEngine.Run(files, directories, outputPath, options, progress, sourceBytes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums the uncompressed payload bytes of an image (exFAT / UFS2-FFPKG / FFPFSC) without
|
||||
/// extracting or building it. Used to preflight the disk space an extract-then-build needs.
|
||||
@@ -218,6 +124,35 @@ public static class VolumeDebugPackageBuilder
|
||||
open = volume.OpenFile;
|
||||
return volume;
|
||||
}
|
||||
case Ps5ImageFormat.ZArchive:
|
||||
{
|
||||
formatName = "ZArchive";
|
||||
stream.Dispose();
|
||||
var volume = new ZArchiveVolume(imagePath);
|
||||
try
|
||||
{
|
||||
const string paramSuffix = "sce_sys/param.json";
|
||||
ZArchiveEntry[] roots = volume.Entries
|
||||
.Where(item => !item.IsDirectory && (item.Path.Equals(paramSuffix, StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Path.EndsWith("/" + paramSuffix, StringComparison.OrdinalIgnoreCase))).ToArray();
|
||||
if (roots.Length == 0) throw new InvalidDataException("The ZArchive has no sce_sys/param.json.");
|
||||
if (roots.Length > 1) throw new InvalidDataException("The ZArchive contains multiple PS5 game roots.");
|
||||
string root = roots[0].Path.Length == paramSuffix.Length ? string.Empty : roots[0].Path[..^(paramSuffix.Length + 1)];
|
||||
string prefix = root.Length == 0 ? string.Empty : root + "/";
|
||||
entries = volume.Entries
|
||||
.Where(item => prefix.Length == 0 || item.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => new VolumeFile(Normalize(item.Path[prefix.Length..]), item.IsDirectory, false, item.Size))
|
||||
.Where(item => item.Path.Length > 0)
|
||||
.ToArray();
|
||||
open = relative => volume.OpenFile(prefix + relative);
|
||||
return volume;
|
||||
}
|
||||
catch
|
||||
{
|
||||
volume.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new InvalidDataException($"Unrecognised PS5 image format: {imagePath}");
|
||||
}
|
||||
@@ -229,28 +164,7 @@ public static class VolumeDebugPackageBuilder
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadEntry(Func<string, Stream> open, string path, long size)
|
||||
{
|
||||
if (size < 0 || size > int.MaxValue)
|
||||
throw new IOException($"The image file is too large to load into memory: {path}");
|
||||
using Stream stream = open(path);
|
||||
byte[] data = new byte[checked((int)size)];
|
||||
stream.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
private sealed record VolumeFile(string Path, bool IsDirectory, bool IsSymlink, long Size);
|
||||
|
||||
private sealed class VolumeFileSource(long length, Func<Stream> open) : IProsperoFileSource
|
||||
{
|
||||
public long Length => length;
|
||||
public Stream Open() => open();
|
||||
}
|
||||
|
||||
private sealed class RelayProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace Packwright.Core.Models;
|
||||
public enum Ps5SourceKind
|
||||
{
|
||||
LooseDump,
|
||||
SonyPackage,
|
||||
Ps5Package,
|
||||
Ffpfsc,
|
||||
FilesystemImage,
|
||||
Ffpkg,
|
||||
@@ -60,7 +60,7 @@ public sealed class Ps5GameInfo
|
||||
public string RawParamJson { get; set; } = string.Empty;
|
||||
public List<string> DataWarnings { get; set; } = [];
|
||||
public DateTime LastWriteTimeUtc { get; set; }
|
||||
public SonyPkgSummary? Package { get; set; }
|
||||
public Ps5PkgSummary? Package { get; set; }
|
||||
|
||||
public string DisplayVersion => string.IsNullOrWhiteSpace(ContentVersion) ? MasterVersion : ContentVersion;
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class Ps5GameInfo
|
||||
|
||||
public string SourceDescription => SourceKind switch
|
||||
{
|
||||
Ps5SourceKind.SonyPackage => Package is { Kind: SonyPkgKind.FinalizedDebug }
|
||||
Ps5SourceKind.Ps5Package => Package is { Kind: Ps5PkgKind.FinalizedDebug }
|
||||
? "Debug PKG"
|
||||
: Package?.KindDisplayName ?? "PKG",
|
||||
Ps5SourceKind.Ffpfsc => "FFPFSC",
|
||||
|
||||
+22
-22
@@ -2,7 +2,7 @@ using Packwright.Core.Parsers;
|
||||
|
||||
namespace Packwright.Core.Models;
|
||||
|
||||
public enum SonyPkgKind
|
||||
public enum Ps5PkgKind
|
||||
{
|
||||
MetadataContainer,
|
||||
FinalizedDebug,
|
||||
@@ -10,9 +10,9 @@ public enum SonyPkgKind
|
||||
FinalizedRetail
|
||||
}
|
||||
|
||||
public sealed class SonyPkgSummary
|
||||
public sealed class Ps5PkgSummary
|
||||
{
|
||||
public SonyPkgKind Kind { get; init; }
|
||||
public Ps5PkgKind Kind { get; init; }
|
||||
public long FileSize { get; init; }
|
||||
public byte? SignedByte { get; init; }
|
||||
public ushort? FormatVersion { get; init; }
|
||||
@@ -28,22 +28,22 @@ public sealed class SonyPkgSummary
|
||||
public uint DrmType { get; init; }
|
||||
public uint ContentType { get; init; }
|
||||
public uint ContentFlags { get; init; }
|
||||
public IReadOnlyList<SonyPkgEntry> Entries { get; init; } = [];
|
||||
public IReadOnlyList<SonyPkgSegment> Segments { get; init; } = [];
|
||||
public SonyPfsSummary? NestedPfs { get; init; }
|
||||
public IReadOnlyList<Ps5PkgEntry> Entries { get; init; } = [];
|
||||
public IReadOnlyList<Ps5PkgSegment> Segments { get; init; } = [];
|
||||
public Ps5PfsSummary? NestedPfs { get; init; }
|
||||
|
||||
public int EncryptedEntryCount => Entries.Count(entry => entry.IsEncrypted);
|
||||
public string KindDisplayName => Kind switch
|
||||
{
|
||||
SonyPkgKind.MetadataContainer => "PS5 CNT metadata",
|
||||
SonyPkgKind.FinalizedDebug => "FPKG",
|
||||
SonyPkgKind.FinalizedPatch => "Patch PKG",
|
||||
SonyPkgKind.FinalizedRetail => "Retail PKG",
|
||||
Ps5PkgKind.MetadataContainer => "PS5 CNT metadata",
|
||||
Ps5PkgKind.FinalizedDebug => "FPKG",
|
||||
Ps5PkgKind.FinalizedPatch => "Patch PKG",
|
||||
Ps5PkgKind.FinalizedRetail => "Retail PKG",
|
||||
_ => "PS5 PKG"
|
||||
};
|
||||
}
|
||||
|
||||
public enum SonyPfsAccessState
|
||||
public enum Ps5PfsAccessState
|
||||
{
|
||||
NotPresent,
|
||||
PlaintextIndexed,
|
||||
@@ -52,9 +52,9 @@ public enum SonyPfsAccessState
|
||||
Invalid
|
||||
}
|
||||
|
||||
public sealed class SonyPfsSummary
|
||||
public sealed class Ps5PfsSummary
|
||||
{
|
||||
public SonyPfsAccessState AccessState { get; init; }
|
||||
public Ps5PfsAccessState AccessState { get; init; }
|
||||
public long ImageOffset { get; init; }
|
||||
public long ImageSize { get; init; }
|
||||
public long Version { get; init; }
|
||||
@@ -67,27 +67,27 @@ public sealed class SonyPfsSummary
|
||||
public bool Uses64BitInodes => (Mode & 0x0002) != 0;
|
||||
public bool IsEncrypted => (Mode & 0x0004) != 0;
|
||||
public string StatusMessage { get; init; } = string.Empty;
|
||||
public IReadOnlyList<SonyPfsEntry> Files { get; init; } = [];
|
||||
internal SonyPfsCryptoContext? CryptoContext { get; init; }
|
||||
internal SonyEnginePackageAccess? EngineAccess { get; init; }
|
||||
public IReadOnlyList<Ps5PfsEntry> Files { get; init; } = [];
|
||||
internal Ps5PfsCryptoContext? CryptoContext { get; init; }
|
||||
internal Ps5EnginePackageAccess? EngineAccess { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SonyPfsEntry
|
||||
public sealed class Ps5PfsEntry
|
||||
{
|
||||
public string RelativePath { get; init; } = string.Empty;
|
||||
public long Size { get; init; }
|
||||
public long StoredSize { get; init; }
|
||||
public uint Flags { get; init; }
|
||||
public IReadOnlyList<SonyPfsExtent> Extents { get; init; } = [];
|
||||
public IReadOnlyList<Ps5PfsExtent> Extents { get; init; } = [];
|
||||
public bool IsCompressed => (Flags & 0x00000001) != 0;
|
||||
public bool UsesPlainDataSector { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct SonyPfsExtent(long Offset, long Length);
|
||||
public readonly record struct Ps5PfsExtent(long Offset, long Length);
|
||||
|
||||
internal sealed record SonyPfsCryptoContext(byte[] DataKey, byte[] TweakKey, int BlockSize);
|
||||
internal sealed record Ps5PfsCryptoContext(byte[] DataKey, byte[] TweakKey, int BlockSize);
|
||||
|
||||
public sealed class SonyPkgEntry
|
||||
public sealed class Ps5PkgEntry
|
||||
{
|
||||
public uint Id { get; init; }
|
||||
public uint NameTableOffset { get; init; }
|
||||
@@ -105,4 +105,4 @@ public sealed class SonyPkgEntry
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? $"entry_0x{Id:X4}.bin" : Name;
|
||||
}
|
||||
|
||||
public sealed record SonyPkgSegment(string Name, long Offset, long Size);
|
||||
public sealed record Ps5PkgSegment(string Name, long Offset, long Size);
|
||||
@@ -11,11 +11,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Packwright.Containers\Packwright.Containers.csproj" />
|
||||
<ProjectReference Include="..\Packwright.Ufs2\Packwright.Ufs2.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="ProsperoPkgTool">
|
||||
<HintPath>..\..\third-party\ProsperoPkgTool\ProsperoPkgTool.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
<ProjectReference Include="..\Packwright.Pkg\Packwright.Pkg.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,311 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Pkg.Reader;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the Packwright package reader (<c>Packwright.Pkg</c>) to the package model the rest of the app
|
||||
/// uses. Opening a package parses only its header and metadata; the inner filesystem is decoded on first use
|
||||
/// and only the blocks that are read are expanded, so scanning a library stays cheap.
|
||||
/// </summary>
|
||||
internal sealed class Ps5EnginePackageAccess : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly string _passcode;
|
||||
private readonly IReadOnlyList<PkgEntry> _entries;
|
||||
private PkgPackage? _package;
|
||||
private IReadOnlyList<PkgFile>? _files;
|
||||
private string? _decodeError;
|
||||
private bool _decodeUnsupported;
|
||||
private bool _decodeAttempted;
|
||||
private bool _disposed;
|
||||
private int _leases;
|
||||
private bool _releasePending;
|
||||
|
||||
private Ps5EnginePackageAccess(PkgPackage package, string? passcode)
|
||||
{
|
||||
PackagePath = package.Path;
|
||||
_package = package;
|
||||
_passcode = string.IsNullOrEmpty(passcode) ? Ps5DebugPackageCredentials.DefaultPasscode : passcode;
|
||||
_entries = package.Entries;
|
||||
HasInnerImage = !package.IsMetadataOnly && package.PfsSize > 0;
|
||||
IsDebug = !package.IsRetail;
|
||||
PfsOffset = package.PfsOffset;
|
||||
PfsSize = package.PfsSize;
|
||||
}
|
||||
|
||||
public string PackagePath { get; }
|
||||
public bool HasInnerImage { get; }
|
||||
public bool IsDebug { get; }
|
||||
public long PfsOffset { get; }
|
||||
public long PfsSize { get; }
|
||||
|
||||
/// <summary>Opens a package for reading. Throws <see cref="InvalidDataException"/> when it is not a PS5 package.</summary>
|
||||
public static Ps5EnginePackageAccess Open(string packagePath, string? passcode) =>
|
||||
new(PkgPackage.Open(Path.GetFullPath(packagePath), passcode), passcode);
|
||||
|
||||
/// <summary>Wraps a package that was already opened (so a scan does not parse it twice).</summary>
|
||||
internal static Ps5EnginePackageAccess FromPackage(PkgPackage package, string? passcode) => new(package, passcode);
|
||||
|
||||
/// <summary>The files of the inner filesystem. Decoding happens on first use.</summary>
|
||||
public IEnumerable<PkgFile> Files
|
||||
{
|
||||
get { EnsureDecoded(); return _files!; }
|
||||
}
|
||||
|
||||
public string? DecodeError
|
||||
{
|
||||
get { EnsureDecoded(); return _decodeError; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="DecodeError"/> is an unsupported format or layout rather than corruption, so the
|
||||
/// UI can report the real cause instead of blaming the passcode.
|
||||
/// </summary>
|
||||
public bool DecodeUnsupported
|
||||
{
|
||||
get { EnsureDecoded(); return _decodeUnsupported; }
|
||||
}
|
||||
|
||||
private void EnsureDecoded()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_decodeAttempted) return;
|
||||
_decodeAttempted = true;
|
||||
// A package with no inner image, or a retail one (encrypted with a key we do not have), simply
|
||||
// has no browsable inner files; that is not an error.
|
||||
if (!HasInnerImage || !IsDebug) { _files = []; return; }
|
||||
try
|
||||
{
|
||||
_files = Acquire().Files;
|
||||
}
|
||||
catch (PkgNotSupportedException ex)
|
||||
{
|
||||
_files = [];
|
||||
_decodeError = ex.Message;
|
||||
_decodeUnsupported = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or EndOfStreamException
|
||||
or ArgumentException or OverflowException or InvalidOperationException)
|
||||
{
|
||||
_files = [];
|
||||
_decodeError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The package handle is opened on demand so it can be released while the app is idle.
|
||||
private PkgPackage Acquire()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _package ??= PkgPackage.Open(PackagePath, _passcode);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- metadata entries ----------------------------------------------------------------------------
|
||||
|
||||
public byte[] ReadCntEntry(uint id, long? length = null)
|
||||
{
|
||||
PkgEntry entry = _entries.FirstOrDefault(candidate => candidate.Id == id)
|
||||
?? throw new InvalidDataException($"CNT entry 0x{id:X4} is not present.");
|
||||
byte[] data;
|
||||
try { data = Acquire().ReadEntry(entry, _passcode); }
|
||||
catch (PkgNotSupportedException ex) { throw new InvalidDataException(ex.Message, ex); }
|
||||
return length is { } limit && limit >= 0 && limit < data.Length ? data[..(int)limit] : data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata entries exposed under the paths they have inside the inner image, so the metadata, artwork,
|
||||
/// trophy and activity readers work even when the inner image cannot be decoded.
|
||||
/// </summary>
|
||||
private static readonly (uint Id, string Path)[] ContentEntries =
|
||||
[
|
||||
(0x0402, "sce_sys/nptitle.dat"),
|
||||
(0x0403, "sce_sys/npbind.dat"),
|
||||
(0x040A, "sce_sys/imagedigs.dat"),
|
||||
(0x1000, "sce_sys/param.sfo"),
|
||||
(0x1001, "sce_sys/playgo-chunk.dat"),
|
||||
(0x1200, "sce_sys/icon0.png"),
|
||||
(0x1220, "sce_sys/pic0.png"),
|
||||
(0x1240, "sce_sys/snd0.at9"),
|
||||
(0x1280, "sce_sys/icon0.dds"),
|
||||
(0x12A0, "sce_sys/pic0.dds"),
|
||||
(0x12C0, "sce_sys/pic1.dds"),
|
||||
(0x1480, "sce_sys/trophy2/trophy00.ucp"),
|
||||
(0x14A0, "sce_sys/uds/uds00.ucp"),
|
||||
(0x2000, "sce_sys/param.json"),
|
||||
(0x2010, "sce_sys/playgo-hash-table.dat"),
|
||||
(0x2011, "sce_sys/playgo-ficm.dat"),
|
||||
(0x2020, "sce_sys/uds/npbind.dat"),
|
||||
(0x2021, "sce_sys/trophy2/npbind.dat"),
|
||||
(0x2060, "sce_sys/pic2.dds")
|
||||
];
|
||||
|
||||
public IEnumerable<(uint Id, string Path, long Size)> ContentFileEntries()
|
||||
{
|
||||
foreach ((uint id, string path) in ContentEntries)
|
||||
{
|
||||
PkgEntry? entry = _entries.FirstOrDefault(candidate => candidate.Id == id);
|
||||
if (entry is null) continue;
|
||||
yield return (id, path, entry.Size);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every metadata entry. Known entries are mapped to their inner-image paths; the rest keep their own
|
||||
/// name. Used so the file browser still shows what the package exposes when the inner image cannot be read.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint Id, string Path, long Size)> ListReadableEntries()
|
||||
{
|
||||
foreach (PkgEntry entry in _entries)
|
||||
{
|
||||
string? mapped = null;
|
||||
foreach ((uint id, string path) in ContentEntries)
|
||||
if (id == entry.Id) { mapped = path; break; }
|
||||
string name = mapped
|
||||
?? (!string.IsNullOrWhiteSpace(entry.Name) ? entry.Name : $"entry_0x{entry.Id:X4}.bin");
|
||||
yield return (entry.Id, name, entry.Size);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inner files ---------------------------------------------------------------------------------
|
||||
|
||||
public string ToRelativePath(PkgFile file) => Normalize(file.Path);
|
||||
|
||||
public bool TryFindFile(string relativePath, out PkgFile file)
|
||||
{
|
||||
string key = Normalize(relativePath);
|
||||
foreach (PkgFile candidate in Files)
|
||||
{
|
||||
if (string.Equals(Normalize(candidate.Path), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
file = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
file = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public byte[] ReadFile(PkgFile file)
|
||||
{
|
||||
if (file.Size is < 0 or > int.MaxValue)
|
||||
throw new InvalidDataException($"The inner file '{file.Path}' is too large to read into memory.");
|
||||
using Stream stream = OpenInnerFile(file);
|
||||
byte[] data = new byte[file.Size];
|
||||
stream.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
public byte[] ReadFile(string relativePath)
|
||||
{
|
||||
if (!TryFindFile(relativePath, out PkgFile file))
|
||||
throw new InvalidDataException($"The inner file '{relativePath}' is not present in the package.");
|
||||
return ReadFile(file);
|
||||
}
|
||||
|
||||
public byte[] ReadFileRange(PkgFile file, long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > file.Size || count > file.Size - offset)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
using Stream stream = OpenInnerFile(file);
|
||||
stream.Position = offset;
|
||||
byte[] result = new byte[count];
|
||||
stream.ReadExactly(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Opens an inner file; the package stays open until the stream is disposed.</summary>
|
||||
public Stream OpenInnerFile(PkgFile file)
|
||||
{
|
||||
PkgPackage package;
|
||||
lock (_gate)
|
||||
{
|
||||
package = Acquire();
|
||||
_leases++;
|
||||
}
|
||||
try { return new LeasedStream(package.OpenFile(file), ReleaseLease); }
|
||||
catch { ReleaseLease(); throw; }
|
||||
}
|
||||
|
||||
private void ReleaseLease()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (--_leases != 0 || !_releasePending) return;
|
||||
_releasePending = false;
|
||||
DisposePackage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the package file handle without discarding what has been decoded, so another process can
|
||||
/// replace or delete the package while this app is not reading it. It is reopened on the next read.
|
||||
/// </summary>
|
||||
public void ReleaseHandles()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_leases > 0) { _releasePending = true; return; }
|
||||
DisposePackage();
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposePackage()
|
||||
{
|
||||
PkgPackage? package = _package;
|
||||
_package = null;
|
||||
package?.Dispose();
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
string value = path.Replace('\\', '/').TrimStart('/');
|
||||
if (value.StartsWith("uroot/", StringComparison.OrdinalIgnoreCase)) value = value[6..];
|
||||
else if (string.Equals(value, "uroot", StringComparison.OrdinalIgnoreCase)) value = string.Empty;
|
||||
return value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
DisposePackage();
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~Ps5EnginePackageAccess() => Dispose();
|
||||
|
||||
/// <summary>Notifies its owner once, when disposed, so the shared package handle can be released.</summary>
|
||||
private sealed class LeasedStream(Stream inner, Action onDispose) : Stream
|
||||
{
|
||||
private bool _disposed;
|
||||
public override bool CanRead => inner.CanRead;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position { get => inner.Position; set => inner.Position = value; }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
inner.Dispose();
|
||||
onDispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -2,11 +2,11 @@ using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Packwright.Core.Models;
|
||||
using ProsperoPkgTool.Crypto;
|
||||
using Packwright.Pkg.Util;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
internal static class SonyPfsCrypto
|
||||
internal static class Ps5PfsCrypto
|
||||
{
|
||||
public const ulong SignedSectorFlag = 0x800000000000UL;
|
||||
|
||||
@@ -19,15 +19,14 @@ internal static class SonyPfsCrypto
|
||||
byte[] paddedContentId = new byte[48];
|
||||
Encoding.ASCII.GetBytes(contentId).CopyTo(paddedContentId, 0);
|
||||
byte[] input = new byte[96];
|
||||
// Use the engine's managed SHA3-256: the BCL SHA3_256 throws PlatformNotSupportedException on
|
||||
// Windows builds without CNG SHA-3 support, and this runs at the start of every package build.
|
||||
Sha3.Sha3_256(index).CopyTo(input, 0);
|
||||
Sha3.Sha3_256(paddedContentId).CopyTo(input, 32);
|
||||
// Sha3 falls back to a managed implementation on Windows builds without platform SHA-3.
|
||||
Sha3.Hash(index).CopyTo(input, 0);
|
||||
Sha3.Hash(paddedContentId).CopyTo(input, 32);
|
||||
Encoding.ASCII.GetBytes(passcode).CopyTo(input, 64);
|
||||
return Sha3.Sha3_256(input);
|
||||
return Sha3.Hash(input);
|
||||
}
|
||||
|
||||
public static SonyPfsCryptoContext DeriveContext(byte[] ekpfs, ReadOnlySpan<byte> seed,
|
||||
public static Ps5PfsCryptoContext DeriveContext(byte[] ekpfs, ReadOnlySpan<byte> seed,
|
||||
int blockSize, bool newCrypt)
|
||||
{
|
||||
byte[] baseKey = newCrypt ? HMACSHA256.HashData(ekpfs, seed) : ekpfs;
|
||||
@@ -35,18 +34,18 @@ internal static class SonyPfsCrypto
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(material, 1);
|
||||
seed.CopyTo(material.AsSpan(4));
|
||||
byte[] result = HMACSHA256.HashData(baseKey, material);
|
||||
return new SonyPfsCryptoContext(result[16..32], result[0..16], blockSize);
|
||||
return new Ps5PfsCryptoContext(result[16..32], result[0..16], blockSize);
|
||||
}
|
||||
|
||||
public static void DecryptBlock(Span<byte> block, SonyPfsCryptoContext context,
|
||||
public static void DecryptBlock(Span<byte> block, Ps5PfsCryptoContext context,
|
||||
ulong blockIndex, bool signedDomain)
|
||||
=> TransformBlock(block, context, blockIndex, signedDomain, encrypt: false);
|
||||
|
||||
public static void EncryptBlock(Span<byte> block, SonyPfsCryptoContext context,
|
||||
public static void EncryptBlock(Span<byte> block, Ps5PfsCryptoContext context,
|
||||
ulong blockIndex, bool signedDomain)
|
||||
=> TransformBlock(block, context, blockIndex, signedDomain, encrypt: true);
|
||||
|
||||
private static void TransformBlock(Span<byte> block, SonyPfsCryptoContext context,
|
||||
private static void TransformBlock(Span<byte> block, Ps5PfsCryptoContext context,
|
||||
ulong blockIndex, bool signedDomain, bool encrypt)
|
||||
{
|
||||
if ((block.Length & 15) != 0) throw new InvalidDataException("An AES XTS block must be 16-byte aligned.");
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
using Packwright.Core.Models;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using Packwright.Pkg.Reader;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
internal static class SonyPfsEntryDataReader
|
||||
internal static class Ps5PfsEntryDataReader
|
||||
{
|
||||
public static byte[] ReadAll(string packagePath, SonyPkgSummary package, SonyPfsSummary image,
|
||||
SonyPfsEntry entry, int maximumBytes)
|
||||
public static byte[] ReadAll(string packagePath, Ps5PkgSummary package, Ps5PfsSummary image,
|
||||
Ps5PfsEntry entry, int maximumBytes)
|
||||
{
|
||||
if (entry.Size < 0 || entry.Size > maximumBytes || entry.Size > Array.MaxLength)
|
||||
throw new InvalidDataException($"The PFS entry exceeds the {maximumBytes:N0} byte read limit.");
|
||||
@@ -16,14 +16,14 @@ internal static class SonyPfsEntryDataReader
|
||||
return ReadRange(packagePath, package, image, entry, 0, checked((int)entry.Size));
|
||||
}
|
||||
|
||||
public static byte[] ReadRange(string packagePath, SonyPkgSummary package, SonyPfsSummary image,
|
||||
SonyPfsEntry entry, long offset, int count)
|
||||
public static byte[] ReadRange(string packagePath, Ps5PkgSummary package, Ps5PfsSummary image,
|
||||
Ps5PfsEntry entry, long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > entry.Size || count > entry.Size - offset)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (image.EngineAccess is { } engine)
|
||||
{
|
||||
if (!engine.TryFindFile(entry.RelativePath, out ProsperoInnerPfsReader.Entry inner))
|
||||
if (!engine.TryFindFile(entry.RelativePath, out PkgFile inner))
|
||||
throw new InvalidDataException($"The PFS entry '{entry.RelativePath}' is not present in the reconstructed image.");
|
||||
return engine.ReadFileRange(inner, offset, count);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ internal static class SonyPfsEntryDataReader
|
||||
FileShare.ReadWrite | FileShare.Delete, 64 * 1024, FileOptions.RandomAccess);
|
||||
int written = 0;
|
||||
long skip = offset;
|
||||
foreach (SonyPfsExtent extent in entry.Extents)
|
||||
foreach (Ps5PfsExtent extent in entry.Extents)
|
||||
{
|
||||
if (skip >= extent.Length) { skip -= extent.Length; continue; }
|
||||
long extentOffset = checked(extent.Offset + skip);
|
||||
@@ -59,7 +59,7 @@ internal static class SonyPfsEntryDataReader
|
||||
byte[] block = new byte[image.CryptoContext.BlockSize];
|
||||
stream.Position = absolute;
|
||||
stream.ReadExactly(block);
|
||||
SonyPfsCrypto.DecryptBlock(block, image.CryptoContext, checked((ulong)relativeBlock),
|
||||
Ps5PfsCrypto.DecryptBlock(block, image.CryptoContext, checked((ulong)relativeBlock),
|
||||
!entry.UsesPlainDataSector);
|
||||
block.AsSpan(innerOffset, take).CopyTo(output.AsSpan(written + consumed));
|
||||
}
|
||||
+78
-75
@@ -1,90 +1,105 @@
|
||||
using System.Buffers.Binary;
|
||||
using Packwright.Core.Models;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using Packwright.Pkg.Reader;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the public CNT/FIH metadata layer of a Sony PS5 package. Parsing is delegated to the
|
||||
/// vendored, validated <c>ProsperoPkgTool</c> engine; this type only maps the engine inspection
|
||||
/// Reads the public CNT/FIH metadata layer of a PS5 package with the Packwright package reader and maps it
|
||||
/// onto Packwright's public package model.
|
||||
/// </summary>
|
||||
public sealed class SonyPkgReader
|
||||
public sealed class Ps5PkgReader
|
||||
{
|
||||
private const int BlockSize = 0x10000;
|
||||
private const int CntHeaderSize = 0x5A0;
|
||||
private const int FihHeaderProbeSize = 0x60;
|
||||
|
||||
private static ReadOnlySpan<byte> CntMagic => [0x7F, (byte)'C', (byte)'N', (byte)'T'];
|
||||
private static ReadOnlySpan<byte> LihMagic => [0x7F, (byte)'L', (byte)'I', (byte)'H'];
|
||||
private static ReadOnlySpan<byte> FihMagic => [0x7F, (byte)'F', (byte)'I', (byte)'H'];
|
||||
|
||||
public bool IsSonyPackage(string path)
|
||||
public bool IsPs5Package(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using FileStream stream = Open(path);
|
||||
Span<byte> magic = stackalloc byte[4];
|
||||
if (stream.Length < magic.Length || stream.Read(magic) != magic.Length) return false;
|
||||
return magic.SequenceEqual(CntMagic) || magic.SequenceEqual(FihMagic);
|
||||
return magic.SequenceEqual(CntMagic) || magic.SequenceEqual(FihMagic) || magic.SequenceEqual(LihMagic);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
|
||||
public SonyPkgSummary Read(string path) => Read(path, null);
|
||||
public Ps5PkgSummary Read(string path) => Read(path, null);
|
||||
|
||||
public SonyPkgSummary Read(string path, string? debugPasscode)
|
||||
public Ps5PkgSummary Read(string path, string? debugPasscode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
long fileSize = new FileInfo(fullPath).Length;
|
||||
if (fileSize < 4) throw new InvalidDataException("The file is too small to be a PS5 package.");
|
||||
|
||||
ProsperoPackageInspection inspection;
|
||||
PkgPackage package;
|
||||
try
|
||||
{
|
||||
inspection = ProsperoPackageReader.Read(fullPath);
|
||||
package = PkgPackage.Open(fullPath, debugPasscode);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
// Metadata-less finalized images (CntOffset at/after EOF, e.g. retail disc PKGs) are
|
||||
// rejected by the engine inspection; describe them from the FIH header alone.
|
||||
if (TryReadHeaderOnly(fullPath, fileSize, out SonyPkgSummary? headerOnly)) return headerOnly!;
|
||||
// Metadata-less finalized images (CntOffset at/after EOF, e.g. retail disc PKGs) cannot be opened as a
|
||||
// full package; describe them from the FIH header alone.
|
||||
if (TryReadHeaderOnly(fullPath, fileSize, out Ps5PkgSummary? headerOnly)) return headerOnly!;
|
||||
throw;
|
||||
}
|
||||
|
||||
SonyEnginePackageAccess access = SonyEnginePackageAccess.FromInspection(fullPath, inspection, debugPasscode);
|
||||
|
||||
return new SonyPkgSummary
|
||||
Ps5EnginePackageAccess access = Ps5EnginePackageAccess.FromPackage(package, debugPasscode);
|
||||
return new Ps5PkgSummary
|
||||
{
|
||||
Kind = MapKind(inspection.Kind),
|
||||
Kind = package.IsPatchImage ? Ps5PkgKind.FinalizedPatch
|
||||
: package.IsMetadataOnly ? Ps5PkgKind.MetadataContainer
|
||||
: package.IsRetail ? Ps5PkgKind.FinalizedRetail
|
||||
: IsPatch(package.ContentFlags) ? Ps5PkgKind.FinalizedPatch
|
||||
: Ps5PkgKind.FinalizedDebug,
|
||||
FileSize = fileSize,
|
||||
SignedByte = inspection.Fih?.SignedByte,
|
||||
FormatVersion = inspection.Fih?.FormatVersion,
|
||||
PfsImageOffset = inspection.Fih?.PfsOffset ?? 0,
|
||||
PfsImageSize = inspection.Fih?.PfsSize ?? 0,
|
||||
EmbeddedCntOffset = inspection.Fih?.CntOffset ?? 0,
|
||||
HeaderFlags = inspection.Cnt.Flags,
|
||||
SystemEntryCount = inspection.Cnt.ScEntryCount,
|
||||
BodyOffset = inspection.Cnt.BodyOffset,
|
||||
BodySize = inspection.Cnt.BodySize,
|
||||
ContentId = inspection.Cnt.ContentId ?? string.Empty,
|
||||
DrmType = inspection.Cnt.DrmType,
|
||||
ContentType = inspection.Cnt.ContentType,
|
||||
ContentFlags = inspection.Cnt.ContentFlags,
|
||||
Entries = inspection.Entries.Select(MapEntry).ToArray(),
|
||||
Segments = inspection.Segments.Select(segment => new SonyPkgSegment(segment.Name, segment.Offset, segment.Size)).ToArray(),
|
||||
NestedPfs = BuildNestedPfs(access)
|
||||
SignedByte = package.IsMetadataOnly && !package.IsPatchImage ? null : package.SignedByte,
|
||||
FormatVersion = package.IsMetadataOnly && !package.IsPatchImage ? null : package.FormatVersion,
|
||||
PfsImageOffset = (ulong)package.PfsOffset,
|
||||
PfsImageSize = (ulong)package.PfsSize,
|
||||
PfsSuperblockOffset = (ulong)package.SuperblockOffset,
|
||||
EmbeddedCntOffset = (ulong)package.CntOffset,
|
||||
HeaderFlags = package.HeaderFlags,
|
||||
SystemEntryCount = package.ScEntryCount,
|
||||
BodyOffset = package.BodyOffset,
|
||||
BodySize = package.BodySize,
|
||||
ContentId = package.ContentId,
|
||||
DrmType = package.DrmType,
|
||||
ContentType = package.ContentType,
|
||||
ContentFlags = package.ContentFlags,
|
||||
Entries = package.Entries.Select(MapEntry).ToArray(),
|
||||
Segments = package.IsMetadataOnly && !package.IsPatchImage
|
||||
? []
|
||||
: package.Segments.Select(s => new Ps5PkgSegment(s.Name, s.Offset, s.Size)).ToArray(),
|
||||
NestedPfs = package.IsPatchImage
|
||||
? new Ps5PfsSummary
|
||||
{
|
||||
AccessState = Ps5PfsAccessState.UnsupportedLayout,
|
||||
StatusMessage = "This package type is not supported by the file reader yet."
|
||||
}
|
||||
: BuildNestedPfs(access)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Returns the entry count the engine indexed inside the reconstructed inner image.</summary>
|
||||
// Content flags that mark a patch (first, patch-go and subsequent patches).
|
||||
private static bool IsPatch(uint contentFlags) => (contentFlags & (0x00100000u | 0x00200000u | 0x40000000u)) != 0;
|
||||
|
||||
/// <summary>Returns the number of files in the package's inner filesystem.</summary>
|
||||
public int CountIndexedFiles(string path, string? debugPasscode = null)
|
||||
{
|
||||
using SonyEnginePackageAccess access = SonyEnginePackageAccess.Open(Path.GetFullPath(path), debugPasscode);
|
||||
using Ps5EnginePackageAccess access = Ps5EnginePackageAccess.Open(Path.GetFullPath(path), debugPasscode);
|
||||
return access.Files.Count();
|
||||
}
|
||||
|
||||
public byte[] ReadEntryBytes(string path, SonyPkgSummary package, SonyPkgEntry entry, int maximumBytes)
|
||||
public byte[] ReadEntryBytes(string path, Ps5PkgSummary package, Ps5PkgEntry entry, int maximumBytes)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(package);
|
||||
@@ -95,14 +110,13 @@ public sealed class SonyPkgReader
|
||||
if (entry.DataSize > maximumBytes)
|
||||
throw new InvalidDataException($"PKG entry 0x{entry.Id:X4} is larger than the {maximumBytes:N0}-byte read limit.");
|
||||
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
ProsperoPackageInspection inspection = ProsperoPackageReader.Read(fullPath);
|
||||
ProsperoCntEntry cntEntry = inspection.Entries.FirstOrDefault(candidate => candidate.Id == entry.Id)
|
||||
using PkgPackage pkg = PkgPackage.Open(Path.GetFullPath(path));
|
||||
PkgEntry found = pkg.Entries.FirstOrDefault(candidate => candidate.Id == entry.Id)
|
||||
?? throw new InvalidDataException($"PKG entry 0x{entry.Id:X4} is not present in the CNT metadata.");
|
||||
return ProsperoPackageContent.ReadCntEntry(fullPath, inspection, cntEntry, entry.DataSize);
|
||||
return pkg.ReadEntry(found);
|
||||
}
|
||||
|
||||
private static bool TryReadHeaderOnly(string fullPath, long fileSize, out SonyPkgSummary? summary)
|
||||
private static bool TryReadHeaderOnly(string fullPath, long fileSize, out Ps5PkgSummary? summary)
|
||||
{
|
||||
summary = null;
|
||||
if (fileSize < FihHeaderProbeSize) return false;
|
||||
@@ -122,10 +136,10 @@ public sealed class SonyPkgReader
|
||||
if (cntOffset <= (ulong)Math.Max(0, fileSize - CntHeaderSize)) return false;
|
||||
|
||||
byte signedByte = header[0x05];
|
||||
SonyPkgKind kind = signedByte switch
|
||||
Ps5PkgKind kind = signedByte switch
|
||||
{
|
||||
0x00 => SonyPkgKind.FinalizedDebug,
|
||||
0x80 => SonyPkgKind.FinalizedRetail,
|
||||
0x00 => Ps5PkgKind.FinalizedDebug,
|
||||
0x80 => Ps5PkgKind.FinalizedRetail,
|
||||
_ => throw new InvalidDataException($"Unknown PS5 FIH signed byte 0x{signedByte:X2}.")
|
||||
};
|
||||
ushort formatVersion = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(0x06, 2));
|
||||
@@ -133,7 +147,7 @@ public sealed class SonyPkgReader
|
||||
ulong pfsSize = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(0x18, 8));
|
||||
ulong superblockOffset = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(0x20, 8));
|
||||
|
||||
summary = new SonyPkgSummary
|
||||
summary = new Ps5PkgSummary
|
||||
{
|
||||
Kind = kind,
|
||||
FileSize = fileSize,
|
||||
@@ -145,11 +159,11 @@ public sealed class SonyPkgReader
|
||||
EmbeddedCntOffset = cntOffset,
|
||||
Entries = [],
|
||||
NestedPfs = pfsSize > 0
|
||||
? new SonyPfsSummary
|
||||
? new Ps5PfsSummary
|
||||
{
|
||||
AccessState = kind == SonyPkgKind.FinalizedRetail
|
||||
? SonyPfsAccessState.EncryptedKeyRequired
|
||||
: SonyPfsAccessState.UnsupportedLayout,
|
||||
AccessState = kind == Ps5PkgKind.FinalizedRetail
|
||||
? Ps5PfsAccessState.EncryptedKeyRequired
|
||||
: Ps5PfsAccessState.UnsupportedLayout,
|
||||
ImageOffset = (long)pfsOffset,
|
||||
ImageSize = (long)pfsSize,
|
||||
BlockSize = BlockSize,
|
||||
@@ -160,51 +174,40 @@ public sealed class SonyPkgReader
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SonyPfsSummary BuildNestedPfs(SonyEnginePackageAccess access)
|
||||
private static Ps5PfsSummary BuildNestedPfs(Ps5EnginePackageAccess access)
|
||||
{
|
||||
ProsperoFihHeader? fih = access.Inspection.Fih;
|
||||
long imageOffset = (long)(fih?.PfsOffset ?? 0);
|
||||
long imageSize = (long)(fih?.PfsSize ?? 0);
|
||||
if (fih is null || imageSize <= 0)
|
||||
if (!access.HasInnerImage)
|
||||
{
|
||||
return new SonyPfsSummary
|
||||
return new Ps5PfsSummary
|
||||
{
|
||||
AccessState = SonyPfsAccessState.NotPresent,
|
||||
AccessState = Ps5PfsAccessState.NotPresent,
|
||||
EngineAccess = access,
|
||||
StatusMessage = "The package has no embedded PFS image."
|
||||
};
|
||||
}
|
||||
|
||||
// The inner image is decoded lazily by the engine accessor. Scanning only parses the CNT,
|
||||
// so this summary stays cheap and the real state/file list is materialized on selection.
|
||||
return new SonyPfsSummary
|
||||
// The inner image is decoded lazily by the access object. Scanning only parses the metadata, so this
|
||||
// summary stays cheap and the real state and file list are materialized when the game is selected.
|
||||
return new Ps5PfsSummary
|
||||
{
|
||||
AccessState = SonyPfsAccessState.PlaintextIndexed,
|
||||
ImageOffset = imageOffset,
|
||||
ImageSize = imageSize,
|
||||
AccessState = Ps5PfsAccessState.PlaintextIndexed,
|
||||
ImageOffset = access.PfsOffset,
|
||||
ImageSize = access.PfsSize,
|
||||
BlockSize = BlockSize,
|
||||
EngineAccess = access,
|
||||
StatusMessage = "Inner image, trophies, activities, and files load when the game is selected."
|
||||
};
|
||||
}
|
||||
|
||||
private static SonyPkgKind MapKind(ProsperoPackageKind kind) => kind switch
|
||||
{
|
||||
ProsperoPackageKind.MetadataContainer => SonyPkgKind.MetadataContainer,
|
||||
ProsperoPackageKind.FinalizedPatchDebug => SonyPkgKind.FinalizedPatch,
|
||||
ProsperoPackageKind.FinalizedRetail => SonyPkgKind.FinalizedRetail,
|
||||
_ => SonyPkgKind.FinalizedDebug
|
||||
};
|
||||
|
||||
private static SonyPkgEntry MapEntry(ProsperoCntEntry entry) => new()
|
||||
private static Ps5PkgEntry MapEntry(PkgEntry entry) => new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
NameTableOffset = entry.NameOffset,
|
||||
NameTableOffset = entry.NameTableOffset,
|
||||
Flags1 = entry.Flags1,
|
||||
Flags2 = entry.Flags2,
|
||||
DataOffset = entry.DataOffset,
|
||||
DataSize = entry.DataSize,
|
||||
Name = entry.DisplayName
|
||||
DataOffset = checked((uint)entry.Offset),
|
||||
DataSize = checked((uint)entry.Size),
|
||||
Name = entry.Name
|
||||
};
|
||||
|
||||
internal static FileStream Open(string path) => new(path, FileMode.Open, FileAccess.Read,
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
/// <summary>Reads the few fields of an executable's SELF container header the app shows.</summary>
|
||||
internal static class Ps5SelfHeader
|
||||
{
|
||||
private const uint SelfMagic = 0x1D3D154F;
|
||||
private const uint SignedModuleMagic = 0xEEF51454;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the firmware version recorded in the container's extended info block, which sits after the embedded ELF
|
||||
/// header and its program headers.
|
||||
/// </summary>
|
||||
public static bool TryReadFirmwareVersion(ReadOnlySpan<byte> data, out ulong firmware)
|
||||
{
|
||||
firmware = 0;
|
||||
if (data.Length < 32) return false;
|
||||
uint magic = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
if (magic != SelfMagic && magic != SignedModuleMagic) return false;
|
||||
|
||||
int segments = BinaryPrimitives.ReadUInt16LittleEndian(data[24..]);
|
||||
long elf = 32 + segments * 32L;
|
||||
if (elf + 64 > data.Length) return false;
|
||||
if (data[(int)elf] != 0x7F || data[(int)elf + 1] != (byte)'E' || data[(int)elf + 2] != (byte)'L' || data[(int)elf + 3] != (byte)'F') return false;
|
||||
|
||||
int programHeaders = BinaryPrimitives.ReadUInt16LittleEndian(data[((int)elf + 56)..]);
|
||||
long headerEnd = elf + 64 + programHeaders * 56L;
|
||||
if (headerEnd > data.Length) return false;
|
||||
long info = (headerEnd + 15) & ~15L;
|
||||
if (info + 64 > data.Length) return false;
|
||||
firmware = BinaryPrimitives.ReadUInt64LittleEndian(data[((int)info + 24)..]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,55 @@ public sealed class Ps5TrophyReader
|
||||
|
||||
public Ps5TrophySet? Read(string gameRoot, string preferredLanguage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = Path.Combine(gameRoot, "sce_sys", "trophy2", "trophy00.ucp");
|
||||
if (!File.Exists(path)) return null;
|
||||
string? path = FindArchive(gameRoot);
|
||||
if (path is null) return null;
|
||||
UcpArchive archive = _ucp.Read(path);
|
||||
return ReadArchive(archive, preferredLanguage, cancellationToken);
|
||||
}
|
||||
|
||||
// The usual file is sce_sys/trophy2/trophy00.ucp. Some titles name it differently or keep it in the older
|
||||
// sce_sys/trophy folder, so fall back to the lowest-numbered trophy archive found there.
|
||||
private static string? FindArchive(string gameRoot)
|
||||
{
|
||||
string preferred = Path.Combine(gameRoot, "sce_sys", "trophy2", "trophy00.ucp");
|
||||
if (File.Exists(preferred)) return preferred;
|
||||
foreach (string folder in new[] { "trophy2", "trophy" })
|
||||
{
|
||||
string directory = Path.Combine(gameRoot, "sce_sys", folder);
|
||||
if (!Directory.Exists(directory)) continue;
|
||||
string? found = Directory.EnumerateFiles(directory, "trophy*.ucp")
|
||||
.OrderBy(file => file, StringComparer.OrdinalIgnoreCase).FirstOrDefault();
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindArchive(IReadOnlyGameFileSystem files)
|
||||
{
|
||||
const string preferred = "sce_sys/trophy2/trophy00.ucp";
|
||||
if (files.FileExists(preferred)) return preferred;
|
||||
return files.Files.Select(file => file.RelativePath)
|
||||
.Where(IsTrophyArchive)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static bool IsTrophyArchive(string path)
|
||||
{
|
||||
string[] parts = path.Split('/');
|
||||
return parts.Length == 3 &&
|
||||
parts[0].Equals("sce_sys", StringComparison.OrdinalIgnoreCase) &&
|
||||
(parts[1].Equals("trophy2", StringComparison.OrdinalIgnoreCase) ||
|
||||
parts[1].Equals("trophy", StringComparison.OrdinalIgnoreCase)) &&
|
||||
parts[2].StartsWith("trophy", StringComparison.OrdinalIgnoreCase) &&
|
||||
parts[2].EndsWith(".ucp", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public Ps5TrophySet? Read(IReadOnlyGameFileSystem files, string preferredLanguage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string path = "sce_sys/trophy2/trophy00.ucp";
|
||||
if (!files.FileExists(path)) return null;
|
||||
string? path = FindArchive(files);
|
||||
if (path is null) return null;
|
||||
UcpArchive archive = _ucp.Read(path, () => files.OpenRead(path));
|
||||
return ReadArchive(archive, preferredLanguage, cancellationToken);
|
||||
}
|
||||
@@ -109,7 +147,14 @@ public sealed class Ps5TrophyReader
|
||||
if (Exists(preferred)) return preferred;
|
||||
if (Exists(fallback)) return fallback;
|
||||
if (Exists("en-US")) return "en-US";
|
||||
return languages.FirstOrDefault(Exists) ?? string.Empty;
|
||||
if (languages.FirstOrDefault(Exists) is { } listed) return listed;
|
||||
|
||||
// Last resort: any localized metadata the archive actually contains.
|
||||
const string prefix = "tropmeta_", suffix = ".json";
|
||||
UcpEntry? any = archive.Entries.FirstOrDefault(entry =>
|
||||
entry.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.Name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
|
||||
return any is null ? string.Empty : any.Name[prefix.Length..^suffix.Length];
|
||||
}
|
||||
|
||||
private static Dictionary<string, (string Name, string Description)> ReadLocalizedMetadata(JsonElement root)
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using Packwright.Core.Builders;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using ProsperoPkgTool.Crypto;
|
||||
|
||||
namespace Packwright.Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the vendored, validated <c>ProsperoPkgTool</c> engine to Packwright's package model.
|
||||
/// The engine owns every byte of PS5 PKG/NAPS/PFS/CNT/FIH parsing, reconstruction and decryption;
|
||||
/// this type only exposes the decoded inner image to the existing model layer.
|
||||
/// </summary>
|
||||
internal sealed class SonyEnginePackageAccess : IDisposable
|
||||
{
|
||||
private readonly string _passcode;
|
||||
private readonly string _contentId;
|
||||
private IReadOnlyList<ProsperoInnerPfsReader.Entry>? _entries;
|
||||
private ProsperoDecodedPackage? _decoded;
|
||||
private ProsperoFileBackedPackage? _fileBacked;
|
||||
private string? _decodeError;
|
||||
private bool _decodeUnsupported;
|
||||
private bool _decodeAttempted;
|
||||
private bool _disposed;
|
||||
private bool _usedFileBacked;
|
||||
private int _openMounts;
|
||||
private bool _releasePending;
|
||||
private readonly object _mountGate = new();
|
||||
|
||||
private SonyEnginePackageAccess(string packagePath, ProsperoPackageInspection inspection, string? passcode,
|
||||
string? contentId)
|
||||
{
|
||||
PackagePath = packagePath;
|
||||
Inspection = inspection;
|
||||
_passcode = passcode ?? string.Empty;
|
||||
_contentId = contentId ?? string.Empty;
|
||||
}
|
||||
|
||||
public string PackagePath { get; }
|
||||
public ProsperoPackageInspection Inspection { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructed inner-image entries. Accessing this performs the expensive outer PFS/NAPS/Kraken
|
||||
/// decode on first use, so scanning a package stays cheap until details are actually requested.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProsperoInnerPfsReader.Entry> Entries
|
||||
{
|
||||
get { EnsureDecoded(); return _entries!; }
|
||||
}
|
||||
|
||||
public string? DecodeError
|
||||
{
|
||||
get { EnsureDecoded(); return _decodeError; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="DecodeError"/> is an unsupported format/layout rather than a wrong key or
|
||||
/// corruption, so the UI can report the real cause instead of blaming the passcode.
|
||||
/// </summary>
|
||||
public bool DecodeUnsupported
|
||||
{
|
||||
get { EnsureDecoded(); return _decodeUnsupported; }
|
||||
}
|
||||
|
||||
public IEnumerable<ProsperoInnerPfsReader.Entry> Files => Entries.Where(entry => !entry.IsDirectory);
|
||||
|
||||
/// <summary>Parses the CNT/FIH metadata only. The inner image is decoded lazily on first use.</summary>
|
||||
public static SonyEnginePackageAccess Open(string packagePath, string? passcode)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(packagePath);
|
||||
ProsperoPackageInspection inspection = ProsperoPackageReader.Read(fullPath);
|
||||
return FromInspection(fullPath, inspection, passcode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an accessor from an inspection the caller has already parsed, so the package
|
||||
/// header/CNT is not parsed a second time during a scan.
|
||||
/// </summary>
|
||||
internal static SonyEnginePackageAccess FromInspection(string packagePath, ProsperoPackageInspection inspection,
|
||||
string? passcode) => new(Path.GetFullPath(packagePath), inspection, passcode, inspection.Cnt.ContentId);
|
||||
|
||||
private void EnsureDecoded()
|
||||
{
|
||||
if (_decodeAttempted) return;
|
||||
_decodeAttempted = true;
|
||||
|
||||
ProsperoFihHeader? fih = Inspection.Fih;
|
||||
bool isDebug = Inspection.Kind is ProsperoPackageKind.FinalizedDebug or ProsperoPackageKind.FinalizedPatchDebug;
|
||||
if (fih is null || fih.PfsSize == 0 || !isDebug)
|
||||
{
|
||||
_entries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? passcode = _passcode.Length == 0 ? null : _passcode;
|
||||
if (ProsperoPackageContent.RequiresFileBacked(PackagePath, passcode))
|
||||
{
|
||||
_usedFileBacked = true;
|
||||
_fileBacked = ProsperoPackageContent.ReadFileBacked(PackagePath, passcode);
|
||||
_entries = _fileBacked.Entries;
|
||||
}
|
||||
else
|
||||
{
|
||||
_decoded = ProsperoPackageContent.Read(PackagePath, passcode);
|
||||
_entries = ProsperoInnerPfsReader.Enumerate(_decoded.Mount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_entries = [];
|
||||
_decodeError = ex.Message;
|
||||
_decodeUnsupported = ProsperoErrorInfo.IsUnsupported(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] ReadCntEntry(uint id, long? length = null)
|
||||
{
|
||||
ProsperoCntEntry entry = Inspection.Entries.FirstOrDefault(candidate => candidate.Id == id)
|
||||
?? throw new InvalidDataException($"CNT entry 0x{id:X4} is not present.");
|
||||
return entry.IsEncrypted
|
||||
? ReadProtectedCntEntry(entry)
|
||||
: ProsperoPackageContent.ReadCntEntry(PackagePath, Inspection, entry, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a protected CNT entry (the <c>0x80000000</c> flag). The stored body is AES-128-CBC
|
||||
/// over the payload zero-padded to a 16-byte boundary; the key/IV seed binds the finalized entry
|
||||
/// meta row, the package content id and the passcode. Requires the 32-character passcode and the
|
||||
/// package content id (both known for debug packages). The PS5 publisher profile (SHA3) is tried
|
||||
/// first, then the legacy SHA-256 profile; a known-magic check guards against a wrong passcode.
|
||||
/// </summary>
|
||||
private byte[] ReadProtectedCntEntry(ProsperoCntEntry entry)
|
||||
{
|
||||
string passcode = _passcode.Length == 0 ? SonyDebugPackageCredentials.DefaultPasscode : _passcode;
|
||||
if (passcode.Length != 32)
|
||||
throw new InvalidDataException(
|
||||
$"CNT entry 0x{entry.Id:X8} is encrypted and needs the 32-character passcode.");
|
||||
if (string.IsNullOrWhiteSpace(_contentId))
|
||||
throw new InvalidDataException(
|
||||
$"CNT entry 0x{entry.Id:X8} is encrypted but the package content id is unavailable.");
|
||||
|
||||
long padded = ProsperoEntryCipher.PaddedLength(checked((int)entry.DataSize));
|
||||
byte[] ciphertext = ProsperoPackageContent.ReadCntEntry(PackagePath, Inspection, entry, padded);
|
||||
byte[] metaRow = BuildMetaRow(entry);
|
||||
uint keyIndex = (entry.Flags2 >> 12) & 0xF;
|
||||
|
||||
foreach (bool ps5Profile in new[] { true, false })
|
||||
{
|
||||
byte[] plain;
|
||||
try
|
||||
{
|
||||
plain = ProsperoEntryCipher.Decrypt(ciphertext, metaRow, _contentId, passcode, keyIndex, ps5Profile);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (plain.Length >= entry.DataSize && IsRecognizedProtectedEntry(entry.Id, plain))
|
||||
return plain[..(int)entry.DataSize];
|
||||
}
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"CNT entry 0x{entry.Id:X8} is encrypted and could not be decrypted with the supplied " +
|
||||
"passcode, or it uses an encryption profile this build does not support yet.");
|
||||
}
|
||||
|
||||
/// <summary>The 32-byte finalized entry meta row the key seed is bound to (six big-endian fields, then 8 zero bytes).</summary>
|
||||
private static byte[] BuildMetaRow(ProsperoCntEntry entry)
|
||||
{
|
||||
byte[] row = new byte[ProsperoEntryCipher.MetaRowSize];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(0), entry.Id);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(4), entry.NameOffset);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(8), entry.Flags1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(12), entry.Flags2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(16), entry.DataOffset);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(20), entry.DataSize);
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>Known plaintext magics for protected entries, so a wrong passcode is rejected rather
|
||||
/// than returning garbage. Unknown ids are accepted as-is.</summary>
|
||||
private static bool IsRecognizedProtectedEntry(uint id, byte[] plain) => id switch
|
||||
{
|
||||
0x0402 => plain.Length >= 4 && plain[0] == (byte)'N' && plain[1] == (byte)'P' &&
|
||||
plain[2] == (byte)'T' && plain[3] == (byte)'D',
|
||||
0x2020 or 0x2021 or 0x0403 => plain.Length >= 4 && plain[0] == 0xD2 && plain[1] == 0x94 &&
|
||||
plain[2] == 0xA0 && plain[3] == 0x18,
|
||||
_ => true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plaintext CNT content entries exposed under the same relative paths used inside the inner
|
||||
/// image, so the metadata/artwork/trophy/UDS readers work even when the inner PFS needs a key.
|
||||
/// </summary>
|
||||
private static readonly (uint Id, string Path)[] ContentEntries =
|
||||
[
|
||||
(0x0402, "sce_sys/nptitle.dat"),
|
||||
(0x0403, "sce_sys/npbind.dat"),
|
||||
(0x040A, "sce_sys/imagedigs.dat"),
|
||||
(0x1000, "sce_sys/param.sfo"),
|
||||
(0x1001, "sce_sys/playgo-chunk.dat"),
|
||||
(0x1200, "sce_sys/icon0.png"),
|
||||
(0x1220, "sce_sys/pic0.png"),
|
||||
(0x1240, "sce_sys/snd0.at9"),
|
||||
(0x1280, "sce_sys/icon0.dds"),
|
||||
(0x12A0, "sce_sys/pic0.dds"),
|
||||
(0x12C0, "sce_sys/pic1.dds"),
|
||||
(0x1480, "sce_sys/trophy2/trophy00.ucp"),
|
||||
(0x14A0, "sce_sys/uds/uds00.ucp"),
|
||||
(0x2000, "sce_sys/param.json"),
|
||||
(0x2010, "sce_sys/playgo-hash-table.dat"),
|
||||
(0x2011, "sce_sys/playgo-ficm.dat"),
|
||||
(0x2020, "sce_sys/uds/npbind.dat"),
|
||||
(0x2021, "sce_sys/trophy2/npbind.dat"),
|
||||
(0x2060, "sce_sys/pic2.dds")
|
||||
];
|
||||
|
||||
public IEnumerable<(uint Id, string Path, long Size)> ContentFileEntries()
|
||||
{
|
||||
foreach ((uint id, string path) in ContentEntries)
|
||||
{
|
||||
ProsperoCntEntry? entry = Inspection.Entries.FirstOrDefault(candidate => candidate.Id == id);
|
||||
if (entry is null) continue;
|
||||
yield return (id, path, entry.DataSize);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every CNT entry, mirroring the engine's structural file listing. Known content IDs are mapped
|
||||
/// to their inner-image paths; the rest keep the engine name. Protected entries are listed too
|
||||
/// and decrypted on read when a passcode is available. This is used when the inner PFS cannot be
|
||||
/// decoded (for example a package that needs a custom passcode) so the file browser still shows
|
||||
/// what the package exposes.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint Id, string Path, long Size)> ListReadableEntries()
|
||||
{
|
||||
foreach (ProsperoCntEntry entry in Inspection.Entries)
|
||||
{
|
||||
string? mapped = null;
|
||||
foreach ((uint id, string path) in ContentEntries)
|
||||
if (id == entry.Id) { mapped = path; break; }
|
||||
string name = mapped
|
||||
?? (!string.IsNullOrWhiteSpace(entry.Name) ? entry.Name
|
||||
: !string.IsNullOrWhiteSpace(entry.DisplayName) && entry.DisplayName != "(unnamed)"
|
||||
? entry.DisplayName
|
||||
: $"entry_0x{entry.Id:X4}.bin");
|
||||
yield return (entry.Id, name, entry.DataSize);
|
||||
}
|
||||
}
|
||||
|
||||
public string ToRelativePath(ProsperoInnerPfsReader.Entry entry) => Normalize(entry.Path);
|
||||
|
||||
public bool TryFindFile(string relativePath, out ProsperoInnerPfsReader.Entry entry)
|
||||
{
|
||||
string key = Normalize(relativePath);
|
||||
foreach (ProsperoInnerPfsReader.Entry candidate in Entries)
|
||||
{
|
||||
if (!candidate.IsDirectory &&
|
||||
string.Equals(Normalize(candidate.Path), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entry = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
entry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public byte[] ReadFile(ProsperoInnerPfsReader.Entry entry)
|
||||
{
|
||||
if (entry.Size is < 0 or > int.MaxValue)
|
||||
throw new InvalidDataException($"The inner PFS file '{entry.Path}' is too large to read into memory.");
|
||||
using Stream mount = OpenMount();
|
||||
using var buffer = new MemoryStream(checked((int)entry.Size));
|
||||
ProsperoInnerPfsReader.ExtractFile(mount, entry, buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
public byte[] ReadFile(string relativePath)
|
||||
{
|
||||
if (!TryFindFile(relativePath, out ProsperoInnerPfsReader.Entry entry))
|
||||
throw new InvalidDataException($"The inner PFS file '{relativePath}' is not present in the reconstructed image.");
|
||||
return ReadFile(entry);
|
||||
}
|
||||
|
||||
public byte[] ReadFileRange(ProsperoInnerPfsReader.Entry entry, long offset, int count)
|
||||
{
|
||||
if (entry.IsDirectory) throw new InvalidDataException($"'{entry.Path}' is a directory.");
|
||||
if (offset < 0 || count < 0 || offset > entry.Size || count > entry.Size - offset)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
using Stream mount = OpenMount();
|
||||
using var range = new BoundedStream(mount, checked(entry.LogicalOffset + offset), count);
|
||||
byte[] result = new byte[count];
|
||||
range.ReadExactly(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Stream OpenInnerFile(ProsperoInnerPfsReader.Entry entry)
|
||||
{
|
||||
if (entry.IsDirectory) throw new InvalidDataException($"'{entry.Path}' is a directory.");
|
||||
return new BoundedStream(OpenMount(), entry.LogicalOffset, entry.Size);
|
||||
}
|
||||
|
||||
private Stream OpenMount()
|
||||
{
|
||||
EnsureDecoded();
|
||||
if (_decodeError is not null)
|
||||
throw new InvalidDataException($"Package contents could not be decoded: {_decodeError}");
|
||||
Stream inner;
|
||||
if (_fileBacked is not null)
|
||||
{
|
||||
inner = _fileBacked.OpenMount();
|
||||
}
|
||||
else if (_usedFileBacked)
|
||||
{
|
||||
_fileBacked = ReopenFileBacked();
|
||||
inner = _fileBacked.OpenMount();
|
||||
}
|
||||
else
|
||||
{
|
||||
inner = new MemoryStream(_decoded!.Mount, writable: false);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _openMounts);
|
||||
return new MountLease(inner, OnMountClosed);
|
||||
}
|
||||
|
||||
private ProsperoFileBackedPackage ReopenFileBacked() =>
|
||||
ProsperoPackageContent.ReadFileBacked(PackagePath, _passcode.Length == 0 ? null : _passcode);
|
||||
|
||||
private void OnMountClosed()
|
||||
{
|
||||
if (Interlocked.Decrement(ref _openMounts) != 0) return;
|
||||
lock (_mountGate)
|
||||
{
|
||||
if (_openMounts != 0 || !_releasePending) return;
|
||||
_releasePending = false;
|
||||
ProsperoFileBackedPackage? fileBacked = _fileBacked;
|
||||
_fileBacked = null;
|
||||
fileBacked?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the package file handle held by a large, lazily-decoded image without discarding the
|
||||
/// already-decoded entry list. The handle is reopened on the next file read, so another process
|
||||
/// can replace or delete the package while this app is not actively reading it.
|
||||
/// </summary>
|
||||
public void ReleaseHandles()
|
||||
{
|
||||
lock (_mountGate)
|
||||
{
|
||||
if (_openMounts > 0)
|
||||
{
|
||||
_releasePending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ProsperoFileBackedPackage? fileBacked = _fileBacked;
|
||||
_fileBacked = null;
|
||||
fileBacked?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
string value = path.Replace('\\', '/').TrimStart('/');
|
||||
if (value.StartsWith("uroot/", StringComparison.OrdinalIgnoreCase)) value = value[6..];
|
||||
else if (string.Equals(value, "uroot", StringComparison.OrdinalIgnoreCase)) value = string.Empty;
|
||||
return value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_fileBacked?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~SonyEnginePackageAccess() => Dispose();
|
||||
|
||||
/// <summary>Delegating stream that notifies its owner once, when disposed, so the shared package
|
||||
/// handle can be released after the last concurrent reader finishes.</summary>
|
||||
private sealed class MountLease : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly Action _onDispose;
|
||||
private bool _disposed;
|
||||
|
||||
public MountLease(Stream inner, Action onDispose)
|
||||
{
|
||||
_inner = inner;
|
||||
_onDispose = onDispose;
|
||||
}
|
||||
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => _inner.CanSeek;
|
||||
public override bool CanWrite => _inner.CanWrite;
|
||||
public override long Length => _inner.Length;
|
||||
public override long Position { get => _inner.Position; set => _inner.Position = value; }
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
||||
public override int Read(Span<byte> buffer) => _inner.Read(buffer);
|
||||
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => _inner.SetLength(value);
|
||||
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_inner.Dispose();
|
||||
_onDispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BoundedStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _start;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public BoundedStream(Stream inner, long start, long length)
|
||||
{
|
||||
if (start < 0 || length < 0) throw new ArgumentOutOfRangeException(nameof(start));
|
||||
_inner = inner;
|
||||
_start = start;
|
||||
_length = length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
public override long Position { get => _position; set => Seek(value, SeekOrigin.Begin); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
long remaining = _length - _position;
|
||||
if (remaining <= 0 || buffer.Length == 0) return 0;
|
||||
int wanted = (int)Math.Min(buffer.Length, remaining);
|
||||
_inner.Position = _start + _position;
|
||||
int read = _inner.Read(buffer[..wanted]);
|
||||
_position += read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long value = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
SeekOrigin.End => _length + offset,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(origin))
|
||||
};
|
||||
if (value < 0) throw new IOException("Cannot seek before the start of the stream.");
|
||||
_position = value;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
protected override void Dispose(bool disposing) { if (disposing) _inner.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Parsers;
|
||||
using Packwright.Containers;
|
||||
using ProsperoPkgTool.Containers;
|
||||
using Packwright.Pkg.Reader;
|
||||
using UFS2Tool;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
@@ -29,7 +29,7 @@ public static class GameFileSystem
|
||||
Ps5SourceKind.FilesystemImage => new FilesystemImageGameFileSystem(game.RootPath, game.VirtualRoot),
|
||||
Ps5SourceKind.Ffpkg => new FfpkgGameFileSystem(game.RootPath, game.VirtualRoot),
|
||||
Ps5SourceKind.ZArchive => new ZArchiveGameFileSystem(game.RootPath, game.VirtualRoot),
|
||||
Ps5SourceKind.SonyPackage => new SonyPackageGameFileSystem(game),
|
||||
Ps5SourceKind.Ps5Package => new Ps5PackageGameFileSystem(game),
|
||||
_ => new LocalGameFileSystem(game.RootPath, cancellationToken)
|
||||
};
|
||||
|
||||
@@ -108,18 +108,18 @@ public static class GameFileSystem
|
||||
return new GameFileChunk(data, offset, input.Length);
|
||||
}
|
||||
|
||||
private sealed class SonyPackageGameFileSystem : IReadOnlyGameFileSystem
|
||||
private sealed class Ps5PackageGameFileSystem : IReadOnlyGameFileSystem
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly Dictionary<string, Segment[]> _segments;
|
||||
private readonly SonyPfsCryptoContext? _cryptoContext;
|
||||
private readonly SonyEnginePackageAccess? _engine;
|
||||
private readonly Ps5PfsCryptoContext? _cryptoContext;
|
||||
private readonly Ps5EnginePackageAccess? _engine;
|
||||
private readonly Dictionary<string, (uint Id, long Size)>? _cntEntries;
|
||||
|
||||
public SonyPackageGameFileSystem(Ps5GameInfo game)
|
||||
public Ps5PackageGameFileSystem(Ps5GameInfo game)
|
||||
{
|
||||
_path = Path.GetFullPath(game.RootPath);
|
||||
SonyPkgSummary package = ResolveSonyPackage(game, _path);
|
||||
Ps5PkgSummary package = ResolvePs5Package(game, _path);
|
||||
_segments = new Dictionary<string, Segment[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (package.NestedPfs?.EngineAccess is { } engine)
|
||||
@@ -130,7 +130,7 @@ public static class GameFileSystem
|
||||
// trophies, activities...). Real packages keep these in the CNT container rather than
|
||||
// the inner PFS, so hiding them whenever the inner image decodes would drop metadata,
|
||||
// artwork, and trophies. The inner PFS wins for any path present in both.
|
||||
ProsperoInnerPfsReader.Entry[] innerFiles = engine.Files.ToArray();
|
||||
PkgFile[] innerFiles = engine.Files.ToArray();
|
||||
if (engine.DecodeError is { } decodeError)
|
||||
throw new InvalidDataException(engine.DecodeUnsupported
|
||||
? $"This package uses a format this build does not support yet: {decodeError}"
|
||||
@@ -151,7 +151,7 @@ public static class GameFileSystem
|
||||
}
|
||||
|
||||
var records = new Dictionary<string, (long Size, string Origin)>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (ProsperoInnerPfsReader.Entry file in innerFiles)
|
||||
foreach (PkgFile file in innerFiles)
|
||||
{
|
||||
string relative = NormalizePath(engine.ToRelativePath(file));
|
||||
if (relative.Length > 0) records[relative] = (file.Size, "PFS");
|
||||
@@ -166,10 +166,10 @@ public static class GameFileSystem
|
||||
return;
|
||||
}
|
||||
|
||||
if (package.NestedPfs?.AccessState == SonyPfsAccessState.PlaintextIndexed)
|
||||
if (package.NestedPfs?.AccessState == Ps5PfsAccessState.PlaintextIndexed)
|
||||
{
|
||||
_cryptoContext = package.NestedPfs.CryptoContext;
|
||||
foreach (SonyPfsEntry file in package.NestedPfs.Files)
|
||||
foreach (Ps5PfsEntry file in package.NestedPfs.Files)
|
||||
{
|
||||
string path = NormalizePath(file.RelativePath);
|
||||
if (path.Length == 0 || file.Extents.Count == 0 || _segments.ContainsKey(path)) continue;
|
||||
@@ -182,7 +182,7 @@ public static class GameFileSystem
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (SonyPkgEntry entry in package.Entries.Where(entry => !entry.IsEncrypted))
|
||||
foreach (Ps5PkgEntry entry in package.Entries.Where(entry => !entry.IsEncrypted))
|
||||
{
|
||||
string path = NormalizePath(entry.DisplayName);
|
||||
if (path.Length == 0 || _segments.ContainsKey(path)) continue;
|
||||
@@ -194,13 +194,13 @@ public static class GameFileSystem
|
||||
.OrderBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static SonyPkgSummary ResolveSonyPackage(Ps5GameInfo game, string path)
|
||||
private static Ps5PkgSummary ResolvePs5Package(Ps5GameInfo game, string path)
|
||||
{
|
||||
// A manifest-restored package has no live engine access, so re-open it for inner reads.
|
||||
SonyPkgSummary? cached = game.Package;
|
||||
Ps5PkgSummary? cached = game.Package;
|
||||
if (cached is not null && (cached.NestedPfs is null || cached.NestedPfs.EngineAccess is not null))
|
||||
return cached;
|
||||
SonyPkgSummary fresh = new Parsers.SonyPkgReader().Read(path);
|
||||
Ps5PkgSummary fresh = new Parsers.Ps5PkgReader().Read(path);
|
||||
game.Package = fresh;
|
||||
return fresh;
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public static class GameFileSystem
|
||||
string normalized = NormalizePath(relativePath);
|
||||
if (_engine is not null)
|
||||
{
|
||||
if (_engine.TryFindFile(normalized, out ProsperoInnerPfsReader.Entry entry))
|
||||
if (_engine.TryFindFile(normalized, out PkgFile entry))
|
||||
return _engine.OpenInnerFile(entry);
|
||||
if (_cntEntries is not null && _cntEntries.TryGetValue(normalized, out (uint Id, long Size) info))
|
||||
return new MemoryStream(_engine.ReadCntEntry(info.Id), writable: false);
|
||||
@@ -245,12 +245,12 @@ public static class GameFileSystem
|
||||
private readonly Stream _source;
|
||||
private readonly Segment[] _segments;
|
||||
private readonly long[] _starts;
|
||||
private readonly SonyPfsCryptoContext? _cryptoContext;
|
||||
private readonly Ps5PfsCryptoContext? _cryptoContext;
|
||||
private byte[]? _decryptedBlock;
|
||||
private int _cachedSegmentIndex = -1;
|
||||
private long _position;
|
||||
|
||||
public SegmentedReadStream(Stream source, Segment[] segments, SonyPfsCryptoContext? cryptoContext = null)
|
||||
public SegmentedReadStream(Stream source, Segment[] segments, Ps5PfsCryptoContext? cryptoContext = null)
|
||||
{
|
||||
_source = source;
|
||||
_segments = segments;
|
||||
@@ -312,7 +312,7 @@ public static class GameFileSystem
|
||||
Segment segment = _segments[segmentIndex];
|
||||
_source.Position = checked(segment.Offset - segment.BlockInnerOffset);
|
||||
_source.ReadExactly(_decryptedBlock);
|
||||
SonyPfsCrypto.DecryptBlock(_decryptedBlock, _cryptoContext,
|
||||
Ps5PfsCrypto.DecryptBlock(_decryptedBlock, _cryptoContext,
|
||||
checked((ulong)segment.BlockIndex), segment.SignedDomain);
|
||||
_cachedSegmentIndex = segmentIndex;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ProsperoPkgTool.Containers;
|
||||
using Packwright.Pkg.Reader;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
@@ -9,7 +9,7 @@ public sealed record PackageReaderVerificationResult(int FileCount, string? Eboo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a package with the one canonical engine reader (the same reader used to browse and extract
|
||||
/// Opens a package with the one canonical reader (the same reader used to browse and extract
|
||||
/// packages) so both builders are held to a single acceptance criterion.
|
||||
/// </summary>
|
||||
public static class PackageReaderVerification
|
||||
@@ -21,23 +21,23 @@ public static class PackageReaderVerification
|
||||
public static PackageReaderVerificationResult Inspect(string packagePath, string? passcode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
using ProsperoFileBackedPackage package =
|
||||
ProsperoPackageContent.ReadFileBacked(packagePath, string.IsNullOrEmpty(passcode) ? null : passcode);
|
||||
using PkgPackage package = PkgPackage.Open(packagePath, passcode);
|
||||
|
||||
IReadOnlyList<PkgFile> files;
|
||||
try { files = package.Files; }
|
||||
catch (PkgNotSupportedException ex) { throw new InvalidDataException(ex.Message, ex); }
|
||||
|
||||
int fileCount = 0;
|
||||
string? ebootPath = null;
|
||||
foreach (ProsperoInnerPfsReader.Entry entry in package.Entries)
|
||||
foreach (PkgFile file in files)
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
continue;
|
||||
fileCount++;
|
||||
if (ebootPath is not null)
|
||||
continue;
|
||||
string path = entry.Path.Replace('\\', '/').TrimStart('/');
|
||||
string path = file.Path.Replace('\\', '/').TrimStart('/');
|
||||
if (path.Equals("eboot.bin", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith("/eboot.bin", StringComparison.OrdinalIgnoreCase))
|
||||
ebootPath = entry.Path;
|
||||
{
|
||||
ebootPath = file.Path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new PackageReaderVerificationResult(fileCount, ebootPath);
|
||||
return new PackageReaderVerificationResult(files.Count, ebootPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Buffers.Binary;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a picture as the BC7 texture (<c>.dds</c>) a package carries next to each of its PNG images. Every 4x4 block
|
||||
/// is stored in BC7 mode 6 (one subset, 7-bit endpoints with a shared extra bit, 4-bit indices), which every BC7
|
||||
/// decoder reads. The encoder is deliberately simple and fast; it is used when a title has the PNG but no texture.
|
||||
/// </summary>
|
||||
public static class Ps5DdsEncoder
|
||||
{
|
||||
public const int HeaderSize = 148;
|
||||
private const uint DxgiFormatBc7Unorm = 98;
|
||||
|
||||
private static readonly int[] Weights = [0, 4, 9, 13, 17, 21, 26, 30, 34, 38, 43, 47, 51, 55, 60, 64];
|
||||
|
||||
public static byte[] EncodePng(byte[] png)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(png);
|
||||
using Image<Rgba32> image = Image.Load<Rgba32>(png);
|
||||
byte[] rgba = new byte[checked(image.Width * image.Height * 4)];
|
||||
image.CopyPixelDataTo(rgba);
|
||||
return EncodeRgba(rgba, image.Width, image.Height);
|
||||
}
|
||||
|
||||
public static byte[] EncodeRgba(byte[] rgba, int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) throw new ArgumentOutOfRangeException(nameof(width), "Invalid image size.");
|
||||
if (rgba.Length < checked(width * height * 4)) throw new ArgumentException("The pixel buffer is too small.", nameof(rgba));
|
||||
int blocksX = (width + 3) / 4, blocksY = (height + 3) / 4;
|
||||
int dataSize = checked(blocksX * blocksY * 16);
|
||||
byte[] dds = new byte[HeaderSize + dataSize];
|
||||
WriteHeader(dds, width, height, (uint)dataSize);
|
||||
|
||||
Parallel.For(0, blocksY, by =>
|
||||
{
|
||||
Span<byte> block = stackalloc byte[64];
|
||||
for (int bx = 0; bx < blocksX; bx++)
|
||||
{
|
||||
Gather(rgba, width, height, bx * 4, by * 4, block);
|
||||
EncodeBlock(block, dds.AsSpan(HeaderSize + (by * blocksX + bx) * 16, 16));
|
||||
}
|
||||
});
|
||||
return dds;
|
||||
}
|
||||
|
||||
private static void WriteHeader(byte[] d, int width, int height, uint linearSize)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d, 0x20534444); // "DDS "
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(4), 124);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(8), 0x000A1007); // caps, height, width, pixel format, linear size
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(12), (uint)height);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(16), (uint)width);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(20), linearSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(28), 1); // mip levels
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(76), 32); // pixel format size
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(80), 4); // four-cc present
|
||||
"DX10"u8.CopyTo(d.AsSpan(84));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(108), 0x1000); // texture
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(128), DxgiFormatBc7Unorm);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(132), 3); // 2D texture
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(140), 1); // array size
|
||||
}
|
||||
|
||||
// Copies a 4x4 block, repeating the last row and column at the edges of the picture.
|
||||
private static void Gather(byte[] rgba, int width, int height, int x0, int y0, Span<byte> block)
|
||||
{
|
||||
for (int y = 0; y < 4; y++)
|
||||
{
|
||||
int sy = Math.Min(y0 + y, height - 1);
|
||||
for (int x = 0; x < 4; x++)
|
||||
{
|
||||
int sx = Math.Min(x0 + x, width - 1);
|
||||
rgba.AsSpan((sy * width + sx) * 4, 4).CopyTo(block.Slice((y * 4 + x) * 4, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EncodeBlock(ReadOnlySpan<byte> block, Span<byte> destination)
|
||||
{
|
||||
Span<int> low = stackalloc int[4], high = stackalloc int[4];
|
||||
low.Fill(255);
|
||||
high.Fill(0);
|
||||
for (int i = 0; i < 16; i++)
|
||||
for (int c = 0; c < 4; c++)
|
||||
{
|
||||
int v = block[i * 4 + c];
|
||||
if (v < low[c]) low[c] = v;
|
||||
if (v > high[c]) high[c] = v;
|
||||
}
|
||||
|
||||
Span<int> q0 = stackalloc int[4], q1 = stackalloc int[4], r0 = stackalloc int[4], r1 = stackalloc int[4];
|
||||
Quantize(low, q0, out int p0, r0);
|
||||
Quantize(high, q1, out int p1, r1);
|
||||
|
||||
Span<byte> index = stackalloc byte[16];
|
||||
for (int i = 0; i < 16; i++) index[i] = BestIndex(r0, r1, block.Slice(i * 4, 4));
|
||||
|
||||
// the first index is stored with three bits, so its top bit must be clear: swap the endpoints if it is not
|
||||
if (index[0] >= 8)
|
||||
{
|
||||
for (int c = 0; c < 4; c++) { (q0[c], q1[c]) = (q1[c], q0[c]); }
|
||||
(p0, p1) = (p1, p0);
|
||||
for (int i = 0; i < 16; i++) index[i] = (byte)(15 - index[i]);
|
||||
}
|
||||
|
||||
ulong lo = 0, hi = 0;
|
||||
int position = 0;
|
||||
void Put(uint value, int bits)
|
||||
{
|
||||
for (int b = 0; b < bits; b++)
|
||||
{
|
||||
ulong bit = (value >> b) & 1;
|
||||
if (position < 64) lo |= bit << position; else hi |= bit << (position - 64);
|
||||
position++;
|
||||
}
|
||||
}
|
||||
Put(0x40, 7); // mode 6
|
||||
for (int c = 0; c < 4; c++) { Put((uint)q0[c], 7); Put((uint)q1[c], 7); }
|
||||
Put((uint)p0, 1);
|
||||
Put((uint)p1, 1);
|
||||
for (int i = 0; i < 16; i++) Put(index[i], i == 0 ? 3 : 4);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(destination, lo);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], hi);
|
||||
}
|
||||
|
||||
// Seven bits per channel plus one shared low bit: pick the low bit that reproduces the endpoint best.
|
||||
private static void Quantize(ReadOnlySpan<int> endpoint, Span<int> quantized, out int pbit, Span<int> restored)
|
||||
{
|
||||
int bestError = int.MaxValue;
|
||||
pbit = 0;
|
||||
for (int p = 0; p < 2; p++)
|
||||
{
|
||||
int error = 0;
|
||||
for (int c = 0; c < 4; c++)
|
||||
{
|
||||
int v = Math.Clamp((endpoint[c] - p + 1) / 2, 0, 127);
|
||||
error += Math.Abs(((v << 1) | p) - endpoint[c]);
|
||||
}
|
||||
if (error < bestError) { bestError = error; pbit = p; }
|
||||
}
|
||||
for (int c = 0; c < 4; c++)
|
||||
{
|
||||
quantized[c] = Math.Clamp((endpoint[c] - pbit + 1) / 2, 0, 127);
|
||||
restored[c] = (quantized[c] << 1) | pbit;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte BestIndex(ReadOnlySpan<int> e0, ReadOnlySpan<int> e1, ReadOnlySpan<byte> pixel)
|
||||
{
|
||||
int best = 0, bestDistance = int.MaxValue;
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
int w = Weights[i], distance = 0;
|
||||
for (int c = 0; c < 4; c++)
|
||||
{
|
||||
int value = (e0[c] * (64 - w) + e1[c] * w + 32) >> 6;
|
||||
int d = value - pixel[c];
|
||||
distance += d * d;
|
||||
}
|
||||
if (distance < bestDistance) { bestDistance = distance; best = i; }
|
||||
}
|
||||
return (byte)best;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public sealed class Ps5DetailsLoader
|
||||
private static Ps5GameDetails Load(Ps5GameInfo game, CancellationToken cancellationToken,
|
||||
IProgress<Ps5Artwork>? artwork)
|
||||
{
|
||||
if (game.SourceKind == Ps5SourceKind.SonyPackage)
|
||||
if (game.SourceKind == Ps5SourceKind.Ps5Package)
|
||||
return LoadPackage(game, cancellationToken, artwork);
|
||||
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(game, cancellationToken);
|
||||
@@ -68,8 +68,8 @@ public sealed class Ps5DetailsLoader
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var errors = new List<string>();
|
||||
var reader = new SonyPkgReader();
|
||||
SonyPkgSummary package = ResolvePackage(game, reader);
|
||||
var reader = new Ps5PkgReader();
|
||||
Ps5PkgSummary package = ResolvePackage(game, reader);
|
||||
Ps5FileInventory files;
|
||||
Ps5ImageData? icon = null;
|
||||
|
||||
@@ -102,7 +102,7 @@ public sealed class Ps5DetailsLoader
|
||||
Ps5ImageData? pic0 = ReadPackageImage(reader, game.RootPath, package, 0x1220, 0x12A0, "PIC0", errors);
|
||||
Ps5ImageData? pic1 = ReadPackageImage(reader, game.RootPath, package, null, 0x12C0, "PIC1", errors);
|
||||
artwork?.Report(new Ps5Artwork(icon, pic0, pic1, null));
|
||||
if (package.NestedPfs is { } nested && nested.AccessState != SonyPfsAccessState.NotPresent)
|
||||
if (package.NestedPfs is { } nested && nested.AccessState != Ps5PfsAccessState.NotPresent)
|
||||
errors.Add(nested.StatusMessage);
|
||||
|
||||
return new Ps5GameDetails
|
||||
@@ -117,8 +117,8 @@ public sealed class Ps5DetailsLoader
|
||||
};
|
||||
}
|
||||
|
||||
private static Ps5GameDetails LoadReadablePackage(Ps5GameInfo game, SonyPkgSummary package,
|
||||
SonyPkgReader packageReader, IReadOnlyGameFileSystem fileSystem, Ps5FileInventory inventory,
|
||||
private static Ps5GameDetails LoadReadablePackage(Ps5GameInfo game, Ps5PkgSummary package,
|
||||
Ps5PkgReader packageReader, IReadOnlyGameFileSystem fileSystem, Ps5FileInventory inventory,
|
||||
Ps5ImageData? icon, CancellationToken cancellationToken, List<string> errors,
|
||||
IProgress<Ps5Artwork>? artwork)
|
||||
{
|
||||
@@ -190,19 +190,19 @@ public sealed class Ps5DetailsLoader
|
||||
};
|
||||
}
|
||||
|
||||
private static SonyPkgSummary ResolvePackage(Ps5GameInfo game, SonyPkgReader reader)
|
||||
private static Ps5PkgSummary ResolvePackage(Ps5GameInfo game, Ps5PkgReader reader)
|
||||
{
|
||||
// A package restored from the cached manifest has no live engine access (the field is
|
||||
// internal and therefore not serialized), so re-open it before reading inner files.
|
||||
SonyPkgSummary? cached = game.Package;
|
||||
Ps5PkgSummary? cached = game.Package;
|
||||
if (cached is not null && (cached.NestedPfs is null || cached.NestedPfs.EngineAccess is not null))
|
||||
return cached;
|
||||
SonyPkgSummary fresh = reader.Read(game.RootPath);
|
||||
Ps5PkgSummary fresh = reader.Read(game.RootPath);
|
||||
game.Package = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private static Ps5FileInventory ReadPackageEntries(SonyPkgSummary package)
|
||||
private static Ps5FileInventory ReadPackageEntries(Ps5PkgSummary package)
|
||||
{
|
||||
Ps5FileInfo[] entries = package.Entries.Select(entry =>
|
||||
{
|
||||
@@ -231,19 +231,19 @@ public sealed class Ps5DetailsLoader
|
||||
};
|
||||
}
|
||||
|
||||
private static Ps5ImageData? ReadPackageImage(SonyPkgReader reader, string path, SonyPkgSummary package,
|
||||
private static Ps5ImageData? ReadPackageImage(Ps5PkgReader reader, string path, Ps5PkgSummary package,
|
||||
uint? pngId, uint? ddsId, string description, List<string> errors)
|
||||
{
|
||||
const int maximumImageBytes = 128 * 1024 * 1024;
|
||||
try
|
||||
{
|
||||
SonyPkgEntry? png = pngId.HasValue
|
||||
Ps5PkgEntry? png = pngId.HasValue
|
||||
? package.Entries.FirstOrDefault(entry => entry.Id == pngId && !entry.IsEncrypted)
|
||||
: null;
|
||||
if (png is not null)
|
||||
return Ps5ImageData.FromPng(reader.ReadEntryBytes(path, package, png, maximumImageBytes));
|
||||
|
||||
SonyPkgEntry? dds = ddsId.HasValue
|
||||
Ps5PkgEntry? dds = ddsId.HasValue
|
||||
? package.Entries.FirstOrDefault(entry => entry.Id == ddsId && !entry.IsEncrypted)
|
||||
: null;
|
||||
if (dds is null) return null;
|
||||
@@ -294,7 +294,7 @@ public sealed class Ps5DetailsLoader
|
||||
/// </summary>
|
||||
public static Ps5ImageData? LoadFullBackground(Ps5GameInfo game, int index)
|
||||
{
|
||||
if (game.SourceKind == Ps5SourceKind.SonyPackage) return null;
|
||||
if (game.SourceKind == Ps5SourceKind.Ps5Package) return null;
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(game);
|
||||
return ReadBackground(files, index, [], full: true);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using ProsperoPkgTool.Containers;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
/// <summary>Free-space preflight outcome, mirroring the engine without exposing its types.</summary>
|
||||
/// <summary>Free-space preflight outcome.</summary>
|
||||
public enum Ps5DiskSpaceStatus
|
||||
{
|
||||
Ok,
|
||||
@@ -14,69 +14,97 @@ public enum Ps5DiskSpaceStatus
|
||||
public readonly record struct Ps5DiskSpaceCheck(Ps5DiskSpaceStatus Status, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Thin facade over the engine's <see cref="DiskSpaceGuard"/> so the UI layer does not reference the
|
||||
/// engine assembly directly.
|
||||
/// Free-space preflight for building a package. The builder streams file data straight into the output, so the only
|
||||
/// space it needs is the package itself (the inner image is stored as it is, plus a small amount of metadata).
|
||||
/// </summary>
|
||||
public static class Ps5DiskSpace
|
||||
{
|
||||
private const long Megabyte = 1024L * 1024;
|
||||
private const int SafetyPercent = 12;
|
||||
private const int WarnPercent = 25;
|
||||
private const long Headroom = 512 * Megabyte;
|
||||
|
||||
private sealed record Requirement(string Root, long Bytes, string What);
|
||||
|
||||
/// <summary>Estimates and checks free space for a package build.</summary>
|
||||
/// <param name="rawPayloadBytes">Source payload upper bound (dump total, or image file length).</param>
|
||||
/// <param name="outputPath">Destination package path.</param>
|
||||
/// <param name="tempDirectory">Workspace folder, or null for the system temp folder.</param>
|
||||
public static Ps5DiskSpaceCheck Check(long rawPayloadBytes, string outputPath, string? tempDirectory)
|
||||
{
|
||||
DiskSpaceReport report = DiskSpaceGuard.Check(
|
||||
DiskSpaceGuard.Estimate(rawPayloadBytes, outputPath, tempDirectory));
|
||||
Ps5DiskSpaceStatus status = report.Status switch
|
||||
{
|
||||
DiskSpaceStatus.Insufficient => Ps5DiskSpaceStatus.Insufficient,
|
||||
DiskSpaceStatus.NearLimit => Ps5DiskSpaceStatus.NearLimit,
|
||||
_ => Ps5DiskSpaceStatus.Ok
|
||||
};
|
||||
return new Ps5DiskSpaceCheck(status, DiskSpaceGuard.Describe(report));
|
||||
}
|
||||
/// <param name="tempDirectory">Not used by the builder; kept so callers do not need to change.</param>
|
||||
public static Ps5DiskSpaceCheck Check(long rawPayloadBytes, string outputPath, string? tempDirectory) =>
|
||||
Evaluate([new Requirement(RootOf(outputPath), PackageSize(rawPayloadBytes), "output package")]);
|
||||
|
||||
/// <summary>
|
||||
/// Free-space preflight for an extract-then-build: the extracted staging tree plus the build's
|
||||
/// temp workspace and output, measured against the affected volumes.
|
||||
/// Free-space preflight for an extract-then-build: the extracted staging tree, measured against the volume it is
|
||||
/// written to, plus the output package.
|
||||
/// </summary>
|
||||
public static Ps5DiskSpaceCheck CheckStagedImage(long rawPayloadBytes, long stagingBytes,
|
||||
string outputPath, string? tempDirectory)
|
||||
{
|
||||
var requirements = DiskSpaceGuard.Estimate(rawPayloadBytes, outputPath, tempDirectory).ToList();
|
||||
if (stagingBytes > 0)
|
||||
string output = RootOf(outputPath);
|
||||
string staging = RootOf(tempDirectory ?? Path.GetTempPath());
|
||||
var requirements = new List<Requirement>();
|
||||
if (string.Equals(output, staging, StringComparison.OrdinalIgnoreCase))
|
||||
requirements.Add(new Requirement(output, PackageSize(rawPayloadBytes) + stagingBytes, "output package + staging tree"));
|
||||
else
|
||||
{
|
||||
string tempRoot = Path.GetPathRoot(Path.GetFullPath(tempDirectory ?? Path.GetTempPath())) ?? string.Empty;
|
||||
int index = requirements.FindIndex(requirement =>
|
||||
string.Equals(requirement.Root, tempRoot, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0)
|
||||
{
|
||||
requirements[index] = requirements[index] with
|
||||
{
|
||||
Bytes = requirements[index].Bytes + stagingBytes,
|
||||
What = requirements[index].What + " + staging tree",
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
requirements.Add(new SpaceRequirement(tempRoot, stagingBytes, "staging tree"));
|
||||
}
|
||||
requirements.Add(new Requirement(output, PackageSize(rawPayloadBytes), "output package"));
|
||||
if (stagingBytes > 0) requirements.Add(new Requirement(staging, stagingBytes, "staging tree"));
|
||||
}
|
||||
|
||||
DiskSpaceReport report = DiskSpaceGuard.Check(requirements);
|
||||
Ps5DiskSpaceStatus status = report.Status switch
|
||||
{
|
||||
DiskSpaceStatus.Insufficient => Ps5DiskSpaceStatus.Insufficient,
|
||||
DiskSpaceStatus.NearLimit => Ps5DiskSpaceStatus.NearLimit,
|
||||
_ => Ps5DiskSpaceStatus.Ok,
|
||||
};
|
||||
return new Ps5DiskSpaceCheck(status, DiskSpaceGuard.Describe(report));
|
||||
return Evaluate(requirements);
|
||||
}
|
||||
|
||||
/// <summary>True when the exception is the engine's insufficient-free-space failure.</summary>
|
||||
public static bool IsInsufficient(Exception exception) => exception is ProsperoInsufficientSpaceException;
|
||||
/// <summary>True when the exception is an out-of-disk-space failure.</summary>
|
||||
public static bool IsInsufficient(Exception exception) =>
|
||||
exception is IOException io && (io.HResult & 0xFFFF) is 112 or 39; // ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL
|
||||
|
||||
/// <summary>The engine's free-space message for an insufficient-space exception.</summary>
|
||||
public static string Describe(Exception exception) =>
|
||||
exception is ProsperoInsufficientSpaceException space ? space.Message : exception.Message;
|
||||
IsInsufficient(exception) ? "there is not enough free space on the destination drive" : exception.Message;
|
||||
|
||||
private static long PackageSize(long payload) => Math.Max(0, payload) + Math.Max(16 * Megabyte, payload / 50) + 64 * Megabyte;
|
||||
|
||||
private static Ps5DiskSpaceCheck Evaluate(IReadOnlyList<Requirement> requirements)
|
||||
{
|
||||
Requirement? worstRequirement = null;
|
||||
Ps5DiskSpaceStatus worst = Ps5DiskSpaceStatus.Ok;
|
||||
long worstFree = 0, worstNeed = 0;
|
||||
foreach (Requirement requirement in requirements)
|
||||
{
|
||||
long need = WithMargin(requirement.Bytes, SafetyPercent);
|
||||
long free = Free(requirement.Root);
|
||||
Ps5DiskSpaceStatus status = free < need ? Ps5DiskSpaceStatus.Insufficient
|
||||
: free < WithMargin(requirement.Bytes, WarnPercent) ? Ps5DiskSpaceStatus.NearLimit : Ps5DiskSpaceStatus.Ok;
|
||||
if (worstRequirement is null || status > worst)
|
||||
{
|
||||
worst = status; worstRequirement = requirement; worstFree = free; worstNeed = need;
|
||||
}
|
||||
}
|
||||
if (worstRequirement is null) return new Ps5DiskSpaceCheck(Ps5DiskSpaceStatus.Ok, "free space not checked");
|
||||
string message = worst switch
|
||||
{
|
||||
Ps5DiskSpaceStatus.Insufficient => $"not enough free space: need ~{Gb(worstNeed)} GB on {worstRequirement.Root} ({worstRequirement.What}), have {Gb(worstFree)} GB free",
|
||||
Ps5DiskSpaceStatus.NearLimit => $"low free space on {worstRequirement.Root} ({worstRequirement.What}): need ~{Gb(worstNeed)} GB, have {Gb(worstFree)} GB free",
|
||||
_ => $"free space OK on {worstRequirement.Root} ({worstRequirement.What}): have {Gb(worstFree)} GB",
|
||||
};
|
||||
return new Ps5DiskSpaceCheck(worst, message);
|
||||
}
|
||||
|
||||
private static long WithMargin(long bytes, int percent)
|
||||
{
|
||||
try { return checked((long)Math.Ceiling(bytes * (1.0 + percent / 100.0)) + Headroom); }
|
||||
catch (OverflowException) { return long.MaxValue; }
|
||||
}
|
||||
|
||||
private static long Free(string root)
|
||||
{
|
||||
try { return new DriveInfo(root).AvailableFreeSpace; }
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) { return long.MaxValue; }
|
||||
}
|
||||
|
||||
private static string RootOf(string path)
|
||||
{
|
||||
try { return Path.GetPathRoot(Path.GetFullPath(path)) ?? path; }
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { return path; }
|
||||
}
|
||||
|
||||
private static string Gb(long bytes) => (bytes / 1073741824.0).ToString("N1", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public sealed class Ps5LibraryScanner
|
||||
{
|
||||
private readonly Ps5DumpLocator _locator = new();
|
||||
private readonly Ps5ParamReader _paramReader = new();
|
||||
private readonly SonyPkgGameReader _packageReader = new();
|
||||
private readonly Ps5PkgGameReader _packageReader = new();
|
||||
private readonly FfpfscGameReader _ffpfscReader = new();
|
||||
private readonly FilesystemImageGameReader _filesystemImageReader = new();
|
||||
private readonly FfpkgGameReader _ffpkgReader = new();
|
||||
@@ -70,7 +70,7 @@ public sealed class Ps5LibraryScanner
|
||||
|
||||
int processed = 0;
|
||||
var sources = paramPaths.Select(path => (Path: path, Kind: Ps5SourceKind.LooseDump))
|
||||
.Concat(packagePaths.Select(path => (Path: path, Kind: Ps5SourceKind.SonyPackage)))
|
||||
.Concat(packagePaths.Select(path => (Path: path, Kind: Ps5SourceKind.Ps5Package)))
|
||||
.Concat(ffpfscPaths.Select(path => (Path: path, Kind: Ps5SourceKind.Ffpfsc)))
|
||||
.Concat(filesystemImagePaths.Select(path => (Path: path, Kind: Ps5SourceKind.FilesystemImage)))
|
||||
.Concat(ffpkgPaths.Select(path => (Path: path, Kind: Ps5SourceKind.Ffpkg)))
|
||||
@@ -102,7 +102,7 @@ public sealed class Ps5LibraryScanner
|
||||
|
||||
Ps5GameInfo game = kind switch
|
||||
{
|
||||
Ps5SourceKind.SonyPackage => _packageReader.Read(path),
|
||||
Ps5SourceKind.Ps5Package => _packageReader.Read(path),
|
||||
Ps5SourceKind.Ffpfsc => _ffpfscReader.Read(path),
|
||||
Ps5SourceKind.FilesystemImage => _filesystemImageReader.Read(path),
|
||||
Ps5SourceKind.Ffpkg => _ffpkgReader.Read(path),
|
||||
@@ -128,6 +128,7 @@ public sealed class Ps5LibraryScanner
|
||||
}
|
||||
processed++;
|
||||
}
|
||||
ComputeDumpSizes(result.Games, cancellationToken);
|
||||
progress?.Report(new Ps5ScanProgress { Processed = processed, Total = sources.Length });
|
||||
result.Games.Sort((left, right) => StringComparer.CurrentCultureIgnoreCase.Compare(left.Title, right.Title));
|
||||
return result;
|
||||
@@ -156,10 +157,58 @@ public sealed class Ps5LibraryScanner
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or NotSupportedException)
|
||||
{
|
||||
}
|
||||
string label = kind == Ps5SourceKind.SonyPackage ? "package" : "image";
|
||||
string label = kind == Ps5SourceKind.Ps5Package ? "package" : "image";
|
||||
return SourceStructure.Unreadable(kind, path, label, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A loose dump has no single file to measure, so its size is the sum of its files. The scan lists dumps
|
||||
/// first and measures them here, a few at a time and without opening any file, so every library card has
|
||||
/// a size from the start instead of only after its details were opened.
|
||||
/// </summary>
|
||||
private static void ComputeDumpSizes(List<Ps5GameInfo> games, CancellationToken cancellationToken)
|
||||
{
|
||||
List<Ps5GameInfo> pending = games
|
||||
.Where(game => game.SourceKind == Ps5SourceKind.LooseDump && game.SourceSize <= 0 &&
|
||||
Directory.Exists(game.RootPath))
|
||||
.ToList();
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
Parallel.ForEach(pending,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cancellationToken },
|
||||
game => game.SourceSize = SumFileSizes(game.RootPath, cancellationToken));
|
||||
}
|
||||
|
||||
private static long SumFileSizes(string root, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
ReturnSpecialDirectories = false
|
||||
};
|
||||
var sizes = new System.IO.Enumeration.FileSystemEnumerable<long>(root,
|
||||
(ref System.IO.Enumeration.FileSystemEntry entry) => entry.Length, options)
|
||||
{
|
||||
ShouldIncludePredicate = (ref System.IO.Enumeration.FileSystemEntry entry) => !entry.IsDirectory
|
||||
};
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
foreach (long length in sizes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
total += length;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Report what could be counted rather than failing the scan over one unreadable folder.
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static string ResolveRootPath(string path, Ps5SourceKind kind)
|
||||
{
|
||||
if (kind != Ps5SourceKind.LooseDump) return path;
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Parsers;
|
||||
using ProsperoPkgTool.Content;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
@@ -209,8 +208,7 @@ public static partial class Ps5MetadataSuggester
|
||||
using Stream stream = files.OpenRead("eboot.bin");
|
||||
byte[] head = new byte[(int)Math.Min(stream.Length, 1 << 20)];
|
||||
stream.ReadExactly(head);
|
||||
if (!ProsperoSelfReader.TryParse(head, out ProsperoSelfImage? image) || image is null) return false;
|
||||
ulong value = image.ExtInfo.FirmwareVersion;
|
||||
if (!Ps5SelfHeader.TryReadFirmwareVersion(head, out ulong value)) return false;
|
||||
if (value == 0) return false;
|
||||
string formatted = Ps5ParamReader.FormatSystemVersion("0x" + value.ToString("X16", CultureInfo.InvariantCulture));
|
||||
if (formatted.Length == 0 || formatted.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Pkg.Reader;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
public readonly record struct Ps5PackageExtractProgress(long CompletedBytes, long TotalBytes, string CurrentPath);
|
||||
public sealed class Ps5PackageExtractResult
|
||||
{
|
||||
public required string Destination { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
public required long ExtractedBytes { get; init; }
|
||||
}
|
||||
|
||||
public static class Ps5PackageExtraction
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the application filesystem of a package to <paramref name="destination"/>, which must not exist.
|
||||
/// The passcode is only needed for the protected metadata entries; the files themselves are not encrypted.
|
||||
/// </summary>
|
||||
public static Task<Ps5PackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<Ps5PackageExtractProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
string output = Path.GetFullPath(destination);
|
||||
if (Directory.Exists(output)) throw new IOException("The extraction destination already exists: " + output);
|
||||
|
||||
using PkgPackage package = PkgPackage.Open(packagePath, passcode);
|
||||
IReadOnlyList<PkgFile> files;
|
||||
try { files = package.Files; }
|
||||
catch (PkgNotSupportedException ex) { throw new InvalidDataException(ex.Message, ex); }
|
||||
if (package.IsMetadataOnly || files.Count == 0)
|
||||
throw new InvalidDataException("The package contains no files that can be extracted.");
|
||||
|
||||
IProgress<PkgExtractProgress>? forward = progress is null
|
||||
? null
|
||||
: new Progress<PkgExtractProgress>(p => progress.Report(new Ps5PackageExtractProgress(p.CompletedBytes, p.TotalBytes, p.CurrentPath)));
|
||||
package.ExtractTo(output, forward, cancellationToken, includeMetadata: true);
|
||||
return new Ps5PackageExtractResult
|
||||
{
|
||||
Destination = output,
|
||||
FileCount = files.Count,
|
||||
ExtractedBytes = files.Sum(file => file.Size)
|
||||
};
|
||||
}, cancellationToken);
|
||||
}
|
||||
+7
-19
@@ -6,23 +6,11 @@ namespace Packwright.Core.Services;
|
||||
/// <summary>
|
||||
/// Converts a PS5 debug package into a standalone filesystem image (exFAT, FFPKG/UFS2 or FFPFSC).
|
||||
/// The package's readable content (inner PFS files plus outer CNT metadata such as
|
||||
/// <c>sce_sys/param.json</c>, artwork and trophies) is materialized to a temporary tree after
|
||||
/// dropping the container's internal bookkeeping files, then rebuilt in the target format.
|
||||
/// <c>sce_sys/param.json</c>, artwork and trophies) is materialized to a temporary tree unchanged, with
|
||||
/// no file left out, then rebuilt in the target format.
|
||||
/// </summary>
|
||||
public static class SonyPackageImageConversion
|
||||
public static class Ps5PackageImageConversion
|
||||
{
|
||||
// Inner-PFS system tables synthesized by the assembler, plus the debug markers the builder
|
||||
// injects, are not part of a normal dump and must not be re-emitted as regular files.
|
||||
private static readonly HashSet<string> ExcludedPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"inode_flat_path_table",
|
||||
"apr_flat_path_table",
|
||||
"afid_to_ino_table",
|
||||
"sce_sys/keystone",
|
||||
"sce_sys/about/right.sprx",
|
||||
"sce_sys/pfs-version.dat"
|
||||
};
|
||||
|
||||
public static async Task<Ps5ImageConversionResult> ConvertAsync(string packagePath, string outputPath,
|
||||
Ps5ImageConversionTarget target, bool overwrite = false,
|
||||
IProgress<Ps5ImageConversionProgress>? progress = null, CancellationToken cancellationToken = default,
|
||||
@@ -32,8 +20,8 @@ public static class SonyPackageImageConversion
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
Ps5GameInfo game = new SonyPkgGameReader().Read(packagePath);
|
||||
if (game.SourceKind != Ps5SourceKind.SonyPackage)
|
||||
Ps5GameInfo game = new Ps5PkgGameReader().Read(packagePath);
|
||||
if (game.SourceKind != Ps5SourceKind.Ps5Package)
|
||||
throw new InvalidDataException("The source is not a PS5 package.");
|
||||
|
||||
string packageFull = Path.GetFullPath(packagePath);
|
||||
@@ -64,7 +52,7 @@ public static class SonyPackageImageConversion
|
||||
string reason = string.Empty;
|
||||
try
|
||||
{
|
||||
if (Packwright.Core.Builders.SonyDebugPackageBuilder.Validate(game.RootPath) is { IsValid: false } result)
|
||||
if (Packwright.Core.Builders.Ps5DebugPackageBuilder.Validate(game.RootPath) is { IsValid: false } result)
|
||||
reason = result.Message;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||
@@ -84,7 +72,7 @@ public static class SonyPackageImageConversion
|
||||
{
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(game, cancellationToken);
|
||||
GameFileRecord[] wanted = files.Files
|
||||
.Where(file => file.RelativePath.Length > 0 && !ExcludedPaths.Contains(file.RelativePath))
|
||||
.Where(file => file.RelativePath.Length > 0)
|
||||
.ToArray();
|
||||
if (wanted.Length == 0)
|
||||
throw new InvalidDataException(NoReadableFilesMessage(game));
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Pkg.Reader;
|
||||
using Packwright.Pkg.Writer;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Edits a debug package's details (param.json, artwork) without rebuilding it: the inner image is copied as it is and
|
||||
/// only the metadata container is written again. This works while the content id stays the same.
|
||||
/// </summary>
|
||||
public static class Ps5PackageMetadataEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes <paramref name="targetPath"/> from <paramref name="sourcePath"/> with a patched param.json and, when given, a new
|
||||
/// icon (the PNG and its texture). Returns false, and writes nothing, when the package needs a full rebuild instead (the
|
||||
/// patch changes the content id, the package is not a readable debug package).
|
||||
/// </summary>
|
||||
public static Task<bool> TryEditAsync(string sourcePath, string targetPath, Func<byte[], byte[]> patchParam, byte[]? iconPng,
|
||||
string passcode, IProgress<Ps5DebugPackageProgress>? progress, CancellationToken cancellationToken) =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
string source = Path.GetFullPath(sourcePath);
|
||||
string target = Path.GetFullPath(targetPath);
|
||||
string staging = target + ".packwright-new";
|
||||
bool inPlace = string.Equals(source, target, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using (PkgPackage package = PkgPackage.Open(source, passcode))
|
||||
{
|
||||
if (package.IsMetadataOnly || package.IsRetail || package.IsPatchImage) return false;
|
||||
PkgEntry? paramEntry = package.Entries.FirstOrDefault(entry => entry.Id == PkgEntryTable.ParamJson);
|
||||
if (paramEntry is null) return false;
|
||||
|
||||
byte[] param = patchParam(package.ReadEntry(paramEntry));
|
||||
string? contentId = ParseContentId(param);
|
||||
if (contentId is not null && !contentId.Equals(package.ContentId, StringComparison.Ordinal)) return false;
|
||||
|
||||
var replacements = new Dictionary<uint, byte[]> { [PkgEntryTable.ParamJson] = param };
|
||||
if (iconPng is not null)
|
||||
{
|
||||
replacements[0x1200] = iconPng;
|
||||
try { replacements[0x1280] = Ps5DdsEncoder.EncodePng(iconPng); }
|
||||
catch (Exception ex) when (ex is SixLabors.ImageSharp.ImageFormatException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException("The icon is not a picture Packwright can read.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var relay = progress is null ? null : new Progress<PkgBuildProgress>(value =>
|
||||
progress.Report(new Ps5DebugPackageProgress(value.Stage, value.Done, value.Total, value.Item)));
|
||||
try
|
||||
{
|
||||
PkgRepackager.ReplaceEntries(package, staging, replacements, Ps5BuildKeys.Current, passcode, null, relay, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(staging)) File.Delete(staging); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// the source is closed now: swap the new file in
|
||||
try
|
||||
{
|
||||
if (inPlace) File.Move(staging, target, overwrite: true);
|
||||
else
|
||||
{
|
||||
if (File.Exists(target)) throw new IOException("The output already exists: " + target);
|
||||
File.Move(staging, target);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(staging)) File.Delete(staging); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
private static string? ParseContentId(byte[] param)
|
||||
{
|
||||
try { return JsonNode.Parse(param)?["contentId"]?.GetValue<string>(); }
|
||||
catch (Exception ex) when (ex is System.Text.Json.JsonException or InvalidOperationException or FormatException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Small, targeted edits of a title's <c>sce_sys/param.json</c>. The file is rewritten the way the SDK tools write
|
||||
/// it: keys in ordinal order, two-space indent, CRLF line endings and a trailing newline.
|
||||
/// </summary>
|
||||
public static class Ps5ParamJson
|
||||
{
|
||||
// requiredSystemSoftwareVersion is never raised above this (firmware 9.00)
|
||||
private const ulong RequiredSystemSoftwareVersionCap = 0x0900000000000000UL;
|
||||
|
||||
public static byte[] ApplyDrmType(ReadOnlySpan<byte> source, string drmType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(drmType);
|
||||
JsonObject root = Parse(source);
|
||||
root["applicationDrmType"] = drmType;
|
||||
return Serialize(root);
|
||||
}
|
||||
|
||||
/// <summary>Stamps a full 64-bit executable SDK id: sdkVersion becomes its package version and the required firmware is raised to match.</summary>
|
||||
public static byte[] ApplySdkOverride(ReadOnlySpan<byte> source, ulong executableVersion)
|
||||
{
|
||||
JsonObject root = Parse(source);
|
||||
ulong package = executableVersion & 0xFFFF000000000000UL;
|
||||
root["sdkVersion"] = VersionText(package);
|
||||
ulong required = Math.Max(Math.Min(HexVersion(root["requiredSystemSoftwareVersion"]), RequiredSystemSoftwareVersionCap), package);
|
||||
root["requiredSystemSoftwareVersion"] = VersionText(required);
|
||||
return Serialize(root);
|
||||
}
|
||||
|
||||
/// <summary>The value of a top-level string property, or null.</summary>
|
||||
public static string? ReadString(ReadOnlySpan<byte> source, string name)
|
||||
{
|
||||
try { return Parse(source)[name]?.GetValue<string>(); }
|
||||
catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or FormatException) { return null; }
|
||||
}
|
||||
|
||||
private static JsonObject Parse(ReadOnlySpan<byte> source)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(source) as JsonObject ?? throw new InvalidDataException("sce_sys/param.json must contain a JSON object.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException("sce_sys/param.json is not valid JSON.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Serialize(JsonObject root)
|
||||
{
|
||||
string text = Sorted(root).ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
text = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", "\r\n", StringComparison.Ordinal) + "\r\n";
|
||||
return new UTF8Encoding(false).GetBytes(text);
|
||||
}
|
||||
|
||||
private static JsonNode Sorted(JsonNode? node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
var sorted = new JsonObject();
|
||||
foreach (KeyValuePair<string, JsonNode?> item in obj.OrderBy(p => p.Key, StringComparer.Ordinal))
|
||||
sorted[item.Key] = Sorted(item.Value);
|
||||
return sorted;
|
||||
}
|
||||
if (node is JsonArray array)
|
||||
{
|
||||
var copy = new JsonArray();
|
||||
foreach (JsonNode? item in array) copy.Add(Sorted(item));
|
||||
return copy;
|
||||
}
|
||||
return node?.DeepClone() ?? JsonValue.Create((string?)null)!;
|
||||
}
|
||||
|
||||
private static ulong HexVersion(JsonNode? node)
|
||||
{
|
||||
string? text = node?.GetValue<string>();
|
||||
return text is not null && text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
&& ulong.TryParse(text.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out ulong value) ? value : 0;
|
||||
}
|
||||
|
||||
private static string VersionText(ulong value) => $"0x{value:x16}";
|
||||
}
|
||||
+10
-10
@@ -5,23 +5,23 @@ using Packwright.Core.Parsers;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
public sealed partial class SonyPkgGameReader
|
||||
public sealed partial class Ps5PkgGameReader
|
||||
{
|
||||
private const int MaximumParamJsonSize = 16 * 1024 * 1024;
|
||||
private readonly SonyPkgReader _packageReader = new();
|
||||
private readonly Ps5PkgReader _packageReader = new();
|
||||
private readonly Ps5ParamReader _paramReader = new();
|
||||
|
||||
public Ps5GameInfo Read(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
string fullPath = Path.GetFullPath(packagePath);
|
||||
SonyPkgSummary package = _packageReader.Read(fullPath);
|
||||
Ps5PkgSummary package = _packageReader.Read(fullPath);
|
||||
DateTime lastWrite = File.GetLastWriteTimeUtc(fullPath);
|
||||
string inferredContentId = ExtractContentId(Path.GetFileNameWithoutExtension(fullPath));
|
||||
string effectiveContentId = string.IsNullOrWhiteSpace(package.ContentId)
|
||||
? inferredContentId
|
||||
: package.ContentId;
|
||||
SonyPkgEntry? paramEntry = package.Entries.FirstOrDefault(entry => entry.Id == 0x2000 && !entry.IsEncrypted);
|
||||
Ps5PkgEntry? paramEntry = package.Entries.FirstOrDefault(entry => entry.Id == 0x2000 && !entry.IsEncrypted);
|
||||
|
||||
Ps5GameInfo game;
|
||||
if (paramEntry is not null && paramEntry.DataSize <= MaximumParamJsonSize)
|
||||
@@ -54,15 +54,15 @@ public sealed partial class SonyPkgGameReader
|
||||
: "param.json exceeds the safe metadata read limit.");
|
||||
}
|
||||
|
||||
game.SourceKind = Ps5SourceKind.SonyPackage;
|
||||
game.SourceKind = Ps5SourceKind.Ps5Package;
|
||||
game.RootPath = fullPath;
|
||||
game.SourceSize = package.FileSize;
|
||||
game.Package = package;
|
||||
if (string.IsNullOrWhiteSpace(game.ContentId)) game.ContentId = effectiveContentId;
|
||||
if (string.IsNullOrWhiteSpace(game.TitleId)) game.TitleId = ExtractTitleId(game.ContentId);
|
||||
if (string.IsNullOrWhiteSpace(package.ContentId) && !string.IsNullOrWhiteSpace(inferredContentId))
|
||||
game.DataWarnings.Add("The content ID and title ID were inferred from the standard Sony package filename.");
|
||||
if (package.Kind == SonyPkgKind.FinalizedRetail && package.Entries.Count == 0)
|
||||
game.DataWarnings.Add("The content ID and title ID were inferred from the standard PS5 package filename.");
|
||||
if (package.Kind == Ps5PkgKind.FinalizedRetail && package.Entries.Count == 0)
|
||||
game.DataWarnings.Add("This retail FIH has no embedded CNT metadata. Detailed game metadata and artwork are inside the encrypted PFS and require the matching retail image key.");
|
||||
if (!string.IsNullOrWhiteSpace(package.ContentId) &&
|
||||
!string.IsNullOrWhiteSpace(game.ContentId) &&
|
||||
@@ -79,7 +79,7 @@ public sealed partial class SonyPkgGameReader
|
||||
/// Fallback for packages whose outer CNT has no readable param.json entry: read the copy inside
|
||||
/// the inner PFS when the game filesystem decodes. Returns false when it is unavailable.
|
||||
/// </summary>
|
||||
private static bool TryReadInnerParamJson(SonyPkgSummary package, out string raw, out string origin)
|
||||
private static bool TryReadInnerParamJson(Ps5PkgSummary package, out string raw, out string origin)
|
||||
{
|
||||
raw = string.Empty;
|
||||
origin = string.Empty;
|
||||
@@ -88,7 +88,7 @@ public sealed partial class SonyPkgGameReader
|
||||
try
|
||||
{
|
||||
if (!access.TryFindFile("sce_sys/param.json",
|
||||
out ProsperoPkgTool.Containers.ProsperoInnerPfsReader.Entry entry) ||
|
||||
out Packwright.Pkg.Reader.PkgFile entry) ||
|
||||
entry.Size <= 0 || entry.Size > MaximumParamJsonSize)
|
||||
return false;
|
||||
byte[] data = access.ReadFile(entry);
|
||||
@@ -106,7 +106,7 @@ public sealed partial class SonyPkgGameReader
|
||||
|
||||
// Reads param.json through the already-parsed engine access when available, so the package is
|
||||
// not re-parsed a third time just to read its metadata entry.
|
||||
private byte[] ReadParamBytes(string fullPath, SonyPkgSummary package, SonyPkgEntry paramEntry) =>
|
||||
private byte[] ReadParamBytes(string fullPath, Ps5PkgSummary package, Ps5PkgEntry paramEntry) =>
|
||||
package.NestedPfs?.EngineAccess is { } access
|
||||
? access.ReadCntEntry(paramEntry.Id, paramEntry.DataSize)
|
||||
: _packageReader.ReadEntryBytes(fullPath, package, paramEntry, MaximumParamJsonSize);
|
||||
@@ -1,10 +1,8 @@
|
||||
using ProsperoPkgTool.Content;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The known Prospero application SDK releases, surfaced for the UI without referencing the engine
|
||||
/// assembly directly. A release id is the full 64-bit executable SDK identifier.
|
||||
/// The known Prospero application SDK releases, surfaced for the UI. A release id is the full 64-bit executable SDK
|
||||
/// identifier: the major and minor version in binary-coded decimal in the top two bytes, a build number below.
|
||||
/// </summary>
|
||||
public static class Ps5SdkVersions
|
||||
{
|
||||
@@ -12,9 +10,19 @@ public static class Ps5SdkVersions
|
||||
|
||||
/// <summary>Known SDK releases in ascending major order.</summary>
|
||||
public static IReadOnlyList<Release> Releases { get; } =
|
||||
ProsperoSdkVersions.Releases
|
||||
.Select(release => new Release(release.Major, release.Version, release.ExecutableVersion))
|
||||
.ToArray();
|
||||
[
|
||||
new(1, "1.00.00.50", 72057937635311617UL),
|
||||
new(2, "2.00.00.26", 144115351284613121UL),
|
||||
new(3, "3.00.00.27", 216172949617508881UL),
|
||||
new(4, "4.00.00.31", 288230586605109777UL),
|
||||
new(5, "5.00.00.33", 360288189232971777UL),
|
||||
new(6, "6.00.00.38", 432345804745736193UL),
|
||||
new(7, "7.00.00.38", 504403398783664913UL),
|
||||
new(8, "8.00.00.41", 576461031476297729UL),
|
||||
new(9, "9.00.00.40", 648518621219259409UL),
|
||||
new(10, "10.00.00.40", 1152921779484753921UL),
|
||||
new(11, "11.00.00.40", 1224979373522681857UL),
|
||||
];
|
||||
|
||||
/// <summary>The executable SDK id at <paramref name="index"/>, or null when out of range.</summary>
|
||||
public static ulong? ExecutableVersionAt(int index) =>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Parsers;
|
||||
using ProsperoPkgTool.Containers;
|
||||
|
||||
namespace Packwright.Core.Services;
|
||||
|
||||
public readonly record struct SonyPackageExtractProgress(long CompletedBytes, long TotalBytes, string CurrentPath);
|
||||
public sealed class SonyPackageExtractResult
|
||||
{
|
||||
public required string Destination { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
public required long ExtractedBytes { get; init; }
|
||||
}
|
||||
|
||||
public static class SonyPackageExtraction
|
||||
{
|
||||
public static async Task<SonyPackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<SonyPackageExtractProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string output = Path.GetFullPath(destination);
|
||||
if (Directory.Exists(output)) throw new IOException("The extraction destination already exists: " + output);
|
||||
SonyPkgSummary package = new SonyPkgReader().Read(packagePath, passcode);
|
||||
SonyPfsSummary pfs = package.NestedPfs ?? throw new InvalidDataException("The package contains no indexed PFS image.");
|
||||
string staging = output + ".extracting." + Guid.NewGuid().ToString("N");
|
||||
long completed = 0;
|
||||
long total;
|
||||
int fileCount;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(staging);
|
||||
if (pfs.EngineAccess is { } engine)
|
||||
{
|
||||
ProsperoInnerPfsReader.Entry[] files = engine.Files.ToArray();
|
||||
if (files.Length == 0)
|
||||
throw new InvalidDataException("The package inner image could not be decoded for extraction.");
|
||||
total = files.Sum(file => file.Size);
|
||||
fileCount = files.Length;
|
||||
foreach (ProsperoInnerPfsReader.Entry file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = engine.ToRelativePath(file);
|
||||
if (relative.Length == 0) continue;
|
||||
string target = ResolveContainedPath(staging, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
await using var outputFile = new FileStream(target, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, 1024 * 1024, FileOptions.Asynchronous);
|
||||
await using Stream input = engine.OpenInnerFile(file);
|
||||
byte[] buffer = new byte[1024 * 1024];
|
||||
long position = 0;
|
||||
while (position < file.Size)
|
||||
{
|
||||
int take = checked((int)Math.Min(buffer.Length, file.Size - position));
|
||||
int read = await input.ReadAsync(buffer.AsMemory(0, take), cancellationToken).ConfigureAwait(false);
|
||||
if (read <= 0) throw new EndOfStreamException($"Inner file '{relative}' ended unexpectedly.");
|
||||
await outputFile.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
position += read;
|
||||
completed += read;
|
||||
progress?.Report(new SonyPackageExtractProgress(completed, total, relative));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (pfs.AccessState != SonyPfsAccessState.PlaintextIndexed || pfs.Files.Any(file => file.Extents.Count == 0))
|
||||
throw new InvalidDataException("The package contains compressed or unavailable files that cannot be extracted by the current reader.");
|
||||
total = pfs.Files.Sum(file => file.Size);
|
||||
fileCount = pfs.Files.Count;
|
||||
foreach (SonyPfsEntry entry in pfs.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string target = ResolveContainedPath(staging, entry.RelativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
await using var outputFile = new FileStream(target, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, 1024 * 1024, FileOptions.Asynchronous);
|
||||
long position = 0;
|
||||
while (position < entry.Size)
|
||||
{
|
||||
int count = checked((int)Math.Min(1024 * 1024, entry.Size - position));
|
||||
byte[] data = SonyPfsEntryDataReader.ReadRange(packagePath, package, pfs, entry, position, count);
|
||||
await outputFile.WriteAsync(data, cancellationToken).ConfigureAwait(false);
|
||||
position += count;
|
||||
completed += count;
|
||||
progress?.Report(new SonyPackageExtractProgress(completed, total, entry.RelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.Move(staging, output);
|
||||
return new SonyPackageExtractResult { Destination = output, FileCount = fileCount, ExtractedBytes = completed };
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// This access is private to the extraction; dispose it so the source package is not
|
||||
// left open (a large package holds a write-blocking handle while decoded).
|
||||
pfs.EngineAccess?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveContainedPath(string root, string relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Package path is unsafe.");
|
||||
string rootFull = Path.GetFullPath(root) + Path.DirectorySeparatorChar;
|
||||
string target = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!target.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Package path escapes the extraction root.");
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ namespace Packwright.Core.Tasks;
|
||||
/// <summary>Stable task type identifiers used for display and future persistence/restore routing.</summary>
|
||||
public static class PackageTaskTypes
|
||||
{
|
||||
public const string SonyDebugPkg = "FPKG";
|
||||
public const string Ps5DebugPkg = "FPKG";
|
||||
public const string FfpfscFromDump = "FFPFSC (dump)";
|
||||
public const string FfpfscFromImage = "FFPFSC (image)";
|
||||
public const string ExfatCreate = "exFAT image";
|
||||
|
||||
@@ -118,25 +118,9 @@ public static class PackageTaskPlans
|
||||
new("Finalize", "finaliz")
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Build package from an image with LibProsperoPkg: the image is extracted to a staging folder
|
||||
/// first, then built. Adds an "Extract image" step before the shared build stages.
|
||||
/// </summary>
|
||||
public static readonly PackageTaskStage[] BuildPackageImageLpp =
|
||||
[
|
||||
new("Extract image", "extract", "reading exfat", "reading ufs2", "reading ffpkg", "reading ffpfsc"),
|
||||
new("Inner image", "inner image"),
|
||||
new("NAPS", "naps"),
|
||||
new("Outer PFS", "outer pfs"),
|
||||
new("CNT", "cnt"),
|
||||
new("Finalize", "finaliz")
|
||||
];
|
||||
|
||||
/// <summary>Selects the build-package step plan for a dump folder or an image file.</summary>
|
||||
/// <param name="extractImageFirst">True when an image source is extracted to a staging folder first.</param>
|
||||
public static PackageTaskStage[] BuildPackageFor(string sourcePath, bool extractImageFirst = false) =>
|
||||
extractImageFirst ? BuildPackageImageLpp
|
||||
: Directory.Exists(sourcePath) ? BuildPackageDump : BuildPackageImage;
|
||||
public static PackageTaskStage[] BuildPackageFor(string sourcePath) =>
|
||||
Directory.Exists(sourcePath) ? BuildPackageDump : BuildPackageImage;
|
||||
|
||||
public static readonly PackageTaskStage[] Ampr =
|
||||
[
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Kraken (newLZ) bit writer — the inverse of the decoder's bit reader.
|
||||
//
|
||||
// The bit-level layout it produces is the Kraken format. Portions carry a GPLv3 attribution; see
|
||||
// NOTICE. LibProsperoPkg is licensed under the GNU GPLv3 (section 13 permits combining GPLv3 code).
|
||||
//
|
||||
// Bits are packed MSB-first to mirror the reader's 24-bit refill window. A "forward" stream's bytes go
|
||||
// at the start of the bit region in order; a "backward" stream's bytes are written in reverse order onto the region's
|
||||
// tail (region = forwardBytes ++ reverse(backwardBytes)) so the two readers meet at the same byte.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression.Oodle;
|
||||
|
||||
/// <summary>Accumulates bits MSB-first into bytes; one instance per bitstream direction.</summary>
|
||||
internal sealed class KrakenBitWriter
|
||||
{
|
||||
private readonly List<byte> _bytes = new();
|
||||
private uint _cur;
|
||||
private int _curBits;
|
||||
|
||||
/// <summary>Number of whole bytes produced once the trailing partial byte is flushed.</summary>
|
||||
public int ByteLength => _bytes.Count + (_curBits > 0 ? 1 : 0);
|
||||
|
||||
/// <summary>Writes the low <paramref name="n"/> bits of <paramref name="value"/>, MSB-first.</summary>
|
||||
public void WriteBits(uint value, int n)
|
||||
{
|
||||
for (int i = n - 1; i >= 0; i--)
|
||||
WriteBit((value >> i) & 1);
|
||||
}
|
||||
|
||||
/// <summary>Writes <paramref name="count"/> zero bits.</summary>
|
||||
public void WriteZeros(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
WriteBit(0);
|
||||
}
|
||||
|
||||
private void WriteBit(uint bit)
|
||||
{
|
||||
_cur = (_cur << 1) | (bit & 1);
|
||||
_curBits++;
|
||||
if (_curBits == 8)
|
||||
{
|
||||
_bytes.Add((byte)_cur);
|
||||
_cur = 0;
|
||||
_curBits = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes the Elias-gamma length-stream-size prefix for <paramref name="size"/> (>= 0).</summary>
|
||||
public void WriteLenStreamSizePrefix(int size)
|
||||
{
|
||||
uint field = (uint)size + 1;
|
||||
int fieldBits = 32 - BitOperations.LeadingZeroCount(field);
|
||||
WriteZeros(fieldBits - 1);
|
||||
WriteBits(field, fieldBits);
|
||||
}
|
||||
|
||||
/// <summary>Writes a Kraken length code for <paramref name="value"/> (the u32 length-stream value).</summary>
|
||||
public void WriteLength(uint value)
|
||||
{
|
||||
uint field = value + 64;
|
||||
int fieldBits = 32 - BitOperations.LeadingZeroCount(field);
|
||||
WriteZeros(fieldBits - 7);
|
||||
WriteBits(field, fieldBits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes distance <paramref name="distance"/> (>= 8): returns the packed-offset command byte and
|
||||
/// writes the associated extra bits. Only the < 0xF0 bucket (window <= 256 KiB) is produced.
|
||||
/// </summary>
|
||||
public byte WriteDistance(int distance)
|
||||
{
|
||||
uint t = (uint)distance + 248;
|
||||
int top = 31 - BitOperations.LeadingZeroCount(t); // floor(log2(t))
|
||||
int vhi = top - 8;
|
||||
uint rem = t - (1u << (vhi + 8));
|
||||
uint vlo = rem & 0xF;
|
||||
uint e = rem >> 4;
|
||||
int n = vhi + 4;
|
||||
WriteBits(e, n);
|
||||
return (byte)((vhi << 4) | (int)vlo);
|
||||
}
|
||||
|
||||
/// <summary>Flushes any partial byte (zero-padded) and returns the produced bytes in write order.</summary>
|
||||
public byte[] ToBytes()
|
||||
{
|
||||
if (_curBits > 0)
|
||||
{
|
||||
_bytes.Add((byte)(_cur << (8 - _curBits)));
|
||||
_cur = 0;
|
||||
_curBits = 0;
|
||||
}
|
||||
return _bytes.ToArray();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,732 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Kraken (newLZ) Huffman ARRAY encoder — the inverse of KrakenDecoder's entropy
|
||||
// array decode path (DecodeBytes -> DecodeBytesType12 -> HuffMakeLut + 3-stream scalar core). It emits
|
||||
// a single entropy-coded "array" (chunk type 2 / single 3-stream split, "Old/simple" code-length
|
||||
// transmission) that the decoder accepts. Operates on byte[]/int indices only.
|
||||
//
|
||||
// This is the entropy layer of the Kraken encoder: literal/command/length/offset arrays that
|
||||
// today are emitted RAW (type 0) can instead be Huffman-coded here, reducing block sizes toward
|
||||
// encoder output. Matching encoder output additionally requires the same length-limited Huffman,
|
||||
// transmission-format choice and optimal LZ parse. This encoder provides the decoder-validated
|
||||
// foundation for that through an in-process round-trip against the decoder.
|
||||
//
|
||||
// The bit layout is defined entirely by KrakenDecoder (which carries a GPLv3 attribution; see NOTICE).
|
||||
// LibProsperoPkg is GPLv3; GPLv3 §13 permits the combination.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression.Oodle;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a byte array into a Kraken entropy "array" (Huffman, chunk-type 2) that
|
||||
/// <see cref="KrakenDecoder"/> decodes to the original bytes. Returns <c>null</c> when an entropy array
|
||||
/// would not be smaller than the raw form (caller should then store the array raw, type 0).
|
||||
/// </summary>
|
||||
internal static class KrakenHuffmanArrayEncoder
|
||||
{
|
||||
private const int MaxCodeLen = 11;
|
||||
|
||||
/// <summary>Largest payload the five-byte compressed array header can describe.</summary>
|
||||
private const int MaxArrayPayload = 0x3FFFF;
|
||||
|
||||
/// <summary>
|
||||
/// The inputs the array-selection cost model needs about a Huffman candidate: the total code-length
|
||||
/// bits over the array, the number of symbols with a nonzero count, the number of contiguous runs of
|
||||
/// such symbols in the code-length table, and the size of the code-length transmission header.
|
||||
/// </summary>
|
||||
internal readonly struct CostInputs
|
||||
{
|
||||
public readonly long CodeLengthBits;
|
||||
public readonly int NonZeroSymbols;
|
||||
public readonly int AlphabetRuns;
|
||||
public readonly int HeaderBytes;
|
||||
|
||||
public CostInputs(long codeLengthBits, int nonZeroSymbols, int alphabetRuns, int headerBytes)
|
||||
{
|
||||
CodeLengthBits = codeLengthBits;
|
||||
NonZeroSymbols = nonZeroSymbols;
|
||||
AlphabetRuns = alphabetRuns;
|
||||
HeaderBytes = headerBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A code assignment for one array: everything needed to score the entropy form, and everything
|
||||
/// needed to emit it afterwards. Building this is much cheaper than packing the streams, so a caller
|
||||
/// that may reject the form should prepare it, score it, and only then finish it.
|
||||
/// </summary>
|
||||
internal sealed class Prepared
|
||||
{
|
||||
internal CostInputs Cost;
|
||||
internal byte[] LengthOfSymbol = Array.Empty<byte>();
|
||||
internal ushort[] ReversedCode = Array.Empty<ushort>();
|
||||
internal byte[] Header = Array.Empty<byte>();
|
||||
|
||||
/// <summary>True when every byte is the same symbol, which has its own eight-byte form.</summary>
|
||||
internal bool SingleSymbol;
|
||||
|
||||
/// <summary>The only symbol present, when <see cref="SingleSymbol"/> is set.</summary>
|
||||
internal byte Symbol;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The eight-byte array a run of one repeated symbol takes: a five-byte header declaring a
|
||||
/// three-byte payload, and a payload that is nothing but the code-length transmission for a
|
||||
/// one-symbol alphabet. No symbol stream follows it.
|
||||
/// </summary>
|
||||
internal static byte[] BuildSingleSymbolArray(byte symbol, int length)
|
||||
{
|
||||
byte[] array = BuildEntropyArray(chunkType: 2, srcSize: SingleSymbolPayload, dstSize: length, out int bodyOff);
|
||||
array[bodyOff] = 0x00;
|
||||
array[bodyOff + 1] = (byte)((symbol >> 2) | 0x40);
|
||||
array[bodyOff + 2] = (byte)(symbol << 6);
|
||||
return array;
|
||||
}
|
||||
|
||||
/// <summary>Payload bytes the one-symbol form carries.</summary>
|
||||
private const int SingleSymbolPayload = 3;
|
||||
|
||||
/// <summary>Total bytes the one-symbol form occupies.</summary>
|
||||
internal const int SingleSymbolArrayLength = 5 + SingleSymbolPayload;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to Huffman-encode <paramref name="data"/> as a single entropy array. Returns the
|
||||
/// on-disk array bytes (header + payload) or <c>null</c> if not representable.
|
||||
/// </summary>
|
||||
public static byte[]? TryEncode(ReadOnlySpan<byte> data) => TryEncode(data, out _);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="TryEncode(ReadOnlySpan{byte})"/>, and also reports the cost-model inputs the
|
||||
/// caller needs to decide between this form and the uncompressed one.
|
||||
/// </summary>
|
||||
public static byte[]? TryEncode(ReadOnlySpan<byte> data, out CostInputs cost)
|
||||
{
|
||||
Prepared? prepared = Prepare(data, out cost);
|
||||
return prepared is null ? null : Finish(data, prepared);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the code assignment and the code-length header for <paramref name="data"/> and reports the
|
||||
/// cost-model inputs, without packing the symbol streams. Returns null when the array is not
|
||||
/// representable in this form.
|
||||
/// </summary>
|
||||
internal static Prepared? Prepare(ReadOnlySpan<byte> data, out CostInputs cost)
|
||||
{
|
||||
cost = default;
|
||||
int d = data.Length;
|
||||
if (d < 2 || d > 0x3FFFF) return null;
|
||||
|
||||
// Histogram.
|
||||
Span<int> freq = stackalloc int[256];
|
||||
for (int i = 0; i < d; i++) freq[data[i]]++;
|
||||
int distinct = 0;
|
||||
for (int s = 0; s < 256; s++) if (freq[s] != 0) distinct++;
|
||||
|
||||
if (distinct == 1)
|
||||
{
|
||||
// One repeated symbol has its own eight-byte form, whose whole payload is the code-length
|
||||
// transmission. The score model treats it separately, so no cost inputs are reported.
|
||||
int only = 0;
|
||||
for (int s = 0; s < 256; s++) if (freq[s] != 0) { only = s; break; }
|
||||
return new Prepared { SingleSymbol = true, Symbol = (byte)only };
|
||||
}
|
||||
|
||||
// Code lengths (length-limited to 11, complete prefix code).
|
||||
byte[] lenOfSym = BuildCodeLengths(freq);
|
||||
if (lenOfSym.Length == 0) return null;
|
||||
|
||||
// Canonical codes matching HuffMakeLut (codePrefixOrg) — symbols grouped by (len asc, sym asc).
|
||||
BuildCanonicalCodes(lenOfSym, out ushort[] codeOfSym);
|
||||
|
||||
// Per-symbol low-bit-first code (the 3-stream core reads LSB-first against the low-bit-first LUT).
|
||||
var revOfSym = new ushort[256];
|
||||
for (int s = 0; s < 256; s++)
|
||||
if (lenOfSym[s] != 0) revOfSym[s] = (ushort)ReverseBits(codeOfSym[s], lenOfSym[s]);
|
||||
|
||||
// 1) Code-length transmission (MSB-first), byte-padded. The encoder selects the
|
||||
// HuffReadCodeLengthsOld sub-mode purely by alphabet size, not by which encoding is smaller.
|
||||
// The packer tests HI->num_non_zero (HI[0x288] — the count of symbols with a nonzero histogram
|
||||
// count, set by the code-length builder):
|
||||
// num_non_zero < 5 -> write bit 0, SIMPLE list
|
||||
// num_non_zero >= 5 -> write bit 1, COMPLEX form
|
||||
// `distinct` here is that num_non_zero; both count nonzero histogram buckets. The "New"/Golomb
|
||||
// transmission (tx=1) is disabled at lvl7 unless (flags & 0x40) != 0 && num_non_zero >= 5,
|
||||
// so the "Old" form is always written here. This matters for small alphabets; for example,
|
||||
// an 11-symbol command array can be smaller as SIMPLE, but the count rule writes COMPLEX
|
||||
// because 11 >= 5. A size-min selector can choose the wrong form.
|
||||
byte[]? headerBytes = distinct < 5
|
||||
? BuildSimpleCodeLengthHeader(lenOfSym)
|
||||
: BuildComplexCodeLengthHeader(lenOfSym);
|
||||
if (headerBytes is null) return null;
|
||||
|
||||
long codeLengthBits = 0;
|
||||
int nonZero = 0, runs = 0;
|
||||
bool inRun = false;
|
||||
for (int s = 0; s < 256; s++)
|
||||
{
|
||||
if (lenOfSym[s] == 0) { inRun = false; continue; }
|
||||
nonZero++;
|
||||
if (!inRun) { runs++; inRun = true; }
|
||||
codeLengthBits += (long)freq[s] * lenOfSym[s];
|
||||
}
|
||||
cost = new CostInputs(codeLengthBits, nonZero, runs, headerBytes.Length);
|
||||
return new Prepared
|
||||
{
|
||||
Cost = cost,
|
||||
LengthOfSymbol = lenOfSym,
|
||||
ReversedCode = revOfSym,
|
||||
Header = headerBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Packs the symbol streams and returns the finished array, or null when it cannot be framed.</summary>
|
||||
internal static byte[]? Finish(ReadOnlySpan<byte> data, Prepared prepared)
|
||||
{
|
||||
if (prepared.SingleSymbol)
|
||||
return BuildSingleSymbolArray(prepared.Symbol, data.Length);
|
||||
|
||||
int d = data.Length;
|
||||
byte[] lenOfSym = prepared.LengthOfSymbol;
|
||||
ushort[] revOfSym = prepared.ReversedCode;
|
||||
byte[] headerBytes = prepared.Header;
|
||||
|
||||
// 2) Three interleaved symbol streams (output positions mod 3): A=0,3,..; B=1,4,..; C=2,5,..
|
||||
var aw = new LsbBitWriter();
|
||||
var bw = new LsbBitWriter();
|
||||
var cw = new LsbBitWriter();
|
||||
for (int i = 0; i < d; i++)
|
||||
{
|
||||
byte sym = data[i];
|
||||
int len = lenOfSym[sym];
|
||||
uint rev = revOfSym[sym];
|
||||
switch (i % 3)
|
||||
{
|
||||
case 0: aw.Write(rev, len); break;
|
||||
case 1: bw.Write(rev, len); break;
|
||||
default: cw.Write(rev, len); break;
|
||||
}
|
||||
}
|
||||
byte[] aBytes = aw.ToBytesPadded();
|
||||
byte[] bBytes = bw.ToBytesPadded();
|
||||
byte[] cBytes = cw.ToBytesPadded();
|
||||
|
||||
int lenA = aBytes.Length;
|
||||
if (lenA > 0xFFFF) return null; // type-1 splitMid is 16-bit; bigger blocks need the type-4 split.
|
||||
|
||||
// 3) Assemble payload = [2B splitMid=lenA][A][C][reverse(B)].
|
||||
int payloadLen = headerBytes.Length + 2 + lenA + cBytes.Length + bBytes.Length;
|
||||
if (payloadLen > MaxArrayPayload) return null;
|
||||
|
||||
byte[] arr = BuildEntropyArray(chunkType: 2, srcSize: payloadLen, dstSize: d, out int bodyOff);
|
||||
int p = bodyOff;
|
||||
Array.Copy(headerBytes, 0, arr, p, headerBytes.Length); p += headerBytes.Length;
|
||||
arr[p++] = (byte)(lenA & 0xFF);
|
||||
arr[p++] = (byte)((lenA >> 8) & 0xFF);
|
||||
Array.Copy(aBytes, 0, arr, p, lenA); p += lenA;
|
||||
Array.Copy(cBytes, 0, arr, p, cBytes.Length); p += cBytes.Length;
|
||||
for (int i = 0; i < bBytes.Length; i++) arr[p + i] = bBytes[bBytes.Length - 1 - i];
|
||||
p += bBytes.Length;
|
||||
return arr;
|
||||
}
|
||||
|
||||
// ============================ array header (DecodeBytes entropy mode) ============================
|
||||
// The array compression header always uses the 5-byte long form:
|
||||
// lVar = (from_len-1)<<18 | comp_len | (chunkType<<36); byte0 = lVar>>32; bytes1..4 =
|
||||
// big-endian low32(lVar). This array subtype uses the long entropy header here; the decoder
|
||||
// accepts src[0]>=0x80 short headers, but newLZ produces them only via other array sub-types.
|
||||
// Always use the long form here, even when the short form would fit.
|
||||
private static byte[] BuildEntropyArray(int chunkType, int srcSize, int dstSize, out int bodyOff)
|
||||
{
|
||||
const int hdrLen = 5;
|
||||
var arr = new byte[hdrLen + srcSize];
|
||||
int dm1 = dstSize - 1;
|
||||
arr[0] = (byte)((chunkType << 4) | ((dm1 >> 14) & 0xF));
|
||||
uint bits = (uint)(srcSize | ((dm1 & 0x3FFF) << 18));
|
||||
arr[1] = (byte)(bits >> 24);
|
||||
arr[2] = (byte)(bits >> 16);
|
||||
arr[3] = (byte)(bits >> 8);
|
||||
arr[4] = (byte)bits;
|
||||
bodyOff = hdrLen;
|
||||
return arr;
|
||||
}
|
||||
|
||||
// ===================== code-length transmission (HuffReadCodeLengthsOld inverse) =====================
|
||||
// SIMPLE sub-mode: [0][0][numSyms:8][codeLenBits:3] then per present symbol {sym:8, (len-1):codeLenBits}.
|
||||
// Returns null when not representable (>=256 distinct symbols, or a code length needing >4 bits-1).
|
||||
private static byte[]? BuildSimpleCodeLengthHeader(byte[] lenOfSym)
|
||||
{
|
||||
int distinct = 0, maxLen = 0;
|
||||
for (int s = 0; s < 256; s++)
|
||||
if (lenOfSym[s] != 0) { distinct++; if (lenOfSym[s] > maxLen) maxLen = lenOfSym[s]; }
|
||||
if (distinct < 2 || distinct >= 256) return null; // count is 8-bit; numSyms==1 has a distinct form
|
||||
int codeLenBits = BitWidth(maxLen - 1);
|
||||
if (codeLenBits > 4) return null;
|
||||
|
||||
var hdr = new MsbBitWriter();
|
||||
hdr.Write(0, 1); // DecodeBytesType12: 0 => HuffReadCodeLengthsOld
|
||||
hdr.Write(0, 1); // HuffReadCodeLengthsOld: 0 => simple list
|
||||
hdr.Write((uint)distinct, 8); // numSymbols
|
||||
hdr.Write((uint)codeLenBits, 3);
|
||||
for (int s = 0; s < 256; s++)
|
||||
{
|
||||
if (lenOfSym[s] == 0) continue;
|
||||
hdr.Write((uint)s, 8);
|
||||
if (codeLenBits > 0) hdr.Write((uint)(lenOfSym[s] - 1), codeLenBits);
|
||||
}
|
||||
return hdr.ToBytesPadded();
|
||||
}
|
||||
|
||||
// COMPLEX sub-mode: [0][1][forcedBits:2][skip:1] then, per maximal run
|
||||
// of consecutive present symbols: a gap gamma (advance to the run), a run-length gamma, then each
|
||||
// symbol's code length as a zig-zag delta from a running predictor, Golomb-Rice coded with forcedBits
|
||||
// low bits. Exact inverse of HuffReadCodeLengthsOld's complex branch. Tries forcedBits 0..3, returns
|
||||
// the smallest. Returns null only when there are no present symbols.
|
||||
private static byte[]? BuildComplexCodeLengthHeader(byte[] lenOfSym)
|
||||
{
|
||||
int first = -1;
|
||||
for (int s = 0; s < 256; s++) if (lenOfSym[s] != 0) { first = s; break; }
|
||||
if (first < 0) return null;
|
||||
|
||||
byte[]? best = null;
|
||||
int bestBits = int.MaxValue;
|
||||
for (int fb = 0; fb <= 3; fb++)
|
||||
{
|
||||
var w = BuildComplexHeaderForFb(lenOfSym, fb, first == 0);
|
||||
if (w is null) continue;
|
||||
int bits = w.BitLength;
|
||||
if (bits < bestBits) { bestBits = bits; best = w.ToBytesPadded(); }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Per-fb complex header builder. Returns the writer (with exact BitLength) or null if forcedBits fb
|
||||
// cannot represent some zig-zag quotient (q > thres). Factored out so the selector can compare EXACT
|
||||
// bit cost, not byte-padded length, across fb candidates.
|
||||
private static MsbBitWriter? BuildComplexHeaderForFb(byte[] lenOfSym, int fb, bool skip)
|
||||
{
|
||||
int maxLz = (int)(20u >> fb); // decoder's thres bounds the unary quotient
|
||||
var w = new MsbBitWriter();
|
||||
w.Write(0, 1); // Old
|
||||
w.Write(1, 1); // complex
|
||||
w.Write((uint)fb, 2);
|
||||
w.Write(skip ? 1u : 0u, 1);
|
||||
|
||||
int avgBitsX4 = 32;
|
||||
int sym = 0;
|
||||
bool firstSeg = true;
|
||||
int s2 = 0;
|
||||
while (s2 < 256)
|
||||
{
|
||||
if (lenOfSym[s2] == 0) { s2++; continue; }
|
||||
int runStart = s2;
|
||||
int runEnd = s2;
|
||||
while (runEnd + 1 < 256 && lenOfSym[runEnd + 1] != 0) runEnd++;
|
||||
int n = runEnd - runStart + 1;
|
||||
|
||||
if (!(firstSeg && skip))
|
||||
WriteGamma(w, (runStart - sym) + 1); // sym += G-1 lands on runStart
|
||||
sym = runStart;
|
||||
firstSeg = false;
|
||||
|
||||
WriteGamma(w, n + 1);
|
||||
for (int s = runStart; s <= runEnd; s++)
|
||||
{
|
||||
int predictor = (avgBitsX4 + 2) >> 2;
|
||||
int e = lenOfSym[s] - predictor;
|
||||
uint v = (uint)((e << 1) ^ (e >> 31)); // zig-zag
|
||||
int q = (int)(v >> fb);
|
||||
if (q > maxLz) return null;
|
||||
for (int z = 0; z < q; z++) w.Write(0, 1);
|
||||
w.Write(1, 1);
|
||||
if (fb > 0) w.Write(v & ((1u << fb) - 1), fb);
|
||||
avgBitsX4 = lenOfSym[s] + ((3 * avgBitsX4 + 2) >> 2);
|
||||
}
|
||||
sym = runEnd + 1;
|
||||
s2 = runEnd + 1;
|
||||
}
|
||||
if (sym < 256) WriteGamma(w, (256 - sym) + 1); // terminating gap -> sym >= 256
|
||||
return w;
|
||||
}
|
||||
|
||||
// Writes a gamma-style field F (>= 2) as the decoder reads it: 2*bitlen(F)-2 bits, MSB-first, so the
|
||||
// bitlen(F)-2 leading zeros + leading 1 are self-delimiting (decoder reads 2*(clz+1) bits).
|
||||
private static void WriteGamma(MsbBitWriter w, int f)
|
||||
{
|
||||
int bl = 0, t = f;
|
||||
while (t > 0) { bl++; t >>= 1; }
|
||||
w.Write((uint)f, 2 * bl - 2);
|
||||
}
|
||||
|
||||
// ===================== Length-limited Huffman code lengths =====================
|
||||
// Returns per-symbol code length (0 = unused). Handles bare-entropy arrays, including
|
||||
// hard L11-binding cases.
|
||||
// The exact pipeline is:
|
||||
// 1. ScaleCounts — scale so total and max both fit in 0xFFFF.
|
||||
// 2. Sort — stable ascending by (count, symbol id).
|
||||
// 3. InPlaceHuffman (Moffat/Katajainen) — unlimited optimal lengths; if its max <= 11 it is final.
|
||||
// 4. Otherwise the length-limited boundary package-merge: MaxCodeLen lists each capped
|
||||
// at 2n-2 items, a package winning a tie against an equal-weight leaf, then a top-down count.
|
||||
// The array Huffman encoder drives this with limit 11 at every level; at -lvl 7 the package-merge
|
||||
// path, not the heuristic, produces the required length limit. Combined work GPLv3.
|
||||
private const int PackageBit = 0x40000000;
|
||||
|
||||
private static byte[] BuildCodeLengths(ReadOnlySpan<int> freq)
|
||||
{
|
||||
var result = new byte[256];
|
||||
var present = new List<int>();
|
||||
long total = 0;
|
||||
for (int s = 0; s < 256; s++) { int c = freq[s]; if (c != 0) { present.Add(s); total += c; } }
|
||||
int n = present.Count;
|
||||
if (n == 0) return Array.Empty<byte>();
|
||||
if (n == 1) { result[present[0]] = 1; return result; }
|
||||
|
||||
// (1) Scale counts so total and max both fit in 0xFFFF (no-op for small arrays).
|
||||
var weight = new long[256];
|
||||
ScaleCounts(freq, total, weight);
|
||||
|
||||
// (2) Collect present symbols, sort ascending by count. The sort dispatches by alphabet
|
||||
// size: <=32 distinct symbols -> an unstable median-of-3 introsort keyed on count only;
|
||||
// >=33 -> a stable radix
|
||||
// (counting) sort. The two disagree only on how equal-count symbols are ordered, and that tie
|
||||
// order is exactly what the Moffat builder turns into which symbol gets the shorter code.
|
||||
// Preserve both: the introsort permutation for small alphabets and the symbol-ascending
|
||||
// stable order for large ones. Entries are built symbol-ascending (present is 0..255 order).
|
||||
var sym = new int[n + 1];
|
||||
var cnt = new int[n + 1];
|
||||
for (int i = 0; i < n; i++) { sym[i] = present[i]; cnt[i] = (int)weight[present[i]]; }
|
||||
if (n <= 32) SortByCountSmall(sym, cnt, n);
|
||||
else StableSortByCount(sym, cnt, n);
|
||||
|
||||
// (3) Unlimited Moffat lengths; if the max is already within the limit, use them directly.
|
||||
var moff = new int[n];
|
||||
Array.Copy(cnt, moff, n);
|
||||
InPlaceHuffman(moff, n);
|
||||
if (moff[0] <= MaxCodeLen)
|
||||
{
|
||||
for (int i = 0; i < n; i++) result[sym[i]] = (byte)moff[i];
|
||||
return ValidateKraft(result, present, n) ? result : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// (4) Length-limited boundary package-merge: MaxCodeLen lists, each capped at 2n-2 items.
|
||||
int nLevels = MaxCodeLen;
|
||||
cnt[n] = int.MaxValue; // sentinel leaf
|
||||
int cap = 2 * n - 2;
|
||||
var lvField0 = new int[nLevels + 1][];
|
||||
var lvWeight = new long[nLevels + 1][];
|
||||
var lvLen = new int[nLevels + 1];
|
||||
for (int L = 1; L <= nLevels; L++) { lvField0[L] = new int[cap]; lvWeight[L] = new long[cap]; }
|
||||
|
||||
for (int L = 1; L <= nLevels; L++)
|
||||
{
|
||||
int i0 = 0, pk = 0, outc = 0;
|
||||
int prevCount = (L >= 2) ? lvLen[L - 1] : 0;
|
||||
long[]? pw = (L >= 2) ? lvWeight[L - 1] : null;
|
||||
while (outc < cap)
|
||||
{
|
||||
long leafWeight = cnt[i0];
|
||||
if (pk + 1 < prevCount && pw![pk] + pw[pk + 1] <= leafWeight)
|
||||
{
|
||||
lvWeight[L][outc] = pw[pk] + pw[pk + 1];
|
||||
lvField0[L][outc] = pk | PackageBit;
|
||||
pk += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (i0 >= n) break;
|
||||
lvWeight[L][outc] = leafWeight;
|
||||
lvField0[L][outc] = sym[i0];
|
||||
i0++;
|
||||
}
|
||||
outc++;
|
||||
}
|
||||
lvLen[L] = outc;
|
||||
}
|
||||
|
||||
// Count phase: walk lists top-down; each leaf appearance lengthens that symbol's code by one.
|
||||
int active = lvLen[nLevels];
|
||||
for (int L = nLevels; L >= 1; L--)
|
||||
{
|
||||
int nextActive = 0;
|
||||
int[] f0 = lvField0[L];
|
||||
for (int j = 0; j < active; j++)
|
||||
{
|
||||
int v = f0[j];
|
||||
if ((v & PackageBit) == 0) result[v]++;
|
||||
else nextActive = (v & ~PackageBit) + 2;
|
||||
}
|
||||
active = nextActive;
|
||||
}
|
||||
return ValidateKraft(result, present, n) ? result : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// Kraft completeness guard: a valid prefix code satisfies sum 2^-len == 1 over present symbols.
|
||||
private static bool ValidateKraft(byte[] result, List<int> present, int n)
|
||||
{
|
||||
long kraft = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int l = result[present[i]];
|
||||
if (l < 1 || l > MaxCodeLen) return false;
|
||||
kraft += 1L << (MaxCodeLen - l);
|
||||
}
|
||||
return kraft == (1L << MaxCodeLen);
|
||||
}
|
||||
|
||||
// ScaleCounts: scale so total and max both fit in 0xFFFF; round half up,
|
||||
// clamp to 0xFFFF, floor non-zero counts to 1, and absorb any overshoot into the heaviest symbol.
|
||||
private static void ScaleCounts(ReadOnlySpan<int> freq, long total, long[] dest)
|
||||
{
|
||||
long maxCount = 0; int maxi = 0;
|
||||
for (int s = 0; s < 256; s++) { long cc = freq[s]; if (maxCount < cc) { maxCount = cc; maxi = s; } }
|
||||
const long allowed = 0xFFFF;
|
||||
if (maxCount <= allowed && total <= allowed)
|
||||
{
|
||||
for (int s = 0; s < 256; s++) dest[s] = freq[s];
|
||||
return;
|
||||
}
|
||||
float a = (float)allowed / total;
|
||||
float b = (float)allowed / maxCount;
|
||||
float fscale = a <= b ? a : b;
|
||||
long sumDest = 0;
|
||||
for (int s = 0; s < 256; s++)
|
||||
{
|
||||
if (freq[s] == 0) { dest[s] = 0; continue; }
|
||||
uint v = (uint)((float)freq[s] * fscale + 0.5f);
|
||||
uint clamped = v < allowed ? v : (uint)allowed;
|
||||
long d = clamped < 2 ? 1 : clamped;
|
||||
dest[s] = d; sumDest += d;
|
||||
}
|
||||
if (allowed < sumDest) dest[maxi] += allowed - sumDest;
|
||||
}
|
||||
|
||||
// Sort n>=33 path: a stable counting/radix sort on the u16 count. Because
|
||||
// counting sort is stable and the entries enter in symbol-ascending order, equal-count symbols keep
|
||||
// symbol-ascending order -- which this stable (count, symbol id) sort reproduces exactly.
|
||||
private static void StableSortByCount(int[] sym, int[] cnt, int n)
|
||||
{
|
||||
var idx = new int[n];
|
||||
for (int i = 0; i < n; i++) idx[i] = i;
|
||||
Array.Sort(idx, (x, y) => cnt[x] != cnt[y] ? cnt[x].CompareTo(cnt[y]) : x.CompareTo(y));
|
||||
var s2 = new int[n]; var c2 = new int[n];
|
||||
for (int i = 0; i < n; i++) { s2[i] = sym[idx[i]]; c2[i] = cnt[idx[i]]; }
|
||||
Array.Copy(s2, sym, n); Array.Copy(c2, cnt, n);
|
||||
}
|
||||
|
||||
// Sort n<33 path: an unstable introsort. Median-of-3 quicksort with an insertion base
|
||||
// for n<=4 and a heapsort fallback at recursion-depth exhaustion; comparator is count-only
|
||||
// strict-less, with no symbol tie-break, so the permutation of equal-count symbols is determined
|
||||
// purely by the partition/heap operations. Sorts the symbol-ascending (sym,cnt) entries in place,
|
||||
// ascending by cnt. The depth budget repeatedly decays the count by ~0.75 (x>>1)+(x>>2)
|
||||
// until 0 -> k frames; when the pending-frame depth reaches k, heapsort the current range
|
||||
// instead of partitioning.
|
||||
private static void SortByCountSmall(int[] sym, int[] cnt, int n)
|
||||
{
|
||||
if (n < 2) return;
|
||||
|
||||
void Swap(int i, int j) { (sym[i], sym[j]) = (sym[j], sym[i]); (cnt[i], cnt[j]) = (cnt[j], cnt[i]); }
|
||||
// Rot3(x,y,z): swap(x,y); swap(x,z).
|
||||
void Rot3(int x, int y, int z) { Swap(x, y); Swap(x, z); }
|
||||
|
||||
// Median-of-3: place the median of {a,b,c} at b; fully sorts 3 elements.
|
||||
void Median3(int a, int b, int c)
|
||||
{
|
||||
if (cnt[b] < cnt[a])
|
||||
{
|
||||
if (cnt[b] < cnt[c]) { if (cnt[a] < cnt[c]) Swap(a, b); else Rot3(b, a, c); }
|
||||
else Swap(a, c);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (cnt[c] < cnt[b]) { if (cnt[a] < cnt[c]) Swap(b, c); else Rot3(a, b, c); }
|
||||
}
|
||||
}
|
||||
|
||||
// Heapsort fallback: max-heap by count, strict-less sift.
|
||||
void SiftDown(int lo, int nodeRel, int heapCount)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int childRel = nodeRel * 2 + 1;
|
||||
if (childRel >= heapCount) break;
|
||||
int childAbs = lo + childRel;
|
||||
if (childRel + 1 < heapCount && cnt[childAbs] < cnt[childAbs + 1]) { childRel++; childAbs++; }
|
||||
if (!(cnt[lo + nodeRel] < cnt[childAbs])) break;
|
||||
Swap(lo + nodeRel, childAbs);
|
||||
nodeRel = childRel;
|
||||
}
|
||||
}
|
||||
void HeapSort(int lo, int count)
|
||||
{
|
||||
for (int s = count / 2 - 1; s >= 0; s--) SiftDown(lo, s, count);
|
||||
for (int end = count - 1; end >= 1; end--) { Swap(lo, lo + end); SiftDown(lo, 0, end); }
|
||||
}
|
||||
|
||||
// Introsort recursion-depth budget k.
|
||||
int k = 0;
|
||||
{ uint u = (uint)n; do { u = (u >> 1) + (u >> 2); k++; } while (u != 0); }
|
||||
|
||||
var stack = new System.Collections.Generic.Stack<(int lo, int hi)>();
|
||||
int first = 0, last = n - 1, count = n;
|
||||
while (true)
|
||||
{
|
||||
if (count > 1)
|
||||
{
|
||||
if (count == 2) { if (cnt[last] < cnt[first]) Swap(first, last); }
|
||||
else
|
||||
{
|
||||
int mid = first + (count >> 1);
|
||||
Median3(first, mid, last);
|
||||
if (count < 5) { if (count == 4) Insert4(first, mid, last); }
|
||||
else if (stack.Count == k) HeapSort(first, count);
|
||||
else
|
||||
{
|
||||
// Hoare partition with the median (now at mid) as pivot, moved to first.
|
||||
int left = first, right = last;
|
||||
Swap(mid, first);
|
||||
while (true)
|
||||
{
|
||||
do { right--; } while (cnt[first] < cnt[right]);
|
||||
if (right <= left) break;
|
||||
do { left++; } while (cnt[left] < cnt[first]);
|
||||
if (right <= left) { left--; break; }
|
||||
Swap(left, right);
|
||||
}
|
||||
Swap(first, left);
|
||||
int pivot = left;
|
||||
// Extend over the equal-to-pivot band so neither half re-sorts it.
|
||||
int hiScan = left;
|
||||
do { hiScan++; } while (hiScan < last && !(cnt[pivot] < cnt[hiScan]));
|
||||
int loScan = left;
|
||||
do { loScan--; } while (first < loScan && !(cnt[loScan] < cnt[pivot]));
|
||||
int leftCount = loScan - first + 1;
|
||||
int rightCount = last - hiScan + 1;
|
||||
// Push the smaller half, loop on the larger (bounds pending depth to O(log n)).
|
||||
if (rightCount < leftCount) { stack.Push((hiScan, last)); last = loScan; count = leftCount; continue; }
|
||||
else { stack.Push((first, loScan)); first = hiScan; count = rightCount; continue; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stack.Count == 0) break;
|
||||
(first, last) = stack.Pop();
|
||||
count = last - first + 1;
|
||||
}
|
||||
|
||||
// Insertion of the 4th element for n==4, after Median3 sorted 3.
|
||||
void Insert4(int f, int m, int l)
|
||||
{
|
||||
int x = f + 1;
|
||||
if (cnt[m] < cnt[x]) { if (cnt[x] < cnt[l]) Swap(x, m); else Rot3(m, x, l); }
|
||||
else { if (cnt[x] < cnt[f]) Swap(f, x); }
|
||||
}
|
||||
}
|
||||
|
||||
// In-place Moffat/Katajainen Huffman code lengths. cnt ascending in,
|
||||
// per-leaf lengths out (index 0 = the lowest-count symbol = the longest code).
|
||||
private static void InPlaceHuffman(int[] cnt, int n)
|
||||
{
|
||||
cnt[0] = cnt[0] + cnt[1];
|
||||
int leafLow = 0, nextLeaf = 2;
|
||||
for (int next = 1; next < n - 1; next++)
|
||||
{
|
||||
if (nextLeaf < n && cnt[nextLeaf] <= cnt[leafLow]) { cnt[next] = cnt[nextLeaf]; nextLeaf++; }
|
||||
else { cnt[next] = cnt[leafLow]; cnt[leafLow] = next; leafLow++; }
|
||||
if (nextLeaf < n && (next <= leafLow || cnt[nextLeaf] <= cnt[leafLow])) { cnt[next] += cnt[nextLeaf]; nextLeaf++; }
|
||||
else { cnt[next] += cnt[leafLow]; cnt[leafLow] = next; leafLow++; }
|
||||
}
|
||||
cnt[n - 2] = 0;
|
||||
for (int i = n - 3; i >= 0; i--) cnt[i] = cnt[cnt[i]] + 1;
|
||||
int depth = 0, intNode = n - 2, leafW = n - 1, avail = 1;
|
||||
while (avail > 0)
|
||||
{
|
||||
int used = 0;
|
||||
while (intNode >= 0 && cnt[intNode] == depth) { used++; intNode--; }
|
||||
for (; used < avail; avail--) { cnt[leafW] = depth; leafW--; }
|
||||
depth++; avail = used << 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ canonical code assignment (HuffMakeLut order) ============================
|
||||
private static void BuildCanonicalCodes(byte[] lenOfSym, out ushort[] codeOfSym)
|
||||
{
|
||||
codeOfSym = new ushort[256];
|
||||
uint currslot = 0;
|
||||
for (int len = 1; len <= MaxCodeLen; len++)
|
||||
{
|
||||
int stepsize = 1 << (MaxCodeLen - len);
|
||||
for (int s = 0; s < 256; s++)
|
||||
{
|
||||
if (lenOfSym[s] != len) continue;
|
||||
codeOfSym[s] = (ushort)(currslot >> (MaxCodeLen - len));
|
||||
currslot += (uint)stepsize;
|
||||
}
|
||||
}
|
||||
// currslot must be exactly 2048 for a complete code (asserted by the round-trip self-test).
|
||||
}
|
||||
|
||||
private static int ReverseBits(int v, int len)
|
||||
{
|
||||
int r = 0;
|
||||
for (int i = 0; i < len; i++) { r = (r << 1) | (v & 1); v >>= 1; }
|
||||
return r;
|
||||
}
|
||||
|
||||
private static int BitWidth(int v)
|
||||
{
|
||||
int b = 0;
|
||||
while (v > 0) { b++; v >>= 1; }
|
||||
return b;
|
||||
}
|
||||
|
||||
// ============================ bit writers ============================
|
||||
// MSB-first writer for forward-mode bit reading (first bit written -> bit 7 of byte 0).
|
||||
private sealed class MsbBitWriter
|
||||
{
|
||||
private readonly List<byte> _bytes = new();
|
||||
private uint _acc;
|
||||
private int _nbits;
|
||||
|
||||
public int BitLength => (_bytes.Count * 8) + _nbits;
|
||||
|
||||
public void Write(uint v, int len)
|
||||
{
|
||||
for (int i = len - 1; i >= 0; i--)
|
||||
{
|
||||
_acc = (_acc << 1) | ((v >> i) & 1u);
|
||||
if (++_nbits == 8) { _bytes.Add((byte)_acc); _acc = 0; _nbits = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] ToBytesPadded()
|
||||
{
|
||||
if (_nbits > 0) { _bytes.Add((byte)(_acc << (8 - _nbits))); _acc = 0; _nbits = 0; }
|
||||
return _bytes.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// LSB-first writer mirroring the 3-stream core (first bit written -> bit 0 of byte 0).
|
||||
private sealed class LsbBitWriter
|
||||
{
|
||||
private readonly List<byte> _bytes = new();
|
||||
private uint _acc;
|
||||
private int _nbits;
|
||||
|
||||
public void Write(uint v, int len)
|
||||
{
|
||||
_acc |= (v & ((1u << len) - 1)) << _nbits;
|
||||
_nbits += len;
|
||||
while (_nbits >= 8) { _bytes.Add((byte)_acc); _acc >>= 8; _nbits -= 8; }
|
||||
}
|
||||
|
||||
public byte[] ToBytesPadded()
|
||||
{
|
||||
if (_nbits > 0) { _bytes.Add((byte)_acc); _acc = 0; _nbits = 0; }
|
||||
return _bytes.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Kraken (newLZ) OPTIMAL parse cost model — the deterministic integer core of the
|
||||
// level-7 "Optimal3" encoder. No bytes are copied from external source; only the
|
||||
// integer formulas and the fixed-point log2 mantissa table are encoded here. GPLv3.
|
||||
//
|
||||
// This file contains ONLY the cost quantizer (the guess-proof, float-free foundation):
|
||||
// * Log2Fix — fixed-point log2 of the
|
||||
// RECIPROCAL: Log2Fix(x) = round( log2(2^32 / x) * 8192 ). 65-entry u16 mantissa table.
|
||||
// * HistoToCodeCost — turns a symbol
|
||||
// histogram into a 256-entry per-symbol bit-cost table (cost units = bits * 32):
|
||||
// total = Σhisto; D = total*4 + alphabet; log2D = Log2Fix(D)
|
||||
// for s: n = histo[s]*4 + 1; raw = (Log2Fix(n) - log2D) * 32 >> 13; cost[s] = raw + bias
|
||||
// ENTROPY-FLATTEN: if (threshold * D < Σ raw*n) → all cost[s] = bias + 0x100
|
||||
// Because Log2Fix returns the reciprocal-log, Log2Fix(n) >= Log2Fix(D) (n <= D), so
|
||||
// raw = log2(D/n) * 32 >= 0 — the Shannon self-information of the symbol, in bits*32.
|
||||
//
|
||||
// The per-table biases and the entropy threshold are supplied by the caller. The forward trellis
|
||||
// recurrence that consumes these tables lives in the encoder.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression.Oodle;
|
||||
|
||||
/// <summary>
|
||||
/// Integer implementation of the newLZ optimal-parse cost model: the fixed-point log2 quantizer
|
||||
/// behind histogram-to-code-cost conversion. Deterministic; no floating
|
||||
/// point is used on any path that influences a cost. All costs are expressed in
|
||||
/// <c>bits * 32</c> (the 1/32-bit fixed-point the parser compares).
|
||||
/// </summary>
|
||||
internal static class KrakenOptimalCost
|
||||
{
|
||||
/// <summary>Alphabet size every newLZ entropy array is built over (asserted == 256 by the format).</summary>
|
||||
internal const int Alphabet = 256;
|
||||
|
||||
/// <summary>
|
||||
/// 65-entry unsigned-16 mantissa table. Entry
|
||||
/// <c>k = round( log2(1 + k/64) * 8192 )</c>; <c>Table[0] = 0</c> (log2 1), <c>Table[64] = 0x2000</c>
|
||||
/// (log2 2 = one full bit, 8192). Interpolated by <see cref="Log2Fix"/>.
|
||||
/// </summary>
|
||||
private static readonly ushort[] Log2Mantissa =
|
||||
{
|
||||
0x0000, 0x00B7, 0x016C, 0x021D, 0x02CC, 0x0379, 0x0423, 0x04CB,
|
||||
0x0570, 0x0613, 0x06B4, 0x0752, 0x07EF, 0x088A, 0x0922, 0x09B9,
|
||||
0x0A4D, 0x0AE0, 0x0B71, 0x0C00, 0x0C8E, 0x0D1A, 0x0DA4, 0x0E2D,
|
||||
0x0EB4, 0x0F39, 0x0FBD, 0x1040, 0x10C1, 0x1141, 0x11BF, 0x123C,
|
||||
0x12B8, 0x1332, 0x13AC, 0x1424, 0x149A, 0x1510, 0x1585, 0x15F8,
|
||||
0x166A, 0x16DB, 0x174B, 0x17BA, 0x1828, 0x1895, 0x1901, 0x196C,
|
||||
0x19D6, 0x1A3F, 0x1AA7, 0x1B0E, 0x1B75, 0x1BDA, 0x1C3F, 0x1CA2,
|
||||
0x1D05, 0x1D67, 0x1DC9, 0x1E29, 0x1E89, 0x1EE8, 0x1F46, 0x1FA3,
|
||||
0x2000,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// <c>bitlen(x)</c> = <c>x == 0 ? 0 : 32 - clz(x)</c>. The index of
|
||||
/// the most-significant set bit plus one.
|
||||
/// </summary>
|
||||
internal static int BitLen(uint x) => 32 - BitOperations.LeadingZeroCount(x);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-point <c>log2</c> of the reciprocal, scaled by
|
||||
/// 2^13: returns <c>round( log2(2^32 / x) * 8192 )</c>. Exact for powers of two
|
||||
/// (<c>Log2Fix(2^k) = (32 - k) * 8192</c>); table-interpolated otherwise. <paramref name="x"/> must be
|
||||
/// non-zero (the caller always passes <c>count*4+1 >= 1</c>).
|
||||
/// </summary>
|
||||
internal static int Log2Fix(uint x)
|
||||
{
|
||||
int e = BitLen(x) - 1; // MSB index 0..31
|
||||
uint norm = x << ((32 - e) & 31); // shift the leading 1 out; mantissa bits move to the top
|
||||
int idx = (int)(norm >> 26); // top 6 fractional bits -> 0..63
|
||||
int t0 = Log2Mantissa[idx];
|
||||
int t1 = Log2Mantissa[idx + 1];
|
||||
int frac = (int)((norm & 0x3FFFFFFu) >> 10); // next 16 fractional bits
|
||||
int interp = (((t1 - t0) * frac) + 0x8000) >> 16;
|
||||
return ((32 - e) * 0x2000) - t0 - interp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a per-symbol bit-cost table (units = bits*32)
|
||||
/// from a symbol histogram, then applies the entropy-flatten clamp.
|
||||
/// </summary>
|
||||
/// <param name="histo">Symbol counts, length <see cref="Alphabet"/> (256).</param>
|
||||
/// <param name="cost">Destination cost table, length <see cref="Alphabet"/> (256); overwritten.</param>
|
||||
/// <param name="bias">Per-table additive bias (from <c>passinfo_to_codecost</c>).</param>
|
||||
/// <param name="threshold">Entropy-flatten threshold (expected in [7*32, 32*8] = [224, 256]).</param>
|
||||
internal static void HistoToCodeCost(ReadOnlySpan<int> histo, Span<int> cost, int bias, int threshold)
|
||||
{
|
||||
if (histo.Length < Alphabet || cost.Length < Alphabet)
|
||||
throw new ArgumentException("histo/cost must have length >= 256.");
|
||||
|
||||
int total = 0;
|
||||
for (int i = 0; i < Alphabet; i++) total += histo[i];
|
||||
|
||||
int dEnt = total * 4 + Alphabet; // D = total*4 + alphabet
|
||||
int log2D = Log2Fix((uint)dEnt);
|
||||
long sumCost = 0; // Σ raw*n (pre-bias) — entropy estimate, 64-bit to avoid overflow
|
||||
|
||||
for (int s = 0; s < Alphabet; s++)
|
||||
{
|
||||
int n = histo[s] * 4 + 1;
|
||||
int raw = (Log2Fix((uint)n) - log2D) * 0x20 >> 0xd;
|
||||
sumCost += (long)raw * n;
|
||||
cost[s] = raw + bias;
|
||||
}
|
||||
|
||||
// Entropy-flatten: a near-uniform distribution (>~8 bits/symbol) is coded as flat 8-bit literals.
|
||||
if ((long)threshold * dEnt < sumCost)
|
||||
{
|
||||
int flat = bias + 0x100;
|
||||
for (int s = 0; s < Alphabet; s++) cost[s] = flat;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// COST-HELPER LAYER — the five deterministic cost functions the forward trellis DP calls per
|
||||
// candidate, plus the per-pass table builder. The offset cost matches the packer used by
|
||||
// KrakenBitWriter.WriteDistance.
|
||||
// * BuildFromPassinfo — builds per-pass code costs
|
||||
// * CostLiterals — literal-run cost
|
||||
// * CostOffset — offset cost, mode-0 / standard newLZ window
|
||||
// * CostLen — length cost
|
||||
// * CostLoMatch — repeat-offset match cost
|
||||
// * CostNormalMatch — new-offset match packet/length cost; add CostOffset
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The per-pass quantized cost tables (<c>codecosts</c>, serialized size ~0x1808 bytes). Every
|
||||
/// table is a 256-entry <c>bits*32</c> cost array built by <see cref="BuildFromPassinfo"/>. Byte
|
||||
/// offsets of the serialized layout are noted for each field.
|
||||
/// </summary>
|
||||
internal sealed class CodeCosts
|
||||
{
|
||||
/// <summary>codecosts[0]: literal model — 1 = raw literals, otherwise "sub" (delta) literals.</summary>
|
||||
internal int LitMode;
|
||||
/// <summary>codecosts+0x04: literal-delta mask — 0 (raw) or 0xff (sub).</summary>
|
||||
internal int LitSubMask;
|
||||
/// <summary>codecosts+0x08: literal-byte cost table, indexed by <c>(byte - (ref & mask)) & 0xff</c>.</summary>
|
||||
internal readonly int[] Lit = new int[Alphabet];
|
||||
/// <summary>codecosts+0x408: command/packet cost table, indexed by the newLZ command byte.</summary>
|
||||
internal readonly int[] Packet = new int[Alphabet];
|
||||
/// <summary>codecosts+0x808: offset alt-modulo (offset coding mode; 0 = standard bucket reader).</summary>
|
||||
internal int OffsetAltModulo;
|
||||
/// <summary>codecosts+0x80c: offset-bucket cost table, indexed by the packed-offset bucket byte.</summary>
|
||||
internal readonly int[] OffsetBucket = new int[Alphabet];
|
||||
/// <summary>codecosts+0xc0c: offset alt cost table (only built when <see cref="OffsetAltModulo"/> > 1).</summary>
|
||||
internal readonly int[] OffsetAlt = new int[Alphabet];
|
||||
/// <summary>codecosts+0x100c: length cost table; [0..0xFE] per-length, [0xFF] = escape base.</summary>
|
||||
internal readonly int[] Length = new int[Alphabet];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the five cost tables from a pass
|
||||
/// histogram block via <see cref="HistoToCodeCost"/> with the fixed per-table biases (threshold 0xff).
|
||||
/// The <paramref name="passinfo"/> block is int-indexed:
|
||||
/// <c>[0..0xFF]</c> lit-raw histo, <c>[0x100..0x1FF]</c> lit-sub histo, <c>[0x200..0x2FF]</c> packet,
|
||||
/// <c>[0x300..0x3FF]</c> length, <c>[0x400]</c> offset_alt_modulo, <c>[0x401..0x500]</c> offset-bucket,
|
||||
/// <c>[0x501..0x600]</c> offset-alt. <paramref name="litMode"/> is the carried codecosts[0] flag.
|
||||
/// </summary>
|
||||
internal static CodeCosts BuildFromPassinfo(ReadOnlySpan<int> passinfo, int litMode)
|
||||
{
|
||||
if (passinfo.Length < 0x601)
|
||||
throw new ArgumentException("passinfo must have length >= 0x601.");
|
||||
|
||||
var cc = new CodeCosts
|
||||
{
|
||||
LitMode = litMode,
|
||||
LitSubMask = litMode == 1 ? 0 : 0xff,
|
||||
OffsetAltModulo = passinfo[0x400],
|
||||
};
|
||||
|
||||
HistoToCodeCost(passinfo.Slice(0x401, Alphabet), cc.OffsetBucket, 0x24, 0xff);
|
||||
if (passinfo[0x400] > 1)
|
||||
HistoToCodeCost(passinfo.Slice(0x501, Alphabet), cc.OffsetAlt, 0, 0xff);
|
||||
HistoToCodeCost(passinfo.Slice(0x200, Alphabet), cc.Packet, 0x12, 0xff);
|
||||
HistoToCodeCost(passinfo.Slice(0x300, Alphabet), cc.Length, 0xc, 0xff);
|
||||
HistoToCodeCost(litMode == 1 ? passinfo.Slice(0, Alphabet) : passinfo.Slice(0x100, Alphabet),
|
||||
cc.Lit, 0, 0xff);
|
||||
return cc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cost of a <paramref name="lrl"/>-byte literal run that
|
||||
/// starts at <paramref name="start"/> in <paramref name="buf"/>, against the most-recent offset
|
||||
/// <paramref name="lo"/> (>= 8). Each byte costs <c>Lit[(buf[p] - (buf[p-lo] & mask)) & 0xff]</c>.
|
||||
/// </summary>
|
||||
internal static int CostLiterals(ReadOnlySpan<byte> buf, int start, int lrl, int lo, CodeCosts cc)
|
||||
{
|
||||
if (lrl == 0) return 0;
|
||||
int submask = cc.LitSubMask;
|
||||
int sum = 0;
|
||||
for (int i = 0; i < lrl; i++)
|
||||
{
|
||||
int p = start + i;
|
||||
int refByte = submask == 0 ? 0 : (buf[p - lo] & submask);
|
||||
int delta = (buf[p] - refByte) & 0xff;
|
||||
sum += cc.Lit[delta];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cost of a single literal at <paramref name="pos"/>
|
||||
/// against the most-recent offset <paramref name="lo"/> (>= 8). In raw mode (<c>LitSubMask == 0</c>)
|
||||
/// this is simply <c>Lit[buf[pos]]</c>; in sub mode it is the delta against <c>buf[pos-lo]</c>.
|
||||
/// </summary>
|
||||
internal static int CostAddLiteral(ReadOnlySpan<byte> buf, int pos, int lo, CodeCosts cc)
|
||||
{
|
||||
int refByte = cc.LitSubMask == 0 ? 0 : (buf[pos - lo] & cc.LitSubMask);
|
||||
int delta = (buf[pos] - refByte) & 0xff;
|
||||
return cc.Lit[delta];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packed-offset bucket byte for <paramref name="dist"/> (>= 8), identical to
|
||||
/// <c>KrakenBitWriter.WriteDistance</c> for the
|
||||
/// standard window (bucket < 0xF0, dist <= ~256 KiB). Outputs the extra-bit count in
|
||||
/// <paramref name="extraBits"/>.
|
||||
/// </summary>
|
||||
internal static int OffsetBucketByte(int dist, out int extraBits)
|
||||
{
|
||||
uint t = (uint)dist + 248;
|
||||
int top = 31 - BitOperations.LeadingZeroCount(t);
|
||||
int vhi = top - 8;
|
||||
uint rem = t - (1u << (vhi + 8));
|
||||
uint vlo = rem & 0xF;
|
||||
extraBits = vhi + 4;
|
||||
return (vhi << 4) | (int)vlo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard mode-0 offset path: <c>OffsetBucket[bucket] +
|
||||
/// extraBits*32</c> (+0xc for the > 0x7FFF07 huge-offset bucket). Valid for the newLZ window the
|
||||
/// nwonly encoder emits (offset coding mode 0).
|
||||
/// </summary>
|
||||
internal static int CostOffset(int dist, CodeCosts cc)
|
||||
{
|
||||
int v = OffsetBucketByte(dist, out int extraBits);
|
||||
int cost = cc.OffsetBucket[v] + extraBits * 0x20;
|
||||
if (dist > 0x7fff07) cost += 0xc;
|
||||
return cost;
|
||||
}
|
||||
|
||||
/// <summary>Elias-gamma bit count: <c>2*floor(log2(v+1)) + 1</c>.</summary>
|
||||
internal static int VarBitsEliasGamma(int val) => (BitLen((uint)(val + 1)) - 1) * 2 + 1;
|
||||
|
||||
/// <summary>Length-escape bit count: <c>VarBitsEliasGamma(val >> nbits) + nbits</c>.</summary>
|
||||
internal static int CountBits(uint val, int nbits) => VarBitsEliasGamma((int)(val >> nbits)) + nbits;
|
||||
|
||||
/// <summary>
|
||||
/// Length cost: <c>Length[v]</c> for <c>v < 0xFF</c>; otherwise the escape base
|
||||
/// <c>Length[0xFF] + CountBits(v - 0xFF, 6)*32</c>.
|
||||
/// </summary>
|
||||
internal static int CostLen(CodeCosts cc, int v)
|
||||
{
|
||||
if (v < 0xFF) return cc.Length[v];
|
||||
return cc.Length[0xFF] + CountBits((uint)(v - 0xFF), 6) * 0x20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cost of a repeat-offset match of length
|
||||
/// <paramref name="ml"/> with literal field <paramref name="litField"/> (0..3) and recent-offset
|
||||
/// index <paramref name="loi"/> (0..2). Short match (<c>ml < 17</c>) → one packet-table lookup;
|
||||
/// long match → <c>CostLen(ml-17)</c> plus the escape packet entry.
|
||||
/// </summary>
|
||||
internal static int CostLoMatch(CodeCosts cc, int litField, int ml, int loi)
|
||||
{
|
||||
int lenEsc = ml - 0x11;
|
||||
if (lenEsc < 0)
|
||||
return cc.Packet[litField + (ml - 2) * 4 + loi * 0x40];
|
||||
return CostLen(cc, lenEsc) + cc.Packet[litField + 0x3c + loi * 0x40];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packet/length cost of a new-offset match
|
||||
/// (offset index 3) of length <paramref name="ml"/> with literal field <paramref name="litField"/>.
|
||||
/// The caller adds <see cref="CostOffset"/> for the new distance separately.
|
||||
/// </summary>
|
||||
internal static int CostNormalMatch(CodeCosts cc, int litField, int ml)
|
||||
{
|
||||
int lenEsc = ml - 0x11;
|
||||
if (lenEsc < 0)
|
||||
return cc.Packet[litField + (ml - 2) * 4 + 0xc0];
|
||||
return CostLen(cc, lenEsc) + cc.Packet[litField + 0xfc];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Match finder for the Kraken (newLZ) level-7 parse: a compacted suffix trie with suffix links, built
|
||||
// incrementally over one whole block, producing four (length, offset) pairs per position.
|
||||
//
|
||||
// The trie is built once per block and the whole per-position table is filled in a single left-to-right
|
||||
// pass. Work per position is amortised constant: the descent resumes at the previous position's suffix
|
||||
// link, the byte comparison starts at the previous match length minus one, and the output walk visits at
|
||||
// most sixteen ancestors. That is what keeps a 256 KiB block linear instead of quadratic.
|
||||
//
|
||||
// Table layout: PairsPerPosition (4) pairs of two int32 per position, i.e. eight ints per position.
|
||||
// Pair 0 is the longest match the trie represents at that position; each later pair is strictly shorter
|
||||
// at a strictly smaller offset. A list shorter than four pairs ends with a length-0 entry. A position
|
||||
// with no match writes a single 0. Lengths are NOT clipped to any tail reserve — the consumer applies
|
||||
// its own match-end limit, so the ranking here is by the untruncated length.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression.Oodle;
|
||||
|
||||
/// <summary>
|
||||
/// Compacted suffix trie over one block, filling a four-pair match table for every position.
|
||||
/// One instance covers one block; matches never reach outside it.
|
||||
/// </summary>
|
||||
internal sealed class KrakenSuffixTrieMatcher
|
||||
{
|
||||
/// <summary>Pairs reported per position.</summary>
|
||||
public const int PairsPerPosition = 4;
|
||||
|
||||
/// <summary>Ints per position in the match table: one length and one offset per pair.</summary>
|
||||
public const int IntsPerPosition = PairsPerPosition * 2;
|
||||
|
||||
// Two leading bytes index the hash table directly, so it is collision-free for any block this
|
||||
// format can hold (a block is at most 256 KiB, far below the 8 MiB three-byte threshold).
|
||||
private const int FirstBytes = 2;
|
||||
private const int HashBits = 16;
|
||||
private const int HashMask = (1 << HashBits) - 1;
|
||||
|
||||
// The output walk refreshes at most this many ancestors, which is the one place the reported offset
|
||||
// can be older than the nearest occurrence.
|
||||
private const int AncestorWalkLimit = 16;
|
||||
|
||||
// Ancestor pairs are never reported below this length.
|
||||
private const int MinAncestorLength = 3;
|
||||
|
||||
private readonly byte[] _data;
|
||||
private readonly int _sizePlus1;
|
||||
private readonly int[] _hash;
|
||||
|
||||
// Node fields, struct of arrays. Index 0 means "none"; a negative index is a leaf whose position is
|
||||
// the negated value.
|
||||
private readonly int[] _nodePos;
|
||||
private readonly int[] _nodeParent;
|
||||
private readonly int[] _nodeFollow;
|
||||
private readonly int[] _nodeDepth;
|
||||
|
||||
// Children live in one growable slot pool. Lookup is by exact byte and a node never holds the same
|
||||
// byte twice, so the container shape cannot change which child is returned.
|
||||
private readonly int[] _childStart;
|
||||
private readonly int[] _childCount;
|
||||
private readonly int[] _childCapacity;
|
||||
private byte[] _childChar;
|
||||
private int[] _childNode;
|
||||
private int _childUsed;
|
||||
|
||||
private int _pos = 1; // 1-based; the byte at position p is _data[p - 1]
|
||||
private int _nextNode = 1;
|
||||
private int _lastMl;
|
||||
private int _cur;
|
||||
private int _nodeToUpdateFollow;
|
||||
private int _maxmlOffs;
|
||||
|
||||
/// <summary>Creates a matcher over <paramref name="data"/>, which must be one whole block.</summary>
|
||||
public KrakenSuffixTrieMatcher(byte[] data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
_data = data;
|
||||
_sizePlus1 = data.Length + 1;
|
||||
_hash = new int[1 << HashBits];
|
||||
|
||||
int nodeCapacity = data.Length + 2;
|
||||
_nodePos = new int[nodeCapacity];
|
||||
_nodeParent = new int[nodeCapacity];
|
||||
_nodeFollow = new int[nodeCapacity];
|
||||
_nodeDepth = new int[nodeCapacity];
|
||||
_childStart = new int[nodeCapacity];
|
||||
_childCount = new int[nodeCapacity];
|
||||
_childCapacity = new int[nodeCapacity];
|
||||
|
||||
int slotCapacity = data.Length < 64 ? 256 : data.Length * 2;
|
||||
_childChar = new byte[slotCapacity];
|
||||
_childNode = new int[slotCapacity];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="matches"/> for every position of the block. The array must hold
|
||||
/// <c>data.Length * IntsPerPosition</c> ints.
|
||||
/// </summary>
|
||||
public void Run(int[] matches)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(matches);
|
||||
int count = _data.Length;
|
||||
int startPos = _pos;
|
||||
int processEnd = Math.Min(_pos + count, _sizePlus1 - 4);
|
||||
int posEnd = Math.Min(_pos + count, _sizePlus1);
|
||||
|
||||
while (_pos < processEnd)
|
||||
{
|
||||
if (_maxmlOffs != 0)
|
||||
{
|
||||
FillTerminated(matches, startPos, processEnd);
|
||||
break;
|
||||
}
|
||||
|
||||
int pos = _pos;
|
||||
int maxml = _sizePlus1 - pos;
|
||||
int guaranteedMl = _lastMl > 1 ? _lastMl - 1 : 0;
|
||||
int parent = 0;
|
||||
int deepestNoFollow = 0;
|
||||
int deepestWithFollow = 0;
|
||||
int followLimit = _nodeToUpdateFollow == 0
|
||||
? int.MaxValue
|
||||
: (_nodeFollow[_nodeToUpdateFollow] == 0 ? 0 : _nodeDepth[_nodeFollow[_nodeToUpdateFollow]]);
|
||||
|
||||
int cur;
|
||||
int curml;
|
||||
int matchPos; // 1-based position of the string the match was found against
|
||||
|
||||
if (_cur < 1)
|
||||
{
|
||||
int h = (_data[pos - 1] | (_data[pos] << 8)) & HashMask;
|
||||
cur = _hash[h];
|
||||
if (cur == 0)
|
||||
{
|
||||
// First time these two bytes occur: record the position and report nothing.
|
||||
_hash[h] = -pos;
|
||||
_lastMl = 0;
|
||||
_cur = 0;
|
||||
matches[(pos - startPos) * IntsPerPosition] = 0;
|
||||
_pos = pos + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
curml = FirstBytes;
|
||||
if (cur < 0)
|
||||
{
|
||||
_hash[h] = _nextNode;
|
||||
goto SplitAgainstLeaf;
|
||||
}
|
||||
|
||||
int rootDepth = _nodeDepth[cur];
|
||||
if (curml < guaranteedMl) curml = guaranteedMl;
|
||||
if (rootDepth <= curml)
|
||||
{
|
||||
curml = rootDepth;
|
||||
goto DescendStep;
|
||||
}
|
||||
int rootOther = _nodePos[cur];
|
||||
while (curml < rootDepth)
|
||||
{
|
||||
if (curml == maxml) goto MaxmlReached;
|
||||
if (_data[pos - 1 + curml] != _data[rootOther - 1 + curml])
|
||||
{
|
||||
_hash[h] = _nextNode;
|
||||
goto SplitAgainstNode;
|
||||
}
|
||||
curml++;
|
||||
}
|
||||
goto DescendStep;
|
||||
}
|
||||
|
||||
cur = _cur;
|
||||
curml = _nodeDepth[cur];
|
||||
|
||||
DescendStep:
|
||||
while (true)
|
||||
{
|
||||
if (followLimit < curml)
|
||||
{
|
||||
if (curml < _nodeDepth[_nodeToUpdateFollow])
|
||||
{
|
||||
followLimit = curml;
|
||||
_nodeFollow[_nodeToUpdateFollow] = cur;
|
||||
}
|
||||
else
|
||||
{
|
||||
followLimit = int.MaxValue;
|
||||
}
|
||||
}
|
||||
if (curml >= maxml) goto MaxmlReached;
|
||||
|
||||
byte c = _data[pos - 1 + curml];
|
||||
if (_nodeFollow[cur] == 0) deepestNoFollow = cur; else deepestWithFollow = cur;
|
||||
|
||||
int slot = FindChildSlot(cur, c);
|
||||
if (slot < 0)
|
||||
{
|
||||
AddChild(cur, c, -pos);
|
||||
// A new leaf hung off an existing node: that node is the match.
|
||||
_nodeToUpdateFollow = deepestNoFollow > 0 ? deepestNoFollow : deepestWithFollow;
|
||||
matchPos = _nodePos[cur];
|
||||
parent = cur;
|
||||
goto Emit;
|
||||
}
|
||||
|
||||
int child = _childNode[slot];
|
||||
if (child < 0)
|
||||
{
|
||||
_childNode[slot] = _nextNode;
|
||||
parent = cur;
|
||||
curml++;
|
||||
cur = child;
|
||||
goto SplitAgainstLeaf;
|
||||
}
|
||||
|
||||
curml++;
|
||||
int childDepth = _nodeDepth[child];
|
||||
if (curml < guaranteedMl) curml = guaranteedMl;
|
||||
if (curml < childDepth)
|
||||
{
|
||||
int other = _nodePos[child];
|
||||
for (; curml < childDepth; curml++)
|
||||
{
|
||||
if (curml == maxml) { cur = child; goto MaxmlReached; }
|
||||
if (_data[pos - 1 + curml] != _data[other - 1 + curml])
|
||||
{
|
||||
_childNode[slot] = _nextNode;
|
||||
parent = cur;
|
||||
cur = child;
|
||||
goto SplitAgainstNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
curml = childDepth;
|
||||
}
|
||||
cur = child;
|
||||
}
|
||||
|
||||
SplitAgainstLeaf:
|
||||
{
|
||||
int other = -cur;
|
||||
if (curml < guaranteedMl) curml = guaranteedMl;
|
||||
while (true)
|
||||
{
|
||||
if (curml == maxml) goto MaxmlReached;
|
||||
if (_data[pos - 1 + curml] != _data[other - 1 + curml]) break;
|
||||
curml++;
|
||||
}
|
||||
matchPos = MakeNode(pos, curml, parent, cur, other, ref followLimit);
|
||||
goto Emit;
|
||||
}
|
||||
|
||||
SplitAgainstNode:
|
||||
{
|
||||
int other = _nodePos[cur];
|
||||
_nodeParent[cur] = _nextNode;
|
||||
matchPos = MakeNode(pos, curml, parent, cur, other, ref followLimit);
|
||||
goto Emit;
|
||||
}
|
||||
|
||||
MaxmlReached:
|
||||
{
|
||||
int otherPos = cur < 0 ? -cur : _nodePos[cur];
|
||||
_maxmlOffs = pos - otherPos;
|
||||
_lastMl = maxml;
|
||||
FillTerminated(matches, startPos, processEnd);
|
||||
_pos = processEnd;
|
||||
break;
|
||||
}
|
||||
|
||||
Emit:
|
||||
{
|
||||
_cur = deepestWithFollow == 0 ? 0 : _nodeFollow[deepestWithFollow];
|
||||
_lastMl = curml;
|
||||
|
||||
int outIndex = (pos - startPos) * IntsPerPosition;
|
||||
matches[outIndex] = curml;
|
||||
matches[outIndex + 1] = pos - matchPos;
|
||||
int numPairs = 1;
|
||||
int walked = 0;
|
||||
int p = parent;
|
||||
while (p != 0)
|
||||
{
|
||||
if (numPairs < PairsPerPosition)
|
||||
{
|
||||
int newOffset = pos - _nodePos[p];
|
||||
if (newOffset < matches[outIndex + (numPairs - 1) * 2 + 1])
|
||||
{
|
||||
int length = _nodeDepth[p];
|
||||
if (length < MinAncestorLength) break;
|
||||
matches[outIndex + numPairs * 2] = length;
|
||||
matches[outIndex + numPairs * 2 + 1] = newOffset;
|
||||
numPairs++;
|
||||
}
|
||||
}
|
||||
if (++walked > AncestorWalkLimit) break;
|
||||
_nodePos[p] = pos;
|
||||
p = _nodeParent[p];
|
||||
}
|
||||
if (numPairs < PairsPerPosition)
|
||||
matches[outIndex + numPairs * 2] = 0;
|
||||
_pos = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Positions past the trie loop carry no match.
|
||||
for (int p = _pos; p < posEnd; p++)
|
||||
matches[(p - startPos) * IntsPerPosition] = 0;
|
||||
_pos = posEnd;
|
||||
}
|
||||
|
||||
// Once the current suffix matches an earlier occurrence to the end of the block the trie stops
|
||||
// being updated and every remaining position reports one pair at the same offset.
|
||||
private void FillTerminated(int[] matches, int startPos, int processEnd)
|
||||
{
|
||||
for (int p = _pos; p < processEnd; p++)
|
||||
{
|
||||
int outIndex = (p - startPos) * IntsPerPosition;
|
||||
matches[outIndex] = _sizePlus1 - p;
|
||||
matches[outIndex + 1] = _maxmlOffs;
|
||||
matches[outIndex + 2] = 0;
|
||||
}
|
||||
_pos = processEnd;
|
||||
}
|
||||
|
||||
// Creates the branching node that separates the current suffix from the string it diverged from,
|
||||
// and returns that string's position.
|
||||
private int MakeNode(int pos, int curml, int parent, int cur, int otherPos, ref int followLimit)
|
||||
{
|
||||
int newIdx = _nextNode++;
|
||||
_nodeDepth[newIdx] = curml;
|
||||
_nodeFollow[newIdx] = 0;
|
||||
_nodePos[newIdx] = pos;
|
||||
_nodeParent[newIdx] = parent;
|
||||
_childStart[newIdx] = 0;
|
||||
_childCount[newIdx] = 0;
|
||||
_childCapacity[newIdx] = 0;
|
||||
AddChild(newIdx, _data[pos - 1 + curml], -pos);
|
||||
AddChild(newIdx, _data[otherPos - 1 + curml], cur);
|
||||
if (followLimit < curml)
|
||||
{
|
||||
if (curml < _nodeDepth[_nodeToUpdateFollow])
|
||||
{
|
||||
followLimit = curml;
|
||||
_nodeFollow[_nodeToUpdateFollow] = newIdx;
|
||||
}
|
||||
else
|
||||
{
|
||||
followLimit = int.MaxValue;
|
||||
}
|
||||
}
|
||||
_nodeToUpdateFollow = newIdx;
|
||||
return otherPos;
|
||||
}
|
||||
|
||||
private int FindChildSlot(int node, byte c)
|
||||
{
|
||||
int start = _childStart[node];
|
||||
int count = _childCount[node];
|
||||
for (int i = 0; i < count; i++)
|
||||
if (_childChar[start + i] == c)
|
||||
return start + i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void AddChild(int node, byte c, int value)
|
||||
{
|
||||
int count = _childCount[node];
|
||||
int capacity = _childCapacity[node];
|
||||
if (count == capacity)
|
||||
{
|
||||
int newCapacity = capacity == 0 ? 2 : capacity * 2;
|
||||
EnsureSlots(_childUsed + newCapacity);
|
||||
int newStart = _childUsed;
|
||||
_childUsed += newCapacity;
|
||||
if (count > 0)
|
||||
{
|
||||
Array.Copy(_childChar, _childStart[node], _childChar, newStart, count);
|
||||
Array.Copy(_childNode, _childStart[node], _childNode, newStart, count);
|
||||
}
|
||||
_childStart[node] = newStart;
|
||||
_childCapacity[node] = newCapacity;
|
||||
}
|
||||
int slot = _childStart[node] + count;
|
||||
_childChar[slot] = c;
|
||||
_childNode[slot] = value;
|
||||
_childCount[node] = count + 1;
|
||||
}
|
||||
|
||||
private void EnsureSlots(int needed)
|
||||
{
|
||||
if (needed <= _childChar.Length)
|
||||
return;
|
||||
int size = _childChar.Length;
|
||||
while (size < needed) size *= 2;
|
||||
Array.Resize(ref _childChar, size);
|
||||
Array.Resize(ref _childNode, size);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,455 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// Reader/parser for the PS5 PFSv3 (and PFSv2) compression file format — the "PFSC"
|
||||
// container.
|
||||
//
|
||||
// IMPORTANT: this is a DIFFERENT format from the zlib "PFSC" image handled by
|
||||
// LibProsperoPkg.PFS.ProsperoPfscEncoder/ProsperoPfscReader. The two collide on the 4-byte 'PFSC' magic but
|
||||
// differ in everything else; they are disambiguated by the format-version field at offset 0x04
|
||||
// (version 2 or 3; the zlib variant stores 0 there).
|
||||
//
|
||||
// Every field decoded here is modeled, including: the SHA3-256 file digest at 0x28, the 7-entry section directory at
|
||||
// 0x48, the block boundary table (id=3) and the per-block SHA3-256 hash table (id=4).
|
||||
#nullable enable
|
||||
using LibProsperoPkg.PFS.Compression.Oodle;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression;
|
||||
|
||||
/// <summary>
|
||||
/// One block of a parsed <see cref="ProsperoCompressedPfsFile"/>.
|
||||
/// </summary>
|
||||
public readonly struct PfsBlock
|
||||
{
|
||||
/// <summary>The zero-based block index.</summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>The absolute byte offset of this block's compressed data within the container file.</summary>
|
||||
public long CompressedOffset { get; init; }
|
||||
|
||||
/// <summary>The compressed size, in bytes, of this block within the container.</summary>
|
||||
public int CompressedSize { get; init; }
|
||||
|
||||
/// <summary>The logical (uncompressed) byte offset this block expands to.</summary>
|
||||
public long UncompressedOffset { get; init; }
|
||||
|
||||
/// <summary>The uncompressed size, in bytes, this block expands to.</summary>
|
||||
public int UncompressedSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The block's stored SHA3-256 digest (32 bytes), taken over the <i>uncompressed</i> block bytes.
|
||||
/// Verify a decoded block with <see cref="ProsperoPfsDigest.VerifyBlockDigest"/>.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> Hash { get; init; }
|
||||
|
||||
/// <summary>The raw compressed bytes of this block (a slice of the container buffer).</summary>
|
||||
public ReadOnlyMemory<byte> CompressedData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when the block is stored uncompressed (<see cref="CompressedSize"/> equals
|
||||
/// <see cref="UncompressedSize"/>), as the format does for incompressible data; otherwise the
|
||||
/// block is a Kraken bitstream.
|
||||
/// </summary>
|
||||
public bool IsStored => CompressedSize == UncompressedSize;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when a compressed block is split into two newLZ chunks (boundary flag <c>0x26</c>
|
||||
/// rather than <c>0x06</c>), which happens for blocks larger than 128 KiB. The first chunk's
|
||||
/// compressed size is then <see cref="FirstChunkCompressedSize"/>.
|
||||
/// </summary>
|
||||
public bool IsMultiChunk { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when a compressed block is a single bare-entropy array rather than an LZ stream
|
||||
/// (boundary flag bit <c>0x02</c> clear). The whole block payload is then one Kraken entropy array
|
||||
/// (typically a type-2 Huffman array) that expands directly to the uncompressed bytes — no seed,
|
||||
/// no LZ table, no literal model. The encoder emits this form when entropy coding helps but LZ matching
|
||||
/// does not (skewed-frequency data). Always <c>false</c> for stored and newLZ blocks.
|
||||
/// </summary>
|
||||
public bool IsBareEntropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The compressed size in bytes of the block's first sub-chunk, recovered from the boundary size
|
||||
/// hint. The second sub-chunk, when present, occupies the remaining bytes. The hint carries this
|
||||
/// value for every block, including single-sub-chunk and stored blocks.
|
||||
/// </summary>
|
||||
public int FirstChunkCompressedSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The uncompressed size in bytes of the block's first sub-chunk: the whole block for a block of
|
||||
/// at most 128 KiB, otherwise exactly 128 KiB.
|
||||
/// </summary>
|
||||
public int Chunk0UncompressedSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The uncompressed size in bytes of the block's second sub-chunk, or zero when the block has one
|
||||
/// sub-chunk.
|
||||
/// </summary>
|
||||
public int Chunk1UncompressedSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The newLZ literal model for this compressed block, recovered from the boundary flag's low bit
|
||||
/// (the bit the PFS layer feeds into the reconstructed Oodle chunk header): <c>1</c> = raw literals,
|
||||
/// <c>0</c> = sub/delta literals. Irrelevant for stored blocks.
|
||||
/// </summary>
|
||||
public int LiteralMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw boundary-table flag byte for this block (bits 48..55 of the id=3 entry). This is the
|
||||
/// authoritative per-sub-chunk type/literal-model selector consumed by
|
||||
/// <see cref="Oodle.KrakenDecoder.DecodeBlock"/>: chunk0 newLZ = bit <c>0x02</c> (literal model
|
||||
/// bit <c>0x01</c>); chunk1 newLZ = bit <c>0x20</c> (literal model bit <c>0x10</c>), bare-entropy
|
||||
/// chunk1 = bit <c>0x40</c>. Zero for stored blocks.
|
||||
/// </summary>
|
||||
public int Flags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The pre-compression shuffle applied to this block. Containers produced without region hints
|
||||
/// (all known default-output containers) always use <see cref="ProsperoPfsShufflePattern.None"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The per-block storage location of a non-<see cref="ProsperoPfsShufflePattern.None"/> pattern has not
|
||||
/// been characterized, so this reader
|
||||
/// reports <see cref="ProsperoPfsShufflePattern.None"/>. Do not rely on it to detect shuffled blocks.
|
||||
/// </remarks>
|
||||
public ProsperoPfsShufflePattern ShufflePattern => ProsperoPfsShufflePattern.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A parsed PS5 PFSv2/PFSv3 compression container ("PFSC"). Provides the header fields,
|
||||
/// the file-level SHA3-256 digest, the per-block table and full decompression of containers
|
||||
/// produced by <see cref="ProsperoCompressedPfsFileWriter"/> as well as finalized containers: stored
|
||||
/// blocks plus this library's Kraken codec, which decodes the entropy-coded
|
||||
/// (Huffman) arrays and the post-seed excess framing used by finalized blocks.
|
||||
/// </summary>
|
||||
public sealed class ProsperoCompressedPfsFile
|
||||
{
|
||||
/// <summary>The 4-byte container magic, 'P','F','S','C' (little-endian <c>0x43534650</c>).</summary>
|
||||
public const uint Magic = 0x43534650;
|
||||
|
||||
private const ulong NewLzFlagBit = 0x02; // boundary flag bit: set = newLZ (LZ table); clear = bare-entropy
|
||||
private const int ChunkMaxUncompressed = 0x20000; // one internal Kraken chunk decodes at most 128 KiB
|
||||
|
||||
// Header field offsets.
|
||||
private const int OffMagic = 0x00;
|
||||
private const int OffVersion = 0x04;
|
||||
private const int OffSectionCount = 0x06;
|
||||
private const int OffBlockSize = 0x08;
|
||||
private const int OffEncodeParams = 0x10;
|
||||
private const int OffUncompressedSize = 0x18;
|
||||
private const int OffTotalCompressedSize = 0x20;
|
||||
private const int OffFileDigest = 0x28;
|
||||
private const int OffSectionDirectory = 0x48;
|
||||
private const int SectionEntrySize = 16;
|
||||
private const int MinHeaderSize = OffSectionDirectory + SectionEntrySize;
|
||||
|
||||
// Section ids in the directory.
|
||||
private const int SectionGitHash = 1;
|
||||
private const int SectionShuffleTable = 2;
|
||||
private const int SectionBlockBoundaries = 3;
|
||||
private const int SectionBlockHashes = 4;
|
||||
private const int SectionBlockData = 7;
|
||||
|
||||
private readonly ReadOnlyMemory<byte> _buffer;
|
||||
|
||||
private ProsperoCompressedPfsFile(ReadOnlyMemory<byte> buffer) => _buffer = buffer;
|
||||
|
||||
/// <summary>The container format version (<see cref="ProsperoPfsCompressionFormat.Version2"/> or <see cref="ProsperoPfsCompressionFormat.Version3"/>).</summary>
|
||||
public ProsperoPfsCompressionFormat Version { get; private init; }
|
||||
|
||||
/// <summary>The compression algorithm recorded in the header (always <see cref="ProsperoCompressionAlgorithm.Kraken"/> for PS5).</summary>
|
||||
public ProsperoCompressionAlgorithm Algorithm { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Kraken compression level recorded in the header (encode-parameter byte <c>0x11</c>).
|
||||
/// Accepted levels are <c>0..9</c> and the fast range <c>-4..-1</c>; default-output containers use level 7.
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; private init; }
|
||||
|
||||
/// <summary>The Kraken sliding-window bits recorded in the header (encode-parameter byte <c>0x12</c>, normally 18).</summary>
|
||||
public int WindowBits { get; private init; }
|
||||
|
||||
/// <summary>The logical block size, in bytes (256 KiB for PS5 v3).</summary>
|
||||
public int BlockSize { get; private init; }
|
||||
|
||||
/// <summary>The total uncompressed size, in bytes, of the payload the container expands to.</summary>
|
||||
public long UncompressedSize { get; private init; }
|
||||
|
||||
/// <summary>The total compressed size, in bytes, of the whole container file (including metadata).</summary>
|
||||
public long TotalCompressedSize { get; private init; }
|
||||
|
||||
/// <summary>The header's 32-byte SHA3-256 file digest (offset <c>0x28</c>).</summary>
|
||||
public ReadOnlyMemory<byte> FileDigest { get; private init; }
|
||||
|
||||
/// <summary>The 20-byte revision hash recorded in the metadata (section id=1), or empty when absent.</summary>
|
||||
public ReadOnlyMemory<byte> GitHash { get; private init; }
|
||||
|
||||
/// <summary>The absolute byte offset where compressed block data begins (the metadata size).</summary>
|
||||
public long DataOffset { get; private init; }
|
||||
|
||||
/// <summary>The parsed per-block table.</summary>
|
||||
public IReadOnlyList<PfsBlock> Blocks { get; private init; } = Array.Empty<PfsBlock>();
|
||||
|
||||
// Byte ranges of the three metadata sections that feed the file digest.
|
||||
private (int Offset, int Size) _shuffleRange;
|
||||
private (int Offset, int Size) _boundaryRange;
|
||||
private (int Offset, int Size) _blockHashRange;
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the file-level SHA3-256 digest from the parsed metadata and compares it to the
|
||||
/// value stored at header offset <c>0x28</c>. Returns <c>true</c> when they match.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the file-level integrity check performed when opening a container: <c>SHA3-256(header32 || shuffleSection || boundarySection || blockHashSection)</c>.
|
||||
/// </remarks>
|
||||
/// <exception cref="PlatformNotSupportedException">SHA3-256 is unavailable on this host.</exception>
|
||||
public bool VerifyFileDigest()
|
||||
{
|
||||
ReadOnlySpan<byte> span = _buffer.Span;
|
||||
return ProsperoPfsDigest.VerifyFileDigest(
|
||||
span.Slice(OffBlockSize, ProsperoPfsDigest.FileDigestHeaderParamsLength),
|
||||
span.Slice(_shuffleRange.Offset, _shuffleRange.Size),
|
||||
span.Slice(_boundaryRange.Offset, _boundaryRange.Size),
|
||||
span.Slice(_blockHashRange.Offset, _blockHashRange.Size),
|
||||
FileDigest.Span);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses the whole container back to its original payload. Stored blocks are copied
|
||||
/// verbatim; compressed blocks are decoded with the Kraken codec
|
||||
/// (<see cref="Oodle.KrakenDecoder"/>), which reads both this library's own output and
|
||||
/// finalized blocks (entropy-coded arrays + the post-seed excess framing). The result has
|
||||
/// length <see cref="UncompressedSize"/>.
|
||||
/// </summary>
|
||||
/// <returns>The reconstructed uncompressed payload.</returns>
|
||||
/// <exception cref="InvalidDataException">
|
||||
/// A block is malformed or uses an unsupported entropy/excess form.
|
||||
/// </exception>
|
||||
public byte[] Decompress()
|
||||
{
|
||||
if (UncompressedSize > Array.MaxLength)
|
||||
throw new NotSupportedException(
|
||||
$"The container expands to {UncompressedSize:N0} bytes, which no single array can hold. " +
|
||||
"Decompress it block by block instead.");
|
||||
var output = new byte[UncompressedSize];
|
||||
foreach (PfsBlock block in Blocks)
|
||||
{
|
||||
Span<byte> dstBlock = output.AsSpan((int)block.UncompressedOffset, block.UncompressedSize);
|
||||
if (block.IsStored)
|
||||
{
|
||||
block.CompressedData.Span.CopyTo(dstBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
KrakenDecodeStatus status = KrakenDecoder.DecodeBlock(
|
||||
block.CompressedData.Span, block.Flags, block.FirstChunkCompressedSize, dstBlock);
|
||||
if (status != KrakenDecodeStatus.Success)
|
||||
throw new InvalidDataException($"Block {block.Index} could not be decoded ({status}).");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when <paramref name="data"/> begins with a PS5 PFSv2/PFSv3 compression
|
||||
/// container, distinguishing it from the zlib "PFSC" image (which stores 0 in the
|
||||
/// version field).
|
||||
/// </summary>
|
||||
public static bool IsPfsCompressed(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < MinHeaderSize)
|
||||
return false;
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(data[OffMagic..]) != Magic)
|
||||
return false;
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(data[OffVersion..]);
|
||||
return version is 2 or 3;
|
||||
}
|
||||
|
||||
/// <summary>Parses a PS5 PFSv2/PFSv3 compression container from <paramref name="buffer"/>.</summary>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="buffer"/> is null.</exception>
|
||||
/// <exception cref="InvalidDataException">The buffer is not a valid PS5 PFS compression container.</exception>
|
||||
public static ProsperoCompressedPfsFile Parse(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
return Parse(buffer.AsMemory());
|
||||
}
|
||||
|
||||
/// <summary>Parses a PS5 PFSv2/PFSv3 compression container from <paramref name="buffer"/>.</summary>
|
||||
/// <exception cref="InvalidDataException">The buffer is not a valid PS5 PFS compression container.</exception>
|
||||
public static ProsperoCompressedPfsFile Parse(ReadOnlyMemory<byte> buffer)
|
||||
{
|
||||
ReadOnlySpan<byte> span = buffer.Span;
|
||||
if (span.Length < MinHeaderSize)
|
||||
throw new InvalidDataException("Buffer is too small to be a PFS compression container.");
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(span[OffMagic..]) != Magic)
|
||||
throw new InvalidDataException("Missing 'PFSC' magic.");
|
||||
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(span[OffVersion..]);
|
||||
if (version is not (2 or 3))
|
||||
throw new InvalidDataException(
|
||||
$"Unsupported PFS compression version {version}; expected 2 or 3 (a zlib 'PFSC' image stores 0 here).");
|
||||
|
||||
long uncompressedSize = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(span[OffUncompressedSize..]));
|
||||
long totalCompressedSize = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(span[OffTotalCompressedSize..]));
|
||||
|
||||
// Section directory: 16-byte entries from 0x48 up to the first (lowest) section offset.
|
||||
Dictionary<int, (long Offset, long Size)> sections = ReadSectionDirectory(span);
|
||||
|
||||
long dataOffset = sections.TryGetValue(SectionBlockData, out var dataSec) ? dataSec.Offset : 0;
|
||||
var blocks = ReadBlocks(buffer, span, sections, dataOffset);
|
||||
|
||||
ReadOnlyMemory<byte> gitHash = ReadOnlyMemory<byte>.Empty;
|
||||
if (sections.TryGetValue(SectionGitHash, out var git) && InRange(span.Length, git.Offset, git.Size))
|
||||
gitHash = buffer.Slice((int)git.Offset, (int)git.Size);
|
||||
|
||||
int len = span.Length;
|
||||
(int, int) RangeOf(int id) =>
|
||||
sections.TryGetValue(id, out var s) && InRange(len, s.Offset, s.Size)
|
||||
? ((int)s.Offset, (int)s.Size)
|
||||
: (0, 0);
|
||||
|
||||
return new ProsperoCompressedPfsFile(buffer)
|
||||
{
|
||||
Version = version == 3 ? ProsperoPfsCompressionFormat.Version3 : ProsperoPfsCompressionFormat.Version2,
|
||||
Algorithm = (ProsperoCompressionAlgorithm)span[OffEncodeParams],
|
||||
CompressionLevel = (sbyte)span[OffEncodeParams + 1],
|
||||
WindowBits = span[OffEncodeParams + 2],
|
||||
BlockSize = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(span[OffBlockSize..])),
|
||||
UncompressedSize = uncompressedSize,
|
||||
TotalCompressedSize = totalCompressedSize,
|
||||
FileDigest = buffer.Slice(OffFileDigest, ProsperoPfsDigest.DigestLength),
|
||||
GitHash = gitHash,
|
||||
DataOffset = dataOffset,
|
||||
Blocks = blocks,
|
||||
_shuffleRange = RangeOf(SectionShuffleTable),
|
||||
_boundaryRange = RangeOf(SectionBlockBoundaries),
|
||||
_blockHashRange = RangeOf(SectionBlockHashes),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<int, (long Offset, long Size)> ReadSectionDirectory(ReadOnlySpan<byte> span)
|
||||
{
|
||||
var sections = new Dictionary<int, (long, long)>();
|
||||
|
||||
// The directory holds exactly the declared number of 16-byte entries (header offset 0x06)
|
||||
// and ends at the lowest section offset (the first section immediately follows it).
|
||||
int declaredCount = BinaryPrimitives.ReadUInt16LittleEndian(span[OffSectionCount..]);
|
||||
uint firstOffset = BinaryPrimitives.ReadUInt32LittleEndian(span[(OffSectionDirectory + 2)..]);
|
||||
long dirEnd = firstOffset >= OffSectionDirectory + SectionEntrySize && firstOffset <= span.Length
|
||||
? firstOffset
|
||||
: span.Length;
|
||||
|
||||
int read = 0;
|
||||
for (long p = OffSectionDirectory;
|
||||
p + SectionEntrySize <= dirEnd && (declaredCount == 0 || read < declaredCount);
|
||||
p += SectionEntrySize, read++)
|
||||
{
|
||||
int pos = (int)p;
|
||||
ushort id = BinaryPrimitives.ReadUInt16LittleEndian(span[pos..]);
|
||||
if (id == 0)
|
||||
break;
|
||||
// Both fields are 48 bits: a low 32-bit word and a 16-bit high word, so a container may
|
||||
// exceed 4 GiB.
|
||||
long offset = BinaryPrimitives.ReadUInt32LittleEndian(span[(pos + 2)..])
|
||||
| ((long)BinaryPrimitives.ReadUInt16LittleEndian(span[(pos + 6)..]) << 32);
|
||||
long size = BinaryPrimitives.ReadUInt32LittleEndian(span[(pos + 10)..])
|
||||
| ((long)BinaryPrimitives.ReadUInt16LittleEndian(span[(pos + 14)..]) << 32);
|
||||
sections[id] = (offset, size);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
private static List<PfsBlock> ReadBlocks(
|
||||
ReadOnlyMemory<byte> buffer,
|
||||
ReadOnlySpan<byte> span,
|
||||
Dictionary<int, (long Offset, long Size)> sections,
|
||||
long dataOffset)
|
||||
{
|
||||
var blocks = new List<PfsBlock>();
|
||||
if (!sections.TryGetValue(SectionBlockBoundaries, out var bnd))
|
||||
throw new InvalidDataException("Compression container is missing the block-boundary section.");
|
||||
if (bnd.Size < 2 * SectionEntrySize)
|
||||
return blocks;
|
||||
if (!InRange(span.Length, bnd.Offset, bnd.Size))
|
||||
throw new InvalidDataException("Block boundary table is out of range.");
|
||||
|
||||
int blockCount = (int)(bnd.Size / SectionEntrySize) - 1;
|
||||
sections.TryGetValue(SectionBlockHashes, out var hashSec);
|
||||
bool haveHashes = hashSec.Size >= (long)blockCount * ProsperoPfsDigest.DigestLength
|
||||
&& InRange(span.Length, hashSec.Offset, hashSec.Size);
|
||||
|
||||
for (int i = 0; i < blockCount; i++)
|
||||
{
|
||||
int e = (int)bnd.Offset + i * SectionEntrySize;
|
||||
ulong e0 = BinaryPrimitives.ReadUInt64LittleEndian(span[e..]);
|
||||
ulong e1 = BinaryPrimitives.ReadUInt64LittleEndian(span[(e + 8)..]);
|
||||
long compRel = (long)(e0 & 0xFFFFFFFFFFFUL); // bits[0:44]
|
||||
ulong flags = (e0 >> 48) & 0xFF; // bits[48:56]
|
||||
long uncompRel = (long)(e1 & 0xFFFFFFFFFFFUL); // bits[0:44]
|
||||
int sizeHint = (int)((e1 >> 44) & 0x1FFFF); // bits[44:61] = (firstChunkComp - 1)
|
||||
|
||||
ulong compNext = BinaryPrimitives.ReadUInt64LittleEndian(span[(e + SectionEntrySize)..]) & 0xFFFFFFFFFFFUL;
|
||||
ulong uncompNext = BinaryPrimitives.ReadUInt64LittleEndian(span[(e + SectionEntrySize + 8)..]) & 0xFFFFFFFFFFFUL;
|
||||
|
||||
long compSizeL = (long)compNext - compRel;
|
||||
long uncompSizeL = (long)uncompNext - uncompRel;
|
||||
if (compSizeL < 0 || uncompSizeL < 0 || compSizeL > int.MaxValue || uncompSizeL > int.MaxValue)
|
||||
throw new InvalidDataException($"Block {i} has an invalid size in the boundary table.");
|
||||
int compSize = (int)compSizeL;
|
||||
int uncompSize = (int)uncompSizeL;
|
||||
|
||||
long absOffset = dataOffset + compRel;
|
||||
if (!InRange(span.Length, absOffset, compSize))
|
||||
throw new InvalidDataException($"Block {i} compressed data is out of range.");
|
||||
|
||||
bool stored = compSize == uncompSize;
|
||||
// A single internal Kraken chunk decodes at most 128 KiB, and a PFS block is at most 256 KiB,
|
||||
// so any compressed block whose uncompressed size exceeds one chunk is split into exactly two
|
||||
// sub-chunks. This is authoritative; the boundary flag bit varies (newLZ uses 0x20 or 0x40,
|
||||
// bare-entropy uses 0x40), so the uncompressed size — not a flag bit — drives the split.
|
||||
bool multiChunk = !stored && uncompSize > ChunkMaxUncompressed;
|
||||
// Sub-chunk geometry. The size hint always carries (sub-chunk 0 compressed length - 1),
|
||||
// for single-sub-chunk and stored blocks as well, so the split is recovered uniformly.
|
||||
int firstChunkComp = sizeHint + 1;
|
||||
int chunk0Uncomp = uncompSize < ChunkMaxUncompressed ? uncompSize : ChunkMaxUncompressed;
|
||||
int chunk1Uncomp = uncompSize - chunk0Uncomp;
|
||||
// A sub-chunk whose compressed length equals its uncompressed length is a verbatim copy.
|
||||
// That is decided per sub-chunk, so a block can mix a copied sub-chunk with a coded one.
|
||||
bool chunk0Raw = firstChunkComp == chunk0Uncomp;
|
||||
bool bareEntropy = !chunk0Raw && (flags & NewLzFlagBit) == 0;
|
||||
int literalMode = (flags & 1) != 0 ? 0 : 1;
|
||||
|
||||
ReadOnlyMemory<byte> hash = ReadOnlyMemory<byte>.Empty;
|
||||
if (haveHashes)
|
||||
hash = buffer.Slice((int)hashSec.Offset + i * ProsperoPfsDigest.DigestLength, ProsperoPfsDigest.DigestLength);
|
||||
|
||||
blocks.Add(new PfsBlock
|
||||
{
|
||||
Index = i,
|
||||
CompressedOffset = absOffset,
|
||||
CompressedSize = compSize,
|
||||
UncompressedOffset = uncompRel,
|
||||
UncompressedSize = uncompSize,
|
||||
Hash = hash,
|
||||
CompressedData = buffer.Slice((int)absOffset, compSize),
|
||||
IsMultiChunk = multiChunk,
|
||||
IsBareEntropy = bareEntropy,
|
||||
FirstChunkCompressedSize = firstChunkComp,
|
||||
LiteralMode = literalMode,
|
||||
Flags = (int)flags,
|
||||
Chunk0UncompressedSize = chunk0Uncomp,
|
||||
Chunk1UncompressedSize = chunk1Uncomp,
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static bool InRange(int length, long offset, long size)
|
||||
=> offset >= 0 && size >= 0 && offset + size <= length;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// Types for the PS5 PFS *compression file format*.
|
||||
//
|
||||
// These match the compression file format's numeric values. They intentionally do NOT describe the PFS
|
||||
// *filesystem* superblock version, which is a distinct, unrelated version number.
|
||||
#nullable enable
|
||||
namespace LibProsperoPkg.PFS.Compression;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm recorded in the PFS compressed-file header.
|
||||
/// Only <see cref="Kraken"/> is supported for compressed-file creation.
|
||||
/// </summary>
|
||||
public enum ProsperoCompressionAlgorithm
|
||||
{
|
||||
/// <summary>Fast Zlib. <b>Not</b> supported for PFS compressed-file creation.</summary>
|
||||
QuickZ = 0,
|
||||
|
||||
/// <summary>High-quality Zlib. <b>Not</b> supported for PFS compressed-file creation.</summary>
|
||||
Zlib = 1,
|
||||
|
||||
/// <summary>Kraken. The only supported algorithm for PFS compressed-file creation.</summary>
|
||||
Kraken = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PFS compression file-format version recorded in the container header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the version of the <i>compression container</i>, not the PFS filesystem superblock
|
||||
/// version. Only <see cref="Version2"/> and <see cref="Version3"/> are valid for PS5.
|
||||
/// </remarks>
|
||||
public enum ProsperoPfsCompressionFormat
|
||||
{
|
||||
/// <summary>PFSv0. Deprecated.</summary>
|
||||
Version0 = 0,
|
||||
|
||||
/// <summary>PFSv1. Deprecated.</summary>
|
||||
Version1 = 1,
|
||||
|
||||
/// <summary>PFSv2. A PS5 compression file format.</summary>
|
||||
Version2 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// PFSv3. The <b>default</b> PS5 compression file format. Supports region hints and
|
||||
/// pre-compression shuffles for a greater compression ratio and includes metadata for
|
||||
/// efficient conversion to package format (i.e. <c>naps_pkg_layout.dat</c>).
|
||||
/// </summary>
|
||||
Version3 = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-compression shuffle pattern recorded in the container metadata.
|
||||
/// A shuffle groups bytes at given positions within fixed 8-byte or 16-byte vectors together
|
||||
/// (a structure-of-arrays de-interleave) so that similar bytes become adjacent and compress better.
|
||||
/// </summary>
|
||||
public enum ProsperoPfsShufflePattern
|
||||
{
|
||||
/// <summary>An invalid shuffle pattern.</summary>
|
||||
Invalid = -1,
|
||||
|
||||
/// <summary>No shuffle is applied.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Tap 2x 4 bytes with an 8 byte stride.</summary>
|
||||
Shuffle44 = 1,
|
||||
|
||||
/// <summary>Tap 2x 2 bytes then 4 bytes with an 8 byte stride.</summary>
|
||||
Shuffle224 = 2,
|
||||
|
||||
/// <summary>Tap 2x 1 bytes then 6 bytes with an 8 byte stride.</summary>
|
||||
Shuffle116 = 3,
|
||||
|
||||
/// <summary>Tap 8x 1 bytes with an 8 byte stride.</summary>
|
||||
Shuffle11111111 = 4,
|
||||
|
||||
/// <summary>Tap 8 bytes, 2x 2 bytes then 4 bytes with a 16 byte stride.</summary>
|
||||
Shuffle8224 = 5,
|
||||
|
||||
/// <summary>Tap 2x 1 bytes, 6 bytes, 2x 2 bytes then 4 bytes with a 16 byte stride.</summary>
|
||||
Shuffle116224 = 6,
|
||||
|
||||
/// <summary>Tap 2x 1 bytes, 6 bytes, 2x 1 bytes then 6 bytes with a 16 byte stride.</summary>
|
||||
Shuffle116116 = 7,
|
||||
|
||||
/// <summary>Tap 4x 4 bytes with a 16 byte stride.</summary>
|
||||
Shuffle4444 = 8,
|
||||
|
||||
/// <summary>Tap 2x 8 bytes with a 16 byte stride.</summary>
|
||||
Shuffle88 = 9,
|
||||
|
||||
/// <summary>Tap 8 bytes then 2x 4 bytes with a 16 byte stride.</summary>
|
||||
Shuffle844 = 10,
|
||||
|
||||
/// <summary>Tap 2 bytes then 6 bytes with an 8 byte stride.</summary>
|
||||
Shuffle26 = 11,
|
||||
|
||||
/// <summary>Tap 2 bytes, 6 bytes, 2 bytes then 6 bytes with a 16 byte stride.</summary>
|
||||
Shuffle2626 = 12,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for PFS Kraken compression.
|
||||
/// </summary>
|
||||
public static class ProsperoPfsCompressionConstants
|
||||
{
|
||||
/// <summary>Number of sliding-window bits used for Kraken (the only valid value).</summary>
|
||||
public const uint KrakenWindowBits = 18;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// SHA3-256 digest helpers for the PS5 PFSv3 compression file format.
|
||||
//
|
||||
// The PFS compression container hashes every block (and the whole file) with SHA3-256, NOT SHA-256.
|
||||
// Layout: each block hash in the container's id=4 section equals
|
||||
// SHA3-256(uncompressed block bytes), and the header's "File Digest (SHA3)" at offset 0x28
|
||||
// is a SHA3-256 value.
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LibProsperoPkg.PFS.Compression;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA3-256 digests the PS5 PFSv3 compression container uses for its
|
||||
/// per-block hashes (the <c>id=4</c> metadata section) and its file-level digest (header
|
||||
/// offset <c>0x28</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SHA3-256 is the hash primitive used throughout the PFS compression format. Per-block
|
||||
/// hashes are taken over the <i>uncompressed</i> block bytes, which is what
|
||||
/// <see cref="ComputeBlockDigest(System.ReadOnlySpan{byte})"/> returns. The host runtime must
|
||||
/// support SHA3-256; query <see cref="IsSupported"/> before use.
|
||||
/// </remarks>
|
||||
public static class ProsperoPfsDigest
|
||||
{
|
||||
/// <summary>The length, in bytes, of a SHA3-256 digest (256 bits).</summary>
|
||||
public const int DigestLength = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the host runtime and operating system provide SHA3-256.
|
||||
/// </summary>
|
||||
public static bool IsSupported => true;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA3-256 digest of a single PFS block's <b>uncompressed</b> bytes, matching the
|
||||
/// per-block hash stored in the container's <c>id=4</c> metadata section.
|
||||
/// </summary>
|
||||
/// <param name="uncompressedBlock">The raw (pre-compression, post-deshuffle) block bytes.</param>
|
||||
/// <returns>The 32-byte SHA3-256 digest.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">SHA3-256 is unavailable on this host.</exception>
|
||||
public static byte[] ComputeBlockDigest(ReadOnlySpan<byte> uncompressedBlock)
|
||||
{
|
||||
EnsureSupported();
|
||||
return Packwright.Pkg.Util.Sha3.Hash(uncompressedBlock);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA3-256 digest of <paramref name="data"/> into <paramref name="destination"/>.
|
||||
/// </summary>
|
||||
/// <param name="data">The bytes to hash.</param>
|
||||
/// <param name="destination">A span of at least <see cref="DigestLength"/> bytes.</param>
|
||||
/// <returns>The number of bytes written (always <see cref="DigestLength"/>).</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">SHA3-256 is unavailable on this host.</exception>
|
||||
public static int ComputeBlockDigest(ReadOnlySpan<byte> data, Span<byte> destination)
|
||||
{
|
||||
EnsureSupported();
|
||||
Packwright.Pkg.Util.Sha3.Hash(data, destination);
|
||||
return DigestLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when <paramref name="expected"/> equals the SHA3-256 digest of
|
||||
/// <paramref name="uncompressedBlock"/>, using a fixed-time comparison.
|
||||
/// </summary>
|
||||
/// <param name="uncompressedBlock">The raw (pre-compression) block bytes.</param>
|
||||
/// <param name="expected">The 32-byte digest read from the container metadata.</param>
|
||||
public static bool VerifyBlockDigest(ReadOnlySpan<byte> uncompressedBlock, ReadOnlySpan<byte> expected)
|
||||
{
|
||||
if (expected.Length != DigestLength)
|
||||
return false;
|
||||
EnsureSupported();
|
||||
Span<byte> actual = stackalloc byte[DigestLength];
|
||||
Packwright.Pkg.Util.Sha3.Hash(uncompressedBlock, actual);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
|
||||
private static void EnsureSupported()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <summary>The length, in bytes, of the header parameter slice (<c>container[0x08..0x20]</c>) the file digest hashes.</summary>
|
||||
public const int FileDigestHeaderParamsLength = 0x18;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the container's file-level SHA3-256 digest (stored at header offset <c>0x28</c>),
|
||||
/// using the target format's file-digest algorithm.
|
||||
/// </summary>
|
||||
/// <param name="headerParams">
|
||||
/// Exactly <see cref="FileDigestHeaderParamsLength"/> (24) bytes copied verbatim from the container
|
||||
/// header at offsets <c>0x08..0x20</c> (block size, encode parameters, and uncompressed size).
|
||||
/// </param>
|
||||
/// <param name="shuffleSection">The shuffle-pattern metadata section bytes (directory id=2).</param>
|
||||
/// <param name="boundarySection">The block-boundary table bytes (directory id=3).</param>
|
||||
/// <param name="blockHashSection">The per-block hash table bytes (directory id=4).</param>
|
||||
/// <returns>The 32-byte SHA3-256 file digest.</returns>
|
||||
/// <remarks>
|
||||
/// The preimage is <c>header32 || shuffleSection || boundarySection || blockHashSection</c>, where
|
||||
/// <c>header32</c> is a 32-byte, 8-byte-aligned little-endian struct:
|
||||
/// <c>{u32 1; u32 blockSize; u32 encodeParam0C; u32 0 (pad); u64 encodeParam10; u64 uncompressedSize}</c>.
|
||||
/// This layout applies to independent encoder outputs (compressed and stored blocks).
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException"><paramref name="headerParams"/> is not 24 bytes.</exception>
|
||||
/// <exception cref="PlatformNotSupportedException">SHA3-256 is unavailable on this host.</exception>
|
||||
public static byte[] ComputeFileDigest(
|
||||
ReadOnlySpan<byte> headerParams,
|
||||
ReadOnlySpan<byte> shuffleSection,
|
||||
ReadOnlySpan<byte> boundarySection,
|
||||
ReadOnlySpan<byte> blockHashSection)
|
||||
{
|
||||
if (headerParams.Length != FileDigestHeaderParamsLength)
|
||||
throw new ArgumentException(
|
||||
$"headerParams must be exactly {FileDigestHeaderParamsLength} bytes (container[0x08..0x20]).",
|
||||
nameof(headerParams));
|
||||
EnsureSupported();
|
||||
|
||||
// header32: {u32 1, u32 blockSize, u32 param0C, u32 0(pad), u64 param10, u64 uncompressedSize}.
|
||||
Span<byte> header32 = stackalloc byte[32];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header32, 1u);
|
||||
headerParams[..8].CopyTo(header32[4..]); // blockSize (0x08) + param0C (0x0C)
|
||||
// header32[12..16] stays zero (alignment padding).
|
||||
headerParams[8..].CopyTo(header32[16..]); // param10 (0x10) + uncompressedSize (0x18)
|
||||
|
||||
var hash = new Packwright.Pkg.Util.Sha3.Keccak();
|
||||
hash.Append(header32);
|
||||
hash.Append(shuffleSection);
|
||||
hash.Append(boundarySection);
|
||||
hash.Append(blockHashSection);
|
||||
byte[] digest = new byte[DigestLength];
|
||||
hash.Finish(digest);
|
||||
return digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when <paramref name="expected"/> equals the file digest computed by
|
||||
/// <see cref="ComputeFileDigest"/>, using a fixed-time comparison.
|
||||
/// </summary>
|
||||
public static bool VerifyFileDigest(
|
||||
ReadOnlySpan<byte> headerParams,
|
||||
ReadOnlySpan<byte> shuffleSection,
|
||||
ReadOnlySpan<byte> boundarySection,
|
||||
ReadOnlySpan<byte> blockHashSection,
|
||||
ReadOnlySpan<byte> expected)
|
||||
{
|
||||
if (expected.Length != DigestLength)
|
||||
return false;
|
||||
byte[] actual = ComputeFileDigest(headerParams, shuffleSection, boundarySection, blockHashSection);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// PS5 PFS image AES-XTS encryption + decryption: a plaintext PFS
|
||||
// image is turned into an encrypted one (and back) by XTS-encrypting every filesystem sector
|
||||
// after the plaintext header block, deriving the (tweak, data) key pair from the EKPFS + the
|
||||
// header seed.
|
||||
//
|
||||
// The inner-PFS image crypto scheme is AES-XTS-128 over 0x1000-byte
|
||||
// sectors, key = PfsGenEncKey(EKPFS, seed) and the header block (block 0) left plaintext so the
|
||||
// kernel can read the superblock's seed/mode. The transform and key derivation are reused from
|
||||
// Util.Crypto.PfsGenEncKey + XtsBlockTransform, so this stays a single,
|
||||
// round-trip-validated code path — the same way ProsperoPfsc reuses the
|
||||
// internal PFSC encoder for compression.
|
||||
#nullable enable
|
||||
using LibProsperoPkg.Util;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>The PFS superblock fields relevant to image encryption.</summary>
|
||||
public sealed class ProsperoPfsImageInfo
|
||||
{
|
||||
/// <summary>Superblock version (2 = PS5).</summary>
|
||||
public required long Version { get; init; }
|
||||
|
||||
/// <summary>The raw PFS mode flag word from the superblock.</summary>
|
||||
public required ushort Mode { get; init; }
|
||||
|
||||
/// <summary>Filesystem block size in bytes (typically 0x10000).</summary>
|
||||
public required uint BlockSize { get; init; }
|
||||
|
||||
/// <summary>The 16-byte header crypto seed (all-zero when the image carries none).</summary>
|
||||
public required byte[] Seed { get; init; }
|
||||
|
||||
/// <summary>True when the superblock declares the image AES-XTS encrypted.</summary>
|
||||
public bool Encrypted => (Mode & ProsperoPfsImage.EncryptedModeFlag) != 0;
|
||||
|
||||
/// <summary>True when the superblock declares the image HMAC signed.</summary>
|
||||
public bool Signed => (Mode & ProsperoPfsImage.SignedModeFlag) != 0;
|
||||
}
|
||||
|
||||
/// <summary>Options controlling a PFS image encrypt/decrypt operation.</summary>
|
||||
public sealed class ProsperoPfsImageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The 32-byte EKPFS. When <c>null</c> the all-zero EKPFS is used — the standard
|
||||
/// package key that pairs with the all-zero passcode.
|
||||
/// </summary>
|
||||
public byte[]? Ekpfs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The 16-byte header seed. When <c>null</c> the seed already present in the image
|
||||
/// header is used (encrypt of an already-prepared image); if the header has no seed a
|
||||
/// fresh cryptographically-random one is generated and written into the header.
|
||||
/// </summary>
|
||||
public byte[]? Seed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, derive the encryption key from <c>HMAC(EKPFS, seed)</c> first (the
|
||||
/// <c>new_crypt</c> path / the <c>newCrypt</c> scheme). Defaults to the classic path.
|
||||
/// </summary>
|
||||
public bool NewCrypt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The outcome of an encrypt/decrypt operation.</summary>
|
||||
public sealed class ProsperoPfsImageResult
|
||||
{
|
||||
/// <summary>The path the image was written to.</summary>
|
||||
public required string OutputPath { get; init; }
|
||||
|
||||
/// <summary>Total image size in bytes.</summary>
|
||||
public required long ImageSize { get; init; }
|
||||
|
||||
/// <summary>Block size used (bytes).</summary>
|
||||
public required uint BlockSize { get; init; }
|
||||
|
||||
/// <summary>Number of XTS sectors transformed.</summary>
|
||||
public required long SectorsTransformed { get; init; }
|
||||
|
||||
/// <summary>The 16-byte seed the keys were derived from.</summary>
|
||||
public required byte[] Seed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PFS image AES-XTS encryptor/decryptor for PS5. See the file header for the scheme.
|
||||
/// </summary>
|
||||
public static class ProsperoPfsImage
|
||||
{
|
||||
/// <summary>AES-XTS sector size used by the PFS image crypto.</summary>
|
||||
public const int XtsSectorSize = 0x1000;
|
||||
|
||||
/// <summary>The PFS mode bit that marks an image AES-XTS encrypted.</summary>
|
||||
public const ushort EncryptedModeFlag = 0x4;
|
||||
|
||||
/// <summary>The PFS mode bit that marks an image HMAC signed.</summary>
|
||||
public const ushort SignedModeFlag = 0x1;
|
||||
|
||||
// On-disk superblock field offsets (see LibProsperoPkg.PFS.PfsHeader).
|
||||
private const long ModeFieldOffset = 0x1C; // ushort PFS mode word
|
||||
private const long UnknownIndexOffset = 0x36C; // int written alongside the seed
|
||||
private const long SeedFieldOffset = 0x370; // 16-byte header seed
|
||||
|
||||
/// <summary>The all-zero EKPFS — the standard package key.</summary>
|
||||
public static byte[] ZeroEkpfs => new byte[32];
|
||||
|
||||
/// <summary>
|
||||
/// Reads the PFS superblock of <paramref name="imagePath"/> and reports the fields
|
||||
/// relevant to encryption (version, mode, block size, seed).
|
||||
/// </summary>
|
||||
public static ProsperoPfsImageInfo Inspect(string imagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
if (!File.Exists(imagePath))
|
||||
throw new FileNotFoundException("PFS image was not found.", imagePath);
|
||||
|
||||
using var fs = File.OpenRead(imagePath);
|
||||
var hdr = ProsperoPfsHeader.ReadFromStream(fs);
|
||||
return new ProsperoPfsImageInfo
|
||||
{
|
||||
Version = hdr.Version,
|
||||
Mode = (ushort)hdr.Mode,
|
||||
BlockSize = hdr.BlockSize,
|
||||
Seed = hdr.Seed ?? new byte[16],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the PFS image at <paramref name="imagePath"/> has the encrypted
|
||||
/// mode flag set in its superblock (i.e. its filesystem sectors are AES-XTS encrypted).
|
||||
/// </summary>
|
||||
/// <param name="imagePath">Path to the PFS image to inspect.</param>
|
||||
/// <returns><c>true</c> if the superblock declares the image encrypted; otherwise <c>false</c>.</returns>
|
||||
public static bool IsEncrypted(string imagePath) => Inspect(imagePath).Encrypted;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a prepared (plaintext) PFS image in place: writes the encrypted-mode bit and
|
||||
/// the seed into the superblock, then AES-XTS-encrypts every filesystem sector after the
|
||||
/// header block.
|
||||
/// </summary>
|
||||
public static ProsperoPfsImageResult EncryptInPlace(
|
||||
string imagePath, ProsperoPfsImageOptions? options = null, Action<string>? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
return Transform(imagePath, imagePath, encrypt: true, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a prepared (plaintext) PFS image, writing the result to
|
||||
/// <paramref name="outputPath"/> (the input is left untouched).
|
||||
/// </summary>
|
||||
public static ProsperoPfsImageResult Encrypt(
|
||||
string inputImagePath, string outputPath, ProsperoPfsImageOptions? options = null, Action<string>? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(inputImagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
CopyTo(inputImagePath, outputPath);
|
||||
return Transform(outputPath, outputPath, encrypt: true, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an encrypted PFS image in place: AES-XTS-decrypts every filesystem sector
|
||||
/// after the header block and clears the encrypted-mode bit. Inverse of <see cref="EncryptInPlace"/>.
|
||||
/// </summary>
|
||||
public static ProsperoPfsImageResult DecryptInPlace(
|
||||
string imagePath, ProsperoPfsImageOptions? options = null, Action<string>? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
return Transform(imagePath, imagePath, encrypt: false, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an encrypted PFS image, writing the plaintext result to
|
||||
/// <paramref name="outputPath"/> (the input is left untouched).
|
||||
/// </summary>
|
||||
public static ProsperoPfsImageResult Decrypt(
|
||||
string inputImagePath, string outputPath, ProsperoPfsImageOptions? options = null, Action<string>? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(inputImagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
CopyTo(inputImagePath, outputPath);
|
||||
return Transform(outputPath, outputPath, encrypt: false, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proves the encrypt/decrypt pair is loss-less for a given image without leaving any
|
||||
/// artefacts: encrypts a temporary copy, decrypts it back and compares the filesystem
|
||||
/// data (every sector after the plaintext header block) to the original exactly.
|
||||
/// The header block legitimately gains the seed + encrypted-mode metadata, so it is
|
||||
/// excluded from the comparison. Used as the self-check that replaces on-hardware
|
||||
/// testing for the image crypto.
|
||||
/// </summary>
|
||||
public static bool VerifyRoundTrip(string plaintextImagePath, ProsperoPfsImageOptions? options = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(plaintextImagePath);
|
||||
string enc = Path.GetTempFileName();
|
||||
string dec = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var info = Inspect(plaintextImagePath);
|
||||
Encrypt(plaintextImagePath, enc, options);
|
||||
Decrypt(enc, dec, options);
|
||||
// Compare the filesystem data region (block 1 onward); the header block carries
|
||||
// the crypto metadata and is expected to differ.
|
||||
return FilesEqual(plaintextImagePath, dec, fromOffset: info.BlockSize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(enc);
|
||||
TryDelete(dec);
|
||||
}
|
||||
}
|
||||
|
||||
// The shared encrypt/decrypt engine. Operates on <paramref name="targetPath"/> in place;
|
||||
// callers that want a copy pre-copy the input to the target.
|
||||
private static ProsperoPfsImageResult Transform(
|
||||
string sourcePath, string targetPath, bool encrypt, ProsperoPfsImageOptions? options, Action<string>? logger)
|
||||
{
|
||||
if (!File.Exists(targetPath))
|
||||
throw new FileNotFoundException("PFS image was not found.", targetPath);
|
||||
|
||||
options ??= new ProsperoPfsImageOptions();
|
||||
var log = logger ?? (_ => { });
|
||||
var ekpfs = ResolveEkpfs(options.Ekpfs);
|
||||
|
||||
using var fs = new FileStream(targetPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||
long length = fs.Length;
|
||||
|
||||
fs.Position = 0;
|
||||
var hdr = ProsperoPfsHeader.ReadFromStream(fs);
|
||||
|
||||
uint blockSize = hdr.BlockSize;
|
||||
if (blockSize == 0 || (blockSize % XtsSectorSize) != 0)
|
||||
throw new InvalidDataException($"PFS block size {blockSize} is not a multiple of the XTS sector size {XtsSectorSize}.");
|
||||
if ((length % XtsSectorSize) != 0)
|
||||
throw new InvalidDataException($"PFS image length {length} is not a multiple of the XTS sector size {XtsSectorSize}.");
|
||||
|
||||
// Decide on the seed: caller-provided > header-resident > freshly generated.
|
||||
byte[] headerSeed = hdr.Seed ?? new byte[16];
|
||||
byte[] seed = options.Seed ?? (IsAllZero(headerSeed) && encrypt ? RandomNumberGenerator.GetBytes(16) : headerSeed);
|
||||
if (seed.Length != 16)
|
||||
throw new ArgumentException("PFS header seed must be exactly 16 bytes.", nameof(options));
|
||||
|
||||
bool alreadyEncrypted = ((ushort)hdr.Mode & EncryptedModeFlag) != 0;
|
||||
if (!encrypt && !alreadyEncrypted)
|
||||
throw new InvalidDataException("The image is not marked encrypted; nothing to decrypt.");
|
||||
|
||||
// For encryption, stamp the seed + encrypted-mode bit into the plaintext superblock so
|
||||
// the kernel can derive the same keys when mounting. For decryption, clear the bit.
|
||||
ushort newMode = encrypt
|
||||
? (ushort)((ushort)hdr.Mode | EncryptedModeFlag)
|
||||
: (ushort)((ushort)hdr.Mode & ~EncryptedModeFlag);
|
||||
PatchHeader(fs, newMode, encrypt ? seed : null);
|
||||
|
||||
// Derive the AES-XTS (tweak, data) key pair.
|
||||
var (tweakKey, dataKey) = Crypto.PfsGenEncKey(ekpfs, seed, options.NewCrypt);
|
||||
using var transform = new XtsBlockTransform(dataKey, tweakKey);
|
||||
|
||||
long startSector = blockSize / XtsSectorSize; // header block (block 0) stays plaintext
|
||||
long totalSectors = length / XtsSectorSize;
|
||||
var buffer = new byte[XtsSectorSize];
|
||||
long transformed = 0;
|
||||
long reported = -1;
|
||||
|
||||
log($"{(encrypt ? "Encrypting" : "Decrypting")} {Path.GetFileName(targetPath)} " +
|
||||
$"({length:N0} bytes, PFS, block size 0x{blockSize:X})...");
|
||||
|
||||
for (long sector = startSector; sector < totalSectors; sector++)
|
||||
{
|
||||
long offset = sector * XtsSectorSize;
|
||||
fs.Position = offset;
|
||||
ReadExact(fs, buffer);
|
||||
transform.CryptSector(buffer, (ulong)sector, encrypt);
|
||||
fs.Position = offset;
|
||||
fs.Write(buffer, 0, buffer.Length);
|
||||
transformed++;
|
||||
|
||||
long pct = totalSectors <= startSector ? 100 : (sector - startSector + 1) * 100 / (totalSectors - startSector);
|
||||
if (pct != reported) { reported = pct; log($" {pct,3}%"); }
|
||||
}
|
||||
|
||||
fs.Flush();
|
||||
log($"Done: {transformed:N0} sectors {(encrypt ? "encrypted" : "decrypted")} (seed {Convert.ToHexString(seed.AsSpan(0, 4))}...).");
|
||||
|
||||
return new ProsperoPfsImageResult
|
||||
{
|
||||
OutputPath = targetPath,
|
||||
ImageSize = length,
|
||||
BlockSize = blockSize,
|
||||
SectorsTransformed = transformed,
|
||||
Seed = seed,
|
||||
};
|
||||
}
|
||||
|
||||
// Writes the mode word (always) and, for encryption, the seed into the plaintext header
|
||||
// using the documented superblock field offsets. Only those bytes are touched.
|
||||
private static void PatchHeader(FileStream fs, ushort mode, byte[]? seed)
|
||||
{
|
||||
Span<byte> modeBytes = stackalloc byte[2];
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(modeBytes, mode);
|
||||
fs.Position = ModeFieldOffset;
|
||||
fs.Write(modeBytes);
|
||||
|
||||
if (seed != null)
|
||||
{
|
||||
// The seed is preceded by a 4-byte "unknown index" the writer sets to 0.
|
||||
Span<byte> idx = stackalloc byte[4];
|
||||
fs.Position = UnknownIndexOffset;
|
||||
fs.Write(idx);
|
||||
fs.Position = SeedFieldOffset;
|
||||
fs.Write(seed, 0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ResolveEkpfs(byte[]? ekpfs)
|
||||
{
|
||||
if (ekpfs is null)
|
||||
return ZeroEkpfs;
|
||||
if (ekpfs.Length != 32)
|
||||
throw new ArgumentException($"EKPFS must be exactly 32 bytes (was {ekpfs.Length}).", nameof(ekpfs));
|
||||
return ekpfs;
|
||||
}
|
||||
|
||||
private static bool IsAllZero(byte[] data)
|
||||
{
|
||||
foreach (var b in data)
|
||||
if (b != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void CopyTo(string sourcePath, string outputPath)
|
||||
{
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException("PFS image was not found.", sourcePath);
|
||||
|
||||
var dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
if (!string.Equals(Path.GetFullPath(sourcePath), Path.GetFullPath(outputPath), StringComparison.Ordinal))
|
||||
File.Copy(sourcePath, outputPath, overwrite: true);
|
||||
}
|
||||
|
||||
private static void ReadExact(Stream s, byte[] buffer)
|
||||
{
|
||||
int read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
int n = s.Read(buffer, read, buffer.Length - read);
|
||||
if (n == 0) throw new EndOfStreamException("Unexpected end of PFS image while reading a sector.");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FilesEqual(string a, string b, long fromOffset = 0)
|
||||
{
|
||||
var fa = new FileInfo(a);
|
||||
var fb = new FileInfo(b);
|
||||
if (fa.Length != fb.Length) return false;
|
||||
|
||||
using var sa = fa.OpenRead();
|
||||
using var sb = fb.OpenRead();
|
||||
sa.Position = fromOffset;
|
||||
sb.Position = fromOffset;
|
||||
var ba = new byte[1 << 16];
|
||||
var bb = new byte[1 << 16];
|
||||
int na;
|
||||
while ((na = sa.Read(ba, 0, ba.Length)) > 0)
|
||||
{
|
||||
int off = 0;
|
||||
while (off < na)
|
||||
{
|
||||
int nb = sb.Read(bb, off, na - off);
|
||||
if (nb == 0) return false;
|
||||
off += nb;
|
||||
}
|
||||
if (!ba.AsSpan(0, na).SequenceEqual(bb.AsSpan(0, na)))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// PFS image structures, builder and reader primitives.
|
||||
#nullable disable
|
||||
using LibProsperoPkg.Util;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>
|
||||
/// Class allowing parallel readonly access to a PFS archive
|
||||
/// </summary>
|
||||
public class ProsperoPfsReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a file or directory in a PFS image.
|
||||
/// </summary>
|
||||
public abstract class Node
|
||||
{
|
||||
public Dir parent;
|
||||
public string name;
|
||||
public long offset;
|
||||
public long size;
|
||||
public long compressed_size;
|
||||
public uint ino;
|
||||
public string FullName => parent != null ? parent.FullName + "/" + name : name;
|
||||
}
|
||||
/// <summary>
|
||||
/// Represents a directory in a PFS image.
|
||||
/// </summary>
|
||||
public class Dir : Node
|
||||
{
|
||||
public List<Node> children = new List<Node>();
|
||||
public Node Get(string name)
|
||||
=> children.Where(x => x.name == name).FirstOrDefault();
|
||||
public Node GetPath(string name)
|
||||
{
|
||||
var breadcrumbs = name.Split('/');
|
||||
Node n = this;
|
||||
var bc = 0;
|
||||
while (n != null && bc < breadcrumbs.Length)
|
||||
{
|
||||
n = (n as Dir)?.Get(breadcrumbs[bc]);
|
||||
bc++;
|
||||
}
|
||||
if (bc < breadcrumbs.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
public IEnumerable<File> GetAllFiles()
|
||||
{
|
||||
foreach (var n in children)
|
||||
{
|
||||
if (n is File f) yield return f;
|
||||
if (n is Dir d)
|
||||
foreach (var x in d.GetAllFiles())
|
||||
yield return x;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Represents a file in a PFS image.
|
||||
/// </summary>
|
||||
public class File : Node
|
||||
{
|
||||
public ProsperoInodeFlags flags;
|
||||
public int blockSize;
|
||||
public int[] blocks;
|
||||
private IMemoryReader reader;
|
||||
public File(IMemoryReader r) { reader = r; }
|
||||
public IMemoryReader GetView()
|
||||
{
|
||||
if (blocks != null)
|
||||
return new ChunkedMemoryReader(reader, blockSize, blocks);
|
||||
return new MemoryAccessor(reader, offset);
|
||||
}
|
||||
public void Save(string path, bool decompress = false)
|
||||
{
|
||||
var buf = new byte[blockSize];
|
||||
using (var file = System.IO.File.OpenWrite(path))
|
||||
{
|
||||
var sz = size;
|
||||
file.SetLength(sz);
|
||||
long pos = 0;
|
||||
var reader = GetView();
|
||||
// Only PFSC-decode when the payload is actually a PFSC container. The outer-PFS inode
|
||||
// template carries the `compressed` flag even on raw files (e.g. nwonly's
|
||||
// naps_pkg_layout.dat, which is stored raw), so the flag alone is not a reliable signal;
|
||||
// wrapping a raw payload in ProsperoPfscReader would throw "missing PFSC magic".
|
||||
if (decompress && flags.HasFlag(ProsperoInodeFlags.compressed) && size >= 4)
|
||||
{
|
||||
var magic = new byte[4];
|
||||
reader.Read(0, magic, 0, 4);
|
||||
if (magic[0] == (byte)'P' && magic[1] == (byte)'F' && magic[2] == (byte)'S' && magic[3] == (byte)'C')
|
||||
{
|
||||
sz = compressed_size;
|
||||
reader = new ProsperoPfscReader(reader);
|
||||
}
|
||||
}
|
||||
while (sz > 0)
|
||||
{
|
||||
var toRead = (int)Math.Min(sz, buf.Length);
|
||||
reader.Read(pos, buf, 0, toRead);
|
||||
file.Write(buf, 0, toRead);
|
||||
pos += toRead;
|
||||
sz -= toRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private state for the PfsReader class
|
||||
private IMemoryReader reader;
|
||||
private ProsperoPfsHeader hdr;
|
||||
private ProsperoInode[] dinodes;
|
||||
private Dir root;
|
||||
private Dir uroot;
|
||||
private byte[] sectorBuf;
|
||||
private Stream sectorStream;
|
||||
|
||||
public ProsperoPfsReader(MemoryMappedViewAccessor r, ulong pfs_flags = 0, byte[] ekpfs = null, byte[] tweak = null, byte[] data = null)
|
||||
: this(new MemoryMappedViewAccessor_(r), pfs_flags, ekpfs, tweak, data)
|
||||
{ }
|
||||
/// <param name="r">Backing memory reader over the PFS image.</param>
|
||||
/// <param name="pfs_flags">Superblock pfs_flags selecting the crypto scheme (e.g. the newCrypt bit).</param>
|
||||
/// <param name="ekpfs">Image key used to derive the per-image XTS keys, or null for a plaintext image.</param>
|
||||
/// <param name="tweak">Explicit XTS tweak key, or null to derive it from <paramref name="ekpfs"/>.</param>
|
||||
/// <param name="data">Explicit XTS data key, or null to derive it from <paramref name="ekpfs"/>.</param>
|
||||
/// <param name="superblockByteOffset">Byte offset of the superblock within the image. 0 for a normal
|
||||
/// superblock-first PFS; for a PS5 "data-first" outer PFS the superblock sits near the end (block D), so the
|
||||
/// caller passes D*blockSize. Block pointers stay absolute from the image base (offset 0); only the superblock
|
||||
/// and the dinode table (block D+1) are located relative to this offset.</param>
|
||||
/// <param name="skipDecryption">When true, the image is already plaintext (e.g. a data-first outer PFS the
|
||||
/// caller decrypted up-front with the per-block plain/signed XTS scheme), so the reader must NOT re-apply its
|
||||
/// linear XTS even though the superblock's mode still carries the Encrypted flag.</param>
|
||||
public ProsperoPfsReader(IMemoryReader r, ulong pfs_flags = 0, byte[] ekpfs = null, byte[] tweak = null, byte[] data = null,
|
||||
long superblockByteOffset = 0, bool skipDecryption = false)
|
||||
{
|
||||
reader = r;
|
||||
var buf = new byte[0x400];
|
||||
reader.Read(superblockByteOffset, buf, 0, 0x400);
|
||||
|
||||
using (var ms = new MemoryStream(buf))
|
||||
{
|
||||
hdr = ProsperoPfsHeader.ReadFromStream(ms);
|
||||
}
|
||||
int dinodeSize;
|
||||
Func<Stream, ProsperoInode> dinodeReader;
|
||||
bool is64 = hdr.Mode.HasFlag(ProsperoPfsMode.Is64Bit);
|
||||
if (hdr.Mode.HasFlag(ProsperoPfsMode.Signed))
|
||||
{
|
||||
// PS5 signed images use 64-bit block pointers in their inodes.
|
||||
if (is64)
|
||||
{
|
||||
dinodes = new ProsperoDinodeS64[hdr.DinodeCount];
|
||||
dinodeReader = ProsperoDinodeS64.ReadFromStream;
|
||||
dinodeSize = (int)ProsperoDinodeS64.SizeOf; // 0x310
|
||||
}
|
||||
else
|
||||
{
|
||||
dinodes = new ProsperoDinodeS32[hdr.DinodeCount];
|
||||
dinodeReader = ProsperoDinodeS32.ReadFromStream;
|
||||
dinodeSize = (int)ProsperoDinodeS32.SizeOf; // 0x2C8
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dinodes = new ProsperoDinodeD32[hdr.DinodeCount];
|
||||
dinodeReader = ProsperoDinodeD32.ReadFromStream;
|
||||
dinodeSize = (int)ProsperoDinodeD32.SizeOf; // 0xA8
|
||||
}
|
||||
if (!skipDecryption && hdr.Mode.HasFlag(ProsperoPfsMode.Encrypted))
|
||||
{
|
||||
const int XtsSectorSize = 0x1000;
|
||||
uint XtsStartSector = hdr.BlockSize / XtsSectorSize;
|
||||
if (ekpfs == null && (tweak == null || data == null))
|
||||
throw new ArgumentException("PFS image is encrypted but no decryption key was provided");
|
||||
if (ekpfs != null)
|
||||
{
|
||||
var (tweakKey, dataKey) = Crypto.PfsGenEncKey(ekpfs, hdr.Seed, (pfs_flags & 0x2000000000000000UL) != 0);
|
||||
reader = new ProsperoXtsDecryptReader(reader, dataKey, tweakKey, XtsStartSector, XtsSectorSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader = new ProsperoXtsDecryptReader(reader, data, tweak, XtsStartSector, XtsSectorSize);
|
||||
}
|
||||
}
|
||||
var total = 0;
|
||||
|
||||
var maxPerSector = hdr.BlockSize / dinodeSize;
|
||||
sectorBuf = new byte[hdr.BlockSize];
|
||||
sectorStream = new MemoryStream(sectorBuf);
|
||||
// skip over indirect blocks, block signatures are never checked anyway. For a data-first outer PFS the
|
||||
// superblock is at superblockByteOffset (block D) and its dinode table follows at block D+1.
|
||||
var dinodeStartPos = superblockByteOffset + hdr.BlockSize + (hdr.BlockSize * hdr.InodeBlockSig.IndirectBlocks.Where(b => b > 0).Count());
|
||||
for (var i = 0; i < hdr.DinodeBlockCount; i++)
|
||||
{
|
||||
var position = dinodeStartPos + (hdr.BlockSize * i);
|
||||
reader.Read(position, sectorBuf, 0, sectorBuf.Length);
|
||||
sectorStream.Position = 0;
|
||||
for (var j = 0; j < maxPerSector && total < hdr.DinodeCount; j++)
|
||||
dinodes[total++] = dinodeReader(sectorStream);
|
||||
}
|
||||
root = LoadDir(0, null, "");
|
||||
uroot = root.Get("uroot") as Dir;
|
||||
if (uroot == null)
|
||||
throw new Exception("Invalid PFS image (no uroot)");
|
||||
uroot.name = "uroot";
|
||||
}
|
||||
|
||||
public ProsperoPfsHeader Header => hdr;
|
||||
|
||||
public File GetFile(string fullPath)
|
||||
{
|
||||
return uroot.GetPath(fullPath) as File;
|
||||
}
|
||||
|
||||
public IEnumerable<File> GetAllFiles()
|
||||
{
|
||||
return uroot.GetAllFiles();
|
||||
}
|
||||
|
||||
public Dir GetURoot()
|
||||
{
|
||||
return uroot;
|
||||
}
|
||||
|
||||
public Dir GetSuperRoot()
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
private Dir LoadDir(uint dinode, Dir parent, string name)
|
||||
{
|
||||
// 100M blocks is enough for a 6TB file.
|
||||
const int MAX_BLOCKS = 100_000_000;
|
||||
var ret = new Dir() { name = name, parent = parent };
|
||||
var ino = dinodes[dinode];
|
||||
var postLoad = new List<Func<Dir>>();
|
||||
var blocks = (int)ino.Blocks;
|
||||
if (blocks < 1 || ino.StartBlock < 1 || ino.StartBlock > MAX_BLOCKS || blocks > MAX_BLOCKS)
|
||||
{
|
||||
throw new Exception($"Inode {dinode} is corrupt. ");
|
||||
}
|
||||
foreach (var x in Enumerable.Range(ino.StartBlock, blocks))
|
||||
{
|
||||
var position = hdr.BlockSize * x;
|
||||
reader.Read(position, sectorBuf, 0, sectorBuf.Length);
|
||||
sectorStream.Position = 0;
|
||||
while (position < hdr.BlockSize * (x + 1))
|
||||
{
|
||||
var dirent = ProsperoPfsDirent.ReadFromStream(sectorStream);
|
||||
if (dirent.EntSize == 0) break;
|
||||
switch (dirent.Type)
|
||||
{
|
||||
case ProsperoDirentType.File:
|
||||
ret.children.Add(LoadFile(dirent.InodeNumber, ret, dirent.Name));
|
||||
break;
|
||||
case ProsperoDirentType.Directory:
|
||||
postLoad.Add(() => LoadDir(dirent.InodeNumber, ret, dirent.Name));
|
||||
break;
|
||||
case ProsperoDirentType.Dot:
|
||||
break;
|
||||
case ProsperoDirentType.DotDot:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
position += dirent.EntSize;
|
||||
}
|
||||
}
|
||||
foreach (var p in postLoad)
|
||||
{
|
||||
ret.children.Add(p());
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private File LoadFile(uint dinode, Dir parent, string name)
|
||||
{
|
||||
int[] blocks = null;
|
||||
if (dinodes[dinode].Blocks > 1 && dinodes[dinode].DirectBlocks[1] != -1)
|
||||
{
|
||||
if (!hdr.Mode.HasFlag(ProsperoPfsMode.Signed))
|
||||
{
|
||||
throw new Exception("Unsigned PFS images probably shouldn't have noncontiguous blocks");
|
||||
}
|
||||
blocks = new int[dinodes[dinode].Blocks];
|
||||
var remainingBlocks = (long)dinodes[dinode].Blocks;
|
||||
var sigsPerBlock = hdr.BlockSize / 36;
|
||||
for (int i = 0; i < 12 && i < remainingBlocks; i++)
|
||||
{
|
||||
blocks[i] = dinodes[dinode].DirectBlocks[i];
|
||||
}
|
||||
|
||||
var bufferedReader = new BufferedMemoryReader(reader, 0x10000);
|
||||
remainingBlocks -= 12;
|
||||
long blockIndexOffset = 12;
|
||||
for (int i = 0; i < remainingBlocks && i < sigsPerBlock; i++)
|
||||
{
|
||||
bufferedReader.Read(dinodes[dinode].IndirectBlocks[0] * hdr.BlockSize + (i * 36) + 32, out blocks[i + blockIndexOffset]);
|
||||
}
|
||||
remainingBlocks -= sigsPerBlock;
|
||||
blockIndexOffset += sigsPerBlock;
|
||||
for (int j = 0; j * sigsPerBlock < remainingBlocks; j++)
|
||||
{
|
||||
bufferedReader.Read(dinodes[dinode].IndirectBlocks[1] * hdr.BlockSize + (j * 36) + 32, out int indirectBlockOffset);
|
||||
for (int i = 0; i < sigsPerBlock && i + (j * sigsPerBlock) < remainingBlocks; i++)
|
||||
{
|
||||
bufferedReader.Read(indirectBlockOffset * hdr.BlockSize + (i * 36) + 32, out blocks[i + blockIndexOffset]);
|
||||
}
|
||||
blockIndexOffset += sigsPerBlock;
|
||||
}
|
||||
bool contiguous = true;
|
||||
for (int i = 1; i < blocks.Length; i++)
|
||||
{
|
||||
if (blocks[i - 1] + 1 != blocks[i])
|
||||
{
|
||||
contiguous = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (contiguous)
|
||||
blocks = null;
|
||||
}
|
||||
return new File(reader)
|
||||
{
|
||||
name = name,
|
||||
parent = parent,
|
||||
offset = dinodes[dinode].StartBlock * hdr.BlockSize,
|
||||
size = dinodes[dinode].Size,
|
||||
compressed_size = dinodes[dinode].SizeCompressed,
|
||||
ino = dinode,
|
||||
blocks = blocks,
|
||||
flags = dinodes[dinode].Flags,
|
||||
blockSize = (int)hdr.BlockSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// PFS image structures, builder and reader primitives.
|
||||
#nullable disable
|
||||
using LibProsperoPkg.Util;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>
|
||||
/// PFS mode flags.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ProsperoPfsMode : ushort
|
||||
{
|
||||
None = 0,
|
||||
Signed = 0x1,
|
||||
Is64Bit = 0x2,
|
||||
Encrypted = 0x4,
|
||||
UnknownFlagAlwaysSet = 0x8,
|
||||
}
|
||||
/// <summary>
|
||||
/// Represents a PFS image suberblock.
|
||||
/// </summary>
|
||||
public class ProsperoPfsHeader
|
||||
{
|
||||
/// <summary>PFS superblock version for PS5 images.</summary>
|
||||
public const long VersionPs5 = 2;
|
||||
|
||||
public long Version = VersionPs5;
|
||||
public long Magic = 20130315; // header magic constant
|
||||
public long Id = 0;
|
||||
public byte Fmode = 0;
|
||||
public byte Clean = 0;
|
||||
public byte ReadOnly = 0;
|
||||
public byte Rsv = 0;
|
||||
public ProsperoPfsMode Mode = ProsperoPfsMode.UnknownFlagAlwaysSet;
|
||||
public ushort Unk1 = 0;
|
||||
public uint BlockSize = 0x10000;
|
||||
public uint NBackup = 0;
|
||||
/// <summary>
|
||||
/// This is always 1 for some reason.
|
||||
/// </summary>
|
||||
public long NBlock = 1;
|
||||
public long DinodeCount = 0;
|
||||
public long Ndblock = 0;
|
||||
public long DinodeBlockCount = 0;
|
||||
public ProsperoDinodeS64 InodeBlockSig = new ProsperoDinodeS64()
|
||||
{
|
||||
Mode = 0,
|
||||
Nlink = 1,
|
||||
Flags = ProsperoInodeFlags.@readonly,
|
||||
Size = 0x10000,
|
||||
SizeCompressed = 0x10000,
|
||||
Blocks = 1,
|
||||
};
|
||||
public int UnknownIndex = 0;
|
||||
public byte[] Seed;
|
||||
|
||||
public void WriteToStream(Stream s)
|
||||
{
|
||||
var start = s.Position;
|
||||
s.WriteInt64LE(Version);
|
||||
s.WriteInt64LE(Magic);
|
||||
s.WriteInt64LE(Id);
|
||||
s.WriteByte(Fmode);
|
||||
s.WriteByte(Clean);
|
||||
s.WriteByte(ReadOnly);
|
||||
s.WriteByte(Rsv);
|
||||
s.WriteUInt16LE((ushort)Mode);
|
||||
s.WriteUInt16LE(Unk1);
|
||||
s.WriteUInt32LE(BlockSize);
|
||||
s.WriteUInt32LE(NBackup);
|
||||
s.WriteInt64LE(NBlock);
|
||||
s.WriteInt64LE(DinodeCount);
|
||||
s.WriteInt64LE(Ndblock);
|
||||
s.WriteInt64LE(DinodeBlockCount);
|
||||
s.WriteInt64LE(0);
|
||||
InodeBlockSig.WriteToStream(s);
|
||||
if (Seed != null)
|
||||
{
|
||||
s.Position = start + 0x36C;
|
||||
s.WriteInt32LE(UnknownIndex);
|
||||
s.Write(Seed, 0, Seed.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.Position = start + 0x368;
|
||||
s.WriteInt32LE(1);
|
||||
}
|
||||
}
|
||||
|
||||
public static ProsperoPfsHeader ReadFromStream(System.IO.Stream s)
|
||||
{
|
||||
var start = s.Position;
|
||||
var hdr = new ProsperoPfsHeader
|
||||
{
|
||||
Version = s.ReadInt64LE(),
|
||||
Magic = s.ReadInt64LE(),
|
||||
Id = s.ReadInt64LE(),
|
||||
Fmode = s.ReadUInt8(),
|
||||
Clean = s.ReadUInt8(),
|
||||
ReadOnly = s.ReadUInt8(),
|
||||
Rsv = s.ReadUInt8(),
|
||||
Mode = (ProsperoPfsMode)s.ReadUInt16LE(),
|
||||
Unk1 = s.ReadUInt16LE(),
|
||||
BlockSize = s.ReadUInt32LE(),
|
||||
NBackup = s.ReadUInt32LE(),
|
||||
NBlock = s.ReadInt64LE(),
|
||||
DinodeCount = s.ReadInt64LE(),
|
||||
Ndblock = s.ReadInt64LE(),
|
||||
DinodeBlockCount = s.ReadInt64LE(),
|
||||
};
|
||||
s.Position += 8; // skip a 64-bit zero
|
||||
hdr.InodeBlockSig = ProsperoDinodeS64.ReadFromStream(s);
|
||||
if (hdr.Version != VersionPs5 || hdr.Magic != 20130315)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid PFS superblock version ({hdr.Version}) or magic ({hdr.Magic})");
|
||||
}
|
||||
s.Position = start + 0x370;
|
||||
hdr.Seed = s.ReadBytes(16);
|
||||
return hdr;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inode mode flags including user/group/other permissions.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ProsperoInodeMode : ushort
|
||||
{
|
||||
o_read = 1,
|
||||
o_write = 2,
|
||||
o_execute = 4,
|
||||
g_read = 8,
|
||||
g_write = 16,
|
||||
g_execute = 32,
|
||||
u_read = 64,
|
||||
u_write = 128,
|
||||
u_execute = 256,
|
||||
dir = 16384,
|
||||
file = 32768,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inode flags for special PFS features
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ProsperoInodeFlags : uint
|
||||
{
|
||||
compressed = 0x1,
|
||||
unk1 = 0x2,
|
||||
unk2 = 0x4,
|
||||
unk3 = 0x8,
|
||||
@readonly = 0x10,
|
||||
unk4 = 0x20,
|
||||
unk5 = 0x40,
|
||||
unk6 = 0x80,
|
||||
unk7 = 0x100,
|
||||
unk8 = 0x200,
|
||||
unk9 = 0x400,
|
||||
unk10 = 0x800,
|
||||
unk11 = 0x1000,
|
||||
unk12 = 0x2000,
|
||||
unk13 = 0x4000,
|
||||
unk14 = 0x8000,
|
||||
unk15 = 0x10000,
|
||||
@internal = 0x20000
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for inodes. Inodes can be signed or unsigned, and 32 or 64 bit.
|
||||
/// </summary>
|
||||
public abstract class ProsperoInode
|
||||
{
|
||||
public const ProsperoInodeMode RXOnly =
|
||||
ProsperoInodeMode.o_read | ProsperoInodeMode.o_execute |
|
||||
ProsperoInodeMode.g_read | ProsperoInodeMode.g_execute |
|
||||
ProsperoInodeMode.u_read | ProsperoInodeMode.u_execute;
|
||||
|
||||
public ProsperoInode()
|
||||
{
|
||||
SetTime((long)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds);
|
||||
}
|
||||
/// <summary>
|
||||
/// The index of this inode in the block of inodes.
|
||||
/// </summary>
|
||||
public uint Number;
|
||||
/// <summary>
|
||||
/// Default is 555 octal.
|
||||
/// </summary>
|
||||
public ProsperoInodeMode Mode = (ProsperoInodeMode)0x16D;
|
||||
/// <summary>
|
||||
/// Number of links to this file in the filesystem.
|
||||
/// 1 for regular files, 1 + 1 for every subdirectory for dirs.
|
||||
/// </summary>
|
||||
public ushort Nlink;
|
||||
public ProsperoInodeFlags Flags;
|
||||
public long Size;
|
||||
public long SizeCompressed;
|
||||
public long Time1_sec;
|
||||
public long Time2_sec;
|
||||
public long Time3_sec;
|
||||
public long Time4_sec;
|
||||
public uint Time1_nsec;
|
||||
public uint Time2_nsec;
|
||||
public uint Time3_nsec;
|
||||
public uint Time4_nsec;
|
||||
public uint Uid;
|
||||
public uint Gid;
|
||||
public ulong Unk1;
|
||||
public ulong Unk2;
|
||||
public uint Blocks;
|
||||
public abstract int StartBlock { get; }
|
||||
public abstract IList<int> DirectBlocks { get; }
|
||||
public abstract IList<int> IndirectBlocks { get; }
|
||||
|
||||
public abstract void SetDirectBlock(int idx, int block);
|
||||
public abstract void WriteToStream(Stream s);
|
||||
public ProsperoInode SetTime(long time)
|
||||
{
|
||||
Time1_sec = time;
|
||||
Time2_sec = time;
|
||||
Time3_sec = time;
|
||||
Time4_sec = time;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 32-bit unsigned inodes
|
||||
/// </summary>
|
||||
public class ProsperoDinodeD32 : ProsperoInode
|
||||
{
|
||||
public const long SizeOf = 0xA8;
|
||||
public int[] db = new int[12];
|
||||
public int[] ib = new int[5];
|
||||
|
||||
public override int StartBlock => db[0];
|
||||
public override IList<int> DirectBlocks => db;
|
||||
public override IList<int> IndirectBlocks => ib;
|
||||
|
||||
public override void SetDirectBlock(int idx, int block)
|
||||
{
|
||||
db[idx] = block;
|
||||
}
|
||||
public override void WriteToStream(Stream s)
|
||||
{
|
||||
s.WriteLE((ushort)Mode);
|
||||
s.WriteLE(Nlink);
|
||||
s.WriteLE((uint)Flags);
|
||||
s.WriteLE(Size);
|
||||
s.WriteLE(SizeCompressed);
|
||||
s.WriteLE(Time1_sec);
|
||||
s.WriteLE(Time2_sec);
|
||||
s.WriteLE(Time3_sec);
|
||||
s.WriteLE(Time4_sec);
|
||||
s.WriteLE(Time1_nsec);
|
||||
s.WriteLE(Time2_nsec);
|
||||
s.WriteLE(Time3_nsec);
|
||||
s.WriteLE(Time4_nsec);
|
||||
s.WriteLE(Uid);
|
||||
s.WriteLE(Gid);
|
||||
s.WriteLE(Unk1);
|
||||
s.WriteLE(Unk2);
|
||||
s.WriteLE(Blocks);
|
||||
foreach (var x in db) s.WriteLE(x);
|
||||
foreach (var x in ib) s.WriteLE(x);
|
||||
}
|
||||
|
||||
public static ProsperoDinodeD32 ReadFromStream(Stream s)
|
||||
{
|
||||
var di = new ProsperoDinodeD32
|
||||
{
|
||||
Mode = (ProsperoInodeMode)s.ReadUInt16LE(),
|
||||
Nlink = s.ReadUInt16LE(),
|
||||
Flags = (ProsperoInodeFlags)s.ReadUInt32LE(),
|
||||
Size = s.ReadInt64LE(),
|
||||
SizeCompressed = s.ReadInt64LE(),
|
||||
Time1_sec = s.ReadInt64LE(),
|
||||
Time2_sec = s.ReadInt64LE(),
|
||||
Time3_sec = s.ReadInt64LE(),
|
||||
Time4_sec = s.ReadInt64LE(),
|
||||
Time1_nsec = s.ReadUInt32LE(),
|
||||
Time2_nsec = s.ReadUInt32LE(),
|
||||
Time3_nsec = s.ReadUInt32LE(),
|
||||
Time4_nsec = s.ReadUInt32LE(),
|
||||
Uid = s.ReadUInt32LE(),
|
||||
Gid = s.ReadUInt32LE(),
|
||||
Unk1 = s.ReadUInt64LE(),
|
||||
Unk2 = s.ReadUInt64LE(),
|
||||
Blocks = s.ReadUInt32LE()
|
||||
};
|
||||
for (var i = 0; i < 12; i++) di.db[i] = s.ReadInt32LE();
|
||||
for (var i = 0; i < 5; i++) di.ib[i] = s.ReadInt32LE();
|
||||
return di;
|
||||
}
|
||||
};
|
||||
/// <summary>
|
||||
/// Data structure used in signed 32 bit PFS images
|
||||
/// </summary>
|
||||
public struct ProsperoBlockSig
|
||||
{
|
||||
public byte[] sig;
|
||||
public int block;
|
||||
}
|
||||
/// <summary>
|
||||
/// Data structure used in signed 64 bit PFS images, and in any signed PFS header.
|
||||
/// </summary>
|
||||
public struct ProsperoBlockSig64
|
||||
{
|
||||
public byte[] sig;
|
||||
public long block;
|
||||
}
|
||||
/// <summary>
|
||||
/// Signed 32-bit inode
|
||||
/// </summary>
|
||||
public class ProsperoDinodeS32 : ProsperoInode
|
||||
{
|
||||
public const long SizeOf = 0x2C8;
|
||||
public ProsperoDinodeS32()
|
||||
{
|
||||
db = new ProsperoBlockSig[12];
|
||||
ib = new ProsperoBlockSig[5];
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
db[i].sig = new byte[32];
|
||||
if (i < 5) ib[i].sig = new byte[32];
|
||||
}
|
||||
}
|
||||
public ProsperoBlockSig[] db;
|
||||
public ProsperoBlockSig[] ib;
|
||||
public override int StartBlock => db[0].block;
|
||||
public override IList<int> DirectBlocks => db.Select(d => d.block).ToList();
|
||||
public override IList<int> IndirectBlocks => ib.Select(d => d.block).ToList();
|
||||
|
||||
public override void SetDirectBlock(int idx, int block)
|
||||
{
|
||||
db[idx].block = block;
|
||||
}
|
||||
public override void WriteToStream(Stream s)
|
||||
{
|
||||
s.WriteLE((ushort)Mode);
|
||||
s.WriteLE(Nlink);
|
||||
s.WriteLE((uint)Flags);
|
||||
s.WriteLE(Size);
|
||||
s.WriteLE(SizeCompressed);
|
||||
s.WriteLE(Time1_sec);
|
||||
s.WriteLE(Time2_sec);
|
||||
s.WriteLE(Time3_sec);
|
||||
s.WriteLE(Time4_sec);
|
||||
s.WriteLE(Time1_nsec);
|
||||
s.WriteLE(Time2_nsec);
|
||||
s.WriteLE(Time3_nsec);
|
||||
s.WriteLE(Time4_nsec);
|
||||
s.WriteLE(Uid);
|
||||
s.WriteLE(Gid);
|
||||
s.WriteLE(Unk1);
|
||||
s.WriteLE(Unk2);
|
||||
s.WriteLE(Blocks);
|
||||
foreach (var x in db)
|
||||
{
|
||||
s.Write(x.sig, 0, 32);
|
||||
s.WriteLE(x.block);
|
||||
}
|
||||
foreach (var x in ib)
|
||||
{
|
||||
s.Write(x.sig, 0, 32);
|
||||
s.WriteLE(x.block);
|
||||
}
|
||||
}
|
||||
public static ProsperoDinodeS32 ReadFromStream(Stream s)
|
||||
{
|
||||
var di = new ProsperoDinodeS32
|
||||
{
|
||||
Mode = (ProsperoInodeMode)s.ReadUInt16LE(),
|
||||
Nlink = s.ReadUInt16LE(),
|
||||
Flags = (ProsperoInodeFlags)s.ReadUInt32LE(),
|
||||
Size = s.ReadInt64LE(),
|
||||
SizeCompressed = s.ReadInt64LE(),
|
||||
Time1_sec = s.ReadInt64LE(),
|
||||
Time2_sec = s.ReadInt64LE(),
|
||||
Time3_sec = s.ReadInt64LE(),
|
||||
Time4_sec = s.ReadInt64LE(),
|
||||
Time1_nsec = s.ReadUInt32LE(),
|
||||
Time2_nsec = s.ReadUInt32LE(),
|
||||
Time3_nsec = s.ReadUInt32LE(),
|
||||
Time4_nsec = s.ReadUInt32LE(),
|
||||
Uid = s.ReadUInt32LE(),
|
||||
Gid = s.ReadUInt32LE(),
|
||||
Unk1 = s.ReadUInt64LE(),
|
||||
Unk2 = s.ReadUInt64LE(),
|
||||
Blocks = s.ReadUInt32LE(),
|
||||
db = new ProsperoBlockSig[12],
|
||||
ib = new ProsperoBlockSig[5],
|
||||
};
|
||||
for (var i = 0; i < 12; i++) di.db[i] = new ProsperoBlockSig
|
||||
{
|
||||
sig = s.ReadBytes(32),
|
||||
block = s.ReadInt32LE()
|
||||
};
|
||||
for (var i = 0; i < 5; i++) di.ib[i] = new ProsperoBlockSig
|
||||
{
|
||||
sig = s.ReadBytes(32),
|
||||
block = s.ReadInt32LE()
|
||||
};
|
||||
return di;
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Signed 64-bit inode
|
||||
/// </summary>
|
||||
public class ProsperoDinodeS64 : ProsperoInode
|
||||
{
|
||||
public const long SizeOf = 0x310;
|
||||
public ProsperoDinodeS64()
|
||||
{
|
||||
db = new ProsperoBlockSig64[12];
|
||||
ib = new ProsperoBlockSig64[5];
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
db[i].sig = new byte[32];
|
||||
if (i < 5) ib[i].sig = new byte[32];
|
||||
}
|
||||
}
|
||||
public ProsperoBlockSig64[] db;
|
||||
public ProsperoBlockSig64[] ib;
|
||||
public override int StartBlock => (int)db[0].block;
|
||||
public override IList<int> DirectBlocks => db.Select(d => (int)d.block).ToList();
|
||||
public override IList<int> IndirectBlocks => ib.Select(d => (int)d.block).ToList();
|
||||
|
||||
public override void SetDirectBlock(int idx, int block)
|
||||
{
|
||||
db[idx].block = block;
|
||||
}
|
||||
public override void WriteToStream(Stream s)
|
||||
{
|
||||
s.WriteLE((ushort)Mode);
|
||||
s.WriteLE(Nlink);
|
||||
s.WriteLE((uint)Flags);
|
||||
s.WriteLE(Size);
|
||||
s.WriteLE(SizeCompressed);
|
||||
s.WriteLE(Time1_sec);
|
||||
s.WriteLE(Time2_sec);
|
||||
s.WriteLE(Time3_sec);
|
||||
s.WriteLE(Time4_sec);
|
||||
s.WriteLE(Time1_nsec);
|
||||
s.WriteLE(Time2_nsec);
|
||||
s.WriteLE(Time3_nsec);
|
||||
s.WriteLE(Time4_nsec);
|
||||
s.WriteLE(Uid);
|
||||
s.WriteLE(Gid);
|
||||
s.WriteLE(Unk1);
|
||||
s.WriteLE(Unk2);
|
||||
s.WriteLE(Blocks);
|
||||
s.WriteLE((int)0); // 4 bytes padding to fake 64-bit block size
|
||||
foreach (var x in db)
|
||||
{
|
||||
s.Write(x.sig, 0, 32);
|
||||
s.WriteLE(x.block);
|
||||
}
|
||||
foreach (var x in ib)
|
||||
{
|
||||
s.Write(x.sig, 0, 32);
|
||||
s.WriteLE(x.block);
|
||||
}
|
||||
}
|
||||
public static ProsperoDinodeS64 ReadFromStream(Stream s)
|
||||
{
|
||||
var di = new ProsperoDinodeS64
|
||||
{
|
||||
Mode = (ProsperoInodeMode)s.ReadUInt16LE(),
|
||||
Nlink = s.ReadUInt16LE(),
|
||||
Flags = (ProsperoInodeFlags)s.ReadUInt32LE(),
|
||||
Size = s.ReadInt64LE(),
|
||||
SizeCompressed = s.ReadInt64LE(),
|
||||
Time1_sec = s.ReadInt64LE(),
|
||||
Time2_sec = s.ReadInt64LE(),
|
||||
Time3_sec = s.ReadInt64LE(),
|
||||
Time4_sec = s.ReadInt64LE(),
|
||||
Time1_nsec = s.ReadUInt32LE(),
|
||||
Time2_nsec = s.ReadUInt32LE(),
|
||||
Time3_nsec = s.ReadUInt32LE(),
|
||||
Time4_nsec = s.ReadUInt32LE(),
|
||||
Uid = s.ReadUInt32LE(),
|
||||
Gid = s.ReadUInt32LE(),
|
||||
Unk1 = s.ReadUInt64LE(),
|
||||
Unk2 = s.ReadUInt64LE(),
|
||||
Blocks = (uint)s.ReadInt64LE(),
|
||||
db = new ProsperoBlockSig64[12],
|
||||
ib = new ProsperoBlockSig64[5],
|
||||
};
|
||||
for (var i = 0; i < 12; i++) di.db[i] = new ProsperoBlockSig64
|
||||
{
|
||||
sig = s.ReadBytes(32),
|
||||
block = s.ReadInt64LE()
|
||||
};
|
||||
for (var i = 0; i < 5; i++) di.ib[i] = new ProsperoBlockSig64
|
||||
{
|
||||
sig = s.ReadBytes(32),
|
||||
block = s.ReadInt64LE()
|
||||
};
|
||||
return di;
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Represents a PFS dirent. Directories are stored ondisk as blocks of dirents.
|
||||
/// </summary>
|
||||
public class ProsperoPfsDirent
|
||||
{
|
||||
public static int MaxSize = 280;
|
||||
|
||||
public uint InodeNumber;
|
||||
public ProsperoDirentType Type;
|
||||
public int NameLength;
|
||||
public int EntSize;
|
||||
|
||||
public string Name
|
||||
{
|
||||
get { return name; }
|
||||
set
|
||||
{
|
||||
name = value;
|
||||
NameLength = name.Length;
|
||||
EntSize = CalculateEntSize();
|
||||
}
|
||||
}
|
||||
|
||||
public int CalculateEntSize()
|
||||
{
|
||||
var EntSize = NameLength + 17;
|
||||
if (EntSize % 8 != 0)
|
||||
EntSize += 8 - (EntSize % 8);
|
||||
return EntSize;
|
||||
}
|
||||
|
||||
private string name;
|
||||
|
||||
public void WriteToStream(Stream s)
|
||||
{
|
||||
var pos = s.Position;
|
||||
s.WriteLE(InodeNumber);
|
||||
s.WriteLE((int)Type);
|
||||
s.WriteLE(NameLength);
|
||||
s.WriteLE(EntSize);
|
||||
s.Write(Encoding.ASCII.GetBytes(Name), 0, NameLength);
|
||||
var remaining = (int)(EntSize - (s.Position - pos));
|
||||
s.Write(new byte[remaining], 0, remaining);
|
||||
}
|
||||
|
||||
public static ProsperoPfsDirent ReadFromStream(Stream s)
|
||||
{
|
||||
var pos = s.Position;
|
||||
var d = new ProsperoPfsDirent
|
||||
{
|
||||
InodeNumber = s.ReadUInt32LE(),
|
||||
Type = (ProsperoDirentType)s.ReadInt32LE(),
|
||||
NameLength = s.ReadInt32LE(),
|
||||
EntSize = s.ReadInt32LE(),
|
||||
};
|
||||
d.name = s.ReadASCIINullTerminated(d.NameLength);
|
||||
s.Position = pos + d.EntSize;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Describes the nature of the dirent
|
||||
/// </summary>
|
||||
public enum ProsperoDirentType : int
|
||||
{
|
||||
/// <summary>
|
||||
/// A regular file
|
||||
/// </summary>
|
||||
File = 2,
|
||||
/// <summary>
|
||||
/// A directory
|
||||
/// </summary>
|
||||
Directory = 3,
|
||||
/// <summary>
|
||||
/// The special "." file that points to the current dir
|
||||
/// </summary>
|
||||
Dot = 4,
|
||||
/// <summary>
|
||||
/// The special ".." file that points to the parent dir
|
||||
/// </summary>
|
||||
DotDot = 5
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// PFS image structures, builder and reader primitives.
|
||||
#nullable disable
|
||||
using LibProsperoPkg.Util;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>
|
||||
/// This wraps a Memory mapped file view of a PFSC file so that you can access it
|
||||
/// as though it were uncompressed.
|
||||
/// </summary>
|
||||
public class ProsperoPfscReader : IMemoryReader
|
||||
{
|
||||
public const int Magic = 0x43534650;
|
||||
private IMemoryAccessor _accessor;
|
||||
private PFSCHdr hdr;
|
||||
private long[] sectorMap;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PFSCReader
|
||||
/// </summary>
|
||||
/// <param name="va">An IMemoryAccessor containing the PFSC file</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the accessor is not a view of a PFSC file.</exception>
|
||||
public ProsperoPfscReader(IMemoryAccessor va)
|
||||
{
|
||||
_accessor = va;
|
||||
_accessor.Read(0, out hdr);
|
||||
if (hdr.Magic != Magic)
|
||||
throw new ArgumentException("Not a PFSC file: missing PFSC magic");
|
||||
if (hdr.Unk4 != 0)
|
||||
throw new ArgumentException($"Not a PFSC file: unknown data at 0x4 (expected 0, got {hdr.Unk4})");
|
||||
if (hdr.BlockSz != (int)hdr.BlockSz2)
|
||||
throw new ArgumentException("Not a PFSC file: block size mismatch");
|
||||
|
||||
var num_blocks = (int)(hdr.DataLength / hdr.BlockSz2);
|
||||
sectorMap = new long[num_blocks + 1];
|
||||
_accessor.ReadArray(hdr.BlockOffsets, sectorMap, 0, num_blocks + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PFSCReader
|
||||
/// </summary>
|
||||
/// <param name="va">A ViewAccessor containing the PFSC file</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the accessor is not a view of a PFSC file.</exception>
|
||||
public ProsperoPfscReader(MemoryMappedViewAccessor va) : this(new MemoryMappedViewAccessor_(va))
|
||||
{ }
|
||||
|
||||
public ProsperoPfscReader(IMemoryReader r) : this(new MemoryAccessor(r))
|
||||
{ }
|
||||
|
||||
public int SectorSize => hdr.BlockSz;
|
||||
|
||||
/// <summary>The logical (decompressed) length in bytes represented by this PFSC image.</summary>
|
||||
public long DataLength => hdr.DataLength;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the sector at the given index into the given byte array.
|
||||
/// </summary>
|
||||
/// <param name="idx">sector index (multiply by SectorSize to get the byte offset)</param>
|
||||
/// <param name="output">byte array where sector will be written</param>
|
||||
public void ReadSector(int idx, byte[] output)
|
||||
{
|
||||
if (idx < 0 || idx >= sectorMap.Length - 1)
|
||||
throw new ArgumentException("Invalid index", nameof(idx));
|
||||
|
||||
var sectorOffset = sectorMap[idx];
|
||||
var sectorSize = sectorMap[idx + 1] - sectorOffset;
|
||||
|
||||
if (sectorSize == hdr.BlockSz2)
|
||||
{
|
||||
// fast case: uncompressed sector
|
||||
_accessor.Read(sectorOffset, output, 0, hdr.BlockSz);
|
||||
}
|
||||
else if (sectorSize > hdr.BlockSz2)
|
||||
{
|
||||
Array.Clear(output, 0, hdr.BlockSz);
|
||||
}
|
||||
else
|
||||
{
|
||||
// slow case: compressed sector
|
||||
var sectorBuf = new byte[(int)sectorSize - 2];
|
||||
_accessor.Read(sectorOffset + 2, sectorBuf, 0, (int)sectorSize - 2);
|
||||
using (var bufStream = new MemoryStream(sectorBuf))
|
||||
using (var ds = new DeflateStream(bufStream, CompressionMode.Decompress))
|
||||
{
|
||||
ds.ReadExactly(output, 0, hdr.BlockSz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Read(long src, long count, Action<byte[], int, int> Write)
|
||||
{
|
||||
if (src + count > hdr.DataLength)
|
||||
throw new ArgumentException("Attempt to read beyond end of file");
|
||||
if (count <= 0)
|
||||
return; // nothing to read; avoid an out-of-range sector fetch at end-of-image
|
||||
var sectorSize = hdr.BlockSz;
|
||||
var sectorBuffer = new byte[sectorSize];
|
||||
var currentSector = (int)(src / sectorSize);
|
||||
var offsetIntoSector = (int)(src - (sectorSize * currentSector));
|
||||
ReadSector(currentSector, sectorBuffer);
|
||||
while (count > 0 && src < hdr.DataLength)
|
||||
{
|
||||
if (offsetIntoSector >= sectorSize)
|
||||
{
|
||||
currentSector++;
|
||||
ReadSector(currentSector, sectorBuffer);
|
||||
offsetIntoSector = 0;
|
||||
}
|
||||
int bufferedRead = (int)Math.Min(sectorSize - offsetIntoSector, count);
|
||||
Write(sectorBuffer, offsetIntoSector, bufferedRead);
|
||||
count -= bufferedRead;
|
||||
offsetIntoSector += bufferedRead;
|
||||
src += bufferedRead;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read `count` bytes at location `src` into the writeable Stream `dest`
|
||||
/// </summary>
|
||||
/// <param name="src">Byte offset into PFSC</param>
|
||||
/// <param name="count">Number of bytes to read</param>
|
||||
/// <param name="dest">Output stream</param>
|
||||
public void Read(long src, long count, Stream dest)
|
||||
{
|
||||
Read(src, count, dest.Write);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read `count` bytes at location `src` into the byte array at offset `offset`
|
||||
/// </summary>
|
||||
/// <param name="src">Byte offset into PFSC</param>
|
||||
/// <param name="buffer">Output byte array</param>
|
||||
/// <param name="offset">Offset into byte array</param>
|
||||
/// <param name="count">Number of bytes to read</param>
|
||||
public void Read(long src, byte[] buffer, int offset, int count)
|
||||
{
|
||||
Read(src, count, (sectorBuffer, offsetIntoSector, bufferedRead) =>
|
||||
{
|
||||
Buffer.BlockCopy(sectorBuffer, offsetIntoSector, buffer, offset, bufferedRead);
|
||||
offset += bufferedRead;
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor.Dispose();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 0x30)]
|
||||
private struct PFSCHdr
|
||||
{
|
||||
public int Magic;
|
||||
public int Unk4;
|
||||
public int Unk8;
|
||||
public int BlockSz;
|
||||
public long BlockSz2;
|
||||
public long BlockOffsets;
|
||||
public ulong DataStart;
|
||||
public long DataLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// PS5 nwonly INNER pfs_image.dat READER (the inverse of ProsperoPs5InnerImageAssembler). A data-first
|
||||
// inner image has NO on-disk block table: it is a raw concatenation of per-file payloads (raw or
|
||||
// header-stripped Kraken chunks) followed by the Kraken-compressed metadata region. The per-block
|
||||
// geometry (on-disk offset, compressed/uncompressed sizes, raw-vs-Kraken flag, even/odd split, byte
|
||||
// shuffle) lives ONLY in the sibling naps_pkg_layout.dat's CblockInfo section.
|
||||
//
|
||||
// This reconstructs the uncompressed PFS mount from (inner image bytes + naps bytes) so the standard
|
||||
// ProsperoPfsReader can walk it. The naps CblockInfo section supplies the per-block decode geometry for
|
||||
// all file data and the metadata region.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
#nullable enable
|
||||
using LibProsperoPkg.PFS.Compression.Oodle;
|
||||
using LibProsperoPkg.PKG;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>A regular file discovered in a reconstructed inner mount.</summary>
|
||||
public sealed class ProsperoPs5InnerFileEntry
|
||||
{
|
||||
/// <summary>User-root-relative path (e.g. <c>sce_sys/keystone</c> or <c>application.ps.bundle</c>).</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Byte offset of the file's (already decoded) data within the reconstructed mount.</summary>
|
||||
public required ulong LogicalOffset { get; init; }
|
||||
|
||||
/// <summary>File size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The reconstructed inner PFS mount plus the offset of its superblock.</summary>
|
||||
public sealed class ProsperoPs5InnerMountResult
|
||||
{
|
||||
/// <summary>The uncompressed inner PFS mount bytes (walk with <see cref="ProsperoPfsReader"/>).</summary>
|
||||
public required byte[] Mount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Byte offset of the PFS superblock within <see cref="Mount"/>. A nwonly inner image is data-first,
|
||||
/// so the superblock is at the metadata-region base (not offset 0).
|
||||
/// </summary>
|
||||
public required long SuperblockOffset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the uncompressed inner PFS mount of a PS5 nwonly package from its data-first
|
||||
/// <c>pfs_image.dat</c> and the sibling <c>naps_pkg_layout.dat</c>. The result is a plaintext PFS
|
||||
/// image whose superblock is at the metadata-region base that <see cref="ProsperoPfsReader"/> can walk.
|
||||
/// </summary>
|
||||
public static class ProsperoPs5InnerImageReader
|
||||
{
|
||||
private const int Chunk128K = 0x20000; // even/odd sub-chunk split
|
||||
private const long Ublock256K = 0x40000; // one CblockInfo STD covers up to one 256K ublock
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the uncompressed PFS mount from a data-first inner image and its naps layout. Files
|
||||
/// and the metadata region are decoded to their logical offsets; unreferenced inter-file padding is
|
||||
/// left zero-filled.
|
||||
/// </summary>
|
||||
/// <param name="innerImage">The on-disk data-first <c>pfs_image.dat</c> bytes.</param>
|
||||
/// <param name="naps">The <c>naps_pkg_layout.dat</c> bytes (its CblockInfo drives the decode).</param>
|
||||
/// <param name="mountSize">
|
||||
/// The uncompressed mount size (Ndblock·64K). Pass 0 to derive it from the naps: the mount end is the
|
||||
/// largest 64K-aligned <c>fidx</c> boundary (trailing typed sentinels are not block-aligned).
|
||||
/// </param>
|
||||
public static ProsperoPs5InnerMountResult ReconstructMount(
|
||||
ReadOnlySpan<byte> innerImage, ReadOnlySpan<byte> naps, long mountSize = 0)
|
||||
{
|
||||
NapsLayoutDocument doc = ProsperoNapsLayout.Parse(naps);
|
||||
IReadOnlyList<NapsCblockInfoEntry> cb = doc.CblockInfos;
|
||||
|
||||
long[] rawOffsets = doc.FileOffsets.Select(f => (long)f.UncompressedOffsetStart).ToArray();
|
||||
if (mountSize <= 0)
|
||||
{
|
||||
// Derive the mount size: the mount end is a 64K-aligned fidx boundary; typed/garbage sentinels
|
||||
// are not block-aligned, so the largest block-aligned boundary is Ndblock*64K.
|
||||
mountSize = rawOffsets.Where(v => v > 0 && (v & 0xFFFF) == 0).DefaultIfEmpty(0).Max();
|
||||
}
|
||||
if (mountSize <= 0 || mountSize > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(mountSize), mountSize, "Implausible inner mount size.");
|
||||
|
||||
// fidx = uncompressed file-offset boundaries. Only values inside the mount are real block
|
||||
// boundaries; trailing sentinels (huge/typed) are ignored by the min-above-cursor lookup.
|
||||
long[] boundaries = rawOffsets
|
||||
.Where(v => v > 0 && v <= mountSize)
|
||||
.Distinct().OrderBy(v => v).ToArray();
|
||||
|
||||
// The metadata region base (= superblock offset) is the last file-offset boundary below the mount
|
||||
// end; the data files occupy everything below it and the metadata PFS is stored above it.
|
||||
long metaBase = boundaries.LastOrDefault(v => v < mountSize);
|
||||
|
||||
var mount = new byte[mountSize];
|
||||
byte[] innerArr = innerImage.ToArray();
|
||||
|
||||
long onDisk = 0, uncompOff = 0;
|
||||
for (int i = 0; i < cb.Count; i++)
|
||||
{
|
||||
NapsCblockInfoEntry e = cb[i];
|
||||
if (e.IsRunBase)
|
||||
{
|
||||
// On-disk cursor re-anchors at each run: 32K-granular base + the fractional byte offset
|
||||
// carried in the following STD's low-15 coffMod bits.
|
||||
long frac = (i + 1 < cb.Count && !cb[i + 1].IsRunBase)
|
||||
? (cb[i + 1].CoffsetStartMod256K & 0x7fff) : 0;
|
||||
onDisk = ((long)e.TweakIdxStart << 15) + frac;
|
||||
continue;
|
||||
}
|
||||
|
||||
long fileEnd = NextBoundary(boundaries, uncompOff, mountSize);
|
||||
long uncompLen = Math.Min(Ublock256K, fileEnd - uncompOff);
|
||||
if (uncompLen <= 0 || i + 1 >= cb.Count)
|
||||
break;
|
||||
|
||||
int evenComp = (int)(e.ClenEvenMinus1 / 2 + 1);
|
||||
NapsCblockInfoEntry next = cb[i + 1];
|
||||
long thisRel = e.CoffsetStartMod256K;
|
||||
long nextRel = next.IsRunBase ? next.CoffsetEndMod256K : next.CoffsetStartMod256K;
|
||||
int totalComp = (int)(nextRel - thisRel);
|
||||
bool kraken = e.KdePredictor == 2;
|
||||
|
||||
DecodeBlockInto(innerArr, onDisk, totalComp, evenComp, (int)uncompLen, kraken,
|
||||
mount, (int)uncompOff);
|
||||
|
||||
uncompOff += uncompLen;
|
||||
onDisk += kraken ? totalComp : uncompLen;
|
||||
}
|
||||
|
||||
// Locate the metadata superblock (version 2 + magic 20130315) at a 64K boundary. The metadata PFS
|
||||
// sits at the top of the mount, so scan downward and take the highest match; fall back to the
|
||||
// fidx-derived base. This is robust to per-package fidx layout differences.
|
||||
long superblockOffset = metaBase;
|
||||
for (long p = (mountSize - 0x10000) & ~0xFFFFL; p >= 0; p -= 0x10000)
|
||||
{
|
||||
if (System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(mount.AsSpan((int)p)) == 2 &&
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(mount.AsSpan((int)p + 8)) == 20130315)
|
||||
{
|
||||
superblockOffset = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ProsperoPs5InnerMountResult { Mount = mount, SuperblockOffset = superblockOffset };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the reconstructed inner mount's PS5 metadata (superblock + inode table + dirents) and returns
|
||||
/// every regular file under <c>uroot</c> with its user-root-relative path, logical offset and size. The
|
||||
/// file bytes are <c>mount[LogicalOffset .. LogicalOffset+Size]</c> (already decoded by
|
||||
/// <see cref="ReconstructMount"/>). The PS5 inner inode layout (LogicalOffset@0x60, parent@0x6c)
|
||||
/// differs from the layout <see cref="ProsperoPfsReader"/> reads, so this does not use it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProsperoPs5InnerFileEntry> ReadFileTree(byte[] mount, long metaBase)
|
||||
{
|
||||
ReadOnlySpan<byte> sb = mount.AsSpan((int)metaBase);
|
||||
if (System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(sb) != 2 ||
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(sb[8..]) != 20130315)
|
||||
throw new InvalidOperationException("Inner mount metadata does not start with a PS5 PFS superblock.");
|
||||
|
||||
int blockSize = (int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(sb[0x20..]);
|
||||
int inodeCount = (int)System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(sb[0x30..]);
|
||||
if (blockSize <= 0 || inodeCount <= 0 || inodeCount > 1_000_000)
|
||||
throw new InvalidOperationException("Implausible inner metadata superblock (block size / inode count).");
|
||||
|
||||
// Parse the inode table (block 1). PS5 inner inode: Mode@0, Size@8, LogicalOffset@0x60, parent@0x6c.
|
||||
long inodeTable = metaBase + blockSize;
|
||||
var nodes = new InnerInode[inodeCount];
|
||||
for (int i = 0; i < inodeCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> e = mount.AsSpan((int)(inodeTable + i * 0xA8), 0xA8);
|
||||
nodes[i] = new InnerInode
|
||||
{
|
||||
Mode = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(e),
|
||||
Size = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(e[8..]),
|
||||
LogicalOffset = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(e[0x60..]),
|
||||
};
|
||||
}
|
||||
|
||||
// Walk the directory tree from the super-root (inode 0), collecting files under "uroot".
|
||||
var files = new List<ProsperoPs5InnerFileEntry>();
|
||||
WalkDir(mount, blockSize, nodes, 0, "", underUroot: false, files, new HashSet<uint>());
|
||||
return files;
|
||||
}
|
||||
|
||||
private sealed class InnerInode
|
||||
{
|
||||
public ushort Mode;
|
||||
public long Size;
|
||||
public ulong LogicalOffset;
|
||||
public bool IsDirectory => (Mode & 0x4000) != 0;
|
||||
}
|
||||
|
||||
private static void WalkDir(byte[] mount, int blockSize, InnerInode[] nodes, uint dirInode,
|
||||
string path, bool underUroot, List<ProsperoPs5InnerFileEntry> files, HashSet<uint> seen)
|
||||
{
|
||||
if (dirInode >= nodes.Length || !seen.Add(dirInode))
|
||||
return;
|
||||
InnerInode dir = nodes[dirInode];
|
||||
if (!dir.IsDirectory || dir.Size <= 0)
|
||||
return;
|
||||
|
||||
long start = (long)dir.LogicalOffset;
|
||||
long end = Math.Min(start + dir.Size, mount.Length);
|
||||
using var ms = new System.IO.MemoryStream(mount, (int)start, (int)(end - start), writable: false);
|
||||
while (ms.Position + 0x10 <= ms.Length)
|
||||
{
|
||||
long entryPos = ms.Position;
|
||||
ProsperoPfsDirent d;
|
||||
try { d = ProsperoPfsDirent.ReadFromStream(ms); }
|
||||
catch { break; }
|
||||
if (d.EntSize <= 0) break;
|
||||
ms.Position = entryPos + d.EntSize;
|
||||
|
||||
string name = d.Name;
|
||||
if (string.IsNullOrEmpty(name) || name == "." || name == "..")
|
||||
continue;
|
||||
if (d.InodeNumber >= nodes.Length)
|
||||
continue;
|
||||
|
||||
// The super-root wraps the content under "uroot" (its other children are internal flat-path
|
||||
// tables); only files at or below uroot are real package content.
|
||||
bool childUnderUroot = underUroot || name == "uroot";
|
||||
string childPath = path.Length == 0
|
||||
? (name == "uroot" ? "" : name)
|
||||
: path + "/" + name;
|
||||
|
||||
if (d.Type == ProsperoDirentType.Directory)
|
||||
WalkDir(mount, blockSize, nodes, d.InodeNumber, childPath, childUnderUroot, files, seen);
|
||||
else if (d.Type == ProsperoDirentType.File && underUroot && childPath.Length > 0)
|
||||
files.Add(new ProsperoPs5InnerFileEntry
|
||||
{
|
||||
Path = childPath,
|
||||
LogicalOffset = nodes[d.InodeNumber].LogicalOffset,
|
||||
Size = nodes[d.InodeNumber].Size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static long NextBoundary(long[] boundaries, long cur, long mountSize)
|
||||
{
|
||||
foreach (long b in boundaries)
|
||||
if (b > cur)
|
||||
return b;
|
||||
return mountSize;
|
||||
}
|
||||
|
||||
// Decode a single block (files + metadata) into the mount at mountOff. Raw blocks copy uncompLen
|
||||
// bytes; Kraken blocks decode even (seeded) + odd (seedless, back-referencing even in the same
|
||||
// 256K buffer). Unreferenced padding blocks may copy garbage past their real footprint, which is
|
||||
// harmless because no inode points into the inter-file padding region.
|
||||
// NOTE: the naps records a per-block shuffle predictor, but the data-first payloads (data files and
|
||||
// the metadata region) are stored UN-shuffled on disk — decoding them yields the final plaintext
|
||||
// directly (applying the de-interleave would corrupt them). A package with genuinely shuffled blocks
|
||||
// would need a per-64K-sub-block de-interleave reintroduced here.
|
||||
private static void DecodeBlockInto(byte[] inner, long onDisk, int totalComp, int evenComp,
|
||||
int uncompLen, bool kraken, byte[] mount, int mountOff)
|
||||
{
|
||||
int room = mount.Length - mountOff;
|
||||
if (room <= 0)
|
||||
return;
|
||||
int copyLen = Math.Min(uncompLen, room);
|
||||
|
||||
if (!kraken)
|
||||
{
|
||||
// Raw store: copy the uncompressed bytes straight through (bounded by the image length).
|
||||
if (onDisk >= 0 && onDisk < inner.Length)
|
||||
{
|
||||
int avail = (int)Math.Min(copyLen, inner.Length - onDisk);
|
||||
Array.Copy(inner, onDisk, mount, mountOff, avail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (onDisk < 0 || onDisk + totalComp > inner.Length || totalComp <= 0)
|
||||
return;
|
||||
|
||||
var src = inner.AsSpan((int)onDisk, totalComp);
|
||||
int firstChunkComp = uncompLen > Chunk128K ? evenComp : 0;
|
||||
var decoded = new byte[uncompLen];
|
||||
|
||||
// The per-chunk newLZ/literal-mode flag bits are not stored in the packed 9-byte CblockInfo, so
|
||||
// select them by trying the observed combinations and taking the first that decodes cleanly.
|
||||
// Order favours the common case (both sub-chunks newLZ + raw-literal, then sub/delta variants).
|
||||
foreach (int flags in FlagCandidates(uncompLen > Chunk128K))
|
||||
{
|
||||
KrakenDecodeStatus st;
|
||||
try { st = KrakenDecoder.DecodeBlock(src, flags, firstChunkComp, decoded); }
|
||||
catch { continue; }
|
||||
if (st != KrakenDecodeStatus.Success)
|
||||
continue;
|
||||
Array.Copy(decoded, 0, mount, mountOff, copyLen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] FlagCandidates(bool multiChunk) => multiChunk
|
||||
? [0x22, 0x02, 0x12, 0x32, 0x23, 0x03, 0x13, 0x33, 0x00, 0x20]
|
||||
: [0x02, 0x00, 0x03, 0x01];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// PFS image structures, builder and reader primitives.
|
||||
#nullable disable
|
||||
using LibProsperoPkg.Util;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LibProsperoPkg.PFS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides XTS decryption on an IMemoryReader
|
||||
/// </summary>
|
||||
public class ProsperoXtsDecryptReader : IMemoryReader
|
||||
{
|
||||
private byte[] dataKey;
|
||||
private byte[] tweakKey;
|
||||
/// <summary>
|
||||
/// Size of each encryption sector
|
||||
/// </summary>
|
||||
private uint sectorSize;
|
||||
/// <summary>
|
||||
/// Sector at and after which the encryption is active
|
||||
/// </summary>
|
||||
private uint cryptStartSector;
|
||||
private IMemoryReader reader;
|
||||
private static byte[] zeroes = new byte[16];
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AES-XTS-128 stream.
|
||||
/// Reads will decrypt data.
|
||||
/// </summary>
|
||||
public ProsperoXtsDecryptReader(
|
||||
IMemoryReader r,
|
||||
byte[] dataKey,
|
||||
byte[] tweakKey, uint startSector = 16, uint sectorSize = 0x1000)
|
||||
{
|
||||
cryptStartSector = startSector;
|
||||
this.sectorSize = sectorSize;
|
||||
this.dataKey = dataKey;
|
||||
this.tweakKey = tweakKey;
|
||||
reader = r;
|
||||
}
|
||||
|
||||
public static unsafe void DecryptSector(
|
||||
Ctx context,
|
||||
byte[] sector,
|
||||
ulong sectorNum)
|
||||
{
|
||||
byte[] tweak = context.tweak,
|
||||
encryptedTweak = context.encryptedTweak,
|
||||
xor = context.xor;
|
||||
|
||||
// Reset tweak to sector number
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(tweak, sectorNum);
|
||||
Buffer.BlockCopy(zeroes, 0, tweak, 8, 8);
|
||||
{
|
||||
ICryptoTransform tweakEncryptor = context.tweakEncryptor;
|
||||
ICryptoTransform decryptor = context.decryptor;
|
||||
tweakEncryptor.TransformBlock(tweak, 0, 16, encryptedTweak, 0);
|
||||
for (int plaintextOffset = 0; plaintextOffset < sector.Length; plaintextOffset += 16)
|
||||
{
|
||||
fixed (byte* xor_ = xor)
|
||||
fixed (byte* encryptedTweak_ = encryptedTweak)
|
||||
fixed (byte* sector_ = §or[plaintextOffset])
|
||||
{
|
||||
*((ulong*)xor_) = *((ulong*)sector_) ^ *((ulong*)encryptedTweak_);
|
||||
*((ulong*)xor_ + 1) = *((ulong*)sector_ + 1) ^ *((ulong*)encryptedTweak_ + 1);
|
||||
}
|
||||
decryptor.TransformBlock(xor, 0, 16, xor, 0);
|
||||
fixed (byte* xor_ = xor)
|
||||
fixed (byte* encryptedTweak_ = encryptedTweak)
|
||||
fixed (byte* sector_ = §or[plaintextOffset])
|
||||
{
|
||||
*((ulong*)sector_) = *((ulong*)xor_) ^ *((ulong*)encryptedTweak_);
|
||||
*((ulong*)sector_ + 1) = *((ulong*)xor_ + 1) ^ *((ulong*)encryptedTweak_ + 1);
|
||||
}
|
||||
// GF-Multiply Tweak
|
||||
int feedback = 0;
|
||||
for (int k = 0; k < 16; k++)
|
||||
{
|
||||
byte tmp = encryptedTweak[k];
|
||||
encryptedTweak[k] = (byte)(2 * encryptedTweak[k] | feedback);
|
||||
feedback = (tmp & 0x80) >> 7;
|
||||
}
|
||||
if (feedback != 0)
|
||||
encryptedTweak[0] ^= 0x87;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class Ctx
|
||||
{
|
||||
public SymmetricAlgorithm cipher;
|
||||
public SymmetricAlgorithm tweakCipher;
|
||||
public ICryptoTransform decryptor;
|
||||
public ICryptoTransform tweakEncryptor;
|
||||
public byte[] tweak;
|
||||
public byte[] xor;
|
||||
public byte[] encryptedTweak;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precondition: activeSector is set
|
||||
/// Postconditions:
|
||||
/// - sectorOffset is reset to 0
|
||||
/// - sectorBuf[] is filled with decrypted sector
|
||||
/// - position is updated
|
||||
/// </summary>
|
||||
private void ReadSectorBuffer(Ctx ctx, int currentSector, byte[] sectorBuf)
|
||||
{
|
||||
reader.Read(currentSector * sectorSize, sectorBuf, 0, (int)sectorSize);
|
||||
if (currentSector >= cryptStartSector)
|
||||
DecryptSector(ctx, sectorBuf, (ulong)currentSector);
|
||||
}
|
||||
|
||||
private Ctx MakeCtx()
|
||||
{
|
||||
var cipher = CreateEcbAes(dataKey);
|
||||
var tweakCipher = CreateEcbAes(tweakKey);
|
||||
return new Ctx
|
||||
{
|
||||
cipher = cipher,
|
||||
tweakCipher = tweakCipher,
|
||||
decryptor = cipher.CreateDecryptor(),
|
||||
tweakEncryptor = tweakCipher.CreateEncryptor(),
|
||||
xor = new byte[16],
|
||||
encryptedTweak = new byte[16],
|
||||
tweak = new byte[16],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single-block AES-128-ECB engine (no padding) used as the primitive for
|
||||
/// the manual XTS transform. Uses the modern <see cref="Aes.Create()"/> factory.
|
||||
/// </summary>
|
||||
private static Aes CreateEcbAes(byte[] key)
|
||||
{
|
||||
var aes = Aes.Create();
|
||||
aes.Mode = CipherMode.ECB;
|
||||
aes.KeySize = 128;
|
||||
aes.Key = key;
|
||||
aes.Padding = PaddingMode.None;
|
||||
aes.BlockSize = 128;
|
||||
return aes;
|
||||
}
|
||||
|
||||
public void Read(long position, byte[] buffer, int offset, int count)
|
||||
{
|
||||
var ctx = MakeCtx();
|
||||
try
|
||||
{
|
||||
var sectorBuf = new byte[sectorSize];
|
||||
var currentSector = (int)(position / sectorSize);
|
||||
var offsetIntoSector = (int)(position - (sectorSize * currentSector));
|
||||
ReadSectorBuffer(ctx, currentSector, sectorBuf);
|
||||
int totalRead = 0;
|
||||
while (count > 0)
|
||||
{
|
||||
if (offsetIntoSector >= sectorSize)
|
||||
{
|
||||
currentSector++;
|
||||
ReadSectorBuffer(ctx, currentSector, sectorBuf);
|
||||
offsetIntoSector = 0;
|
||||
}
|
||||
int bufferedRead = Math.Min((int)sectorSize - offsetIntoSector, count);
|
||||
Buffer.BlockCopy(sectorBuf, offsetIntoSector, buffer, offset, bufferedRead);
|
||||
count -= bufferedRead;
|
||||
offset += bufferedRead;
|
||||
totalRead += bufferedRead;
|
||||
offsetIntoSector += bufferedRead;
|
||||
position += bufferedRead;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.decryptor?.Dispose();
|
||||
ctx.tweakEncryptor?.Dispose();
|
||||
ctx.cipher?.Dispose();
|
||||
ctx.tweakCipher?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// We don't own the IMemoryReader, so do nothing.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// PS Multi Tools - Backup manager and toolkit for PS5 consoles.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program.
|
||||
//
|
||||
//
|
||||
// Decoder and lossless serializer for the NAPS streaming layout (`naps_pkg_layout.dat`) of a PS5
|
||||
// finalized image. The on-disk `PackageLayout_NAPS` structure backs the
|
||||
// streaming output formats (`nwonly` / `bd+nw`).
|
||||
//
|
||||
// Format boundary - the on-disk format is fully modeled. `Parse` decodes and
|
||||
// `BuildLayout` re-serializes; `BuildLayout(Parse(x)) == x` for all 544 bytes of a layout
|
||||
// block (block 5 of the outer image: 533 section content + 11 trailing zero pad to a
|
||||
// 16-byte boundary). `EncodeCblockInfoEntry` repacks every entry from its model fields (not the raw
|
||||
// bytes), so the full bit model - not just the section strides - is exercised by the round-trip.
|
||||
// Header decode: NumFiles=7, compType=2 (Kraken), Keys=1, Shuffle=0, UBlocks=19,
|
||||
// OuterBlocks=5, CblockInfo=45.
|
||||
//
|
||||
// Value boundary - this type does not fabricate the record values; it (de)serializes them. The values
|
||||
// describe the exact layout of the NAPS-compressed + AES-XTS-encrypted download
|
||||
// stream* (the nwonly representation of the package), NOT merely the inner PFS image:
|
||||
// * CblockInfo coffsetStart256K/coffsetStartMod256K/coffsetEndMod256K/clenEvenMinus1 = compressed
|
||||
// byte offsets and lengths of the Oodle-Kraken-compressed download blocks (even/odd halves);
|
||||
// * m_tweakIdxStart / m_keyTableIdx = the AES-XTS sector (tweak) and key-table slot of each
|
||||
// encrypted run in that stream (e.g. tweakIdx runs 0,2,4,4,6,6,...,8,0);
|
||||
// * m_KdePredictor / m_shuffleIdx / m_even / m_odd = the NAPS delta-predictor, pre-compression
|
||||
// shuffle selection and even/odd interleave decisions;
|
||||
// * m_isRunBase segments the stream into runs (a run-base marker + its member blocks);
|
||||
// * fidx (type + 40-bit m_uoffsetStart) and u2c (uint24 m_infoOffset9BBase + 7 deltas =>
|
||||
// start_cblockinfo_index per ublock) map files and uncompressed blocks into that stream.
|
||||
// The values are encoder-defined because compressed sizes are produced by the Kraken encoder.
|
||||
// This type models the on-disk format, serializes each field, and preserves the value semantics above.
|
||||
// The bit packing of the two 64-bit header words and the inter-field bit offsets of the 9-byte
|
||||
// CblockInfo record are exercised by the round-trip serializer.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LibProsperoPkg.PKG;
|
||||
|
||||
/// <summary>
|
||||
/// Section counts of a <c>naps_pkg_layout.dat</c> structure. Every count is the number of records
|
||||
/// in the matching section; the strides are fixed and validated (see <see cref="ProsperoNapsLayout"/>).
|
||||
/// </summary>
|
||||
/// <param name="NumFiles">Files in the package (<c>m_numFilesMinus1 + 1</c>).</param>
|
||||
/// <param name="CompressionType">Oodle codec selector (<c>m_compressionType</c>).</param>
|
||||
/// <param name="NumKeys">Encryption keys (<c>m_numKeysMinus1 + 1</c>).</param>
|
||||
/// <param name="NumShufflePatterns">Shuffle-pattern entries (0 = no shuffling).</param>
|
||||
/// <param name="NumUBlocks">Uncompressed blocks (<c>m_numUBlocks</c>).</param>
|
||||
/// <param name="NumOuterBlocks">Outer-block entries (<c>m_numOuterBlocks</c>).</param>
|
||||
/// <param name="NumCblockInfo">CblockInfo entries (<c>m_numCblockInfoMinus2 + 2</c>).</param>
|
||||
public readonly record struct NapsLayoutCounts(
|
||||
int NumFiles,
|
||||
byte CompressionType,
|
||||
int NumKeys,
|
||||
int NumShufflePatterns,
|
||||
int NumUBlocks,
|
||||
int NumOuterBlocks,
|
||||
int NumCblockInfo)
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of 10-byte <c>CblockInfoOffsetByUblockIdxCompressed</c> (u2c) entries: <c>(m_numUBlocks + 8) >> 3</c>.
|
||||
/// Each entry covers a group of 8 uncompressed blocks (a uint24 base plus 7 per-ublock deltas); the
|
||||
/// <c>+8</c> (rather than <c>+7</c>) adds a trailing group whenever the block count is a multiple of 8.
|
||||
/// </summary>
|
||||
public int NumU2cEntries => ((NumUBlocks + 8) >> 3);
|
||||
|
||||
/// <summary>
|
||||
/// Number of 6-byte <c>UncompressedOffsetStartByFileIdx</c> (fidx) entries. There is one entry per
|
||||
/// file plus one trailing sentinel (the total uncompressed size), i.e. <c>m_numFilesMinus1 + 2</c>
|
||||
/// which equals <see cref="NumFiles"/> + 1.
|
||||
/// </summary>
|
||||
public int NumFileOffsetEntries => NumFiles + 1;
|
||||
}
|
||||
|
||||
/// <summary>Byte offset and size of one NAPS section inside the layout blob.</summary>
|
||||
/// <param name="Offset">Offset from the start of the blob.</param>
|
||||
/// <param name="Size">Section size in bytes.</param>
|
||||
/// <param name="Stride">Per-entry stride in bytes.</param>
|
||||
/// <param name="Count">Number of entries.</param>
|
||||
public readonly record struct NapsSection(long Offset, long Size, int Stride, int Count);
|
||||
|
||||
/// <summary>Map of all NAPS sections in their on-disk order.</summary>
|
||||
/// <param name="Header">The 16-byte <c>[PackageLayout_NAPS]</c> header.</param>
|
||||
/// <param name="OuterBlockDigest">8-byte outer-block entries.</param>
|
||||
/// <param name="ShufflePattern">8-byte shuffle-pattern entries.</param>
|
||||
/// <param name="UncompressedOffsetStartByFileIdx">6-byte per-file uncompressed-offset entries.</param>
|
||||
/// <param name="CblockInfoOffsetByUblockIdxCompressed">10-byte u2c entries.</param>
|
||||
/// <param name="CblockInfo">9-byte CblockInfo entries.</param>
|
||||
/// <param name="TotalSize">Total layout size (end of the last section).</param>
|
||||
public readonly record struct NapsSectionMap(
|
||||
NapsSection Header,
|
||||
NapsSection OuterBlockDigest,
|
||||
NapsSection ShufflePattern,
|
||||
NapsSection UncompressedOffsetStartByFileIdx,
|
||||
NapsSection CblockInfoOffsetByUblockIdxCompressed,
|
||||
NapsSection CblockInfo,
|
||||
long TotalSize);
|
||||
|
||||
/// <summary>
|
||||
/// A 6-byte <c>UncompressedOffsetStartByFileIdx</c> (fidx) entry: a 40-bit little-endian
|
||||
/// uncompressed start offset followed by a 1-byte <c>type</c> (validated layout
|
||||
/// <c>offset[0..5) LE + type[5]</c>). The last real entry carries the total inner logical size with
|
||||
/// <c>type=0x40</c>; a fixed trailer sentinel follows the per-file entries.
|
||||
/// </summary>
|
||||
/// <param name="Type">The per-file <c>type</c> byte (byte 5 of the entry).</param>
|
||||
/// <param name="UncompressedOffsetStart">40-bit little-endian uncompressed start offset (bytes 0..4).</param>
|
||||
public readonly record struct NapsFileOffsetEntry(byte Type, ulong UncompressedOffsetStart);
|
||||
|
||||
/// <summary>
|
||||
/// A 10-byte <c>CblockInfoOffsetByUblockIdxCompressed</c> (u2c) entry. Layout matches the
|
||||
/// dump format <c>u2c[..] : m_infoOffset9BBase=%06lx, m_deltaFromBase=%02x ×7</c>
|
||||
/// (uint24 base + seven delta bytes). Each entry covers 8 uncompressed blocks; the per-ublock
|
||||
/// <c>start_cblockinfo_index</c> is <see cref="StartCblockInfoIndex"/>.
|
||||
/// </summary>
|
||||
/// <param name="InfoOffset9BBase">uint24 base index into the CblockInfo section.</param>
|
||||
/// <param name="DeltaFromBase">Seven per-ublock deltas added to <paramref name="InfoOffset9BBase"/>.</param>
|
||||
public readonly record struct NapsU2cEntry(uint InfoOffset9BBase, byte[] DeltaFromBase)
|
||||
{
|
||||
/// <summary>
|
||||
/// Derived <c>start_cblockinfo_index</c> for each of the 8 uncompressed blocks this entry covers.
|
||||
/// Index 0 is the base itself; indices 1..7 add the matching delta byte.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> StartCblockInfoIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var result = new uint[8];
|
||||
result[0] = InfoOffset9BBase;
|
||||
for (int i = 0; i < 7 && i < DeltaFromBase.Length; i++)
|
||||
result[i + 1] = InfoOffset9BBase + DeltaFromBase[i];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 9-byte <c>CblockInfo</c> entry. The two record formats and every field name come from
|
||||
/// the dump format strings; the exact bit OFFSETS are the modeled layout (see file header).
|
||||
/// </summary>
|
||||
public readonly record struct NapsCblockInfoEntry
|
||||
{
|
||||
/// <summary>Raw 9 bytes of the record, always preserved verbatim.</summary>
|
||||
public byte[] Raw { get; init; }
|
||||
|
||||
/// <summary>True for the run-base format, false for the per-block format.</summary>
|
||||
public bool IsRunBase { get; init; }
|
||||
|
||||
// Non-run-base fields (valid when IsRunBase is false).
|
||||
|
||||
/// <summary><c>m_coffsetStartMod256K</c> (18 bits). Model bit-offset.</summary>
|
||||
public uint CoffsetStartMod256K { get; init; }
|
||||
|
||||
/// <summary><c>m_uoffsetStart</c> (18 bits). Model bit-offset.</summary>
|
||||
public uint UoffsetStart { get; init; }
|
||||
|
||||
/// <summary><c>m_clenEvenMinus1</c> (17 bits). Model bit-offset.</summary>
|
||||
public uint ClenEvenMinus1 { get; init; }
|
||||
|
||||
/// <summary><c>m_even</c> flag. Model bit-offset.</summary>
|
||||
public byte Even { get; init; }
|
||||
|
||||
/// <summary><c>m_odd</c> flag. Model bit-offset.</summary>
|
||||
public byte Odd { get; init; }
|
||||
|
||||
/// <summary><c>m_KdePredictor</c> (3 bits). Model bit-offset.</summary>
|
||||
public byte KdePredictor { get; init; }
|
||||
|
||||
/// <summary><c>m_shuffleIdx</c> (4 bits). Model bit-offset.</summary>
|
||||
public byte ShuffleIdx { get; init; }
|
||||
|
||||
// Run-base fields (valid when IsRunBase is true).
|
||||
|
||||
/// <summary><c>m_coffsetEndMod256K</c> (18 bits). Model bit-offset.</summary>
|
||||
public uint CoffsetEndMod256K { get; init; }
|
||||
|
||||
/// <summary><c>m_tweakIdxStart</c> (up to 28 bits). Model bit-offset.</summary>
|
||||
public uint TweakIdxStart { get; init; }
|
||||
|
||||
/// <summary><c>m_keyTableIdx</c>. Model bit-offset.</summary>
|
||||
public byte KeyTableIdx { get; init; }
|
||||
|
||||
/// <summary><c>m_coffsetStart256K</c> (24 bits). Model bit-offset.</summary>
|
||||
public uint CoffsetStart256K { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A fully parsed <c>naps_pkg_layout.dat</c> blob (raw section slices + decoded entries).</summary>
|
||||
public sealed class NapsLayoutDocument
|
||||
{
|
||||
/// <summary>Section counts.</summary>
|
||||
public required NapsLayoutCounts Counts { get; init; }
|
||||
|
||||
/// <summary>Section offset/size map.</summary>
|
||||
public required NapsSectionMap Map { get; init; }
|
||||
|
||||
/// <summary>Raw 8-byte outer-block entries (values are key-gated; preserved verbatim).</summary>
|
||||
public required IReadOnlyList<byte[]> OuterBlockDigests { get; init; }
|
||||
|
||||
/// <summary>Raw 8-byte shuffle-pattern entries.</summary>
|
||||
public required IReadOnlyList<byte[]> ShufflePatterns { get; init; }
|
||||
|
||||
/// <summary>Decoded 6-byte per-file uncompressed-offset entries.</summary>
|
||||
public required IReadOnlyList<NapsFileOffsetEntry> FileOffsets { get; init; }
|
||||
|
||||
/// <summary>Decoded 10-byte u2c entries.</summary>
|
||||
public required IReadOnlyList<NapsU2cEntry> CblockInfoOffsetByUblock { get; init; }
|
||||
|
||||
/// <summary>Decoded 9-byte CblockInfo entries.</summary>
|
||||
public required IReadOnlyList<NapsCblockInfoEntry> CblockInfos { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the PS5 <c>naps_pkg_layout.dat</c> (<c>PackageLayout_NAPS</c>) structure.
|
||||
/// Decodes existing bytes; never produces or fabricates the key-gated streaming values. See the file
|
||||
/// header for the exact confirmed-vs-model boundary.
|
||||
/// </summary>
|
||||
public static class ProsperoNapsLayout
|
||||
{
|
||||
/// <summary>On-disk file name of the NAPS layout structure.</summary>
|
||||
public const string FileName = "naps_pkg_layout.dat";
|
||||
|
||||
/// <summary>Size of the <c>[PackageLayout_NAPS]</c> header (two packed 64-bit words).</summary>
|
||||
public const int HeaderSize = 16;
|
||||
|
||||
/// <summary>Stride of an <c>OuterBlockDigest</c> entry (validated: <c>(=8*%lld)</c>).</summary>
|
||||
public const int OuterBlockDigestStride = 8;
|
||||
|
||||
/// <summary>Stride of a <c>ShufflePattern</c> entry (validated: <c>(=8*%lld)</c>).</summary>
|
||||
public const int ShufflePatternStride = 8;
|
||||
|
||||
/// <summary>Stride of an <c>UncompressedOffsetStartByFileIdx</c> entry (validated: <c>(=6*%lld)</c>).</summary>
|
||||
public const int FileOffsetStride = 6;
|
||||
|
||||
/// <summary>Stride of a <c>CblockInfoOffsetByUblockIdxCompressed</c> entry (validated: <c>(=10*%lld)</c>).</summary>
|
||||
public const int U2cStride = 10;
|
||||
|
||||
/// <summary>Stride of a <c>CblockInfo</c> entry (validated: <c>(=9*%lld)</c>).</summary>
|
||||
public const int CblockInfoStride = 9;
|
||||
|
||||
// ---- Header (MODEL packing) ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Decode the 16-byte header into section counts. The field names come from the
|
||||
/// dump-format strings (<c>m_numFilesMinus1</c>, <c>m_numKeysMinus1</c>,
|
||||
/// <c>m_numShufflePatterns</c>, <c>m_numUBlocks</c>, <c>m_numOuterBlocks</c>,
|
||||
/// <c>m_numCblockInfoMinus2</c>): <c>NumFiles=7, compType=2, Keys=1, Shuffle=0, UBlocks=19,
|
||||
/// OuterBlocks=5, CblockInfo=45</c> all decode and re-encode to the exact header bytes.
|
||||
/// </summary>
|
||||
public static NapsLayoutCounts DecodeHeader(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < HeaderSize)
|
||||
throw new ArgumentException($"NAPS header needs {HeaderSize} bytes.", nameof(header));
|
||||
|
||||
ulong word0 = BinaryPrimitives.ReadUInt64LittleEndian(header[..8]);
|
||||
ulong word1 = BinaryPrimitives.ReadUInt64LittleEndian(header.Slice(8, 8));
|
||||
|
||||
int numFilesMinus1 = (int)(word0 & 0xFFFFFF);
|
||||
byte compressionType = (byte)((word0 >> 24) & 0x3);
|
||||
int numKeysMinus1 = (int)((word0 >> 26) & 0x3);
|
||||
int numShufflePatterns = (int)((word0 >> 28) & 0xF);
|
||||
int numUBlocks = (int)((word0 >> 32) & 0xFFFFFF);
|
||||
|
||||
int numOuterBlocks = (int)(word1 & 0xFFFFFF);
|
||||
int numCblockInfoMinus2 = (int)((word1 >> 24) & 0xFFFFFF);
|
||||
|
||||
return new NapsLayoutCounts(
|
||||
NumFiles: numFilesMinus1 + 1,
|
||||
CompressionType: compressionType,
|
||||
NumKeys: numKeysMinus1 + 1,
|
||||
NumShufflePatterns: numShufflePatterns,
|
||||
NumUBlocks: numUBlocks,
|
||||
NumOuterBlocks: numOuterBlocks,
|
||||
NumCblockInfo: numCblockInfoMinus2 + 2);
|
||||
}
|
||||
|
||||
/// <summary>Encode section counts back into a 16-byte header (inverse of <see cref="DecodeHeader"/>).</summary>
|
||||
public static byte[] EncodeHeader(NapsLayoutCounts counts)
|
||||
{
|
||||
ulong word0 =
|
||||
((ulong)(uint)(counts.NumFiles - 1) & 0xFFFFFF)
|
||||
| ((ulong)(counts.CompressionType & 0x3) << 24)
|
||||
| ((ulong)(uint)((counts.NumKeys - 1) & 0x3) << 26)
|
||||
| ((ulong)(uint)(counts.NumShufflePatterns & 0xF) << 28)
|
||||
| ((ulong)(uint)(counts.NumUBlocks & 0xFFFFFF) << 32);
|
||||
|
||||
ulong word1 =
|
||||
((ulong)(uint)(counts.NumOuterBlocks & 0xFFFFFF))
|
||||
| ((ulong)(uint)((counts.NumCblockInfo - 2) & 0xFFFFFF) << 24);
|
||||
|
||||
var header = new byte[HeaderSize];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(0, 8), word0);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(8, 8), word1);
|
||||
return header;
|
||||
}
|
||||
|
||||
// ---- Section map (fixed strides) -----------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Compute the offset/size of every section from the counts using the fixed strides and the
|
||||
/// fixed on-disk order. This split is exact given correct counts.
|
||||
/// </summary>
|
||||
public static NapsSectionMap SectionMap(NapsLayoutCounts counts)
|
||||
=> SectionMap(counts, counts.NumFileOffsetEntries);
|
||||
|
||||
/// <summary>
|
||||
/// Section map with an EXPLICIT fidx (UncompressedOffsetStartByFileIdx) entry count. The fidx count is
|
||||
/// NOT a fixed function of <see cref="NapsLayoutCounts.NumFiles"/> — it also carries per-padding/metadata
|
||||
/// pseudo-entries emitted by the compressor's addData/flush, so it varies per package (DebugSettings = 9,
|
||||
/// Downloads = 7). Callers that know the real count (parse: derived from the blob length; build: the
|
||||
/// document's FileOffsets count) pass it here; the fixed-count overload keeps the legacy NumFiles+1 default.
|
||||
/// </summary>
|
||||
public static NapsSectionMap SectionMap(NapsLayoutCounts counts, int fidxEntryCount)
|
||||
{
|
||||
long pos = 0;
|
||||
var header = new NapsSection(pos, HeaderSize, HeaderSize, 1);
|
||||
pos += HeaderSize;
|
||||
|
||||
var ob = new NapsSection(pos, (long)counts.NumOuterBlocks * OuterBlockDigestStride, OuterBlockDigestStride, counts.NumOuterBlocks);
|
||||
pos += ob.Size;
|
||||
|
||||
var sp = new NapsSection(pos, (long)counts.NumShufflePatterns * ShufflePatternStride, ShufflePatternStride, counts.NumShufflePatterns);
|
||||
pos += sp.Size;
|
||||
|
||||
var fidx = new NapsSection(pos, (long)fidxEntryCount * FileOffsetStride, FileOffsetStride, fidxEntryCount);
|
||||
pos += fidx.Size;
|
||||
|
||||
int u2cCount = counts.NumU2cEntries;
|
||||
var u2c = new NapsSection(pos, (long)u2cCount * U2cStride, U2cStride, u2cCount);
|
||||
pos += u2c.Size;
|
||||
|
||||
var cbi = new NapsSection(pos, (long)counts.NumCblockInfo * CblockInfoStride, CblockInfoStride, counts.NumCblockInfo);
|
||||
pos += cbi.Size;
|
||||
|
||||
return new NapsSectionMap(header, ob, sp, fidx, u2c, cbi, pos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the real fidx entry count from an exact-size NAPS blob. CblockInfo is the last section (no
|
||||
/// trailer), so its start = blobLength − NumCblockInfo·9; the fidx count is what remains after the
|
||||
/// fixed header/outer-block/shuffle/u2c sections. Returns <c>NumFiles+1</c> as a fallback when the blob is
|
||||
/// padded or the arithmetic is not exact.
|
||||
/// </summary>
|
||||
public static int DeriveFidxEntryCount(NapsLayoutCounts counts, long blobLength)
|
||||
{
|
||||
long fixedBefore = HeaderSize
|
||||
+ (long)counts.NumOuterBlocks * OuterBlockDigestStride
|
||||
+ (long)counts.NumShufflePatterns * ShufflePatternStride
|
||||
+ (long)counts.NumU2cEntries * U2cStride;
|
||||
long cblockStart = blobLength - (long)counts.NumCblockInfo * CblockInfoStride;
|
||||
long fidxBytes = cblockStart - fixedBefore;
|
||||
if (fidxBytes >= 0 && fidxBytes % FileOffsetStride == 0)
|
||||
{
|
||||
int derived = (int)(fidxBytes / FileOffsetStride);
|
||||
if (derived >= 1)
|
||||
return derived;
|
||||
}
|
||||
return counts.NumFileOffsetEntries;
|
||||
}
|
||||
|
||||
// ---- Entry decoders ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Decode a 6-byte fidx entry (validated layout: 40-bit LE offset + 1 type byte).</summary>
|
||||
public static NapsFileOffsetEntry DecodeFileOffsetEntry(ReadOnlySpan<byte> entry)
|
||||
{
|
||||
if (entry.Length < FileOffsetStride)
|
||||
throw new ArgumentException($"fidx entry needs {FileOffsetStride} bytes.", nameof(entry));
|
||||
|
||||
ulong offset = 0;
|
||||
for (int i = 0; i < 5; i++)
|
||||
offset |= (ulong)entry[i] << (8 * i);
|
||||
byte type = entry[5];
|
||||
return new NapsFileOffsetEntry(type, offset);
|
||||
}
|
||||
|
||||
/// <summary>Encode a 6-byte fidx entry (inverse of <see cref="DecodeFileOffsetEntry"/>).</summary>
|
||||
public static byte[] EncodeFileOffsetEntry(NapsFileOffsetEntry entry)
|
||||
{
|
||||
var buffer = new byte[FileOffsetStride];
|
||||
ulong offset = entry.UncompressedOffsetStart;
|
||||
for (int i = 0; i < 5; i++)
|
||||
buffer[i] = (byte)(offset >> (8 * i));
|
||||
buffer[5] = entry.Type;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Decode a 10-byte u2c entry (validated layout: uint24 base + 7 delta bytes).</summary>
|
||||
public static NapsU2cEntry DecodeU2cEntry(ReadOnlySpan<byte> entry)
|
||||
{
|
||||
if (entry.Length < U2cStride)
|
||||
throw new ArgumentException($"u2c entry needs {U2cStride} bytes.", nameof(entry));
|
||||
|
||||
uint baseOffset = (uint)(entry[0] | (entry[1] << 8) | (entry[2] << 16));
|
||||
var deltas = new byte[7];
|
||||
entry.Slice(3, 7).CopyTo(deltas);
|
||||
return new NapsU2cEntry(baseOffset, deltas);
|
||||
}
|
||||
|
||||
/// <summary>Encode a 10-byte u2c entry (inverse of <see cref="DecodeU2cEntry"/>).</summary>
|
||||
public static byte[] EncodeU2cEntry(NapsU2cEntry entry)
|
||||
{
|
||||
var buffer = new byte[U2cStride];
|
||||
buffer[0] = (byte)entry.InfoOffset9BBase;
|
||||
buffer[1] = (byte)(entry.InfoOffset9BBase >> 8);
|
||||
buffer[2] = (byte)(entry.InfoOffset9BBase >> 16);
|
||||
for (int i = 0; i < 7 && i < entry.DeltaFromBase.Length; i++)
|
||||
buffer[3 + i] = entry.DeltaFromBase[i];
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 9-byte CblockInfo entry. The round-trip re-encodes every CblockInfo record of a
|
||||
/// <c>naps_pkg_layout.dat</c> exactly: the discriminator
|
||||
/// (<c>m_isRunBase</c> at bit 18), the field set, and all bit offsets re-encode to the exact
|
||||
/// on-disk bytes. Field widths match the dump-format hints. The raw bytes
|
||||
/// are always preserved on <see cref="NapsCblockInfoEntry.Raw"/>.
|
||||
/// </summary>
|
||||
public static NapsCblockInfoEntry DecodeCblockInfoEntry(ReadOnlySpan<byte> entry)
|
||||
{
|
||||
if (entry.Length < CblockInfoStride)
|
||||
throw new ArgumentException($"cblockinfo entry needs {CblockInfoStride} bytes.", nameof(entry));
|
||||
|
||||
var raw = new byte[CblockInfoStride];
|
||||
entry[..CblockInfoStride].CopyTo(raw);
|
||||
|
||||
// Read the 72-bit record as a little-endian value.
|
||||
ulong lo = 0;
|
||||
for (int i = 0; i < 8; i++)
|
||||
lo |= (ulong)raw[i] << (8 * i);
|
||||
ulong hi = raw[8];
|
||||
|
||||
bool isRunBase = ((lo >> 18) & 1) != 0;
|
||||
uint coffsetMod256K = (uint)(lo & 0x3FFFF);
|
||||
|
||||
if (!isRunBase)
|
||||
{
|
||||
return new NapsCblockInfoEntry
|
||||
{
|
||||
Raw = raw,
|
||||
IsRunBase = false,
|
||||
CoffsetStartMod256K = coffsetMod256K,
|
||||
UoffsetStart = (uint)((lo >> 19) & 0x3FFFF),
|
||||
ClenEvenMinus1 = (uint)((lo >> 37) & 0x1FFFF),
|
||||
Even = (byte)((lo >> 54) & 0x1),
|
||||
Odd = (byte)((lo >> 55) & 0x1),
|
||||
KdePredictor = (byte)((lo >> 56) & 0x7),
|
||||
ShuffleIdx = (byte)((lo >> 59) & 0xF),
|
||||
};
|
||||
}
|
||||
|
||||
return new NapsCblockInfoEntry
|
||||
{
|
||||
Raw = raw,
|
||||
IsRunBase = true,
|
||||
CoffsetEndMod256K = coffsetMod256K,
|
||||
TweakIdxStart = (uint)((lo >> 19) & 0xFFFFFFF),
|
||||
KeyTableIdx = (byte)((lo >> 47) & 0x3),
|
||||
CoffsetStart256K = (uint)(((lo >> 49) & 0x7FFF) | ((hi & 0x1FF) << 15)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Top-level parse --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parse a full <c>naps_pkg_layout.dat</c> blob using counts decoded from its header. The header
|
||||
/// packing and every section decoder round-trip losslessly.
|
||||
/// </summary>
|
||||
public static NapsLayoutDocument Parse(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
NapsLayoutCounts counts = DecodeHeader(blob);
|
||||
// The fidx count is package-dependent (padding/metadata pseudo-entries), so derive it from the exact
|
||||
// blob length rather than assuming NumFiles+1 (which only holds for some packages, e.g. DebugSettings).
|
||||
int fidxCount = DeriveFidxEntryCount(counts, blob.Length);
|
||||
return Parse(blob, counts, fidxCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a full <c>naps_pkg_layout.dat</c> blob using explicitly supplied counts (e.g. from the
|
||||
/// supplied metric data). The section split uses only the fixed strides, so it is exact.
|
||||
/// </summary>
|
||||
public static NapsLayoutDocument Parse(ReadOnlySpan<byte> blob, NapsLayoutCounts counts)
|
||||
=> Parse(blob, counts, counts.NumFileOffsetEntries);
|
||||
|
||||
/// <summary>
|
||||
/// Parse with an explicit fidx entry count (see <see cref="SectionMap(NapsLayoutCounts, int)"/>).
|
||||
/// </summary>
|
||||
public static NapsLayoutDocument Parse(ReadOnlySpan<byte> blob, NapsLayoutCounts counts, int fidxEntryCount)
|
||||
{
|
||||
NapsSectionMap map = SectionMap(counts, fidxEntryCount);
|
||||
if (blob.Length < map.TotalSize)
|
||||
throw new ArgumentException(
|
||||
$"NAPS blob is {blob.Length} bytes but the counts require {map.TotalSize}.", nameof(blob));
|
||||
|
||||
var outerBlocks = SliceRaw(blob, map.OuterBlockDigest);
|
||||
var shuffles = SliceRaw(blob, map.ShufflePattern);
|
||||
|
||||
var fileOffsets = new List<NapsFileOffsetEntry>(map.UncompressedOffsetStartByFileIdx.Count);
|
||||
for (int i = 0; i < map.UncompressedOffsetStartByFileIdx.Count; i++)
|
||||
fileOffsets.Add(DecodeFileOffsetEntry(EntrySpan(blob, map.UncompressedOffsetStartByFileIdx, i)));
|
||||
|
||||
var u2c = new List<NapsU2cEntry>(map.CblockInfoOffsetByUblockIdxCompressed.Count);
|
||||
for (int i = 0; i < map.CblockInfoOffsetByUblockIdxCompressed.Count; i++)
|
||||
u2c.Add(DecodeU2cEntry(EntrySpan(blob, map.CblockInfoOffsetByUblockIdxCompressed, i)));
|
||||
|
||||
var cblockInfos = new List<NapsCblockInfoEntry>(map.CblockInfo.Count);
|
||||
for (int i = 0; i < map.CblockInfo.Count; i++)
|
||||
cblockInfos.Add(DecodeCblockInfoEntry(EntrySpan(blob, map.CblockInfo, i)));
|
||||
|
||||
return new NapsLayoutDocument
|
||||
{
|
||||
Counts = counts,
|
||||
Map = map,
|
||||
OuterBlockDigests = outerBlocks,
|
||||
ShufflePatterns = shuffles,
|
||||
FileOffsets = fileOffsets,
|
||||
CblockInfoOffsetByUblock = u2c,
|
||||
CblockInfos = cblockInfos,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Top-level serializer ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Default zero-pad alignment of the serialized blob. A <c>naps_pkg_layout.dat</c>
|
||||
/// has 533 bytes of section content padded up to 544 (a multiple of
|
||||
/// both 16 and 32); 16 is used as the default. Pass <c>1</c> to emit
|
||||
/// the unpadded section content only.
|
||||
/// </summary>
|
||||
public const int DefaultAlignment = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a <see cref="NapsLayoutDocument"/> back into a <c>naps_pkg_layout.dat</c> blob.
|
||||
/// This is the inverse of <see cref="Parse(ReadOnlySpan{byte}, NapsLayoutCounts)"/>:
|
||||
/// <c>BuildLayout(Parse(x)) == x</c> for every byte, including the trailing zero pad.
|
||||
/// The section content is emitted in the fixed on-disk order
|
||||
/// (header, outer-block digests, shuffle patterns, fidx, u2c, CblockInfo) using the
|
||||
/// per-field encoders, then zero-padded to <paramref name="alignment"/>.
|
||||
/// </summary>
|
||||
/// <param name="document">The layout to serialize. Its <see cref="NapsLayoutDocument.Counts"/> must
|
||||
/// agree with the lengths of its section lists.</param>
|
||||
/// <param name="alignment">Byte alignment for the trailing zero pad (default <see cref="DefaultAlignment"/>).
|
||||
/// Values <= 1 emit the unpadded content.</param>
|
||||
/// <returns>The serialized blob.</returns>
|
||||
public static byte[] BuildLayout(NapsLayoutDocument document, int alignment = DefaultAlignment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
NapsLayoutCounts counts = document.Counts;
|
||||
|
||||
if (document.OuterBlockDigests.Count != counts.NumOuterBlocks)
|
||||
throw new ArgumentException($"OuterBlockDigests count {document.OuterBlockDigests.Count} != NumOuterBlocks {counts.NumOuterBlocks}.", nameof(document));
|
||||
if (document.ShufflePatterns.Count != counts.NumShufflePatterns)
|
||||
throw new ArgumentException($"ShufflePatterns count {document.ShufflePatterns.Count} != NumShufflePatterns {counts.NumShufflePatterns}.", nameof(document));
|
||||
if (document.FileOffsets.Count < 1)
|
||||
throw new ArgumentException("FileOffsets must have at least one entry.", nameof(document));
|
||||
if (document.CblockInfoOffsetByUblock.Count != counts.NumU2cEntries)
|
||||
throw new ArgumentException($"u2c count {document.CblockInfoOffsetByUblock.Count} != NumU2cEntries {counts.NumU2cEntries}.", nameof(document));
|
||||
if (document.CblockInfos.Count != counts.NumCblockInfo)
|
||||
throw new ArgumentException($"CblockInfos count {document.CblockInfos.Count} != NumCblockInfo {counts.NumCblockInfo}.", nameof(document));
|
||||
|
||||
// The fidx count is package-dependent (padding/metadata pseudo-entries), so lay the sections out for the
|
||||
// document's ACTUAL FileOffsets count rather than the fixed NumFiles+1.
|
||||
NapsSectionMap map = SectionMap(counts, document.FileOffsets.Count);
|
||||
long content = map.TotalSize;
|
||||
long total = alignment > 1 ? (content + alignment - 1) / alignment * alignment : content;
|
||||
|
||||
var blob = new byte[total];
|
||||
var span = blob.AsSpan();
|
||||
|
||||
EncodeHeader(counts).CopyTo(span);
|
||||
int pos = HeaderSize;
|
||||
|
||||
foreach (byte[] digest in document.OuterBlockDigests)
|
||||
{
|
||||
if (digest.Length != OuterBlockDigestStride)
|
||||
throw new ArgumentException($"outer-block digest must be {OuterBlockDigestStride} bytes.", nameof(document));
|
||||
digest.CopyTo(span.Slice(pos, OuterBlockDigestStride));
|
||||
pos += OuterBlockDigestStride;
|
||||
}
|
||||
|
||||
foreach (byte[] shuffle in document.ShufflePatterns)
|
||||
{
|
||||
if (shuffle.Length != ShufflePatternStride)
|
||||
throw new ArgumentException($"shuffle pattern must be {ShufflePatternStride} bytes.", nameof(document));
|
||||
shuffle.CopyTo(span.Slice(pos, ShufflePatternStride));
|
||||
pos += ShufflePatternStride;
|
||||
}
|
||||
|
||||
foreach (NapsFileOffsetEntry entry in document.FileOffsets)
|
||||
{
|
||||
EncodeFileOffsetEntry(entry).CopyTo(span.Slice(pos, FileOffsetStride));
|
||||
pos += FileOffsetStride;
|
||||
}
|
||||
|
||||
foreach (NapsU2cEntry entry in document.CblockInfoOffsetByUblock)
|
||||
{
|
||||
EncodeU2cEntry(entry).CopyTo(span.Slice(pos, U2cStride));
|
||||
pos += U2cStride;
|
||||
}
|
||||
|
||||
foreach (NapsCblockInfoEntry entry in document.CblockInfos)
|
||||
{
|
||||
EncodeCblockInfoEntry(entry).CopyTo(span.Slice(pos, CblockInfoStride));
|
||||
pos += CblockInfoStride;
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> EntrySpan(ReadOnlySpan<byte> blob, NapsSection section, int index)
|
||||
=> blob.Slice((int)section.Offset + index * section.Stride, section.Stride);
|
||||
|
||||
private static List<byte[]> SliceRaw(ReadOnlySpan<byte> blob, NapsSection section)
|
||||
{
|
||||
var list = new List<byte[]>(section.Count);
|
||||
for (int i = 0; i < section.Count; i++)
|
||||
{
|
||||
var entry = new byte[section.Stride];
|
||||
blob.Slice((int)section.Offset + i * section.Stride, section.Stride).CopyTo(entry);
|
||||
list.Add(entry);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a CblockInfo entry from the model fields (inverse of <see cref="DecodeCblockInfoEntry"/>).
|
||||
/// The round-trip re-encodes every CblockInfo record exactly.
|
||||
/// </summary>
|
||||
public static byte[] EncodeCblockInfoEntry(NapsCblockInfoEntry entry)
|
||||
{
|
||||
ulong lo = (entry.IsRunBase ? entry.CoffsetEndMod256K : entry.CoffsetStartMod256K) & 0x3FFFF;
|
||||
ulong hi;
|
||||
if (!entry.IsRunBase)
|
||||
{
|
||||
lo |= (ulong)(entry.UoffsetStart & 0x3FFFF) << 19;
|
||||
lo |= (ulong)(entry.ClenEvenMinus1 & 0x1FFFF) << 37;
|
||||
lo |= (ulong)(entry.Even & 0x1) << 54;
|
||||
lo |= (ulong)(entry.Odd & 0x1) << 55;
|
||||
lo |= (ulong)(entry.KdePredictor & 0x7) << 56;
|
||||
lo |= (ulong)(entry.ShuffleIdx & 0xF) << 59;
|
||||
hi = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
lo |= 1UL << 18; // m_isRunBase
|
||||
lo |= (ulong)(entry.TweakIdxStart & 0xFFFFFFF) << 19;
|
||||
lo |= (ulong)(entry.KeyTableIdx & 0x3) << 47;
|
||||
lo |= (ulong)(entry.CoffsetStart256K & 0x7FFF) << 49;
|
||||
hi = (entry.CoffsetStart256K >> 15) & 0x1FF;
|
||||
}
|
||||
|
||||
var buffer = new byte[CblockInfoStride];
|
||||
for (int i = 0; i < 8; i++)
|
||||
buffer[i] = (byte)(lo >> (8 * i));
|
||||
buffer[8] = (byte)hi;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<AssemblyName>Packwright.Pkg</AssemblyName>
|
||||
<RootNamespace>LibProsperoPkg</RootNamespace>
|
||||
<Description>PS5 package reader. Portions based on LibProsperoPKG by SvenGDK (GPL-3.0-or-later).</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Packwright.SmokeTests")]
|
||||
@@ -0,0 +1,113 @@
|
||||
// Packwright.Pkg - file tree of the inner PFS image of a PS5 package.
|
||||
//
|
||||
// The PS5 inner inode layout (size at 0x08, logical offset at 0x60) follows LibProsperoPKG by SvenGDK
|
||||
// (GPL-3.0-or-later), see ProsperoPs5InnerImageReader.ReadFileTree. This version reads only the
|
||||
// metadata blocks it needs from a streaming image instead of an in-memory copy.
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using LibProsperoPkg.PFS;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
/// <summary>A regular file inside a package's inner image.</summary>
|
||||
public sealed record PkgFile(string Path, long Size, long LogicalOffset)
|
||||
{
|
||||
internal int Inode { get; init; }
|
||||
}
|
||||
|
||||
internal static class InnerFileTree
|
||||
{
|
||||
private const int InodeSize = 0xA8;
|
||||
|
||||
public static IReadOnlyList<PkgFile> Read(Stream mount, long superblockOffset) => ReadAll(mount, superblockOffset).Files;
|
||||
|
||||
/// <summary>The files and every directory (including empty ones) below the user root.</summary>
|
||||
public static (IReadOnlyList<PkgFile> Files, IReadOnlyList<string> Directories) ReadAll(Stream mount, long superblockOffset)
|
||||
{
|
||||
byte[] sb = new byte[0x40];
|
||||
ReadAt(mount, superblockOffset, sb);
|
||||
if (BinaryPrimitives.ReadInt64LittleEndian(sb) != 2 || BinaryPrimitives.ReadInt64LittleEndian(sb.AsSpan(8)) != 20130315)
|
||||
throw new InvalidDataException("The inner image does not start with a PFS superblock.");
|
||||
|
||||
int blockSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(sb.AsSpan(0x20));
|
||||
long inodeCount = BinaryPrimitives.ReadInt64LittleEndian(sb.AsSpan(0x30));
|
||||
if (blockSize <= 0 || inodeCount <= 0 || inodeCount > 1_000_000)
|
||||
throw new InvalidDataException("The inner image has an implausible superblock (block size or inode count).");
|
||||
|
||||
// Inode records never straddle a block: each block holds blockSize / 0xA8 of them, then padding.
|
||||
int perBlock = blockSize / InodeSize;
|
||||
long tableBlocks = (inodeCount + perBlock - 1) / perBlock;
|
||||
byte[] table = new byte[tableBlocks * blockSize];
|
||||
ReadAt(mount, superblockOffset + blockSize, table);
|
||||
var nodes = new Inode[inodeCount];
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> e = table.AsSpan((i / perBlock) * blockSize + (i % perBlock) * InodeSize, InodeSize);
|
||||
nodes[i] = new Inode(BinaryPrimitives.ReadUInt16LittleEndian(e),
|
||||
BinaryPrimitives.ReadInt64LittleEndian(e[8..]), BinaryPrimitives.ReadInt64LittleEndian(e[0x60..]));
|
||||
}
|
||||
|
||||
var files = new List<PkgFile>();
|
||||
var directories = new List<string>();
|
||||
Walk(mount, nodes, 0, string.Empty, false, files, directories, new HashSet<uint>());
|
||||
return (files, directories);
|
||||
}
|
||||
|
||||
private readonly record struct Inode(ushort Mode, long Size, long LogicalOffset)
|
||||
{
|
||||
public bool IsDirectory => (Mode & 0x4000) != 0;
|
||||
}
|
||||
|
||||
// Walks from the super-root; only content at or below "uroot" is real package content.
|
||||
private static void Walk(Stream mount, Inode[] nodes, uint dirInode, string path, bool underUroot,
|
||||
List<PkgFile> files, List<string> directories, HashSet<uint> seen)
|
||||
{
|
||||
if (dirInode >= nodes.Length || !seen.Add(dirInode)) return;
|
||||
Inode dir = nodes[dirInode];
|
||||
if (!dir.IsDirectory || dir.Size <= 0 || dir.Size > 64 * 1024 * 1024) return;
|
||||
|
||||
byte[] listing = new byte[dir.Size];
|
||||
ReadAt(mount, dir.LogicalOffset, listing);
|
||||
using var ms = new MemoryStream(listing, writable: false);
|
||||
while (ms.Position + 0x10 <= ms.Length)
|
||||
{
|
||||
long entryPos = ms.Position;
|
||||
ProsperoPfsDirent d;
|
||||
try { d = ProsperoPfsDirent.ReadFromStream(ms); }
|
||||
catch (Exception) { break; }
|
||||
if (d.EntSize <= 0) break;
|
||||
ms.Position = entryPos + d.EntSize;
|
||||
|
||||
string name = d.Name;
|
||||
if (string.IsNullOrEmpty(name) || name == "." || name == ".." || d.InodeNumber >= nodes.Length) continue;
|
||||
|
||||
bool childUnderUroot = underUroot || name == "uroot";
|
||||
string childPath = path.Length == 0 ? (name == "uroot" ? string.Empty : name) : path + "/" + name;
|
||||
if (d.Type == ProsperoDirentType.Directory)
|
||||
{
|
||||
if (childUnderUroot && childPath.Length > 0) directories.Add(childPath);
|
||||
Walk(mount, nodes, d.InodeNumber, childPath, childUnderUroot, files, directories, seen);
|
||||
}
|
||||
else if (d.Type == ProsperoDirentType.File && underUroot && childPath.Length > 0)
|
||||
files.Add(new PkgFile(childPath, nodes[d.InodeNumber].Size, nodes[d.InodeNumber].LogicalOffset) { Inode = (int)d.InodeNumber });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The raw inode record, for diagnostics.</summary>
|
||||
internal static byte[] ReadInodeRaw(Stream mount, long superblockOffset, int inode)
|
||||
{
|
||||
byte[] sb = new byte[0x40];
|
||||
ReadAt(mount, superblockOffset, sb);
|
||||
int blockSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(sb.AsSpan(0x20));
|
||||
int perBlock = blockSize / InodeSize;
|
||||
byte[] raw = new byte[InodeSize];
|
||||
ReadAt(mount, superblockOffset + blockSize + (long)(inode / perBlock) * blockSize + (long)(inode % perBlock) * InodeSize, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static void ReadAt(Stream s, long offset, byte[] buffer)
|
||||
{
|
||||
s.Position = offset;
|
||||
s.ReadExactly(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
/// <summary>Diagnostics for the test project.</summary>
|
||||
public static class InnerFileTreeDiag
|
||||
{
|
||||
internal static byte[] Raw(Stream mount, long superblock, int inode) => InnerFileTree.ReadInodeRaw(mount, superblock, inode);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Packwright.Pkg - streaming reader for the inner PFS image of a PS5 package.
|
||||
//
|
||||
// How blocks are located and decoded follows the LibProsperoPKG 1.2 NAPS reader (GPL-3.0-or-later, SvenGDK).
|
||||
// Unlike that reader this one never rebuilds the whole expanded image: it keeps the span table and decodes
|
||||
// only the spans that are read, so a multi-gigabyte image costs a few megabytes of memory.
|
||||
#nullable enable
|
||||
using LibProsperoPkg.PFS.Compression.Oodle;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
/// <summary>
|
||||
/// The expanded (logical) inner PFS image of a PS5 package as a read-only, seekable stream. The stored
|
||||
/// <c>pfs_image.dat</c> is a series of raw, Kraken-compressed or deduplicated blocks ("spans"); the layout file
|
||||
/// says where each span is stored and which logical range it expands to. Ranges no span covers read as zeros.
|
||||
/// </summary>
|
||||
internal sealed class InnerMount : Stream
|
||||
{
|
||||
private const long Ublock = 0x40000; // logical blocks are 256K
|
||||
private const int Chunk128K = 0x20000; // even/odd halves of a compressed span
|
||||
private const int CacheSlots = 4;
|
||||
|
||||
private readonly Stream _onDisk;
|
||||
private readonly NapsLayout _layout;
|
||||
private readonly Span[] _spans;
|
||||
private readonly (int Index, byte[] Data)?[] _cache = new (int, byte[])?[CacheSlots];
|
||||
private int _nextSlot;
|
||||
private long _position;
|
||||
|
||||
private readonly record struct Span(long Logical, int Length, long Stored, int StoredLength, int EvenLength,
|
||||
byte Even, byte Odd, byte Shuffle, byte Kde);
|
||||
|
||||
/// <summary>Size of the expanded image in bytes.</summary>
|
||||
public long MountSize { get; }
|
||||
|
||||
/// <summary>Offset of the inner PFS superblock inside the expanded image.</summary>
|
||||
public long SuperblockOffset { get; }
|
||||
|
||||
public InnerMount(Stream onDisk, ReadOnlySpan<byte> naps)
|
||||
{
|
||||
_onDisk = onDisk;
|
||||
_layout = new NapsLayout(naps);
|
||||
MountSize = _layout.UncompressedSize;
|
||||
if (MountSize <= 0) throw new InvalidDataException("The package layout describes an empty inner image.");
|
||||
_spans = BuildSpans(_layout);
|
||||
SuperblockOffset = FindSuperblock();
|
||||
}
|
||||
|
||||
// ---- span plan ---------------------------------------------------------------------------------------
|
||||
|
||||
// Walks the files in order and, for each, consumes block records until the file's logical range is covered.
|
||||
// A span's stored position comes from the run-base record that precedes it (its first stored block number
|
||||
// and offset) or, for spans that follow another span directly, from the end of the previous span.
|
||||
private static Span[] BuildSpans(NapsLayout layout)
|
||||
{
|
||||
IReadOnlyList<NapsBlockInfo> blocks = layout.Blocks;
|
||||
var spans = new List<Span>(blocks.Count);
|
||||
long prevOffset = 0, prevLength = 0;
|
||||
long prevTweak = 0;
|
||||
bool havePrev = false;
|
||||
|
||||
for (int f = 0; f + 1 < layout.Files.Count; f++)
|
||||
{
|
||||
NapsFileRecord file = layout.Files[f];
|
||||
long length = layout.Files[f + 1].UncompressedOffset - file.UncompressedOffset;
|
||||
if (length < 0) throw new InvalidDataException("The package layout has a file boundary that moves backwards.");
|
||||
if ((file.Type & 0x40) != 0) continue; // continuation of the previous file
|
||||
|
||||
long position = file.UncompressedOffset, remaining = length;
|
||||
int index = remaining == 0 ? 0 : FindBlock(layout, position);
|
||||
while (remaining != 0)
|
||||
{
|
||||
if ((uint)index >= (uint)blocks.Count) throw new InvalidDataException("The package layout ends inside a file.");
|
||||
NapsBlockInfo cur = blocks[index];
|
||||
if (cur.IsRunBase) { index++; continue; }
|
||||
if (cur.IsTerminal) throw new InvalidDataException("The package layout ends before a file does.");
|
||||
|
||||
int next = index + 1;
|
||||
if ((uint)next >= (uint)blocks.Count) throw new InvalidDataException("A layout block has no following boundary.");
|
||||
int boundary = blocks[next].IsRunBase ? next + 1 : next;
|
||||
if ((uint)boundary >= (uint)blocks.Count || blocks[boundary].IsRunBase)
|
||||
throw new InvalidDataException("The package layout has an unexpected run of records.");
|
||||
|
||||
int storedLength = Delta18(blocks[next].IsRunBase ? blocks[next].CoffsetEndMod256K : blocks[next].CoffsetStartMod256K,
|
||||
cur.CoffsetStartMod256K);
|
||||
int logicalLength = Delta18(blocks[boundary].UoffsetStart, cur.UoffsetStart);
|
||||
if (logicalLength > remaining)
|
||||
throw new InvalidDataException("A layout block crosses the end of its file.");
|
||||
|
||||
long offset, tweak;
|
||||
if (index > 0 && blocks[index - 1].IsRunBase)
|
||||
{
|
||||
NapsBlockInfo run = blocks[index - 1];
|
||||
offset = ((long)run.CoffsetStart256K << 18) | cur.CoffsetStartMod256K;
|
||||
tweak = run.TweakIdxStart;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!havePrev) throw new InvalidDataException("The first layout block has no run base.");
|
||||
offset = prevOffset + prevLength;
|
||||
tweak = prevTweak + ((offset >> 16) - (prevOffset >> 16));
|
||||
}
|
||||
|
||||
spans.Add(new Span(position, logicalLength, tweak * 0x10000 + (offset & 0xFFFF), storedLength,
|
||||
(int)cur.ClenEvenMinus1 + 1, cur.Even, cur.Odd, cur.ShuffleIdx, cur.KdePredictor));
|
||||
prevOffset = offset; prevLength = storedLength; prevTweak = tweak; havePrev = true;
|
||||
position += logicalLength;
|
||||
remaining -= logicalLength;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return spans.ToArray();
|
||||
}
|
||||
|
||||
// The block record that starts exactly at logical offset 'position', found through the per-block index.
|
||||
private static int FindBlock(NapsLayout layout, long position)
|
||||
{
|
||||
int ublock = checked((int)(position >> 18));
|
||||
uint first = layout.BlockIndexOfUBlock(ublock);
|
||||
uint end = ublock + 1 < layout.UBlockCount ? layout.BlockIndexOfUBlock(ublock + 1) : (uint)layout.Blocks.Count;
|
||||
long ublockStart = (long)ublock << 18;
|
||||
for (uint i = first; i < end && i < layout.Blocks.Count; i++)
|
||||
{
|
||||
NapsBlockInfo b = layout.Blocks[(int)i];
|
||||
if (!b.IsRunBase && !b.IsTerminal && position == ublockStart + b.UoffsetStart)
|
||||
return (int)i;
|
||||
}
|
||||
throw new InvalidDataException($"No layout block starts at logical offset 0x{position:X}.");
|
||||
}
|
||||
|
||||
// Distance from 'previous' to 'next' modulo 256K; an equal pair means a whole 256K.
|
||||
private static int Delta18(uint next, uint previous)
|
||||
{
|
||||
int d = (int)next - (int)previous;
|
||||
return d <= 0 ? d + 0x40000 : d;
|
||||
}
|
||||
|
||||
// ---- superblock --------------------------------------------------------------------------------------
|
||||
|
||||
// The filesystem metadata starts at the beginning of the last real file; a scan of 64K boundaries is the fallback.
|
||||
private long FindSuperblock()
|
||||
{
|
||||
for (int i = _layout.Files.Count - 2; i >= 0 && i >= _layout.Files.Count - 8; i--)
|
||||
if (HasSuperblock(_layout.Files[i].UncompressedOffset)) return _layout.Files[i].UncompressedOffset;
|
||||
for (long p = (MountSize - 0x10000) & ~0xFFFFL; p >= 0; p -= 0x10000)
|
||||
if (HasSuperblock(p)) return p;
|
||||
throw new InvalidDataException("The inner image has no filesystem superblock.");
|
||||
}
|
||||
|
||||
private bool HasSuperblock(long offset)
|
||||
{
|
||||
if (offset < 0 || offset + 16 > MountSize) return false;
|
||||
Span<byte> head = stackalloc byte[16];
|
||||
long saved = _position;
|
||||
try
|
||||
{
|
||||
_position = offset;
|
||||
return Read(head) == 16 &&
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(head) == 2 &&
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(head[8..]) == 20130315;
|
||||
}
|
||||
// A span nothing points at may be unreadable; that just means "not here".
|
||||
catch (InvalidDataException) { return false; }
|
||||
finally { _position = saved; }
|
||||
}
|
||||
|
||||
// ---- Stream ------------------------------------------------------------------------------------------
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => MountSize;
|
||||
public override long Position { get => _position; set => _position = value; }
|
||||
public override void Flush() { }
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long target = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
_ => MountSize + offset
|
||||
};
|
||||
if (target < 0) throw new IOException("Cannot seek before the start of the image.");
|
||||
_position = target;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
int total = 0;
|
||||
while (!buffer.IsEmpty && _position < MountSize)
|
||||
{
|
||||
int index = FindSpan(_position);
|
||||
int take;
|
||||
if (index < 0)
|
||||
{
|
||||
// Before the first span or in a gap: zeros up to the next span (or the end).
|
||||
int next = ~index;
|
||||
long limit = next < _spans.Length ? _spans[next].Logical : MountSize;
|
||||
take = (int)Math.Min(buffer.Length, limit - _position);
|
||||
buffer[..take].Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
Span span = _spans[index];
|
||||
int inside = (int)(_position - span.Logical);
|
||||
take = Math.Min(buffer.Length, span.Length - inside);
|
||||
GetSpanData(index).AsSpan(inside, take).CopyTo(buffer);
|
||||
}
|
||||
buffer = buffer[take..];
|
||||
_position += take;
|
||||
total += take;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Index of the span containing 'pos', or the complement of the next span's index when it falls in a gap.
|
||||
private int FindSpan(long pos)
|
||||
{
|
||||
int lo = 0, hi = _spans.Length - 1;
|
||||
while (lo <= hi)
|
||||
{
|
||||
int mid = (lo + hi) >>> 1;
|
||||
Span s = _spans[mid];
|
||||
if (pos < s.Logical) hi = mid - 1;
|
||||
else if (pos >= s.Logical + s.Length) lo = mid + 1;
|
||||
else return mid;
|
||||
}
|
||||
return ~lo;
|
||||
}
|
||||
|
||||
private byte[] GetSpanData(int index)
|
||||
{
|
||||
foreach ((int Index, byte[] Data)? slot in _cache)
|
||||
if (slot is { } s && s.Index == index)
|
||||
return s.Data;
|
||||
|
||||
byte[] data = Decode(_spans[index], index);
|
||||
_cache[_nextSlot] = (index, data);
|
||||
_nextSlot = (_nextSlot + 1) % CacheSlots;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- decoding ----------------------------------------------------------------------------------------
|
||||
|
||||
private byte[] Decode(Span span, int index)
|
||||
{
|
||||
if (span.Kde != 0)
|
||||
throw new PkgNotSupportedException($"Layout block {index} needs predictor mode {span.Kde}, which is not supported.");
|
||||
if (HasShuffle(span.Shuffle))
|
||||
throw new PkgNotSupportedException($"Layout block {index} uses a byte shuffle, which is not supported.");
|
||||
|
||||
var output = new byte[span.Length];
|
||||
// A deduplicated all-zero block stores only a stub; the expanded bytes are zeros.
|
||||
if (IsDeduplicatedZero(span)) return output;
|
||||
|
||||
if (span.Stored < 0 || span.Stored + span.StoredLength > _onDisk.Length)
|
||||
throw new InvalidDataException($"Layout block {index} lies outside the inner image (offset 0x{span.Stored:X}, {span.StoredLength} bytes).");
|
||||
byte[] payload = new byte[span.StoredLength];
|
||||
_onDisk.Position = span.Stored;
|
||||
_onDisk.ReadExactly(payload);
|
||||
|
||||
if (span.StoredLength == span.Length)
|
||||
{
|
||||
payload.CopyTo(output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
int firstChunk = span.Length > Chunk128K ? span.EvenLength : 0;
|
||||
int flags = (((span.Even & 4) != 0) ? 2 : 0) | (((span.Even & 2) != 0) ? 1 : 0)
|
||||
| (((span.Odd & 4) != 0) ? 32 : 0) | (((span.Odd & 2) != 0) ? 16 : 0);
|
||||
KrakenDecodeStatus status = KrakenDecoder.DecodeBlock(payload, flags, firstChunk, output);
|
||||
if (status != KrakenDecodeStatus.Success)
|
||||
throw new InvalidDataException($"Layout block {index} could not be decompressed ({status}).");
|
||||
return output;
|
||||
}
|
||||
|
||||
// A shuffle index names an entry of the pattern table; an all-zero pattern means "none".
|
||||
private bool HasShuffle(byte shuffleIndex)
|
||||
{
|
||||
if (shuffleIndex == 0) return false;
|
||||
if (shuffleIndex > _layout.ShufflePatterns.Count) throw new InvalidDataException("A layout block names a missing shuffle pattern.");
|
||||
return _layout.ShufflePatterns[shuffleIndex - 1].Any(b => b != 0);
|
||||
}
|
||||
|
||||
private static bool IsDeduplicatedZero(Span s)
|
||||
{
|
||||
bool odd16 = s.Odd == 1 && s.StoredLength == 16 && s.Length > 16;
|
||||
bool even8 = s.Odd == 0 && s.StoredLength == 8 && s.Length > 8 && s.Length <= Chunk128K;
|
||||
return (odd16 || even8) && s.EvenLength == 8 && s.Even == 1 && s.Kde == 0 && s.Shuffle == 0;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _onDisk.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Packwright.Pkg - the "NAPS" layout file (naps_pkg_layout.dat) of a PS5 package.
|
||||
//
|
||||
// The layout says how the stored inner image (pfs_image.dat) expands to the logical inner filesystem image:
|
||||
// which logical ranges belong to which file, and for every compressed block where its bytes are stored and
|
||||
// how they were compressed. The record formats follow the LibProsperoPKG 1.2 reader (GPL-3.0-or-later,
|
||||
// SvenGDK); the section geometry and the span plan were verified against real packages.
|
||||
#nullable enable
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
/// <summary>One 9-byte block record of the layout (a "normal" block, a run base or a terminator).</summary>
|
||||
internal readonly record struct NapsBlockInfo
|
||||
{
|
||||
public bool IsRunBase { get; init; }
|
||||
|
||||
// Normal block:
|
||||
public uint CoffsetStartMod256K { get; init; }
|
||||
public bool IsTerminal { get; init; }
|
||||
public uint UoffsetStart { get; init; }
|
||||
public uint ClenEvenMinus1 { get; init; }
|
||||
public byte Even { get; init; }
|
||||
public byte Odd { get; init; }
|
||||
public byte KdePredictor { get; init; }
|
||||
public byte ShuffleIdx { get; init; }
|
||||
|
||||
// Run base:
|
||||
public uint CoffsetEndMod256K { get; init; }
|
||||
public uint TweakIdxStart { get; init; }
|
||||
public uint CoffsetStart256K { get; init; }
|
||||
}
|
||||
|
||||
internal readonly record struct NapsFileRecord(byte Type, long UncompressedOffset);
|
||||
|
||||
internal sealed class NapsLayout
|
||||
{
|
||||
private const int HeaderSize = 16;
|
||||
|
||||
public int NumFiles { get; }
|
||||
public int NumUBlocks { get; }
|
||||
public int NumShufflePatterns { get; }
|
||||
public IReadOnlyList<NapsFileRecord> Files { get; }
|
||||
public IReadOnlyList<(uint Base, byte[] Deltas)> U2c { get; }
|
||||
public IReadOnlyList<NapsBlockInfo> Blocks { get; }
|
||||
public IReadOnlyList<byte[]> ShufflePatterns { get; }
|
||||
|
||||
public int UBlockCount => NumUBlocks + 1;
|
||||
/// <summary>The size of the logical inner image: the offset recorded after the last file.</summary>
|
||||
public long UncompressedSize => Files[^1].UncompressedOffset;
|
||||
|
||||
public NapsLayout(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.Length < HeaderSize) throw new InvalidDataException("The package layout file is too small.");
|
||||
|
||||
// Header: two little-endian words with counts packed into bit fields.
|
||||
ulong w0 = ReadU64(blob, 0), w1 = ReadU64(blob, 8);
|
||||
NumFiles = (int)(w0 & 0xFFFFFF) + 1;
|
||||
NumShufflePatterns = (int)((w0 >> 28) & 0xF);
|
||||
NumUBlocks = (int)((w0 >> 32) & 0x7FFFFF);
|
||||
int numOuterBlocks = (int)(w1 & 0xFFFFFF);
|
||||
int numBlocks = (int)((w1 >> 24) & 0xFFFFFF) + 2;
|
||||
|
||||
// Sections in order: header, outer-block records (8 bytes), shuffle patterns (8), file records (6),
|
||||
// u2c records (10, one per 8 blocks), padding to 8 bytes, then block records (9).
|
||||
long pos = HeaderSize + 8L * numOuterBlocks;
|
||||
long shuffleStart = pos;
|
||||
pos += 8L * NumShufflePatterns;
|
||||
long fileStart = pos;
|
||||
pos += 6L * NumFiles;
|
||||
long u2cStart = pos;
|
||||
int u2cCount = (NumUBlocks + 8) >> 3;
|
||||
pos += 10L * u2cCount;
|
||||
long blockStart = (pos + 7) & ~7L;
|
||||
if (blockStart + 9L * numBlocks > blob.Length)
|
||||
throw new InvalidDataException("The package layout file is shorter than its header says.");
|
||||
|
||||
var shuffle = new List<byte[]>(NumShufflePatterns);
|
||||
for (int i = 0; i < NumShufflePatterns; i++)
|
||||
shuffle.Add(blob.Slice((int)(shuffleStart + 8L * i), 8).ToArray());
|
||||
ShufflePatterns = shuffle;
|
||||
|
||||
var files = new NapsFileRecord[NumFiles];
|
||||
for (int i = 0; i < files.Length; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> r = blob.Slice((int)(fileStart + 6L * i), 6);
|
||||
long offset = 0;
|
||||
for (int k = 0; k < 5; k++) offset |= (long)r[k] << (8 * k);
|
||||
files[i] = new NapsFileRecord(r[5], offset);
|
||||
}
|
||||
Files = files;
|
||||
|
||||
var u2c = new (uint, byte[])[u2cCount];
|
||||
for (int i = 0; i < u2c.Length; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> r = blob.Slice((int)(u2cStart + 10L * i), 10);
|
||||
u2c[i] = ((uint)(r[0] | (r[1] << 8) | (r[2] << 16)), r.Slice(3, 7).ToArray());
|
||||
}
|
||||
U2c = u2c;
|
||||
|
||||
var blocks = new NapsBlockInfo[numBlocks];
|
||||
for (int i = 0; i < blocks.Length; i++)
|
||||
blocks[i] = DecodeBlock(blob.Slice((int)(blockStart + 9L * i), 9));
|
||||
Blocks = blocks;
|
||||
}
|
||||
|
||||
// The 72-bit record: bit 18 tells a run base from a normal block.
|
||||
private static NapsBlockInfo DecodeBlock(ReadOnlySpan<byte> r)
|
||||
{
|
||||
UInt128 v = ReadU64(r, 0) | ((UInt128)r[8] << 64);
|
||||
uint Bits(int shift, int width) => (uint)((v >> shift) & ((1u << width) - 1));
|
||||
uint low = Bits(0, 18);
|
||||
if (((v >> 18) & 1) == 0)
|
||||
return new NapsBlockInfo
|
||||
{
|
||||
CoffsetStartMod256K = low,
|
||||
IsTerminal = ((v >> 19) & 1) != 0,
|
||||
UoffsetStart = Bits(20, 18),
|
||||
ClenEvenMinus1 = Bits(38, 17),
|
||||
Even = (byte)Bits(55, 3),
|
||||
Odd = (byte)Bits(58, 3),
|
||||
KdePredictor = (byte)Bits(61, 6),
|
||||
ShuffleIdx = (byte)Bits(68, 4),
|
||||
};
|
||||
return new NapsBlockInfo
|
||||
{
|
||||
IsRunBase = true,
|
||||
CoffsetEndMod256K = low,
|
||||
TweakIdxStart = Bits(20, 28),
|
||||
CoffsetStart256K = (uint)((v >> 50) & 0x3FFFFF),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Index of the first block record of logical block <paramref name="ublock"/> (256K units).</summary>
|
||||
public uint BlockIndexOfUBlock(int ublock)
|
||||
{
|
||||
if (ublock < 0 || ublock >= UBlockCount) throw new InvalidDataException($"Logical block {ublock} is outside the layout.");
|
||||
(uint baseIndex, byte[] deltas) = U2c[ublock >> 3];
|
||||
return (ublock & 7) == 0 ? baseIndex : baseIndex + deltas[(ublock & 7) - 1];
|
||||
}
|
||||
|
||||
private static ulong ReadU64(ReadOnlySpan<byte> s, int offset)
|
||||
{
|
||||
ulong v = 0;
|
||||
for (int i = 0; i < 8; i++) v |= (ulong)s[offset + i] << (8 * i);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Packwright.Pkg - decrypting reader for the outer image of packages built with a passcode.
|
||||
//
|
||||
// Packages built by Packwright (and by LibProsperoPKG) encrypt the outer image: every 64 KB block is one
|
||||
// AES-XTS data unit. The key comes from the content id and the passcode, both public for debug packages.
|
||||
// The block layout and sector numbering follow the LibProsperoPKG reader (GPL-3.0-or-later, SvenGDK):
|
||||
// file-data blocks use the block index as the sector number, metadata blocks use the index with bit 47 set,
|
||||
// and the block that holds the superblock is stored plain.
|
||||
#nullable enable
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
internal enum OuterBlockKind : byte
|
||||
{
|
||||
/// <summary>File data: sector number is the block index.</summary>
|
||||
Data = 0,
|
||||
/// <summary>Filesystem metadata: sector number is the block index with bit 47 set.</summary>
|
||||
Signed = 1,
|
||||
/// <summary>The superblock: stored unencrypted.</summary>
|
||||
Plain = 2
|
||||
}
|
||||
|
||||
/// <summary>Reads the outer image through block-wise AES-XTS decryption; only the blocks that are read are decrypted.</summary>
|
||||
internal sealed class OuterXtsReader : IMemoryReader
|
||||
{
|
||||
public const int BlockSize = 0x10000;
|
||||
private const ulong SignedFlag = 0x800000000000UL;
|
||||
private const int CacheSlots = 8;
|
||||
|
||||
private readonly FileStream _file;
|
||||
private readonly long _baseOffset;
|
||||
private readonly XtsBlockTransform _xts;
|
||||
private readonly (long Block, OuterBlockKind Kind, byte[] Data)?[] _cache = new (long, OuterBlockKind, byte[])?[CacheSlots];
|
||||
private int _nextSlot;
|
||||
|
||||
public OuterXtsReader(FileStream file, long baseOffset, byte[] tweakKey, byte[] dataKey, OuterBlockKind[] kinds)
|
||||
{
|
||||
_file = file;
|
||||
_baseOffset = baseOffset;
|
||||
_xts = new XtsBlockTransform(dataKey, tweakKey);
|
||||
Kinds = kinds;
|
||||
}
|
||||
|
||||
/// <summary>How each block is stored. The array can be updated once the filesystem metadata has been read.</summary>
|
||||
public OuterBlockKind[] Kinds { get; }
|
||||
|
||||
public void ClearCache() => Array.Clear(_cache);
|
||||
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
long block = pos / BlockSize;
|
||||
int inside = (int)(pos % BlockSize);
|
||||
int take = Math.Min(count, BlockSize - inside);
|
||||
Buffer.BlockCopy(GetBlock(block), inside, buf, offset, take);
|
||||
pos += take;
|
||||
offset += take;
|
||||
count -= take;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] GetBlock(long block)
|
||||
{
|
||||
if (block < 0 || block >= Kinds.Length) throw new InvalidDataException("The outer image is read past its end.");
|
||||
OuterBlockKind kind = Kinds[block];
|
||||
lock (_xts)
|
||||
{
|
||||
foreach ((long Block, OuterBlockKind Kind, byte[] Data)? slot in _cache)
|
||||
if (slot is { } s && s.Block == block && s.Kind == kind)
|
||||
return s.Data;
|
||||
|
||||
byte[] data = new byte[BlockSize];
|
||||
lock (_file)
|
||||
{
|
||||
_file.Position = _baseOffset + block * BlockSize;
|
||||
_file.ReadExactly(data);
|
||||
}
|
||||
if (kind != OuterBlockKind.Plain)
|
||||
_xts.CryptSector(data, kind == OuterBlockKind.Signed ? SignedFlag | (ulong)block : (ulong)block, encrypt: false);
|
||||
|
||||
_cache[_nextSlot] = (block, kind, data);
|
||||
_nextSlot = (_nextSlot + 1) % CacheSlots;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _xts.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Packwright.Pkg - recomputes the digests that tie a package's parts together and compares them with the stored ones:
|
||||
// the container header's rollups and seal, the general digests entry, the finalised-image header's digests and the
|
||||
// per-block digest table of the outer image. Needs no key (outer blocks are decrypted with the public passcode scheme).
|
||||
#nullable enable
|
||||
using Packwright.Pkg.Util;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>One result per digest: name, whether it matched, and what was compared.</summary>
|
||||
public IReadOnlyList<(string Name, bool Ok, string Detail)> CheckContainerDigests(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<(string, bool, string)>();
|
||||
void Result(string name, bool ok, string detail = "") => results.Add((name, ok, detail));
|
||||
bool Same(byte[] a, byte[] b) => a.AsSpan().SequenceEqual(b);
|
||||
|
||||
byte[] Read(long position, long length)
|
||||
{
|
||||
byte[] b = new byte[length];
|
||||
lock (_file) { _file.Position = position; _file.ReadExactly(b); }
|
||||
return b;
|
||||
}
|
||||
byte[] H3(byte[] data) => Util.Sha3.Hash(data);
|
||||
PkgEntry? Entry(uint id) => Entries.FirstOrDefault(e => e.Id == id);
|
||||
byte[] EntryBytes(uint id, long? length = null)
|
||||
{
|
||||
PkgEntry e = Entry(id) ?? throw new InvalidDataException($"entry 0x{id:X4} is missing");
|
||||
return Read(_cntOffset + e.Offset, length ?? e.Size);
|
||||
}
|
||||
|
||||
byte[] header = Read(_cntOffset, 0x1000);
|
||||
byte[] fih = Read(0, 0x10000);
|
||||
|
||||
// ---- container header ---------------------------------------------------------------------------------
|
||||
Result("Container seal", Same(H3(header[..0xFE0]), header[0xFE0..0x1000]));
|
||||
try
|
||||
{
|
||||
PkgEntry metas = Entry(0x0100)!, digests = Entry(0x0001)!;
|
||||
byte[] sc1 = [.. EntryBytes(0x0010), .. EntryBytes(0x0020), .. EntryBytes(0x0080), .. EntryBytes(0x0100), .. EntryBytes(0x0001)];
|
||||
byte[] sc2 = [.. EntryBytes(0x0010), .. EntryBytes(0x0020), .. EntryBytes(0x0080), .. EntryBytes(0x0100, ScEntryCount * 32L)];
|
||||
Result("System entries rollup 1", Same(H3(sc1), header[0x100..0x120]));
|
||||
Result("System entries rollup 2", Same(H3(sc2), header[0x120..0x140]));
|
||||
Result("Digest table rollup", Same(H3(EntryBytes(0x0001)), header[0x140..0x160]));
|
||||
Result("Body digest", Same(H3(Read(_cntOffset + (long)BodyOffset, (long)BodySize)), header[0x160..0x180]));
|
||||
_ = metas; _ = digests;
|
||||
|
||||
uint keyOffset = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x510)), keySize = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x514));
|
||||
uint mandOffset = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x518)), mandSize = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x51C));
|
||||
byte[] region1 = Read(_cntOffset + keyOffset, keySize), region2 = Read(_cntOffset + mandOffset, mandSize);
|
||||
Result("Image key digest", Same(H3(region1), Read(_cntOffset + 0x520, 32)));
|
||||
Result("Block digest table digest", Same(H3(region2), Read(_cntOffset + 0x540, 32)));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or EndOfStreamException)
|
||||
{
|
||||
Result("Container rollups", false, ex.Message);
|
||||
}
|
||||
|
||||
// ---- general digests ------------------------------------------------------------------------------------
|
||||
try
|
||||
{
|
||||
byte[] general = EntryBytes(0x0080);
|
||||
uint set = BinaryPrimitives.ReadUInt32BigEndian(general.AsSpan(0x1C));
|
||||
byte[] Slot(int i) => general[(0x20 + i * 32)..(0x40 + i * 32)];
|
||||
byte[] game = H3(Read(SuperblockOffset, 0x10000));
|
||||
if ((set & (1u << 1)) != 0)
|
||||
Result("Content digest", Same(H3([.. header.AsSpan(0x40, 0x38), .. game, .. new byte[32]]), Slot(0)));
|
||||
if ((set & (1u << 2)) != 0) Result("Game digest", Same(game, Slot(1)));
|
||||
if ((set & (1u << 3)) != 0)
|
||||
Result("Header digest", Same(H3([.. header.AsSpan(0, 0x40), .. header.AsSpan(0x400, 0x80)]), Slot(2)));
|
||||
if ((set & (1u << 4)) != 0) Result("System digest", Same(ConcatDigest(Entries.Select(e => e.Id).Where(PkgSystemMedia.Contains).ToArray()), Slot(3)));
|
||||
if ((set & (1u << 6)) != 0) Result("Parameter digest", Same(H3(EntryBytes(0x2000)), Slot(5)));
|
||||
if ((set & (1u << 7)) != 0) Result("Play-go digest", Same(ConcatDigest([0x1001, 0x2010, 0x2011, 0x3000]), Slot(6)));
|
||||
if ((set & (1u << 12)) != 0) Result("Target digest", Same(game, Slot(11)));
|
||||
|
||||
byte[] ConcatDigest(uint[] ids)
|
||||
{
|
||||
var parts = new List<byte>();
|
||||
foreach (uint id in ids.OrderBy(i => i))
|
||||
if (Entry(id) is not null) parts.AddRange(H3(EntryBytes(id)));
|
||||
return H3([.. parts]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or EndOfStreamException)
|
||||
{
|
||||
Result("General digests", false, ex.Message);
|
||||
}
|
||||
|
||||
// ---- finalised-image header -----------------------------------------------------------------------------
|
||||
if (!IsMetadataOnly)
|
||||
{
|
||||
byte[] sbDigest = H3(Read(SuperblockOffset, 0x10000));
|
||||
Result("Header: outer superblock digest", Same(sbDigest, fih[0x30..0x50]) && Same(sbDigest, fih[0x70..0x90]) && Same(sbDigest, fih[0xD0..0xF0]));
|
||||
Result("Header: sealed by the container", Same(H3(fih), header[0x460..0x480]));
|
||||
Result("Header: superblock digest in the container", Same(sbDigest, header[0x440..0x460]));
|
||||
try
|
||||
{
|
||||
EnsureInner();
|
||||
byte[] layout = new byte[_layoutFile!.size];
|
||||
_layoutFile.GetView().Read(0, layout, 0, layout.Length);
|
||||
Result("Header: layout digest", Same(H3(layout), fih[0xB0..0xD0]));
|
||||
Result("Header: layout size", BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0xA8)) == (ulong)layout.Length);
|
||||
Result("Header: inner image size", BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0xA0)) == (ulong)InnerImageSize || BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0xA0)) == BinaryPrimitives.ReadUInt32LittleEndian(fih.AsSpan(0x90)) * 0x10000UL,
|
||||
$"{BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0xA0)):X} vs {InnerImageSize:X}");
|
||||
|
||||
// per-block digest table of the outer image (each digest is stored byte-reversed)
|
||||
PkgEntry? table = Entry(0x040A);
|
||||
if (table is not null)
|
||||
{
|
||||
byte[] stored = EntryBytes(0x040A);
|
||||
long blocks = PfsSize / 0x10000;
|
||||
int bad = 0;
|
||||
IMemoryReader reader = (IMemoryReader?)_xtsReader ?? new FileReader(_file, PfsOffset);
|
||||
byte[] block = new byte[0x10000];
|
||||
for (long i = 0; i < blocks && i * 32 + 32 <= stored.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
reader.Read(i * 0x10000, block, 0, block.Length);
|
||||
byte[] want = H3(block);
|
||||
Array.Reverse(want);
|
||||
if (!want.AsSpan().SequenceEqual(stored.AsSpan((int)i * 32, 32))) bad++;
|
||||
}
|
||||
Result("Block digest table", bad == 0 && stored.Length == blocks * 32, $"{bad} of {blocks} blocks differ");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or EndOfStreamException or PkgNotSupportedException)
|
||||
{
|
||||
Result("Image digests", false, ex.Message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The entries that feed the "system" digest: the artwork and sound files.</summary>
|
||||
public static class PkgSystemMedia
|
||||
{
|
||||
public static bool Contains(uint id) =>
|
||||
id is 0x1006 or 0x100D or 0x2040 or 0x2060 || (id >= 0x1200 && id <= 0x125F) || (id >= 0x1280 && id <= 0x12DF);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Packwright.Pkg - decryption of protected metadata entries (title data, license, trophy bindings).
|
||||
//
|
||||
// A protected entry is AES-128-CBC encrypted. The key and IV come from a digest of the entry's own table
|
||||
// row plus a key seed that is derived from the content id and the passcode (both public for debug packages).
|
||||
// The scheme follows the LibProsperoPKG 1.2 reader (GPL-3.0-or-later, SvenGDK). No publisher key is involved.
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
internal static class PkgEntryCipher
|
||||
{
|
||||
public const string DefaultPasscode = "00000000000000000000000000000000";
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a protected entry. Packages built with the publisher profile hash with SHA3-256 and the rest
|
||||
/// with SHA-256; the entry's known magic (where it has one) picks the profile, otherwise
|
||||
/// <paramref name="preferPublisherProfile"/> decides.
|
||||
/// </summary>
|
||||
public static byte[] Decrypt(byte[] stored, PkgEntry entry, string contentId, string passcode, bool preferPublisherProfile)
|
||||
{
|
||||
if (passcode.Length != 32) throw new PkgNotSupportedException("The passcode must be 32 characters.");
|
||||
if (contentId.Length != 36) throw new InvalidDataException("The package has no usable content id to derive the entry key from.");
|
||||
if (stored.Length < 16 || stored.Length % 16 != 0) throw new InvalidDataException($"Entry 0x{entry.Id:X4} has an invalid stored length.");
|
||||
|
||||
uint keyIndex = (entry.Flags2 >> 12) & 0xF;
|
||||
byte[] row = MetaRow(entry);
|
||||
foreach (bool publisher in new[] { preferPublisherProfile, !preferPublisherProfile })
|
||||
{
|
||||
byte[] keySeed = Crypto.ComputeKeys(contentId, passcode, keyIndex, publisher);
|
||||
byte[] data = [.. row, .. keySeed];
|
||||
byte[] digest = publisher ? Crypto.Sha3_256(data) : Crypto.Sha256(data);
|
||||
|
||||
byte[] plain = new byte[stored.Length];
|
||||
using Aes aes = Aes.Create();
|
||||
aes.Key = digest[16..32];
|
||||
int written = aes.DecryptCbc(stored, digest[..16], plain, PaddingMode.None);
|
||||
if (written != plain.Length) continue;
|
||||
|
||||
if (KnownMagic(entry.Id, plain) is { } known)
|
||||
{
|
||||
if (known) return plain[..(int)entry.Size];
|
||||
continue;
|
||||
}
|
||||
return plain[..(int)entry.Size];
|
||||
}
|
||||
throw new InvalidDataException(
|
||||
$"Entry 0x{entry.Id:X4} could not be decrypted with this passcode (or it uses a protection scheme this reader does not support).");
|
||||
}
|
||||
|
||||
// The 32-byte table row the key is bound to: six big-endian fields, then 8 zero bytes.
|
||||
private static byte[] MetaRow(PkgEntry e)
|
||||
{
|
||||
byte[] row = new byte[32];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(0), e.Id);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(4), e.NameTableOffset);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(8), e.Flags1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(12), e.Flags2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(16), checked((uint)e.Offset));
|
||||
BinaryPrimitives.WriteUInt32BigEndian(row.AsSpan(20), checked((uint)e.Size));
|
||||
return row;
|
||||
}
|
||||
|
||||
// true/false when the entry has a known leading magic, null when it has none.
|
||||
private static bool? KnownMagic(uint id, byte[] plain) => id switch
|
||||
{
|
||||
0x0402 => plain.Length >= 4 && plain[0] == (byte)'N' && plain[1] == (byte)'P' && plain[2] == (byte)'T' && plain[3] == (byte)'D',
|
||||
0x0403 or 0x2020 or 0x2021 => plain.Length >= 4 && plain[0] == 0xD2 && plain[1] == 0x94 && plain[2] == 0xA0 && plain[3] == 0x18,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
// Packwright.Pkg - reader for PS5 packages (finalized "FIH" images).
|
||||
//
|
||||
// A package is: a 0x10000-byte FIH header, an outer PFS image holding the inner image (pfs_image.dat) and its
|
||||
// layout (naps_pkg_layout.dat), then the "CNT" metadata container. The container header, the entry table
|
||||
// and the outer image are plain parsing; the inner image is expanded on demand by InnerMount.
|
||||
// Format knowledge is based on the LibProsperoPKG documentation and source by SvenGDK (GPL-3.0-or-later).
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using LibProsperoPkg.PFS;
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
/// <summary>An entry of the package's metadata container (param.json, icons, trophies and so on).</summary>
|
||||
public sealed record PkgEntry(uint Id, string Name, uint Flags1, uint Flags2, long Offset, long Size)
|
||||
{
|
||||
/// <summary>True when the entry is stored encrypted (the 0x80000000 flag) and needs the passcode to read.</summary>
|
||||
public bool IsEncrypted => (Flags1 & 0x80000000u) != 0;
|
||||
|
||||
public uint NameTableOffset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Raised when a package uses a layout this reader does not support (for example a retail image).</summary>
|
||||
public sealed class PkgNotSupportedException(string message) : NotSupportedException(message);
|
||||
|
||||
/// <summary>Progress of an extraction.</summary>
|
||||
public readonly record struct PkgExtractProgress(long CompletedBytes, long TotalBytes, string CurrentPath);
|
||||
|
||||
public sealed partial class PkgPackage : IDisposable
|
||||
{
|
||||
private const int FihHeaderSize = 0x100;
|
||||
private const int CntHeaderSize = 0x5A0;
|
||||
private const int EntryRecordSize = 0x20;
|
||||
private const uint EntryNamesId = 0x0200;
|
||||
private const string PlainSeed = "PPRPLAIN-NOAUTH!";
|
||||
private static ReadOnlySpan<byte> FihMagic => [0x7F, (byte)(char)70, (byte)(char)73, (byte)(char)72];
|
||||
private static ReadOnlySpan<byte> LihMagic => [0x7F, (byte)(char)76, (byte)(char)73, (byte)(char)72];
|
||||
private static ReadOnlySpan<byte> CntMagic => [0x7F, (byte)(char)67, (byte)(char)78, (byte)(char)84];
|
||||
|
||||
private readonly FileStream _file;
|
||||
private readonly long _cntOffset;
|
||||
private InnerMount? _mount;
|
||||
private IReadOnlyList<PkgFile>? _files;
|
||||
private IReadOnlyList<string>? _directories;
|
||||
private ProsperoPfsReader.File? _innerFile;
|
||||
private ProsperoPfsReader.File? _layoutFile;
|
||||
private ProsperoPfsReader? _outer;
|
||||
private OuterXtsReader? _xtsReader;
|
||||
|
||||
private PkgPackage(FileStream file, string path)
|
||||
{
|
||||
_file = file;
|
||||
Path = path;
|
||||
FileSize = file.Length;
|
||||
|
||||
byte[] fih = new byte[FihHeaderSize];
|
||||
if (FileSize < FihHeaderSize) throw new InvalidDataException("The file is too small to be a PS5 package.");
|
||||
file.ReadExactly(fih);
|
||||
if (fih.AsSpan(0, 4).SequenceEqual(CntMagic))
|
||||
{
|
||||
// A bare metadata container: entries only, no inner image.
|
||||
IsMetadataOnly = true;
|
||||
_cntOffset = 0;
|
||||
}
|
||||
else if (fih.AsSpan(0, 4).SequenceEqual(FihMagic))
|
||||
{
|
||||
IsRetail = fih[5] == 0x80;
|
||||
SignedByte = fih[5];
|
||||
FormatVersion = BinaryPrimitives.ReadUInt16LittleEndian(fih.AsSpan(6));
|
||||
PfsOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0x10));
|
||||
PfsSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0x18));
|
||||
SuperblockOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0x20));
|
||||
_cntOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(fih.AsSpan(0x58));
|
||||
if (_cntOffset <= 0 || _cntOffset + CntHeaderSize > FileSize)
|
||||
throw new InvalidDataException("The package has no readable metadata container.");
|
||||
}
|
||||
else if (fih.AsSpan(0, 4).SequenceEqual(LihMagic))
|
||||
{
|
||||
// A patch image: a small leading header, then the finalized image, the container and the install data.
|
||||
// Only its metadata is described; the filesystem inside a patch is not read.
|
||||
IsMetadataOnly = true;
|
||||
IsPatchImage = true;
|
||||
byte[] lih = new byte[80];
|
||||
file.Position = 0;
|
||||
file.ReadExactly(lih);
|
||||
long fihOffset = BinaryPrimitives.ReadUInt32LittleEndian(lih.AsSpan(16));
|
||||
long fihSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(40));
|
||||
long cntOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(48));
|
||||
long cntSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(56));
|
||||
long siOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(64));
|
||||
long siSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(72));
|
||||
long lihSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(lih.AsSpan(32));
|
||||
if (fihOffset < 0 || fihSize < 0 || cntOffset <= 0 || cntSize < 0 || siOffset < 0 || siSize < 0
|
||||
|| fihOffset + fihSize > FileSize || cntOffset + CntHeaderSize > FileSize || siOffset + siSize > FileSize)
|
||||
throw new InvalidDataException("The patch image has an unreadable header.");
|
||||
byte[] inner = new byte[8];
|
||||
file.Position = fihOffset;
|
||||
file.ReadExactly(inner);
|
||||
if (!inner.AsSpan(0, 4).SequenceEqual(FihMagic)) throw new InvalidDataException("The patch image has no finalized image header.");
|
||||
SignedByte = inner[5];
|
||||
FormatVersion = BinaryPrimitives.ReadUInt16LittleEndian(inner.AsSpan(6));
|
||||
_cntOffset = cntOffset;
|
||||
long pfsStart = fihOffset + fihSize;
|
||||
_patchSegments =
|
||||
[
|
||||
("LIH", 0, lihSize), ("FIH", fihOffset, fihSize), ("PFS", pfsStart, Math.Max(0, cntOffset - pfsStart)),
|
||||
("SC", cntOffset, cntSize), ("SI", siOffset, siSize),
|
||||
];
|
||||
}
|
||||
else
|
||||
throw new InvalidDataException("This is not a PS5 package (unknown header).");
|
||||
|
||||
byte[] header = new byte[CntHeaderSize];
|
||||
file.Position = _cntOffset;
|
||||
file.ReadExactly(header);
|
||||
if (!header.AsSpan(0, 4).SequenceEqual(CntMagic))
|
||||
throw new InvalidDataException("The package metadata container has a bad signature.");
|
||||
|
||||
ContentId = Encoding.ASCII.GetString(header, 0x40, 36).TrimEnd('\0');
|
||||
HeaderFlags = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x04));
|
||||
ScEntryCount = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(0x14));
|
||||
BodyOffset = BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(0x20));
|
||||
BodySize = BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(0x28));
|
||||
ContentFlags = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x78));
|
||||
DrmType = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x70));
|
||||
ContentType = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x74));
|
||||
Entries = ReadEntries(header);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public long FileSize { get; }
|
||||
public bool IsRetail { get; }
|
||||
/// <summary>True for a bare metadata (CNT) container, which has entries but no inner image.</summary>
|
||||
public bool IsMetadataOnly { get; }
|
||||
/// <summary>True for a patch image (a package with a leading LIH header). Only its metadata is read.</summary>
|
||||
public bool IsPatchImage { get; }
|
||||
private readonly IReadOnlyList<(string Name, long Offset, long Size)>? _patchSegments;
|
||||
public ushort FormatVersion { get; private init; }
|
||||
public string ContentId { get; }
|
||||
public long CntOffset => _cntOffset;
|
||||
public byte SignedByte { get; private init; }
|
||||
public uint HeaderFlags { get; }
|
||||
public ushort ScEntryCount { get; }
|
||||
public ulong BodyOffset { get; }
|
||||
public ulong BodySize { get; }
|
||||
public uint ContentFlags { get; }
|
||||
|
||||
/// <summary>The four regions of a finalized package: FIH header, PFS image, SC metadata container, SI install data.</summary>
|
||||
public IReadOnlyList<(string Name, long Offset, long Size)> Segments
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_patchSegments is not null) return _patchSegments;
|
||||
long scSize = (long)(BodyOffset + BodySize);
|
||||
long siStart = _cntOffset + scSize;
|
||||
return
|
||||
[
|
||||
("FIH", 0, 0x10000),
|
||||
("PFS", PfsOffset, PfsSize),
|
||||
("SC", _cntOffset, scSize),
|
||||
("SI", siStart, Math.Max(0, FileSize - siStart)),
|
||||
];
|
||||
}
|
||||
}
|
||||
public uint DrmType { get; }
|
||||
public uint ContentType { get; }
|
||||
public long PfsOffset { get; }
|
||||
public long PfsSize { get; }
|
||||
public long SuperblockOffset { get; }
|
||||
public IReadOnlyList<PkgEntry> Entries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Opens a package. The passcode is only needed for packages that were built with one (it unlocks their
|
||||
/// protected metadata and encrypted outer image); packages downloaded as plain debug images use all zeros.
|
||||
/// </summary>
|
||||
public static PkgPackage Open(string path, string? passcode = null)
|
||||
{
|
||||
var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
|
||||
64 * 1024, FileOptions.RandomAccess);
|
||||
try
|
||||
{
|
||||
var package = new PkgPackage(file, System.IO.Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(passcode)) package.Passcode = passcode;
|
||||
return package;
|
||||
}
|
||||
catch { file.Dispose(); throw; }
|
||||
}
|
||||
|
||||
/// <summary>The passcode used for protected entries and an encrypted outer image.</summary>
|
||||
public string Passcode { get; private set; } = PkgEntryCipher.DefaultPasscode;
|
||||
|
||||
private IReadOnlyList<PkgEntry> ReadEntries(byte[] header)
|
||||
{
|
||||
uint count = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x10));
|
||||
uint tableOffset = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(0x18));
|
||||
long room = (FileSize - (_cntOffset + tableOffset)) / EntryRecordSize;
|
||||
if (count > 0x10000 || count > room) throw new InvalidDataException("The package entry table is malformed.");
|
||||
|
||||
byte[] table = new byte[count * EntryRecordSize];
|
||||
_file.Position = _cntOffset + tableOffset;
|
||||
_file.ReadExactly(table);
|
||||
|
||||
var raw = new List<(uint Id, uint NameOffset, uint F1, uint F2, uint Off, uint Size)>((int)count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> r = table.AsSpan(i * EntryRecordSize, EntryRecordSize);
|
||||
raw.Add((BinaryPrimitives.ReadUInt32BigEndian(r), BinaryPrimitives.ReadUInt32BigEndian(r[4..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(r[8..]), BinaryPrimitives.ReadUInt32BigEndian(r[12..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(r[16..]), BinaryPrimitives.ReadUInt32BigEndian(r[20..])));
|
||||
}
|
||||
|
||||
byte[] names = [];
|
||||
foreach (var e in raw)
|
||||
if (e.Id == EntryNamesId && e.Size > 0 && e.Size < 16 * 1024 * 1024)
|
||||
{
|
||||
names = new byte[e.Size];
|
||||
_file.Position = _cntOffset + e.Off;
|
||||
_file.ReadExactly(names);
|
||||
}
|
||||
|
||||
var entries = new List<PkgEntry>(raw.Count);
|
||||
foreach (var e in raw)
|
||||
{
|
||||
string name = string.Empty;
|
||||
if (e.NameOffset > 0 && e.NameOffset < names.Length)
|
||||
{
|
||||
int end = Array.IndexOf(names, (byte)0, (int)e.NameOffset);
|
||||
if (end < 0) end = names.Length;
|
||||
name = Encoding.ASCII.GetString(names, (int)e.NameOffset, end - (int)e.NameOffset);
|
||||
}
|
||||
entries.Add(new PkgEntry(e.Id, name, e.F1, e.F2, e.Off, e.Size) { NameTableOffset = e.NameOffset });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>Reads a metadata entry that is stored in the clear.</summary>
|
||||
public byte[] ReadEntry(PkgEntry entry)
|
||||
{
|
||||
if (entry.IsEncrypted)
|
||||
throw new PkgNotSupportedException($"Entry 0x{entry.Id:X4} is stored encrypted.");
|
||||
if (entry.Size > int.MaxValue) throw new InvalidDataException("The entry is too large to read into memory.");
|
||||
byte[] data = new byte[entry.Size];
|
||||
lock (_file)
|
||||
{
|
||||
_file.Position = _cntOffset + entry.Offset;
|
||||
_file.ReadExactly(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a metadata entry, decrypting it with <paramref name="passcode"/> when it is protected. Debug
|
||||
/// packages use the passcode of all zeros unless the publisher chose another.
|
||||
/// </summary>
|
||||
public byte[] ReadEntry(PkgEntry entry, string passcode)
|
||||
{
|
||||
if (!entry.IsEncrypted) return ReadEntry(entry);
|
||||
long stored = (entry.Size + 15) & ~15L;
|
||||
if (stored > int.MaxValue) throw new InvalidDataException("The entry is too large to read into memory.");
|
||||
byte[] data = new byte[stored];
|
||||
lock (_file)
|
||||
{
|
||||
_file.Position = _cntOffset + entry.Offset;
|
||||
_file.ReadExactly(data);
|
||||
}
|
||||
// Packages built with the publisher profile carry an entry-key table (0x0010) of at least 0xB80 bytes.
|
||||
bool publisher = Entries.Any(e => e.Id == 0x0010 && e.Size >= 2944);
|
||||
return PkgEntryCipher.Decrypt(data, entry, ContentId, passcode, publisher);
|
||||
}
|
||||
|
||||
/// <summary>The files of the application filesystem. Opening the list decodes only the filesystem metadata.</summary>
|
||||
public IReadOnlyList<PkgFile> Files
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureInner();
|
||||
return _files!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every directory below the user root, including empty ones.</summary>
|
||||
public IReadOnlyList<string> Directories
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureInner();
|
||||
return _directories!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens one file as a read-only, seekable stream. Only the blocks that are read are decoded.</summary>
|
||||
internal long InnerImageSize { get { EnsureInner(); return _mount!.MountSize; } }
|
||||
|
||||
internal (Stream Mount, long Superblock) DebugMount() { EnsureInner(); return (_mount!, _mount!.SuperblockOffset); }
|
||||
|
||||
public Stream OpenFile(PkgFile file)
|
||||
{
|
||||
EnsureInner();
|
||||
return new BoundedStream(_mount!, file.LogicalOffset, file.Size);
|
||||
}
|
||||
|
||||
private void EnsureInner()
|
||||
{
|
||||
if (IsMetadataOnly) { _files ??= []; _directories ??= []; return; }
|
||||
if (_mount is not null) return;
|
||||
lock (_file)
|
||||
{
|
||||
if (_mount is not null) return;
|
||||
if (IsRetail)
|
||||
throw new PkgNotSupportedException("This is a retail package. Its contents are encrypted with a key that is not available.");
|
||||
|
||||
OpenOuter();
|
||||
byte[] naps = new byte[_layoutFile!.size];
|
||||
_layoutFile.GetView().Read(0, naps, 0, naps.Length);
|
||||
var onDisk = new ReaderStream(_innerFile!.GetView(), _innerFile.size);
|
||||
InnerMount mount = new(onDisk, naps);
|
||||
(_files, _directories) = InnerFileTree.ReadAll(mount, mount.SuperblockOffset);
|
||||
_mount = mount;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenOuter()
|
||||
{
|
||||
long sbRelative = SuperblockOffset - PfsOffset;
|
||||
// The outer image is read in place. Its header carries a fixed marker seed in these packages and
|
||||
// its blocks are stored plain, so no key is needed.
|
||||
var reader = new FileReader(_file, PfsOffset);
|
||||
byte[] head = new byte[0x400];
|
||||
reader.Read(sbRelative, head, 0, head.Length);
|
||||
ProsperoPfsHeader sb = ProsperoPfsHeader.ReadFromStream(new MemoryStream(head));
|
||||
if (sb.Seed is null) throw new InvalidDataException("The outer image has no seed.");
|
||||
if (Encoding.ASCII.GetString(sb.Seed) != PlainSeed)
|
||||
{
|
||||
OpenEncryptedOuter(sb.Seed, sbRelative);
|
||||
return;
|
||||
}
|
||||
|
||||
_outer = new ProsperoPfsReader(reader, 0, null, null, null, sbRelative, skipDecryption: true);
|
||||
FindInnerFiles();
|
||||
}
|
||||
|
||||
private void FindInnerFiles()
|
||||
{
|
||||
_innerFile = _outer!.GetFile("pfs_image.dat") ?? throw new InvalidDataException("The package has no inner image (pfs_image.dat).");
|
||||
_layoutFile = _outer.GetFile("naps_pkg_layout.dat") ?? throw new InvalidDataException("The package has no layout file (naps_pkg_layout.dat).");
|
||||
}
|
||||
|
||||
// Packages built with a passcode encrypt the outer image block by block. The key is derived from the content id
|
||||
// and the passcode; the hash (SHA-256 or SHA3-256) and key schedule vary between builders, so each combination is
|
||||
// tried until the filesystem metadata decrypts into a coherent directory tree.
|
||||
private void OpenEncryptedOuter(byte[] seed, long sbRelative)
|
||||
{
|
||||
const int block = OuterXtsReader.BlockSize;
|
||||
if (PfsSize % block != 0 || sbRelative <= 0 || sbRelative % block != 0)
|
||||
throw new InvalidDataException("The outer image has an unexpected layout.");
|
||||
int total = (int)(PfsSize / block);
|
||||
int superblock = (int)(sbRelative / block);
|
||||
if (superblock >= total) throw new InvalidDataException("The outer superblock lies outside the outer image.");
|
||||
if (Passcode.Length != 32) throw new PkgNotSupportedException("The passcode must be 32 characters.");
|
||||
if (ContentId.Length != 36) throw new InvalidDataException("The package has no usable content id to derive the key from.");
|
||||
|
||||
foreach (bool sha3 in new[] { true, false })
|
||||
{
|
||||
foreach (bool newCrypt in new[] { true, false })
|
||||
{
|
||||
byte[] ekpfs = Crypto.ComputeKeys(ContentId, Passcode, 1, sha3);
|
||||
var (tweak, data) = Crypto.PfsGenEncKey(ekpfs, seed, newCrypt);
|
||||
|
||||
// First read the filesystem metadata, treating every block as metadata except the superblock.
|
||||
var kinds = new OuterBlockKind[total];
|
||||
Array.Fill(kinds, OuterBlockKind.Signed);
|
||||
kinds[superblock] = OuterBlockKind.Plain;
|
||||
var reader = new OuterXtsReader(_file, PfsOffset, tweak, data, kinds);
|
||||
try
|
||||
{
|
||||
var probe = new ProsperoPfsReader(reader, 0, null, null, null, sbRelative, skipDecryption: true);
|
||||
List<ProsperoPfsReader.File> all = probe.GetAllFiles().ToList();
|
||||
ProsperoPfsReader.File image = probe.GetFile("pfs_image.dat")
|
||||
?? throw new InvalidDataException("no inner image");
|
||||
|
||||
// The inner image's data blocks run from its start to the next file or the superblock; they use
|
||||
// the plain sector numbering.
|
||||
long start = image.offset / block, end = superblock;
|
||||
foreach (ProsperoPfsReader.File f in all)
|
||||
{
|
||||
long first = f.offset / block;
|
||||
if (first > start && first < end) end = first;
|
||||
}
|
||||
for (long b = start; b < end && b < total; b++)
|
||||
if (b != superblock) kinds[b] = OuterBlockKind.Data;
|
||||
reader.ClearCache();
|
||||
|
||||
_outer = new ProsperoPfsReader(reader, 0, null, null, null, sbRelative, skipDecryption: true);
|
||||
FindInnerFiles();
|
||||
_xtsReader = reader;
|
||||
return;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Wrong key or key schedule: the metadata did not decrypt. Try the next combination.
|
||||
reader.Dispose();
|
||||
_outer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException(
|
||||
"The outer image could not be decrypted with this passcode. Check that the passcode is the one the package was built with.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes every file to <paramref name="destination"/>, which must not exist yet. With <paramref name="includeMetadata"/> the package's
|
||||
/// metadata entries (parameters, artwork, trophies and so on) are written too, under sce_sys, so the result is a complete title folder.
|
||||
/// </summary>
|
||||
public void ExtractTo(string destination, IProgress<PkgExtractProgress>? progress = null, CancellationToken cancellationToken = default,
|
||||
bool includeMetadata = false)
|
||||
{
|
||||
string root = System.IO.Path.GetFullPath(destination);
|
||||
if (Directory.Exists(root)) throw new IOException("The extraction destination already exists: " + root);
|
||||
IReadOnlyList<PkgFile> files = Files;
|
||||
long total = files.Sum(f => f.Size), done = 0;
|
||||
string staging = root + ".extracting." + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(staging);
|
||||
byte[] buffer = new byte[1024 * 1024];
|
||||
foreach (PkgFile file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string target = ResolveContained(staging, file.Path);
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!);
|
||||
using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1024 * 1024);
|
||||
using Stream input = OpenFile(file);
|
||||
int read;
|
||||
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
output.Write(buffer, 0, read);
|
||||
done += read;
|
||||
progress?.Report(new PkgExtractProgress(done, total, file.Path));
|
||||
}
|
||||
if (output.Length != file.Size) throw new EndOfStreamException($"'{file.Path}' ended early.");
|
||||
}
|
||||
if (includeMetadata) WriteMetadataEntries(staging, cancellationToken);
|
||||
Directory.Move(staging, root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// The system entries are bookkeeping of the container itself; everything else is content of the title.
|
||||
private static readonly HashSet<uint> SystemEntryIds = [0x0001, 0x0010, 0x0020, 0x0080, 0x0100, 0x0200, 0x040A];
|
||||
|
||||
private void WriteMetadataEntries(string staging, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (PkgEntry entry in Entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (SystemEntryIds.Contains(entry.Id)) continue;
|
||||
string name = Writer.PkgEntryTable.FileNameOf(entry.Id) ?? $"entry_0x{entry.Id:X4}.bin";
|
||||
string target = ResolveContained(staging, "sce_sys/" + name);
|
||||
if (File.Exists(target)) continue;
|
||||
byte[] data = entry.IsEncrypted ? ReadEntry(entry, Passcode) : ReadEntry(entry);
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!);
|
||||
File.WriteAllBytes(target, data);
|
||||
}
|
||||
}
|
||||
private static string ResolveContained(string root, string relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative) || System.IO.Path.IsPathRooted(relative))
|
||||
throw new InvalidDataException("A package path is unsafe.");
|
||||
string rootFull = System.IO.Path.GetFullPath(root) + System.IO.Path.DirectorySeparatorChar;
|
||||
string target = System.IO.Path.GetFullPath(System.IO.Path.Combine(root, relative));
|
||||
if (!target.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("A package path escapes the extraction folder.");
|
||||
return target;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_mount?.Dispose();
|
||||
_xtsReader?.Dispose();
|
||||
_file.Dispose();
|
||||
}
|
||||
|
||||
// ---- small adapters ------------------------------------------------------------------------------
|
||||
|
||||
private sealed class FileReader(FileStream file, long baseOffset) : IMemoryReader
|
||||
{
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
lock (file)
|
||||
{
|
||||
file.Position = baseOffset + pos;
|
||||
file.ReadExactly(buf, offset, count);
|
||||
}
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class ReaderStream(IMemoryReader reader, long length) : Stream
|
||||
{
|
||||
private long _pos;
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
public override long Position { get => _pos; set => _pos = value; }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
int take = (int)Math.Min(count, Math.Max(0, length - _pos));
|
||||
if (take > 0) reader.Read(_pos, buffer, offset, take);
|
||||
_pos += take;
|
||||
return take;
|
||||
}
|
||||
public override long Seek(long offset, SeekOrigin origin) =>
|
||||
_pos = origin switch { SeekOrigin.Begin => offset, SeekOrigin.Current => _pos + offset, _ => length + offset };
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// A window over the shared image; disposing it must not close the image.
|
||||
private sealed class BoundedStream(Stream inner, long start, long length) : Stream
|
||||
{
|
||||
private long _pos;
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
public override long Position { get => _pos; set => _pos = value; }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
int take = (int)Math.Min(count, Math.Max(0, length - _pos));
|
||||
if (take <= 0) return 0;
|
||||
lock (inner)
|
||||
{
|
||||
inner.Position = start + _pos;
|
||||
int read = inner.Read(buffer, offset, take);
|
||||
_pos += read;
|
||||
return read;
|
||||
}
|
||||
}
|
||||
public override long Seek(long offset, SeekOrigin origin) =>
|
||||
_pos = origin switch { SeekOrigin.Begin => offset, SeekOrigin.Current => _pos + offset, _ => length + offset };
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Packwright.Pkg - a plain-text description of a package's structure, for comparing packages byte by byte
|
||||
// when writing or debugging a package builder.
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using LibProsperoPkg.PFS;
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Describes the package layout: headers, entry table, outer image, layout counts and inner image.</summary>
|
||||
public string DescribeStructure()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
void Line(string text = "") => sb.AppendLine(text);
|
||||
|
||||
// ---- FIH header and CNT header, as raw non-zero rows -----------------------------------------
|
||||
byte[] fih = new byte[0x100];
|
||||
byte[] cnt = new byte[CntHeaderSize];
|
||||
lock (_file)
|
||||
{
|
||||
_file.Position = 0; _file.ReadExactly(fih);
|
||||
_file.Position = _cntOffset; _file.ReadExactly(cnt);
|
||||
}
|
||||
Line($"file size 0x{FileSize:X} FIH signed byte 0x{SignedByte:X2} FIH version {FormatVersion}");
|
||||
Line($"PFS offset 0x{PfsOffset:X} size 0x{PfsSize:X}; superblock at 0x{SuperblockOffset:X}; CNT at 0x{CntOffset:X}");
|
||||
Line($"CNT: flags 0x{HeaderFlags:X8} entries {Entries.Count} sc-entries {ScEntryCount} body 0x{BodyOffset:X}+0x{BodySize:X}");
|
||||
Line($"CNT: content id {ContentId} drm {DrmType} type {ContentType} content flags 0x{ContentFlags:X8}");
|
||||
Line("FIH rows (non-zero):");
|
||||
Rows(sb, fih);
|
||||
Line("CNT header rows (non-zero):");
|
||||
Rows(sb, cnt);
|
||||
|
||||
// ---- entries -----------------------------------------------------------------------------------
|
||||
Line("entries:");
|
||||
foreach (PkgEntry e in Entries)
|
||||
Line($" 0x{e.Id:X4} {(e.IsEncrypted ? "enc" : " ")} f1=0x{e.Flags1:X8} f2=0x{e.Flags2:X8} off=0x{e.Offset:X} size=0x{e.Size:X} name='{e.Name}'");
|
||||
|
||||
// ---- outer image ---------------------------------------------------------------------------------
|
||||
if (IsMetadataOnly || IsRetail) return sb.ToString();
|
||||
try
|
||||
{
|
||||
EnsureInner();
|
||||
ProsperoPfsReader outer = _outer!;
|
||||
byte[] head = new byte[0x400];
|
||||
long sbRel = SuperblockOffset - PfsOffset;
|
||||
new FileReader(_file, PfsOffset).Read(sbRel, head, 0, head.Length);
|
||||
ProsperoPfsHeader hdr = ProsperoPfsHeader.ReadFromStream(new MemoryStream(head));
|
||||
Line($"outer PFS: version {hdr.Version} mode {hdr.Mode} block 0x{hdr.BlockSize:X} dinodes {hdr.DinodeCount} dblocks {hdr.Ndblock} seed {Convert.ToHexString(hdr.Seed ?? [])} ({(_xtsReader is null ? "plain" : "encrypted")})");
|
||||
Line("outer files:");
|
||||
foreach (ProsperoPfsReader.File f in outer.GetAllFiles())
|
||||
Line($" {f.name,-24} off=0x{f.offset:X} size=0x{f.size:X} logical/compressed=0x{f.compressed_size:X} flags={f.flags}");
|
||||
|
||||
// ---- layout file -----------------------------------------------------------------------------
|
||||
byte[] naps = new byte[_layoutFile!.size];
|
||||
_layoutFile.GetView().Read(0, naps, 0, naps.Length);
|
||||
var layout = new NapsLayout(naps);
|
||||
Line($"layout: {naps.Length} bytes, files {layout.NumFiles}, ublocks {layout.NumUBlocks}, shuffle patterns {layout.NumShufflePatterns}, blocks {layout.Blocks.Count}, u2c {layout.U2c.Count}");
|
||||
Line($"layout file offsets: first {string.Join(", ", layout.Files.Take(4).Select(f => $"0x{f.UncompressedOffset:X}/t{f.Type:X2}"))} ... last {string.Join(", ", layout.Files.TakeLast(3).Select(f => $"0x{f.UncompressedOffset:X}/t{f.Type:X2}"))}");
|
||||
int rawBlocks = 0, krakenBlocks = 0, zeroish = 0;
|
||||
foreach (NapsBlockInfo b in layout.Blocks.Where(b => !b.IsRunBase && !b.IsTerminal))
|
||||
{
|
||||
if (b.KdePredictor != 0) continue;
|
||||
if (b.Even == 0 && b.Odd == 0) rawBlocks++; else krakenBlocks++;
|
||||
}
|
||||
_ = zeroish;
|
||||
Line($"layout normal blocks: {rawBlocks} with no compression flags, {krakenBlocks} with compression flags");
|
||||
|
||||
// ---- inner image -----------------------------------------------------------------------------
|
||||
InnerMount mount = _mount!;
|
||||
Line($"inner image: logical size 0x{mount.MountSize:X}, superblock at 0x{mount.SuperblockOffset:X}, {Files.Count} files, {Files.Sum(f => f.Size):N0} bytes of file data");
|
||||
byte[] isb = new byte[0x80];
|
||||
mount.Position = mount.SuperblockOffset;
|
||||
mount.ReadExactly(isb);
|
||||
Line("inner superblock rows (non-zero):");
|
||||
Rows(sb, isb);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or NotSupportedException or IOException)
|
||||
{
|
||||
Line("outer/inner not described: " + ex.Message);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void Rows(StringBuilder sb, byte[] data)
|
||||
{
|
||||
for (int i = 0; i < data.Length; i += 32)
|
||||
{
|
||||
ReadOnlySpan<byte> row = data.AsSpan(i, Math.Min(32, data.Length - i));
|
||||
if (row.IndexOfAnyExcept((byte)0) < 0) continue;
|
||||
sb.AppendLine($" {i:X4}: {Convert.ToHexString(row)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Dumps the inner filesystem: superblock, every inode record and every directory listing.</summary>
|
||||
public string DescribeInnerFilesystem()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
EnsureInner();
|
||||
InnerMount mount = _mount ?? throw new InvalidDataException("This package has no inner image.");
|
||||
long meta = mount.SuperblockOffset;
|
||||
|
||||
byte[] head = new byte[0x400];
|
||||
mount.Position = meta; mount.ReadExactly(head);
|
||||
int blockSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(head.AsSpan(0x20));
|
||||
long inodeCount = BinaryPrimitives.ReadInt64LittleEndian(head.AsSpan(0x30));
|
||||
sb.AppendLine($"inner superblock @0x{meta:X}: mode 0x{BinaryPrimitives.ReadUInt16LittleEndian(head.AsSpan(0x1C)):X} block 0x{blockSize:X} dinodes {inodeCount} nblock {BinaryPrimitives.ReadInt64LittleEndian(head.AsSpan(0x28))} ndblock {BinaryPrimitives.ReadInt64LittleEndian(head.AsSpan(0x38))} inode-blocks {BinaryPrimitives.ReadInt64LittleEndian(head.AsSpan(0x40))}");
|
||||
sb.AppendLine("superblock bytes (non-zero rows, full 0x400):");
|
||||
Rows(sb, head);
|
||||
|
||||
int perBlock = blockSize / 0xA8;
|
||||
var inodes = new byte[inodeCount][];
|
||||
for (long i = 0; i < inodeCount; i++)
|
||||
{
|
||||
byte[] raw = new byte[0xA8];
|
||||
mount.Position = meta + blockSize + (i / perBlock) * blockSize + (i % perBlock) * 0xA8;
|
||||
mount.ReadExactly(raw);
|
||||
inodes[i] = raw;
|
||||
}
|
||||
sb.AppendLine("inodes: index mode nlink flags size sizeC | @0x60 u64 | @0x68 u32 | @0x6C u32 | tail");
|
||||
for (long i = 0; i < inodeCount; i++)
|
||||
{
|
||||
byte[] r = inodes[i];
|
||||
sb.AppendLine($" [{i,3}] mode=0x{BinaryPrimitives.ReadUInt16LittleEndian(r):X4} nlink={BinaryPrimitives.ReadUInt16LittleEndian(r.AsSpan(2))} flags=0x{BinaryPrimitives.ReadUInt32LittleEndian(r.AsSpan(4)):X} size={BinaryPrimitives.ReadInt64LittleEndian(r.AsSpan(8))} sizeC={BinaryPrimitives.ReadInt64LittleEndian(r.AsSpan(0x10))} | t=0x{BinaryPrimitives.ReadInt64LittleEndian(r.AsSpan(0x18)):X} uid={BinaryPrimitives.ReadUInt32LittleEndian(r.AsSpan(0x48))} gid={BinaryPrimitives.ReadUInt32LittleEndian(r.AsSpan(0x4C))} unk1=0x{BinaryPrimitives.ReadUInt64LittleEndian(r.AsSpan(0x50)):X} unk2=0x{BinaryPrimitives.ReadUInt64LittleEndian(r.AsSpan(0x58)):X} | @60=0x{BinaryPrimitives.ReadUInt64LittleEndian(r.AsSpan(0x60)):X} @68={BinaryPrimitives.ReadUInt32LittleEndian(r.AsSpan(0x68))} @6C={BinaryPrimitives.ReadUInt32LittleEndian(r.AsSpan(0x6C))} | {Convert.ToHexString(r.AsSpan(0x70))}");
|
||||
}
|
||||
|
||||
sb.AppendLine("directories:");
|
||||
for (long i = 0; i < inodeCount; i++)
|
||||
{
|
||||
byte[] r = inodes[i];
|
||||
if ((BinaryPrimitives.ReadUInt16LittleEndian(r) & 0x4000) == 0) continue;
|
||||
long size = BinaryPrimitives.ReadInt64LittleEndian(r.AsSpan(8));
|
||||
long where = (long)BinaryPrimitives.ReadUInt64LittleEndian(r.AsSpan(0x60));
|
||||
if (size <= 0 || size > 64 * 1024 * 1024) continue;
|
||||
byte[] listing = new byte[size];
|
||||
mount.Position = where; mount.ReadExactly(listing);
|
||||
sb.AppendLine($" dir inode {i} at 0x{where:X}, {size} bytes:");
|
||||
using var ms = new MemoryStream(listing);
|
||||
while (ms.Position + 0x10 <= ms.Length)
|
||||
{
|
||||
long pos = ms.Position;
|
||||
ProsperoPfsDirent d;
|
||||
try { d = ProsperoPfsDirent.ReadFromStream(ms); } catch (Exception) { break; }
|
||||
if (d.EntSize <= 0) break;
|
||||
ms.Position = pos + d.EntSize;
|
||||
sb.AppendLine($" @{pos,5} ino={d.InodeNumber,3} type={d.Type} entsize={d.EntSize} '{d.Name}'");
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Reads a byte range of the expanded inner image (for diagnostics and comparisons).</summary>
|
||||
public byte[] ReadInnerRange(long offset, int count)
|
||||
{
|
||||
EnsureInner();
|
||||
InnerMount mount = _mount ?? throw new InvalidDataException("This package has no inner image.");
|
||||
byte[] data = new byte[count];
|
||||
mount.Position = offset;
|
||||
mount.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>The package's layout file (naps_pkg_layout.dat), parsed, for diagnostics.</summary>
|
||||
internal NapsLayout ReadLayout()
|
||||
{
|
||||
EnsureInner();
|
||||
byte[] naps = new byte[_layoutFile!.size];
|
||||
_layoutFile.GetView().Read(0, naps, 0, naps.Length);
|
||||
return new NapsLayout(naps);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Reads a range of the stored inner image (pfs_image.dat), for diagnostics.</summary>
|
||||
internal byte[] ReadStoredRange(long offset, int count)
|
||||
{
|
||||
EnsureInner();
|
||||
byte[] data = new byte[count];
|
||||
_innerFile!.GetView().Read(offset, data, 0, count);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>The raw outer-block records of the layout file (8 bytes each), for diagnostics.</summary>
|
||||
internal byte[][] ReadOuterBlockRecords()
|
||||
{
|
||||
EnsureInner();
|
||||
byte[] naps = new byte[_layoutFile!.size];
|
||||
_layoutFile.GetView().Read(0, naps, 0, naps.Length);
|
||||
ulong w1 = BinaryPrimitives.ReadUInt64LittleEndian(naps.AsSpan(8));
|
||||
int count = (int)(w1 & 0xFFFFFF);
|
||||
var records = new byte[count][];
|
||||
for (int i = 0; i < count; i++) records[i] = naps.AsSpan(16 + 8 * i, 8).ToArray();
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Dumps the outer filesystem: superblock fields, the inode table and what lies between the data and the metadata.</summary>
|
||||
public string DescribeOuter()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
EnsureInner();
|
||||
const int block = 0x10000;
|
||||
IMemoryReader reader = (IMemoryReader?)_xtsReader ?? new FileReader(_file, PfsOffset);
|
||||
long sbRel = SuperblockOffset - PfsOffset;
|
||||
byte[] head = new byte[block];
|
||||
reader.Read(sbRel, head, 0, block);
|
||||
sb.AppendLine($"outer superblock block {sbRel / block} of {PfsSize / block}:");
|
||||
Rows(sb, head.AsSpan(0, 0x5A0).ToArray());
|
||||
|
||||
byte[] table = new byte[block];
|
||||
reader.Read(sbRel + block, table, 0, block);
|
||||
var ms = new MemoryStream(table);
|
||||
ProsperoPfsHeader hdr = ProsperoPfsHeader.ReadFromStream(new MemoryStream(head));
|
||||
for (int i = 0; i < hdr.DinodeCount; i++)
|
||||
{
|
||||
ProsperoDinodeS32 d = ProsperoDinodeS32.ReadFromStream(ms);
|
||||
sb.AppendLine($"inode {i}: mode 0x{(ushort)d.Mode:X4} nlink {d.Nlink} flags 0x{(uint)d.Flags:X} size 0x{d.Size:X} sizeC 0x{d.SizeCompressed:X} blocks {d.Blocks} time {d.Time1_sec}/{d.Time1_nsec} uid {d.Uid} gid {d.Gid} unk {d.Unk1:X}/{d.Unk2:X}");
|
||||
sb.AppendLine(" db: " + string.Join(" ", d.db.Select(x => x.block)));
|
||||
sb.AppendLine(" ib: " + string.Join(" ", d.ib.Select(x => x.block)));
|
||||
sb.AppendLine(" db0 sig " + Convert.ToHexString(d.db[0].sig)[..16] + " ib0 sig " + Convert.ToHexString(d.ib[0].sig)[..16]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Reads one block of the outer image as stored on disk (no decryption), for diagnostics.</summary>
|
||||
internal byte[] ReadOuterBlockRaw(long index)
|
||||
{
|
||||
byte[] data = new byte[0x10000];
|
||||
lock (_file) { _file.Position = PfsOffset + index * 0x10000; _file.ReadExactly(data); }
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class PkgPackage
|
||||
{
|
||||
/// <summary>Size of the stored inner image (pfs_image.dat) in the outer filesystem.</summary>
|
||||
internal long StoredImageSize
|
||||
{
|
||||
get { EnsureInner(); return _innerFile!.size; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Packwright.Pkg - integrity checks for PS5 packages.
|
||||
//
|
||||
// Everything here works without any key: structure, the package's own table of per-entry digests, the layout
|
||||
// of the inner image, and a full decode of every file. Packages whose contents are encrypted with a key we do
|
||||
// not have report a warning for the checks that cannot run instead of a failure.
|
||||
#nullable enable
|
||||
using LibProsperoPkg.Util;
|
||||
|
||||
namespace Packwright.Pkg.Reader;
|
||||
|
||||
public enum PkgCheckStatus { Pass, Warning, Fail }
|
||||
|
||||
public sealed record PkgCheck(string Name, PkgCheckStatus Status, string Detail);
|
||||
|
||||
public sealed class PkgValidationReport
|
||||
{
|
||||
public required IReadOnlyList<PkgCheck> Checks { get; init; }
|
||||
|
||||
/// <summary>True when no check failed (warnings are allowed).</summary>
|
||||
public bool Passed => Checks.All(c => c.Status != PkgCheckStatus.Fail);
|
||||
public bool HasWarnings => Checks.Any(c => c.Status == PkgCheckStatus.Warning);
|
||||
public IEnumerable<PkgCheck> Failures => Checks.Where(c => c.Status == PkgCheckStatus.Fail);
|
||||
}
|
||||
|
||||
public static class PkgValidator
|
||||
{
|
||||
private const uint EntryDigestsId = 0x0001;
|
||||
private const int OuterBlockSize = 0x10000;
|
||||
|
||||
/// <summary>
|
||||
/// Checks a package. With <paramref name="decodeContent"/> every file is decoded in full, which is the
|
||||
/// slow part (it reads the whole package); progress is reported in bytes of expanded content.
|
||||
/// </summary>
|
||||
public static PkgValidationReport Validate(PkgPackage pkg, bool decodeContent = true,
|
||||
IProgress<PkgExtractProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checks = new List<PkgCheck>();
|
||||
void Add(string name, PkgCheckStatus status, string detail) => checks.Add(new PkgCheck(name, status, detail));
|
||||
|
||||
CheckHeader(pkg, Add);
|
||||
CheckEntries(pkg, Add);
|
||||
CheckEntryDigests(pkg, Add, cancellationToken);
|
||||
if (!CheckInner(pkg, Add, decodeContent, progress, cancellationToken))
|
||||
return new PkgValidationReport { Checks = checks };
|
||||
return new PkgValidationReport { Checks = checks };
|
||||
}
|
||||
|
||||
private static void CheckHeader(PkgPackage pkg, Action<string, PkgCheckStatus, string> add)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (pkg.ContentId.Length != 36) problems.Add($"the content id '{pkg.ContentId}' is not 36 characters");
|
||||
if (pkg.PfsSize <= 0 || pkg.PfsSize % OuterBlockSize != 0) problems.Add("the outer image size is not a multiple of 64 KB");
|
||||
if (pkg.PfsOffset + pkg.PfsSize > pkg.FileSize) problems.Add("the outer image extends past the end of the file");
|
||||
if (pkg.CntOffset < pkg.PfsOffset + pkg.PfsSize) problems.Add("the metadata container overlaps the outer image");
|
||||
if (pkg.SuperblockOffset < pkg.PfsOffset || pkg.SuperblockOffset >= pkg.PfsOffset + pkg.PfsSize)
|
||||
problems.Add("the outer superblock lies outside the outer image");
|
||||
add("Header", problems.Count == 0 ? PkgCheckStatus.Pass : PkgCheckStatus.Fail,
|
||||
problems.Count == 0 ? $"{pkg.ContentId}, {(pkg.IsRetail ? "retail" : "debug")} package" : string.Join("; ", problems));
|
||||
}
|
||||
|
||||
private static void CheckEntries(PkgPackage pkg, Action<string, PkgCheckStatus, string> add)
|
||||
{
|
||||
var bad = pkg.Entries.Where(e => pkg.CntOffset + e.Offset + e.Size > pkg.FileSize).Select(e => $"0x{e.Id:X4}").ToList();
|
||||
add("Metadata entries", bad.Count == 0 ? PkgCheckStatus.Pass : PkgCheckStatus.Fail,
|
||||
bad.Count == 0 ? $"{pkg.Entries.Count} entries, all inside the file" : "entries extend past the end of the file: " + string.Join(", ", bad));
|
||||
}
|
||||
|
||||
// The container carries one SHA3-256 per entry (table entry 0x0001, slot = entry position; slot 0 is the
|
||||
// table itself). Protected entries are hashed as stored, padded to a 16-byte boundary.
|
||||
private static void CheckEntryDigests(PkgPackage pkg, Action<string, PkgCheckStatus, string> add, CancellationToken ct)
|
||||
{
|
||||
int tableIndex = -1;
|
||||
for (int i = 0; i < pkg.Entries.Count; i++)
|
||||
if (pkg.Entries[i].Id == EntryDigestsId) { tableIndex = i; break; }
|
||||
if (tableIndex < 0) { add("Metadata digests", PkgCheckStatus.Warning, "the package has no digest table to check"); return; }
|
||||
|
||||
byte[] table;
|
||||
try { table = pkg.ReadEntry(pkg.Entries[tableIndex]); }
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidDataException or IOException)
|
||||
{ add("Metadata digests", PkgCheckStatus.Warning, "the digest table cannot be read: " + ex.Message); return; }
|
||||
if (table.Length < 32L * pkg.Entries.Count)
|
||||
{ add("Metadata digests", PkgCheckStatus.Fail, "the digest table is shorter than the entry table"); return; }
|
||||
|
||||
using var file = new FileStream(pkg.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1024 * 1024, FileOptions.SequentialScan);
|
||||
var mismatches = new List<string>();
|
||||
int checkedCount = 0;
|
||||
for (int i = 0; i < pkg.Entries.Count; i++)
|
||||
{
|
||||
if (i == tableIndex) continue;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
PkgEntry e = pkg.Entries[i];
|
||||
long length = e.IsEncrypted ? (e.Size + 15) & ~15L : e.Size;
|
||||
if (pkg.CntOffset + e.Offset + length > pkg.FileSize) continue; // reported by the entry check
|
||||
byte[] hash = Crypto.Sha3_256(file, pkg.CntOffset + e.Offset, length);
|
||||
checkedCount++;
|
||||
if (!table.AsSpan(i * 32, 32).SequenceEqual(hash)) mismatches.Add($"0x{e.Id:X4}");
|
||||
}
|
||||
add("Metadata digests", mismatches.Count == 0 ? PkgCheckStatus.Pass : PkgCheckStatus.Fail,
|
||||
mismatches.Count == 0 ? $"{checkedCount} entries match their digests" : "entries do not match their digests: " + string.Join(", ", mismatches));
|
||||
}
|
||||
|
||||
private static bool CheckInner(PkgPackage pkg, Action<string, PkgCheckStatus, string> add, bool decodeContent,
|
||||
IProgress<PkgExtractProgress>? progress, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<PkgFile> files;
|
||||
try { files = pkg.Files; }
|
||||
catch (PkgNotSupportedException ex)
|
||||
{
|
||||
add("Contents", PkgCheckStatus.Warning, "the contents cannot be checked: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or EndOfStreamException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
add("Contents", PkgCheckStatus.Fail, "the inner image cannot be read: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
|
||||
long total = files.Sum(f => f.Size);
|
||||
long mount = pkg.InnerImageSize;
|
||||
var outside = files.Where(f => f.LogicalOffset < 0 || f.Size < 0 || f.LogicalOffset + f.Size > mount).Select(f => f.Path).ToList();
|
||||
var unsafePaths = files.Where(f => f.Path.StartsWith('/') || f.Path.Split('/').Contains("..")).Select(f => f.Path).ToList();
|
||||
if (outside.Count > 0 || unsafePaths.Count > 0)
|
||||
add("File table", PkgCheckStatus.Fail,
|
||||
(outside.Count > 0 ? $"{outside.Count} files lie outside the image (first: {outside[0]}). " : "") +
|
||||
(unsafePaths.Count > 0 ? $"{unsafePaths.Count} paths are unsafe (first: {unsafePaths[0]})." : ""));
|
||||
else
|
||||
add("File table", PkgCheckStatus.Pass, $"{files.Count:N0} files, {total:N0} bytes");
|
||||
|
||||
if (!decodeContent) return true;
|
||||
|
||||
long done = 0;
|
||||
var failures = new List<string>();
|
||||
byte[] buffer = new byte[1024 * 1024];
|
||||
foreach (PkgFile file in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using Stream input = pkg.OpenFile(file);
|
||||
long read = 0;
|
||||
int n;
|
||||
while ((n = input.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
read += n;
|
||||
done += n;
|
||||
progress?.Report(new PkgExtractProgress(done, total, file.Path));
|
||||
}
|
||||
if (read != file.Size) failures.Add($"{file.Path} (ended after {read:N0} of {file.Size:N0} bytes)");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or NotSupportedException or EndOfStreamException)
|
||||
{
|
||||
failures.Add($"{file.Path} ({ex.Message})");
|
||||
if (failures.Count >= 20) break;
|
||||
}
|
||||
}
|
||||
add("Content", failures.Count == 0 ? PkgCheckStatus.Pass : PkgCheckStatus.Fail,
|
||||
failures.Count == 0 ? $"every file decodes ({done:N0} bytes)" : $"{failures.Count} files cannot be decoded; first: {failures[0]}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Packwright.Pkg - CRC-32C (Castagnoli), the checksum of the per-block table in the install metadata.
|
||||
#nullable enable
|
||||
using System.Runtime.Intrinsics.X86;
|
||||
|
||||
namespace Packwright.Pkg.Util;
|
||||
|
||||
public static class Crc32C
|
||||
{
|
||||
private static readonly uint[] Table = BuildTable();
|
||||
|
||||
private static uint[] BuildTable()
|
||||
{
|
||||
var table = new uint[256];
|
||||
for (uint i = 0; i < 256; i++)
|
||||
{
|
||||
uint c = i;
|
||||
for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? (c >> 1) ^ 0x82F63B78u : c >> 1;
|
||||
table[i] = c;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
public static uint Compute(ReadOnlySpan<byte> data)
|
||||
{
|
||||
uint crc = 0xFFFFFFFF;
|
||||
int i = 0;
|
||||
if (Sse42.X64.IsSupported)
|
||||
{
|
||||
ulong wide = crc;
|
||||
for (; i + 8 <= data.Length; i += 8)
|
||||
wide = Sse42.X64.Crc32(wide, BitConverter.ToUInt64(data.Slice(i, 8)));
|
||||
crc = (uint)wide;
|
||||
}
|
||||
for (; i < data.Length; i++) crc = (crc >> 8) ^ Table[(crc ^ data[i]) & 0xFF];
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// Shared utility primitives: crypto, binary IO and stream helpers.
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace LibProsperoPkg.Util;
|
||||
|
||||
public static class Crypto
|
||||
{
|
||||
/// <summary>
|
||||
/// Key-derivation step:
|
||||
/// a common function to generate a final key for PFS
|
||||
/// </summary>
|
||||
public static byte[] PfsGenCryptoKey(byte[] ekpfs, byte[] seed, uint index)
|
||||
{
|
||||
byte[] d = new byte[4 + seed.Length];
|
||||
Array.Copy(BitConverter.GetBytes(index), d, 4);
|
||||
Array.Copy(seed, 0, d, 4, seed.Length);
|
||||
using (var hmac = new HMACSHA256(ekpfs))
|
||||
{
|
||||
return hmac.ComputeHash(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a (tweak, data) key pair for XTS
|
||||
/// </summary>
|
||||
public static Tuple<byte[], byte[]> PfsGenEncKey(byte[] ekpfs, byte[] seed, bool newCrypt = false)
|
||||
{
|
||||
var encKey = PfsGenCryptoKey(newCrypt ? HMACSHA256.HashData(ekpfs, seed) : ekpfs, seed, 1);
|
||||
var dataKey = new byte[16];
|
||||
var tweakKey = new byte[16];
|
||||
Buffer.BlockCopy(encKey, 0, tweakKey, 0, 16);
|
||||
Buffer.BlockCopy(encKey, 16, dataKey, 0, 16);
|
||||
return Tuple.Create(tweakKey, dataKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key-derivation step:
|
||||
/// asigning key generator based on EKPFS and PFS header seed
|
||||
/// </summary>
|
||||
public static byte[] PfsGenSignKey(byte[] ekpfs, byte[] seed, bool newCrypt = false)
|
||||
{
|
||||
return PfsGenCryptoKey(newCrypt ? HMACSHA256.HashData(ekpfs, seed) : ekpfs, seed, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AES-128-CBC engine (no padding) using the modern <see cref="Aes.Create()"/>
|
||||
/// factory in place of the obsolete <c>AesManaged</c> type.
|
||||
/// </summary>
|
||||
private static Aes CreateCbcAes(byte[] key, byte[] iv)
|
||||
{
|
||||
var aes = Aes.Create();
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.KeySize = 128;
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
aes.Padding = PaddingMode.None;
|
||||
aes.BlockSize = 128;
|
||||
return aes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts <paramref name="data"/> with an RSA public key — big-endian <paramref name="modulus"/>,
|
||||
/// public exponent 65537 — using EME-PKCS#1 v1.5 (type 2) padding. This is the PS5 CNT key-entry
|
||||
/// wrapping: the ciphertext length equals the modulus length (384 bytes = RSA-3072). The PKCS#1 pad
|
||||
/// is random, so successive calls are not byte-identical, but each decrypts to <paramref name="data"/>
|
||||
/// under the corresponding private key.
|
||||
/// </summary>
|
||||
public static byte[] RsaPkcs1EncryptKey(byte[] modulus, byte[] data)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportParameters(new RSAParameters
|
||||
{
|
||||
Modulus = modulus,
|
||||
Exponent = [0x01, 0x00, 0x01],
|
||||
});
|
||||
return rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1);
|
||||
}
|
||||
|
||||
public static int AesCbcCfb128Encrypt(byte[] @out, byte[] @in, int size, byte[] key, byte[] iv)
|
||||
{
|
||||
using var cipher = CreateCbcAes(key, iv);
|
||||
var tmp = new byte[size];
|
||||
using (var pt_stream = new MemoryStream(@in))
|
||||
using (var ct_stream = new MemoryStream(tmp))
|
||||
using (var dec = cipher.CreateEncryptor(key, iv))
|
||||
using (var s = new CryptoStream(ct_stream, dec, CryptoStreamMode.Write))
|
||||
{
|
||||
pt_stream.CopyTo(s);
|
||||
}
|
||||
Buffer.BlockCopy(tmp, 0, @out, 0, tmp.Length);
|
||||
return 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// Computes the SHA256 hash of the given data.
|
||||
/// </summary>
|
||||
public static byte[] Sha256(byte[] data) => SHA256.HashData(data);
|
||||
public static byte[] Sha256(Stream data)
|
||||
{
|
||||
data.Position = 0;
|
||||
return SHA256.HashData(data);
|
||||
}
|
||||
/// <summary>
|
||||
/// Computes the SHA256 hash of the data in the stream between (start) and (start+length)
|
||||
/// </summary>
|
||||
public static byte[] Sha256(Stream data, long start, long length)
|
||||
{
|
||||
using (var s = new SubStream(data, start, length))
|
||||
{
|
||||
return Sha256(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA3-256 hash of the given data. SHA3-256 is the digest primitive used by the
|
||||
/// PS5 PFS: outer-image EKPFS key derivation, the compressed-file 'PFSC' digests, and
|
||||
/// the per-block hashes. Requires a runtime/platform that provides SHA-3 (verified on .NET 10).
|
||||
/// </summary>
|
||||
public static byte[] Sha3_256(byte[] data)
|
||||
{
|
||||
return Packwright.Pkg.Util.Sha3.Hash(data);
|
||||
}
|
||||
|
||||
/// <summary>Computes the SHA3-256 hash over the whole stream (used for PS5 CNT body/entry digests).</summary>
|
||||
public static byte[] Sha3_256(Stream data)
|
||||
{
|
||||
data.Position = 0;
|
||||
return Packwright.Pkg.Util.Sha3.Hash(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA3-256 hash of the data in the stream between (start) and (start+length). This is
|
||||
/// the PS5 CNT digest primitive (per-entry table, body-digest, sc-entry rollups).
|
||||
/// </summary>
|
||||
public static byte[] Sha3_256(Stream data, long start, long length)
|
||||
{
|
||||
using (var s = new SubStream(data, start, length))
|
||||
{
|
||||
return Sha3_256(s);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static byte[] HmacSha256(byte[] key, byte[] data)
|
||||
=> HMACSHA256.HashData(key, data);
|
||||
public static byte[] HmacSha256(byte[] key, Stream data)
|
||||
{
|
||||
data.Position = 0;
|
||||
return HMACSHA256.HashData(key, data);
|
||||
}
|
||||
public static byte[] HmacSha256(byte[] key, Stream data, long start, long length)
|
||||
{
|
||||
using (var s = new SubStream(data, start, length))
|
||||
{
|
||||
return HmacSha256(key, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes keys for the package.
|
||||
/// The key is the result of a SHA256 hash of the concatenation of:
|
||||
/// - The SHA256 hash of the index (4 bytes big-endian)
|
||||
/// - The SHA256 hash of the Content ID (36 bytes padded to 48 with nulls)
|
||||
/// - The passcode
|
||||
/// The EKPFS is Index 1.
|
||||
/// </summary>
|
||||
public static byte[] ComputeKeys(string ContentId, string Passcode, uint Index)
|
||||
=> ComputeKeys(ContentId, Passcode, Index, useSha3: false);
|
||||
|
||||
/// <summary>
|
||||
/// Computes keys for the package, selecting the per-generation digest primitive.
|
||||
/// EKPFS (Index 1) = H( H(Index, 4 bytes big-endian) || H(ContentId padded to 48 with nulls) || Passcode ),
|
||||
/// where H = SHA3-256 (useSha3: true) or SHA-256 (useSha3: false).
|
||||
/// The SHA3 form yields the EKPFS used for the outer PFS image; combine it
|
||||
/// with <see cref="PfsGenEncKey"/>/<see cref="PfsGenSignKey"/> using <c>newCrypt: true</c>.
|
||||
/// </summary>
|
||||
public static byte[] ComputeKeys(string ContentId, string Passcode, uint Index, bool useSha3)
|
||||
{
|
||||
if (ContentId.Length != 36)
|
||||
throw new Exception("Content ID must be 36 characters long");
|
||||
if (Passcode.Length != 32)
|
||||
throw new Exception("Passcode must be 32 characters long");
|
||||
|
||||
Func<byte[], byte[]> h = useSha3 ? Sha3_256 : Sha256;
|
||||
byte[] data = new byte[96];
|
||||
Buffer.BlockCopy(h(BitConverter.GetBytes(Index).Reverse().ToArray()), 0, data, 0, 32);
|
||||
Buffer.BlockCopy(h(Encoding.ASCII.GetBytes(ContentId.PadRight(48, '\0'))), 0, data, 32, 32);
|
||||
Buffer.BlockCopy(Encoding.ASCII.GetBytes(Passcode), 0, data, 64, 32);
|
||||
|
||||
return h(data);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// XORs a with b and stores the result in a
|
||||
/// </summary>
|
||||
public static byte[] Xor(this byte[] a, byte[] b)
|
||||
{
|
||||
for (var i = 0; i < a.Length; i++)
|
||||
{
|
||||
a[i] ^= b[i];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Packwright.Pkg - AES-XTS-128 over whole data units, in bulk.
|
||||
//
|
||||
// Same transform as XtsBlockTransform (the tweak is the data unit number, little-endian, encrypted with the tweak
|
||||
// key, then multiplied by x in GF(2^128) for every following 16-byte block) but done with one ECB call over the
|
||||
// whole unit instead of one call per block, which is several times faster on large images.
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Packwright.Pkg.Util;
|
||||
|
||||
public sealed class FastXts : IDisposable
|
||||
{
|
||||
private readonly Aes _data;
|
||||
private readonly Aes _tweak;
|
||||
private byte[] _mask = new byte[0];
|
||||
|
||||
public FastXts(byte[] dataKey, byte[] tweakKey)
|
||||
{
|
||||
_data = Aes.Create();
|
||||
_data.Key = dataKey;
|
||||
_tweak = Aes.Create();
|
||||
_tweak.Key = tweakKey;
|
||||
}
|
||||
|
||||
/// <summary>Encrypts or decrypts <paramref name="unit"/> (a multiple of 16 bytes) in place as data unit <paramref name="sector"/>.</summary>
|
||||
public void Crypt(Span<byte> unit, ulong sector, bool encrypt)
|
||||
{
|
||||
if (unit.Length % 16 != 0) throw new ArgumentException("An XTS data unit must be a multiple of 16 bytes.", nameof(unit));
|
||||
if (_mask.Length < unit.Length) _mask = new byte[unit.Length];
|
||||
Span<byte> mask = _mask.AsSpan(0, unit.Length);
|
||||
|
||||
Span<byte> t = stackalloc byte[16];
|
||||
t.Clear();
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(t, sector);
|
||||
_tweak.EncryptEcb(t, t, PaddingMode.None);
|
||||
|
||||
ulong lo = BinaryPrimitives.ReadUInt64LittleEndian(t);
|
||||
ulong hi = BinaryPrimitives.ReadUInt64LittleEndian(t[8..]);
|
||||
for (int i = 0; i < mask.Length; i += 16)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(mask[i..], lo);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(mask[(i + 8)..], hi);
|
||||
ulong carry = hi >> 63;
|
||||
hi = (hi << 1) | (lo >> 63);
|
||||
lo <<= 1;
|
||||
if (carry != 0) lo ^= 0x87;
|
||||
}
|
||||
|
||||
Xor(unit, mask);
|
||||
if (encrypt) _data.EncryptEcb(unit, unit, PaddingMode.None);
|
||||
else _data.DecryptEcb(unit, unit, PaddingMode.None);
|
||||
Xor(unit, mask);
|
||||
}
|
||||
|
||||
private static void Xor(Span<byte> a, ReadOnlySpan<byte> b)
|
||||
{
|
||||
var wa = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, ulong>(a);
|
||||
var wb = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, ulong>(b);
|
||||
for (int i = 0; i < wa.Length; i++) wa[i] ^= wb[i];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_data.Dispose();
|
||||
_tweak.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// Shared utility primitives: crypto, binary IO and stream helpers.
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LibProsperoPkg.Util;
|
||||
|
||||
static class MemoryReaderExtensions
|
||||
{
|
||||
public static void Read<T>(this IMemoryReader reader, long pos, out T value) where T : struct
|
||||
{
|
||||
int sizeOfT = Marshal.SizeOf(typeof(T));
|
||||
var buf = new byte[sizeOfT];
|
||||
reader.Read(pos, buf, 0, sizeOfT);
|
||||
value = ByteArrayToStructure<T>(buf, 0);
|
||||
}
|
||||
|
||||
public static void ReadArray<T>(this IMemoryReader reader, long pos, T[] value, int offset, int count) where T : struct
|
||||
{
|
||||
// Fast path for bytes
|
||||
if (value is byte[] b)
|
||||
{
|
||||
reader.Read(pos, b, offset, count);
|
||||
return;
|
||||
}
|
||||
// Slow path
|
||||
int sizeOfT = Marshal.SizeOf(typeof(T));
|
||||
var buf = new byte[sizeOfT * count];
|
||||
reader.Read(pos, buf, 0, sizeOfT * count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
value[i] = ByteArrayToStructure<T>(buf, i * sizeOfT);
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/2887
|
||||
private static unsafe T ByteArrayToStructure<T>(byte[] bytes, int offset) where T : struct
|
||||
{
|
||||
fixed (byte* ptr = &bytes[offset])
|
||||
{
|
||||
return (T)Marshal.PtrToStructure((IntPtr)ptr, typeof(T));
|
||||
}
|
||||
}
|
||||
}
|
||||
public interface IMemoryAccessor : IMemoryReader
|
||||
{
|
||||
void Read<T>(long pos, out T value) where T : struct;
|
||||
void ReadArray<T>(long pos, T[] value, int offset, int count) where T : struct;
|
||||
}
|
||||
public interface IMemoryReader : IDisposable
|
||||
{
|
||||
void Read(long pos, byte[] buf, int offset, int count);
|
||||
}
|
||||
class MemoryAccessor : IMemoryAccessor
|
||||
{
|
||||
private IMemoryReader reader;
|
||||
private long offset;
|
||||
public MemoryAccessor(IMemoryReader mr, long offset = 0)
|
||||
{
|
||||
// Efficient nesting of MemoryAccessors: eliminating long call chains to the base IMemoryReader
|
||||
if (mr is MemoryAccessor ma)
|
||||
{
|
||||
this.offset = offset + ma.offset;
|
||||
this.reader = ma.reader;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.offset = offset;
|
||||
this.reader = mr;
|
||||
}
|
||||
}
|
||||
public void Dispose() { }
|
||||
public void Read<T>(long pos, out T value) where T : struct
|
||||
{
|
||||
reader.Read(pos + offset, out value);
|
||||
}
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
reader.Read(pos + this.offset, buf, offset, count);
|
||||
}
|
||||
public void ReadArray<T>(long pos, T[] value, int offset, int count) where T : struct
|
||||
{
|
||||
reader.ReadArray(pos + this.offset, value, offset, count);
|
||||
}
|
||||
}
|
||||
class ChunkedMemoryReader : IMemoryReader
|
||||
{
|
||||
private int chunkSize;
|
||||
private int[] chunks;
|
||||
private IMemoryReader reader;
|
||||
public ChunkedMemoryReader(IMemoryReader mr, int chunkSize, int[] chunks)
|
||||
{
|
||||
this.chunks = chunks;
|
||||
this.chunkSize = chunkSize;
|
||||
this.reader = mr;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
var chunkIdx = pos / chunkSize;
|
||||
while (count > 0)
|
||||
{
|
||||
int offsetIntoChunk = (int)(pos % chunkSize);
|
||||
int toReadFromChunk = Math.Min(chunkSize - offsetIntoChunk, count);
|
||||
reader.Read((long)chunks[chunkIdx++] * chunkSize + offsetIntoChunk, buf, offset, toReadFromChunk);
|
||||
pos += toReadFromChunk;
|
||||
offset += toReadFromChunk;
|
||||
count -= toReadFromChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Non-thread safe buffered reader
|
||||
/// </summary>
|
||||
class BufferedMemoryReader : IMemoryReader
|
||||
{
|
||||
private long bufferStart;
|
||||
private byte[] buffer;
|
||||
private IMemoryReader reader;
|
||||
public BufferedMemoryReader(IMemoryReader mr, int bufferSize)
|
||||
{
|
||||
buffer = new byte[bufferSize];
|
||||
reader = mr;
|
||||
bufferStart = -buffer.Length - 1;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
if (bufferStart > pos || pos >= bufferStart + buffer.Length)
|
||||
{
|
||||
bufferStart = pos;
|
||||
reader.Read(pos, buffer, 0, buffer.Length);
|
||||
}
|
||||
int offsetIntoBuffer = (int)(pos - bufferStart);
|
||||
int toReadFromChunk = Math.Min(buffer.Length - offsetIntoBuffer, count);
|
||||
Buffer.BlockCopy(buffer, offsetIntoBuffer, buf, offset, toReadFromChunk);
|
||||
pos += toReadFromChunk;
|
||||
offset += toReadFromChunk;
|
||||
count -= toReadFromChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
public class MemoryMappedViewAccessor_ : IMemoryAccessor
|
||||
{
|
||||
private MemoryMappedViewAccessor _va;
|
||||
private bool shouldDispose;
|
||||
public MemoryMappedViewAccessor_(MemoryMappedViewAccessor v, bool shouldDispose = false)
|
||||
{
|
||||
_va = v;
|
||||
this.shouldDispose = shouldDispose;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (shouldDispose)
|
||||
_va.Dispose();
|
||||
}
|
||||
|
||||
public void Read<T>(long pos, out T value) where T : struct
|
||||
{
|
||||
_va.Read(pos, out value);
|
||||
}
|
||||
|
||||
public void ReadArray<T>(long pos, T[] value, int offset, int count) where T : struct
|
||||
{
|
||||
_va.ReadArray(pos, value, offset, count);
|
||||
}
|
||||
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
_va.ReadArray(pos, buf, offset, count);
|
||||
}
|
||||
}
|
||||
|
||||
public class StreamReader : IMemoryReader
|
||||
{
|
||||
bool owns;
|
||||
long startOffset;
|
||||
System.IO.Stream stream;
|
||||
|
||||
public StreamReader(System.IO.Stream s, long offset = 0, bool takeOwnership = false)
|
||||
{
|
||||
stream = s;
|
||||
owns = takeOwnership;
|
||||
startOffset = offset;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (owns)
|
||||
{
|
||||
stream.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Read(long pos, byte[] buf, int offset, int count)
|
||||
{
|
||||
stream.Position = pos + startOffset;
|
||||
stream.ReadExactly(buf, offset, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Packwright.Pkg - SHA3-256 (FIPS 202).
|
||||
//
|
||||
// The platform's SHA3 is used when it has one; Windows builds without it (older Windows 10 and Windows 11 before
|
||||
// 22H2) fall back to the managed Keccak implementation below. Both give the same digest.
|
||||
#nullable enable
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Packwright.Pkg.Util;
|
||||
|
||||
public static class Sha3
|
||||
{
|
||||
public static bool UsesPlatform { get; } = SHA3_256.IsSupported;
|
||||
|
||||
public static byte[] Hash(ReadOnlySpan<byte> data)
|
||||
{
|
||||
byte[] result = new byte[32];
|
||||
Hash(data, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Hash(ReadOnlySpan<byte> data, Span<byte> destination)
|
||||
{
|
||||
if (UsesPlatform) { SHA3_256.HashData(data, destination); return; }
|
||||
var k = new Keccak();
|
||||
k.Append(data);
|
||||
k.Finish(destination);
|
||||
}
|
||||
|
||||
public static byte[] Hash(Stream stream)
|
||||
{
|
||||
if (UsesPlatform) return SHA3_256.HashData(stream);
|
||||
var k = new Keccak();
|
||||
byte[] buffer = new byte[1 << 20];
|
||||
int n;
|
||||
while ((n = stream.Read(buffer, 0, buffer.Length)) > 0) k.Append(buffer.AsSpan(0, n));
|
||||
byte[] result = new byte[32];
|
||||
k.Finish(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Incremental SHA3-256.</summary>
|
||||
public sealed class Keccak
|
||||
{
|
||||
private const int Rate = 136;
|
||||
private readonly ulong[] _state = new ulong[25];
|
||||
private readonly byte[] _buffer = new byte[Rate];
|
||||
private int _filled;
|
||||
|
||||
public void Append(ReadOnlySpan<byte> data)
|
||||
{
|
||||
while (data.Length > 0)
|
||||
{
|
||||
int take = Math.Min(Rate - _filled, data.Length);
|
||||
data[..take].CopyTo(_buffer.AsSpan(_filled));
|
||||
_filled += take;
|
||||
data = data[take..];
|
||||
if (_filled == Rate) { Absorb(); _filled = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
public void Finish(Span<byte> destination)
|
||||
{
|
||||
Array.Clear(_buffer, _filled, Rate - _filled);
|
||||
_buffer[_filled] ^= 0x06;
|
||||
_buffer[Rate - 1] ^= 0x80;
|
||||
Absorb();
|
||||
for (int i = 0; i < 4; i++) BinaryPrimitives.WriteUInt64LittleEndian(destination[(i * 8)..], _state[i]);
|
||||
}
|
||||
|
||||
private void Absorb()
|
||||
{
|
||||
for (int i = 0; i < Rate / 8; i++) _state[i] ^= BinaryPrimitives.ReadUInt64LittleEndian(_buffer.AsSpan(i * 8));
|
||||
Permute(_state);
|
||||
}
|
||||
|
||||
private static readonly ulong[] RoundConstants =
|
||||
[
|
||||
0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, 0x000000000000808B, 0x0000000080000001,
|
||||
0x8000000080008081, 0x8000000000008009, 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
|
||||
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, 0x8000000000008002, 0x8000000000000080,
|
||||
0x000000000000800A, 0x800000008000000A, 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
|
||||
];
|
||||
|
||||
private static readonly int[] Rotations =
|
||||
[1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44];
|
||||
private static readonly int[] Lanes =
|
||||
[10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1];
|
||||
|
||||
private static void Permute(ulong[] a)
|
||||
{
|
||||
Span<ulong> c = stackalloc ulong[5];
|
||||
for (int round = 0; round < 24; round++)
|
||||
{
|
||||
for (int x = 0; x < 5; x++) c[x] = a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20];
|
||||
for (int x = 0; x < 5; x++)
|
||||
{
|
||||
ulong d = c[(x + 4) % 5] ^ ulong.RotateLeft(c[(x + 1) % 5], 1);
|
||||
for (int y = 0; y < 25; y += 5) a[y + x] ^= d;
|
||||
}
|
||||
ulong t = a[1];
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
int j = Lanes[i];
|
||||
ulong next = a[j];
|
||||
a[j] = ulong.RotateLeft(t, Rotations[i]);
|
||||
t = next;
|
||||
}
|
||||
for (int y = 0; y < 25; y += 5)
|
||||
{
|
||||
for (int x = 0; x < 5; x++) c[x] = a[y + x];
|
||||
for (int x = 0; x < 5; x++) a[y + x] = c[x] ^ (~c[(x + 1) % 5] & c[(x + 2) % 5]);
|
||||
}
|
||||
a[0] ^= RoundConstants[round];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// LibProsperoPkg - A library for building and inspecting PS5 packages.
|
||||
// Copyright (C) 2026 SvenGDK
|
||||
//
|
||||
// Shared utility primitives: crypto, binary IO and stream helpers.
|
||||
#nullable disable
|
||||
/*
|
||||
* I hereby release this file, StreamExtensions.cs, to the public domain.
|
||||
* Use it if you wish.
|
||||
*/
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
namespace LibProsperoPkg.Util;
|
||||
|
||||
internal static class StreamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a signed 8-bit integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static sbyte ReadInt8(this Stream s) => unchecked((sbyte)s.ReadUInt8());
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 8-bit integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static byte ReadUInt8(this Stream s)
|
||||
{
|
||||
byte ret;
|
||||
byte[] tmp = new byte[1];
|
||||
s.ReadExactly(tmp, 0, 1);
|
||||
ret = tmp[0];
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 16-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static ushort ReadUInt16LE(this Stream s) => unchecked((ushort)s.ReadInt16LE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 16-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static short ReadInt16LE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[2];
|
||||
s.ReadExactly(tmp, 0, 2);
|
||||
ret = tmp[0] & 0x00FF;
|
||||
ret |= (tmp[1] << 8) & 0xFF00;
|
||||
return (short)ret;
|
||||
}
|
||||
|
||||
public static void WriteInt16LE(this Stream s, short i)
|
||||
{
|
||||
s.WriteUInt16LE(unchecked((ushort)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt16LE(this Stream s, ushort i)
|
||||
{
|
||||
byte[] tmp = new byte[2];
|
||||
tmp[0] = (byte)(i & 0xFF);
|
||||
tmp[1] = (byte)((i >> 8) & 0xFF);
|
||||
s.Write(tmp, 0, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 16-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static ushort ReadUInt16BE(this Stream s) => unchecked((ushort)s.ReadInt16BE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 16-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static short ReadInt16BE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[2];
|
||||
s.ReadExactly(tmp, 0, 2);
|
||||
ret = (tmp[0] << 8) & 0xFF00;
|
||||
ret |= tmp[1] & 0x00FF;
|
||||
return (short)ret;
|
||||
}
|
||||
|
||||
public static void WriteInt16BE(this Stream s, short i)
|
||||
{
|
||||
s.WriteUInt16BE(unchecked((ushort)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt16BE(this Stream s, ushort i)
|
||||
{
|
||||
byte[] tmp = new byte[2];
|
||||
tmp[0] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[1] = (byte)(i & 0xFF);
|
||||
s.Write(tmp, 0, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 24-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadUInt24LE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[3];
|
||||
s.ReadExactly(tmp, 0, 3);
|
||||
ret = tmp[0] & 0x0000FF;
|
||||
ret |= (tmp[1] << 8) & 0x00FF00;
|
||||
ret |= (tmp[2] << 16) & 0xFF0000;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 24-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadInt24LE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[3];
|
||||
s.ReadExactly(tmp, 0, 3);
|
||||
ret = tmp[0] & 0x0000FF;
|
||||
ret |= (tmp[1] << 8) & 0x00FF00;
|
||||
ret |= (tmp[2] << 16) & 0xFF0000;
|
||||
if ((tmp[2] & 0x80) == 0x80)
|
||||
{
|
||||
ret |= 0xFF << 24;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 24-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static uint ReadUInt24BE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[3];
|
||||
s.ReadExactly(tmp, 0, 3);
|
||||
ret = tmp[2] & 0x0000FF;
|
||||
ret |= (tmp[1] << 8) & 0x00FF00;
|
||||
ret |= (tmp[0] << 16) & 0xFF0000;
|
||||
return (uint)ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 24-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadInt24BE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[3];
|
||||
s.ReadExactly(tmp, 0, 3);
|
||||
ret = tmp[2] & 0x0000FF;
|
||||
ret |= (tmp[1] << 8) & 0x00FF00;
|
||||
ret |= (tmp[0] << 16) & 0xFF0000;
|
||||
if ((tmp[0] & 0x80) == 0x80)
|
||||
{
|
||||
ret |= 0xFF << 24; // sign-extend
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 32-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static uint ReadUInt32LE(this Stream s) => unchecked((uint)s.ReadInt32LE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 32-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadInt32LE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[4];
|
||||
s.ReadExactly(tmp, 0, 4);
|
||||
ret = tmp[0] & 0x000000FF;
|
||||
ret |= (tmp[1] << 8) & 0x0000FF00;
|
||||
ret |= (tmp[2] << 16) & 0x00FF0000;
|
||||
ret |= (tmp[3] << 24);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public static void WriteInt32LE(this Stream s, int i)
|
||||
{
|
||||
s.WriteUInt32LE(unchecked((uint)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt32LE(this Stream s, uint i)
|
||||
{
|
||||
byte[] tmp = new byte[4];
|
||||
tmp[0] = (byte)(i & 0xFF);
|
||||
tmp[1] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[2] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[3] = (byte)((i >> 24) & 0xFF);
|
||||
s.Write(tmp, 0, 4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 32-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static uint ReadUInt32BE(this Stream s) => unchecked((uint)s.ReadInt32BE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 32-bit Big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadInt32BE(this Stream s)
|
||||
{
|
||||
int ret;
|
||||
byte[] tmp = new byte[4];
|
||||
s.ReadExactly(tmp, 0, 4);
|
||||
ret = (tmp[0] << 24);
|
||||
ret |= (tmp[1] << 16) & 0x00FF0000;
|
||||
ret |= (tmp[2] << 8) & 0x0000FF00;
|
||||
ret |= tmp[3] & 0x000000FF;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public static void WriteInt32BE(this Stream s, int i)
|
||||
{
|
||||
s.WriteUInt32BE(unchecked((uint)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt32BE(this Stream s, uint i)
|
||||
{
|
||||
byte[] tmp = new byte[4];
|
||||
tmp[3] = (byte)(i & 0xFF);
|
||||
tmp[2] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[1] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[0] = (byte)((i >> 24) & 0xFF);
|
||||
s.Write(tmp, 0, 4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 64-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static ulong ReadUInt64LE(this Stream s) => unchecked((ulong)s.ReadInt64LE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 64-bit little-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static long ReadInt64LE(this Stream s)
|
||||
{
|
||||
long ret;
|
||||
byte[] tmp = new byte[8];
|
||||
s.ReadExactly(tmp, 0, 8);
|
||||
ret = tmp[4] & 0x000000FFL;
|
||||
ret |= (tmp[5] << 8) & 0x0000FF00L;
|
||||
ret |= (tmp[6] << 16) & 0x00FF0000L;
|
||||
ret |= (tmp[7] << 24) & 0xFF000000L;
|
||||
ret <<= 32;
|
||||
ret |= tmp[0] & 0x000000FFL;
|
||||
ret |= (tmp[1] << 8) & 0x0000FF00L;
|
||||
ret |= (tmp[2] << 16) & 0x00FF0000L;
|
||||
ret |= (tmp[3] << 24) & 0xFF000000L;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public static void WriteInt64LE(this Stream s, long i)
|
||||
{
|
||||
s.WriteUInt64LE(unchecked((ulong)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt64LE(this Stream s, ulong i)
|
||||
{
|
||||
byte[] tmp = new byte[8];
|
||||
tmp[0] = (byte)(i & 0xFF);
|
||||
tmp[1] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[2] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[3] = (byte)((i >> 24) & 0xFF);
|
||||
i >>= 32;
|
||||
tmp[4] = (byte)(i & 0xFF);
|
||||
tmp[5] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[6] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[7] = (byte)((i >> 24) & 0xFF);
|
||||
s.Write(tmp, 0, 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an unsigned 64-bit big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static ulong ReadUInt64BE(this Stream s) => unchecked((ulong)s.ReadInt64BE());
|
||||
|
||||
/// <summary>
|
||||
/// Read a signed 64-bit big-endian integer from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static long ReadInt64BE(this Stream s)
|
||||
{
|
||||
long ret;
|
||||
byte[] tmp = new byte[8];
|
||||
s.ReadExactly(tmp, 0, 8);
|
||||
ret = tmp[3] & 0x000000FFL;
|
||||
ret |= (tmp[2] << 8) & 0x0000FF00L;
|
||||
ret |= (tmp[1] << 16) & 0x00FF0000L;
|
||||
ret |= (tmp[0] << 24) & 0xFF000000L;
|
||||
ret <<= 32;
|
||||
ret |= tmp[7] & 0x000000FFL;
|
||||
ret |= (tmp[6] << 8) & 0x0000FF00L;
|
||||
ret |= (tmp[5] << 16) & 0x00FF0000L;
|
||||
ret |= (tmp[4] << 24) & 0xFF000000L;
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
public static void WriteInt64BE(this Stream s, long i)
|
||||
{
|
||||
s.WriteUInt64BE(unchecked((ulong)i));
|
||||
}
|
||||
|
||||
public static void WriteUInt64BE(this Stream s, ulong i)
|
||||
{
|
||||
byte[] tmp = new byte[8];
|
||||
tmp[7] = (byte)(i & 0xFF);
|
||||
tmp[6] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[5] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[4] = (byte)((i >> 24) & 0xFF);
|
||||
i >>= 32;
|
||||
tmp[3] = (byte)(i & 0xFF);
|
||||
tmp[2] = (byte)((i >> 8) & 0xFF);
|
||||
tmp[1] = (byte)((i >> 16) & 0xFF);
|
||||
tmp[0] = (byte)((i >> 24) & 0xFF);
|
||||
s.Write(tmp, 0, 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a multibyte value of the specified length from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s">The stream</param>
|
||||
/// <param name="bytes">Must be less than or equal to 8</param>
|
||||
/// <returns></returns>
|
||||
public static long ReadMultibyteBE(this Stream s, byte bytes)
|
||||
{
|
||||
if (bytes > 8) return 0;
|
||||
long ret = 0;
|
||||
var b = s.ReadBytes(bytes);
|
||||
for (uint i = 0; i < b.Length; i++)
|
||||
{
|
||||
ret <<= 8;
|
||||
ret |= b[i];
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a single-precision (4-byte) floating-point value from the stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static float ReadFloat(this Stream s)
|
||||
{
|
||||
byte[] tmp = new byte[4];
|
||||
s.ReadExactly(tmp, 0, 4);
|
||||
return BitConverter.ToSingle(tmp, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a null-terminated ASCII string from the given stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <param name="limit"></param>
|
||||
/// <returns></returns>
|
||||
public static string ReadASCIINullTerminated(this Stream s, int limit = -1)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(255);
|
||||
int cur;
|
||||
while ((limit == -1 || sb.Length < limit) && (cur = s.ReadByte()) > 0)
|
||||
{
|
||||
sb.Append((char)cur);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a length-prefixed string of the specified encoding type from the file.
|
||||
/// The length is a 32-bit little endian integer.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <param name="e">The encoding to use to decode the string.</param>
|
||||
/// <returns></returns>
|
||||
public static string ReadLengthPrefixedString(this Stream s, Encoding e)
|
||||
{
|
||||
int length = s.ReadInt32LE();
|
||||
byte[] chars = new byte[length];
|
||||
s.ReadExactly(chars, 0, length);
|
||||
return e.GetString(chars);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a length-prefixed UTF-8 string from the given stream.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static string ReadLengthUTF8(this Stream s)
|
||||
{
|
||||
return s.ReadLengthPrefixedString(Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a given number of bytes from a stream into a new byte array.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <param name="count">Number of bytes to read (maximum)</param>
|
||||
/// <returns>New byte array of size <=count.</returns>
|
||||
public static byte[] ReadBytes(this Stream s, int count)
|
||||
{
|
||||
// Size of returned array at most count, at least difference between position and length.
|
||||
int realCount = (int)((s.Position + count > s.Length) ? (s.Length - s.Position) : count);
|
||||
byte[] ret = new byte[realCount];
|
||||
s.ReadExactly(ret, 0, realCount);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a variable-length integral value as found in MIDI messages.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
public static int ReadMidiMultiByte(this Stream s)
|
||||
{
|
||||
int ret = 0;
|
||||
byte b = (byte)(s.ReadByte());
|
||||
ret += b & 0x7f;
|
||||
if (0x80 == (b & 0x80))
|
||||
{
|
||||
ret <<= 7;
|
||||
b = (byte)(s.ReadByte());
|
||||
ret += b & 0x7f;
|
||||
if (0x80 == (b & 0x80))
|
||||
{
|
||||
ret <<= 7;
|
||||
b = (byte)(s.ReadByte());
|
||||
ret += b & 0x7f;
|
||||
if (0x80 == (b & 0x80))
|
||||
{
|
||||
ret <<= 7;
|
||||
b = (byte)(s.ReadByte());
|
||||
ret += b & 0x7f;
|
||||
if (0x80 == (b & 0x80))
|
||||
throw new InvalidDataException("Variable-length MIDI number > 4 bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
public static void WriteLE(this Stream s, ushort i) => s.WriteUInt16LE(i);
|
||||
public static void WriteLE(this Stream s, uint i) => s.WriteUInt32LE(i);
|
||||
public static void WriteLE(this Stream s, ulong i) => s.WriteUInt64LE(i);
|
||||
public static void WriteLE(this Stream s, short i) => s.WriteInt16LE(i);
|
||||
public static void WriteLE(this Stream s, int i) => s.WriteInt32LE(i);
|
||||
public static void WriteLE(this Stream s, long i) => s.WriteInt64LE(i);
|
||||
|
||||
}
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user