diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 723c293..e999bdf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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/"
diff --git a/Packwright.slnx b/Packwright.slnx
index 32c35f3..2fa2cf8 100644
--- a/Packwright.slnx
+++ b/Packwright.slnx
@@ -3,4 +3,6 @@
+
+
diff --git a/README.md b/README.md
index 5948c1c..434ef1d 100644
--- a/README.md
+++ b/README.md
@@ -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.
\ No newline at end of file
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 1c143a9..e378d8d 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -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.
```
diff --git a/docs/KEYS.md b/docs/KEYS.md
new file mode 100644
index 0000000..2d0190b
--- /dev/null
+++ b/docs/KEYS.md
@@ -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.
diff --git a/installer/Packwright.iss b/installer/Packwright.iss
index 0c8c690..a2df1d1 100644
--- a/installer/Packwright.iss
+++ b/installer/Packwright.iss
@@ -1,10 +1,10 @@
; Packwright installer (Inno Setup 6). Built by the release workflow:
-; iscc /DAppVersion=1.0.1 /DSourceDir= /O installer\Packwright.iss
+; iscc /DAppVersion=1.1.0 /DSourceDir= /O 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"
diff --git a/release-notes/1.1.0.md b/release-notes/1.1.0.md
new file mode 100644
index 0000000..b424fb0
--- /dev/null
+++ b/release-notes/1.1.0.md
@@ -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**.
diff --git a/src/Packwright.App/Cli.cs b/src/Packwright.App/Cli.cs
index 6780f24..51023c9 100644
--- a/src/Packwright.App/Cli.cs
+++ b/src/Packwright.App/Cli.cs
@@ -29,7 +29,7 @@ internal static class Cli
private static readonly HashSet 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 --compression auto|kraken|stored --kraken-level N --threads N --playgo N
- --deterministic --no-fake-sign --no-right-sprx --drm upgradable|free|standard|keep --workspace
+ --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);
diff --git a/src/Packwright.App/Infrastructure/AppStateStore.cs b/src/Packwright.App/Infrastructure/AppStateStore.cs
index 5e299df..05ad840 100644
--- a/src/Packwright.App/Infrastructure/AppStateStore.cs
+++ b/src/Packwright.App/Infrastructure/AppStateStore.cs
@@ -128,9 +128,12 @@ public sealed class AppSettings
// Build defaults
public string DebugPasscode { get; set; } = string.Empty;
- /// Selected package backend id ("lpp" or "ppt"). Empty/unknown falls back to the default.
+ /// Kept so settings written by older versions still load; the package engine is no longer selectable.
public string BuildBackend { get; set; } = string.Empty;
+ /// Path of the imported compatibility key file (a copy kept in the app data folder), or empty.
+ 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; }
diff --git a/src/Packwright.App/Packwright.App.csproj b/src/Packwright.App/Packwright.App.csproj
index 68da3c1..286b830 100644
--- a/src/Packwright.App/Packwright.App.csproj
+++ b/src/Packwright.App/Packwright.App.csproj
@@ -9,8 +9,8 @@
Packwright.App
Assets\icon.ico
false
- 1.0.1
- 1.0.1.0
+ 1.1.0
+ 1.1.0.0
Packwright
itsmemac
itsmemac
@@ -45,11 +45,4 @@
-
-
- LibProsperoPkg12\%(RecursiveDir)%(Filename)%(Extension)
- PreserveNewest
- PreserveNewest
-
-
diff --git a/src/Packwright.App/Services/JobSpec.cs b/src/Packwright.App/Services/JobSpec.cs
index 5b5c87b..ab11855 100644
--- a/src/Packwright.App/Services/JobSpec.cs
+++ b/src/Packwright.App/Services/JobSpec.cs
@@ -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:
diff --git a/src/Packwright.App/Services/Jobs.cs b/src/Packwright.App/Services/Jobs.cs
index 5c7a1a7..987ec58 100644
--- a/src/Packwright.App/Services/Jobs.cs
+++ b/src/Packwright.App/Services/Jobs.cs
@@ -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);
///
/// 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(value => target.Report(new PackageTaskProgress(
value.Stage, 0, 0, value.Completed, value.Total, 0, 0, string.Empty)));
- public static IProgress AdaptExtract(IProgress target) =>
- new Progress(value => target.Report(new PackageTaskProgress(
+ public static IProgress AdaptExtract(IProgress target) =>
+ new Progress(value => target.Report(new PackageTaskProgress(
"Extracting", 0, 0, value.CompletedBytes, value.TotalBytes, 0, 0, value.CurrentPath)));
- /// The builder to use: the saved choice, falling back when LibProsperoPkg is not usable on this OS.
- public static IPackageBackend ResolveBackend(string? id)
+ private static bool _keysLoaded;
+
+ /// Loads the imported compatibility key file (once) so package builds use it.
+ 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"));
-
+ /// The package engine. There is one.
+ public static IPackageBackend ResolveBackend(string? id) => BackendRegistry.Get(id);
public static Task ConvertAsync(string source, string output, Ps5ImageConversionTarget target, bool overwrite,
bool fromPackage, IProgress progress, CancellationToken token,
ExfatBuildOptions? exfatOptions = null, FfpfscBuildOptions? ffpfscOptions = null,
@@ -66,7 +66,7 @@ public static class Jobs
{
IProgress 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 ExtractPackageAsync(string source, string output,
+ public static async Task ExtractPackageAsync(string source, string output,
string passcode, IProgress 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);
/// Reads the content ID from a dump folder or image so a package can be built from it.
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(value =>
+ var bridge = new Progress(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);
}
}
diff --git a/src/Packwright.App/Services/LibraryActions.cs b/src/Packwright.App/Services/LibraryActions.cs
index f7e51e0..9e0e3e6 100644
--- a/src/Packwright.App/Services/LibraryActions.cs
+++ b/src/Packwright.App/Services/LibraryActions.cs
@@ -76,7 +76,7 @@ public static class LibraryActions
{
var plans = new List();
foreach (IGrouping 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))
{
diff --git a/src/Packwright.App/Services/MetadataEditor.cs b/src/Packwright.App/Services/MetadataEditor.cs
index ef7136e..9bd22fa 100644
--- a/src/Packwright.App/Services/MetadataEditor.cs
+++ b/src/Packwright.App/Services/MetadataEditor.cs
@@ -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
///
@@ -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 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 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 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(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);
diff --git a/src/Packwright.App/Services/SourceInfo.cs b/src/Packwright.App/Services/SourceInfo.cs
index 1b78e26..4226cfe 100644
--- a/src/Packwright.App/Services/SourceInfo.cs
+++ b/src/Packwright.App/Services/SourceInfo.cs
@@ -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);
}
diff --git a/src/Packwright.App/Views/AboutView.axaml b/src/Packwright.App/Views/AboutView.axaml
index 8f94377..fbc74df 100644
--- a/src/Packwright.App/Views/AboutView.axaml
+++ b/src/Packwright.App/Views/AboutView.axaml
@@ -32,13 +32,12 @@
-
-
-
+
+
-
+
diff --git a/src/Packwright.App/Views/EditDetailsWindow.cs b/src/Packwright.App/Views/EditDetailsWindow.cs
index 0622f8d..292c002 100644
--- a/src/Packwright.App/Views/EditDetailsWindow.cs
+++ b/src/Packwright.App/Views/EditDetailsWindow.cs
@@ -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."
diff --git a/src/Packwright.App/Views/LibraryView.Autofill.cs b/src/Packwright.App/Views/LibraryView.Autofill.cs
index cbe56f4..e601f47 100644
--- a/src/Packwright.App/Views/LibraryView.Autofill.cs
+++ b/src/Packwright.App/Views/LibraryView.Autofill.cs
@@ -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,
diff --git a/src/Packwright.App/Views/LibraryView.Details.cs b/src/Packwright.App/Views/LibraryView.Details.cs
index 9113bfa..431bc88 100644
--- a/src/Packwright.App/Views/LibraryView.Details.cs
+++ b/src/Packwright.App/Views/LibraryView.Details.cs
@@ -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 packages = SelectedGames().Where(game => game.SourceKind == Ps5SourceKind.SonyPackage).ToList();
+ List 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.");
diff --git a/src/Packwright.App/Views/SettingsView.axaml b/src/Packwright.App/Views/SettingsView.axaml
index 3f1aacb..497199f 100644
--- a/src/Packwright.App/Views/SettingsView.axaml
+++ b/src/Packwright.App/Views/SettingsView.axaml
@@ -74,8 +74,19 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Packwright.App/Views/SettingsView.axaml.cs b/src/Packwright.App/Views/SettingsView.axaml.cs
index e3acebd..2d853ef 100644
--- a/src/Packwright.App/Views/SettingsView.axaml.cs
+++ b/src/Packwright.App/Views/SettingsView.axaml.cs
@@ -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();
diff --git a/src/Packwright.App/Views/ToolsView.Presets.cs b/src/Packwright.App/Views/ToolsView.Presets.cs
index d598acb..290c5f3 100644
--- a/src/Packwright.App/Views/ToolsView.Presets.cs
+++ b/src/Packwright.App/Views/ToolsView.Presets.cs
@@ -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().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))
diff --git a/src/Packwright.App/Views/ToolsView.axaml b/src/Packwright.App/Views/ToolsView.axaml
index c6d4bf0..b332140 100644
--- a/src/Packwright.App/Views/ToolsView.axaml
+++ b/src/Packwright.App/Views/ToolsView.axaml
@@ -107,34 +107,34 @@
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
-
+
-
+
@@ -145,13 +145,6 @@
-
-
-
-
-
-
-
diff --git a/src/Packwright.App/Views/ToolsView.axaml.cs b/src/Packwright.App/Views/ToolsView.axaml.cs
index 9fd2a41..1c2b336 100644
--- a/src/Packwright.App/Views/ToolsView.axaml.cs
+++ b/src/Packwright.App/Views/ToolsView.axaml.cs
@@ -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)
diff --git a/src/Packwright.Core/Backends/BackendCapabilities.cs b/src/Packwright.Core/Backends/BackendCapabilities.cs
index a9fe574..024e372 100644
--- a/src/Packwright.Core/Backends/BackendCapabilities.cs
+++ b/src/Packwright.Core/Backends/BackendCapabilities.cs
@@ -1,17 +1,10 @@
namespace Packwright.Core.Backends;
-///
-/// What a 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).
-///
+/// What a can do. The UI uses this to gate actions and options.
public sealed record BackendCapabilities(
bool BuildFromDirectory,
bool BuildFromImage,
bool Validate,
bool Extract,
bool SupportsLargeImages,
- bool SupportsSeed,
- bool SupportsCompressionKnobs,
- bool SupportsRightSprxToggle,
- bool SupportsPlayGoChunks);
+ bool SupportsSeed);
diff --git a/src/Packwright.Core/Backends/BackendNotSupportedException.cs b/src/Packwright.Core/Backends/BackendNotSupportedException.cs
deleted file mode 100644
index 8815ceb..0000000
--- a/src/Packwright.Core/Backends/BackendNotSupportedException.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace Packwright.Core.Backends;
-
-///
-/// 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.
-///
-public sealed class BackendNotSupportedException : NotSupportedException
-{
- public BackendNotSupportedException(string backendId, string message)
- : base(message)
- {
- BackendId = backendId;
- }
-
- /// The id of the backend that could not handle the request.
- public string BackendId { get; }
-}
diff --git a/src/Packwright.Core/Backends/BackendRegistry.cs b/src/Packwright.Core/Backends/BackendRegistry.cs
index c47ad6b..52e70ce 100644
--- a/src/Packwright.Core/Backends/BackendRegistry.cs
+++ b/src/Packwright.Core/Backends/BackendRegistry.cs
@@ -1,38 +1,19 @@
namespace Packwright.Core.Backends;
-///
-/// 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).
-///
+/// The package engine the app uses. There is one: the engine built into Packwright.
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 All { get; } =
- [
- new LppBackend(),
- new PptBackend(),
- ];
+ public static IReadOnlyList All { get; } = [new PackwrightBackend()];
- /// The default backend (LibProsperoPkg).
- public static IPackageBackend Default => GetById(DefaultId);
+ /// The package engine.
+ public static IPackageBackend Default => All[0];
- /// Resolves a backend by id, falling back to the default for an unknown or null id.
- public static IPackageBackend Get(string? id)
- {
- if (string.IsNullOrWhiteSpace(id))
- return Default;
- return All.FirstOrDefault(backend =>
- string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase)) ?? Default;
- }
+ /// Resolves a backend by id. Settings written by older versions name engines that no longer exist; they resolve to the built-in one.
+ public static IPackageBackend Get(string? id) => Default;
- /// True when names a known backend.
+ /// True when names the built-in engine.
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);
}
diff --git a/src/Packwright.Core/Backends/IPackageBackend.cs b/src/Packwright.Core/Backends/IPackageBackend.cs
index ad1abc8..d1b1477 100644
--- a/src/Packwright.Core/Backends/IPackageBackend.cs
+++ b/src/Packwright.Core/Backends/IPackageBackend.cs
@@ -4,14 +4,12 @@ using Packwright.Core.Services;
namespace Packwright.Core.Backends;
///
-/// A selectable PS5 package engine that can build, validate and extract packages. Two
-/// implementations ship: PptBackend (the vendored clean-room ProsperoPkgTool engine) and
-/// LppBackend (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.
///
public interface IPackageBackend
{
- /// Stable id used in settings and the UI (e.g. ppt , lpp ).
+ /// Stable id used in settings.
string Id { get; }
/// Human-readable name shown in the backend selector.
@@ -21,21 +19,21 @@ public interface IPackageBackend
BackendCapabilities Capabilities { get; }
/// Builds a package from an unpacked folder (a dump/app tree).
- Task BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? progress = null,
+ Task BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? progress = null,
CancellationToken cancellationToken = default);
- /// Builds a package from an image source (exFAT / FFPKG / FFPFSC). Not all backends support this.
- Task BuildFromImageAsync(string imagePath, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? progress = null,
+ /// Builds a package from an image source (exFAT / FFPKG / FFPFSC).
+ Task BuildFromImageAsync(string imagePath, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? progress = null,
CancellationToken cancellationToken = default);
/// Validates a package with this backend's own reader.
- SonyDebugPackageValidationResult Validate(string packagePath,
- string passcode = SonyDebugPackageCredentials.DefaultPasscode);
+ Ps5DebugPackageValidationResult Validate(string packagePath,
+ string passcode = Ps5DebugPackageCredentials.DefaultPasscode);
/// Extracts a package's filesystem to .
- Task ExtractAsync(string packagePath, string destination,
- string passcode = SonyDebugPackageCredentials.DefaultPasscode,
- IProgress? progress = null, CancellationToken cancellationToken = default);
+ Task ExtractAsync(string packagePath, string destination,
+ string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
+ IProgress? progress = null, CancellationToken cancellationToken = default);
}
diff --git a/src/Packwright.Core/Backends/LibProsperoPkg12.cs b/src/Packwright.Core/Backends/LibProsperoPkg12.cs
deleted file mode 100644
index 73e9469..0000000
--- a/src/Packwright.Core/Backends/LibProsperoPkg12.cs
+++ /dev/null
@@ -1,216 +0,0 @@
-using System.Collections;
-using System.Reflection;
-using System.Runtime.Loader;
-
-namespace Packwright.Core.Backends;
-
-/// Inputs for a LibProsperoPkg 1.2.0 directory build.
-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; }
- /// 0 = none (stored), 1 = zlib, 2 = kraken.
- public int Compression { get; init; } = 2;
- public int KrakenLevel { get; init; } = 7;
- public int KrakenThreads { get; init; }
- public string? TempDirectory { get; init; }
- public Action? Log { get; init; }
-}
-
-///
-/// Loads the vendored LibProsperoPkg 1.2.0 (the version the working PPR-PKG/fpkg-gui ships) in an
-/// isolated 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.
-///
-internal static class LibProsperoPkg12
-{
- private static readonly Lazy Shared = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
-
- /// True when the vendored 1.2.0 payload is present and loadable.
- public static bool Available => Shared.Value is not null;
-
- private static string Folder => Path.Combine(AppContext.BaseDirectory, "LibProsperoPkg12");
-
- /// Builds a package from a folder and returns the produced package path.
- 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 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.");
- }
-
- /// Validates a package with LibProsperoPkg 1.2.0 (structure + CNT metadata signature).
- 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);
- }
- }
-
- /// Extracts a package's inner filesystem with LibProsperoPkg 1.2.0 and returns the file count.
- 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().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)])
- ?? 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;
- }
- }
-}
diff --git a/src/Packwright.Core/Backends/LppBackend.cs b/src/Packwright.Core/Backends/LppBackend.cs
deleted file mode 100644
index 39e2ccb..0000000
--- a/src/Packwright.Core/Backends/LppBackend.cs
+++ /dev/null
@@ -1,377 +0,0 @@
-using System.Text.RegularExpressions;
-using Packwright.Core.Builders;
-using Packwright.Core.Services;
-using ProsperoPkgTool.Containers;
-
-namespace Packwright.Core.Backends;
-
-///
-/// The LibProsperoPkg backend, using the vendored 1.2.0 build (the version the working
-/// PPR-PKG/fpkg-gui ships) loaded in an isolated context by . 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.
-///
-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 BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? progress = null,
- CancellationToken cancellationToken = default) =>
- Task.Run(() => BuildFromDirectory(sourceDirectory, outputPath, options, progress, cancellationToken),
- cancellationToken);
-
- public Task BuildFromImageAsync(string imagePath, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? 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 ExtractAsync(string packagePath, string destination,
- string passcode = SonyDebugPackageCredentials.DefaultPasscode,
- IProgress? 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? 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";
-
- ///
- /// LibProsperoPkg 1.2.0 has no DRM option and uses the source sce_sys/param.json
- /// verbatim, so a DRM override is applied by patching that file. Before patching, the original
- /// bytes are written to a sibling param.json.packwright-backup 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.
- ///
- 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);
- }
-
- ///
- /// Builds a same-volume hardlink mirror of the source with a real, patched param.json , 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).
- ///
- 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);
- }
-
- ///
- /// 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 [stage N/5] marker (which
- /// maps to the task step) and a data NN% / : NN% percentage; reports are emitted
- /// only when the stage or percentage changes.
- ///
- 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? _progress;
- private string _stage = StageNames[0];
- private int _percent = -1;
-
- public LppProgressTranslator(IProgress? 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));
- }
- }
-}
diff --git a/src/Packwright.Core/Backends/PackwrightBackend.cs b/src/Packwright.Core/Backends/PackwrightBackend.cs
new file mode 100644
index 0000000..c33652c
--- /dev/null
+++ b/src/Packwright.Core/Backends/PackwrightBackend.cs
@@ -0,0 +1,44 @@
+using Packwright.Core.Builders;
+using Packwright.Core.Services;
+
+namespace Packwright.Core.Backends;
+
+///
+/// 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.
+///
+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 BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Ps5DebugPackageBuilder.CreateFromDirectoryAsync(sourceDirectory, outputPath, options, progress, cancellationToken);
+
+ public Task BuildFromImageAsync(string imagePath, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? 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 ExtractAsync(string packagePath, string destination,
+ string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
+ IProgress? progress = null, CancellationToken cancellationToken = default) =>
+ Ps5PackageExtraction.ExtractAsync(packagePath, destination, passcode, progress, cancellationToken);
+}
diff --git a/src/Packwright.Core/Backends/PptBackend.cs b/src/Packwright.Core/Backends/PptBackend.cs
deleted file mode 100644
index 1c52af2..0000000
--- a/src/Packwright.Core/Backends/PptBackend.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Packwright.Core.Builders;
-using Packwright.Core.Services;
-
-namespace Packwright.Core.Backends;
-
-///
-/// 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.
-///
-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 BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? progress = null,
- CancellationToken cancellationToken = default) =>
- SonyDebugPackageBuilder.CreateFromDirectoryAsync(sourceDirectory, outputPath, options, progress, cancellationToken);
-
- public Task BuildFromImageAsync(string imagePath, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? 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 ExtractAsync(string packagePath, string destination,
- string passcode = SonyDebugPackageCredentials.DefaultPasscode,
- IProgress? progress = null, CancellationToken cancellationToken = default) =>
- SonyPackageExtraction.ExtractAsync(packagePath, destination, passcode, progress, cancellationToken);
-}
diff --git a/src/Packwright.Core/Builders/PackwrightEngine.cs b/src/Packwright.Core/Builders/PackwrightEngine.cs
new file mode 100644
index 0000000..a651b05
--- /dev/null
+++ b/src/Packwright.Core/Builders/PackwrightEngine.cs
@@ -0,0 +1,162 @@
+using System.Security.Cryptography;
+using Packwright.Core.Services;
+using Packwright.Pkg.Writer;
+
+namespace Packwright.Core.Builders;
+
+///
+/// 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 for the file format.
+///
+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; } }
+
+ /// Loads the key file at ; null or an empty path clears the imported keys. Throws for an unacceptable file.
+ 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;
+ }
+
+ /// Like but never throws; returns the problem instead.
+ 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;
+ }
+ }
+}
+
+/// The shared part of every package build: overrides, repairs, key import, progress and the result.
+internal static class PackwrightEngine
+{
+ public static Ps5DebugPackageBuildResult Run(IReadOnlyList sourceFiles, IEnumerable? directories,
+ string outputPath, Ps5DebugPackageBuildOptions options, IProgress? progress,
+ long sourceBytes, CancellationToken cancellationToken)
+ {
+ string destination = Path.GetFullPath(outputPath);
+ Ps5DebugPackageCredentials credentials = Ps5DebugPackageCredentials.Create(options.ContentId, options.Passcode);
+
+ List files = Prepare(sourceFiles, options);
+ var relay = progress is null ? null : new Progress(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 Prepare(IReadOnlyList input, Ps5DebugPackageBuildOptions options)
+ {
+ var files = new List(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 files, Ps5DebugPackageBuildOptions options)
+ {
+ var paths = new HashSet(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;
+ }
+}
+
+/// Trophy and activity archives (.ucp ) carry a SHA-1 of the whole file; an edited archive needs it refreshed.
+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;
+ }
+}
diff --git a/src/Packwright.Core/Builders/ProsperoDebugPackageBuilder.cs b/src/Packwright.Core/Builders/ProsperoDebugPackageBuilder.cs
deleted file mode 100644
index c3606fd..0000000
--- a/src/Packwright.Core/Builders/ProsperoDebugPackageBuilder.cs
+++ /dev/null
@@ -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;
-
- ///
- /// 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.
- ///
- public byte[]? Seed { get; init; }
-
- /// Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
- /// Null preserves the source metadata.
- public ulong? SdkVersionOverride { get; init; }
-
- /// Optional workspace folder for the multi-GiB file-backed path. Null uses the system temp folder.
- public string? TempDirectory { get; init; }
-
- /// Optional sink for engine log lines (free-space warnings, stale workspace sweep).
- public Action? Log { get; init; }
-
- /// Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).
- public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
-
- /// Kraken level recorded in the compressed header (Oodle naming, -4..9). Header-only here.
- public int KrakenLevel { get; init; } = 7;
-
- /// Blocks encoded concurrently; 0 selects the processor count.
- public int KrakenThreads { get; init; }
-
- /// PlayGo chunk count for the generated project; 1 matches the debug/nwonly profile.
- public int PlayGoChunkCount { get; init; } = 1;
-
- /// 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.
- public string? DrmTypeOverride { get; init; }
-
- /// Convert raw ELF modules to debug fake-SELF containers. Default true.
- public bool FakeSignModules { get; init; } = true;
-
- /// Inject the built-in debug sce_sys/about/right.sprx when the source lacks one. Default true.
- public bool InjectRightSprx { get; init; } = true;
-}
-
-///
-/// Builds a PS5 debug package from an unpacked dump folder using the vendored, validated
-/// ProsperoPkgTool engine (MIT) as a managed library. Files are passed to the engine's
-/// Build API directly (disk-backed by path).
-///
-public static class ProsperoDebugPackageBuilder
-{
- public static Task CreateFromDirectoryAsync(string sourceDirectory,
- string outputPath, ProsperoDebugPackageBuildOptions options,
- IProgress? 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? 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();
- var sceSys = new Dictionary(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(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(Action report) : IProgress
- {
- public void Report(T value) => report(value);
- }
-}
diff --git a/src/Packwright.Core/Builders/Ps5DebugPackageBuilder.cs b/src/Packwright.Core/Builders/Ps5DebugPackageBuilder.cs
new file mode 100644
index 0000000..d2254a6
--- /dev/null
+++ b/src/Packwright.Core/Builders/Ps5DebugPackageBuilder.cs
@@ -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;
+
+ /// Optional 16-byte outer-PFS seed for a byte-reproducible package.
+ public byte[]? Seed { get; init; }
+
+ /// Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
+ /// Null preserves the source metadata.
+ public ulong? SdkVersionOverride { get; init; }
+
+ /// Optional sink for engine log lines.
+ public Action? Log { get; init; }
+
+ /// Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).
+ public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
+
+ /// Kraken level (Oodle naming, -4..9). The encoder uses one fixed strategy, so the level does not change the output.
+ public int KrakenLevel { get; init; } = 7;
+
+ /// Spans compressed at the same time; 0 selects the processor count.
+ public int KrakenThreads { get; init; }
+
+ /// Play-go chunks, used only when the title brings no play-go files of its own.
+ public int PlayGoChunkCount { get; init; } = 1;
+
+ /// Optional DRM token to force in param.json. Null preserves the source token.
+ 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 CreateFromDirectoryAsync(string sourceDirectory,
+ string outputPath, Ps5DebugPackageBuildOptions options,
+ IProgress? 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? 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();
+ var directories = new List();
+ 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 };
+ }
+ }
+}
diff --git a/src/Packwright.Core/Builders/SonyDebugPackageCredentials.cs b/src/Packwright.Core/Builders/Ps5DebugPackageCredentials.cs
similarity index 81%
rename from src/Packwright.Core/Builders/SonyDebugPackageCredentials.cs
rename to src/Packwright.Core/Builders/Ps5DebugPackageCredentials.cs
index 72681c9..976df97 100644
--- a/src/Packwright.Core/Builders/SonyDebugPackageCredentials.cs
+++ b/src/Packwright.Core/Builders/Ps5DebugPackageCredentials.cs
@@ -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));
}
}
diff --git a/src/Packwright.Core/Builders/Ps5InnerCompression.cs b/src/Packwright.Core/Builders/Ps5InnerCompression.cs
index 4b72692..38b355b 100644
--- a/src/Packwright.Core/Builders/Ps5InnerCompression.cs
+++ b/src/Packwright.Core/Builders/Ps5InnerCompression.cs
@@ -1,5 +1,3 @@
-using ProsperoPkgTool.Containers;
-
namespace Packwright.Core.Builders;
/// Inner-image compression choice for a package build.
@@ -14,13 +12,3 @@ public enum Ps5InnerCompression
/// Kraken where it helps, stored otherwise (engine default).
Auto
}
-
-internal static class Ps5InnerCompressionExtensions
-{
- public static ProsperoInnerCompressionMode ToEngine(this Ps5InnerCompression value) => value switch
- {
- Ps5InnerCompression.Stored => ProsperoInnerCompressionMode.Stored,
- Ps5InnerCompression.Kraken => ProsperoInnerCompressionMode.Kraken,
- _ => ProsperoInnerCompressionMode.Auto
- };
-}
diff --git a/src/Packwright.Core/Builders/SonyDebugPackageBuilder.cs b/src/Packwright.Core/Builders/SonyDebugPackageBuilder.cs
deleted file mode 100644
index 8143d76..0000000
--- a/src/Packwright.Core/Builders/SonyDebugPackageBuilder.cs
+++ /dev/null
@@ -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;
-
- /// Optional 16-byte outer-PFS seed for a byte-reproducible package.
- public byte[]? Seed { get; init; }
-
- /// Optional full 64-bit executable SDK id to stamp (param.json + .sceversion).
- /// Null preserves the source metadata.
- public ulong? SdkVersionOverride { get; init; }
-
- /// Optional workspace folder for the multi-GiB file-backed path. Null uses the system temp folder.
- public string? TempDirectory { get; init; }
-
- /// Optional sink for engine log lines (free-space warnings, stale workspace sweep).
- public Action? Log { get; init; }
-
- /// Inner-image compression. Default is Auto (Kraken where it helps, stored otherwise).
- public Ps5InnerCompression Compression { get; init; } = Ps5InnerCompression.Auto;
-
- /// Kraken level recorded in the compressed header (Oodle naming, -4..9). Header-only here.
- public int KrakenLevel { get; init; } = 7;
-
- /// Blocks encoded concurrently; 0 selects the processor count.
- public int KrakenThreads { get; init; }
-
- /// PlayGo chunk count for the generated project; 1 matches the debug/nwonly profile.
- public int PlayGoChunkCount { get; init; } = 1;
-
- /// 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.
- public string? DrmTypeOverride { get; init; }
-
- /// Convert raw ELF modules to debug fake-SELF containers. Default true.
- public bool FakeSignModules { get; init; } = true;
-
- /// Inject the built-in debug sce_sys/about/right.sprx when the source lacks one. Default true.
- 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 CreateFromDirectoryAsync(string sourceDirectory,
- string outputPath, SonyDebugPackageBuildOptions options,
- IProgress? 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 };
- }
- }
-}
diff --git a/src/Packwright.Core/Builders/SourceFolderValidator.cs b/src/Packwright.Core/Builders/SourceFolderValidator.cs
new file mode 100644
index 0000000..af220a7
--- /dev/null
+++ b/src/Packwright.Core/Builders/SourceFolderValidator.cs
@@ -0,0 +1,17 @@
+namespace Packwright.Core.Builders;
+
+/// Decides which files of a source folder or image are build artefacts rather than part of the title.
+public static class SourceFolderValidator
+{
+ ///
+ /// True for files a package must not carry: publishing project descriptors (.gp4 , .gp5 ) and the
+ /// sce_suppl folder, which a package build generates itself. Everything else is packed as it is.
+ ///
+ 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);
+ }
+}
diff --git a/src/Packwright.Core/Builders/VolumeDebugPackageBuilder.cs b/src/Packwright.Core/Builders/VolumeDebugPackageBuilder.cs
index 98dfb48..4a8e17b 100644
--- a/src/Packwright.Core/Builders/VolumeDebugPackageBuilder.cs
+++ b/src/Packwright.Core/Builders/VolumeDebugPackageBuilder.cs
@@ -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;
///
-/// 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
-/// , so only the small sce_sys 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.
///
public static class VolumeDebugPackageBuilder
{
- public static Task CreateFromImageAsync(string imagePath, string outputPath,
- SonyDebugPackageBuildOptions options, IProgress? progress = null,
+ public static Task CreateFromImageAsync(string imagePath, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? 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? progress,
+ private static Ps5DebugPackageBuildResult CreateFromImage(string imagePath, string outputPath,
+ Ps5DebugPackageBuildOptions options, IProgress? 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 entries,
out Func 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();
- var sceSys = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var files = new List();
+ var directories = new List();
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(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);
}
-
///
/// 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 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 open) : IProsperoFileSource
- {
- public long Length => length;
- public Stream Open() => open();
- }
-
- private sealed class RelayProgress(Action report) : IProgress
- {
- public void Report(T value) => report(value);
- }
}
diff --git a/src/Packwright.Core/Models/Ps5GameInfo.cs b/src/Packwright.Core/Models/Ps5GameInfo.cs
index d1c3980..3a1bf55 100644
--- a/src/Packwright.Core/Models/Ps5GameInfo.cs
+++ b/src/Packwright.Core/Models/Ps5GameInfo.cs
@@ -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 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",
diff --git a/src/Packwright.Core/Models/SonyPkgModels.cs b/src/Packwright.Core/Models/Ps5PkgModels.cs
similarity index 69%
rename from src/Packwright.Core/Models/SonyPkgModels.cs
rename to src/Packwright.Core/Models/Ps5PkgModels.cs
index aa05b1c..da67918 100644
--- a/src/Packwright.Core/Models/SonyPkgModels.cs
+++ b/src/Packwright.Core/Models/Ps5PkgModels.cs
@@ -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 Entries { get; init; } = [];
- public IReadOnlyList Segments { get; init; } = [];
- public SonyPfsSummary? NestedPfs { get; init; }
+ public IReadOnlyList Entries { get; init; } = [];
+ public IReadOnlyList 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 Files { get; init; } = [];
- internal SonyPfsCryptoContext? CryptoContext { get; init; }
- internal SonyEnginePackageAccess? EngineAccess { get; init; }
+ public IReadOnlyList 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 Extents { get; init; } = [];
+ public IReadOnlyList 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);
diff --git a/src/Packwright.Core/Packwright.Core.csproj b/src/Packwright.Core/Packwright.Core.csproj
index 704aa62..2c97508 100644
--- a/src/Packwright.Core/Packwright.Core.csproj
+++ b/src/Packwright.Core/Packwright.Core.csproj
@@ -11,11 +11,6 @@
-
-
-
- ..\..\third-party\ProsperoPkgTool\ProsperoPkgTool.dll
- true
-
+
diff --git a/src/Packwright.Core/Parsers/Ps5EnginePackageAccess.cs b/src/Packwright.Core/Parsers/Ps5EnginePackageAccess.cs
new file mode 100644
index 0000000..c08190d
--- /dev/null
+++ b/src/Packwright.Core/Parsers/Ps5EnginePackageAccess.cs
@@ -0,0 +1,311 @@
+using Packwright.Core.Builders;
+using Packwright.Pkg.Reader;
+
+namespace Packwright.Core.Parsers;
+
+///
+/// Adapts the Packwright package reader (Packwright.Pkg ) 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.
+///
+internal sealed class Ps5EnginePackageAccess : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly string _passcode;
+ private readonly IReadOnlyList _entries;
+ private PkgPackage? _package;
+ private IReadOnlyList? _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; }
+
+ /// Opens a package for reading. Throws when it is not a PS5 package.
+ public static Ps5EnginePackageAccess Open(string packagePath, string? passcode) =>
+ new(PkgPackage.Open(Path.GetFullPath(packagePath), passcode), passcode);
+
+ /// Wraps a package that was already opened (so a scan does not parse it twice).
+ internal static Ps5EnginePackageAccess FromPackage(PkgPackage package, string? passcode) => new(package, passcode);
+
+ /// The files of the inner filesystem. Decoding happens on first use.
+ public IEnumerable Files
+ {
+ get { EnsureDecoded(); return _files!; }
+ }
+
+ public string? DecodeError
+ {
+ get { EnsureDecoded(); return _decodeError; }
+ }
+
+ ///
+ /// True when is an unsupported format or layout rather than corruption, so the
+ /// UI can report the real cause instead of blaming the passcode.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Opens an inner file; the package stays open until the stream is disposed.
+ 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();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+
+ /// Notifies its owner once, when disposed, so the shared package handle can be released.
+ 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);
+ }
+ }
+}
diff --git a/src/Packwright.Core/Parsers/SonyPfsCrypto.cs b/src/Packwright.Core/Parsers/Ps5PfsCrypto.cs
similarity index 79%
rename from src/Packwright.Core/Parsers/SonyPfsCrypto.cs
rename to src/Packwright.Core/Parsers/Ps5PfsCrypto.cs
index e6e0cb6..6e8bd58 100644
--- a/src/Packwright.Core/Parsers/SonyPfsCrypto.cs
+++ b/src/Packwright.Core/Parsers/Ps5PfsCrypto.cs
@@ -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 seed,
+ public static Ps5PfsCryptoContext DeriveContext(byte[] ekpfs, ReadOnlySpan 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 block, SonyPfsCryptoContext context,
+ public static void DecryptBlock(Span block, Ps5PfsCryptoContext context,
ulong blockIndex, bool signedDomain)
=> TransformBlock(block, context, blockIndex, signedDomain, encrypt: false);
- public static void EncryptBlock(Span block, SonyPfsCryptoContext context,
+ public static void EncryptBlock(Span block, Ps5PfsCryptoContext context,
ulong blockIndex, bool signedDomain)
=> TransformBlock(block, context, blockIndex, signedDomain, encrypt: true);
- private static void TransformBlock(Span block, SonyPfsCryptoContext context,
+ private static void TransformBlock(Span 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.");
diff --git a/src/Packwright.Core/Parsers/SonyPfsEntryDataReader.cs b/src/Packwright.Core/Parsers/Ps5PfsEntryDataReader.cs
similarity index 82%
rename from src/Packwright.Core/Parsers/SonyPfsEntryDataReader.cs
rename to src/Packwright.Core/Parsers/Ps5PfsEntryDataReader.cs
index bfb0443..8302874 100644
--- a/src/Packwright.Core/Parsers/SonyPfsEntryDataReader.cs
+++ b/src/Packwright.Core/Parsers/Ps5PfsEntryDataReader.cs
@@ -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));
}
diff --git a/src/Packwright.Core/Parsers/SonyPkgReader.cs b/src/Packwright.Core/Parsers/Ps5PkgReader.cs
similarity index 52%
rename from src/Packwright.Core/Parsers/SonyPkgReader.cs
rename to src/Packwright.Core/Parsers/Ps5PkgReader.cs
index 9c9bedc..67dcf1c 100644
--- a/src/Packwright.Core/Parsers/SonyPkgReader.cs
+++ b/src/Packwright.Core/Parsers/Ps5PkgReader.cs
@@ -1,90 +1,105 @@
using System.Buffers.Binary;
using Packwright.Core.Models;
-using ProsperoPkgTool.Containers;
+using Packwright.Pkg.Reader;
namespace Packwright.Core.Parsers;
///
-/// Reads the public CNT/FIH metadata layer of a Sony PS5 package. Parsing is delegated to the
-/// vendored, validated ProsperoPkgTool 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.
///
-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 CntMagic => [0x7F, (byte)'C', (byte)'N', (byte)'T'];
+ private static ReadOnlySpan LihMagic => [0x7F, (byte)'L', (byte)'I', (byte)'H'];
private static ReadOnlySpan 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 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)
};
}
- /// Returns the entry count the engine indexed inside the reconstructed inner image.
+ // Content flags that mark a patch (first, patch-go and subsequent patches).
+ private static bool IsPatch(uint contentFlags) => (contentFlags & (0x00100000u | 0x00200000u | 0x40000000u)) != 0;
+
+ /// Returns the number of files in the package's inner filesystem.
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,
diff --git a/src/Packwright.Core/Parsers/Ps5SelfHeader.cs b/src/Packwright.Core/Parsers/Ps5SelfHeader.cs
new file mode 100644
index 0000000..e36066c
--- /dev/null
+++ b/src/Packwright.Core/Parsers/Ps5SelfHeader.cs
@@ -0,0 +1,35 @@
+using System.Buffers.Binary;
+
+namespace Packwright.Core.Parsers;
+
+/// Reads the few fields of an executable's SELF container header the app shows.
+internal static class Ps5SelfHeader
+{
+ private const uint SelfMagic = 0x1D3D154F;
+ private const uint SignedModuleMagic = 0xEEF51454;
+
+ ///
+ /// Reads the firmware version recorded in the container's extended info block, which sits after the embedded ELF
+ /// header and its program headers.
+ ///
+ public static bool TryReadFirmwareVersion(ReadOnlySpan 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;
+ }
+}
diff --git a/src/Packwright.Core/Parsers/Ps5TrophyReader.cs b/src/Packwright.Core/Parsers/Ps5TrophyReader.cs
index a70efad..3b17576 100644
--- a/src/Packwright.Core/Parsers/Ps5TrophyReader.cs
+++ b/src/Packwright.Core/Parsers/Ps5TrophyReader.cs
@@ -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 ReadLocalizedMetadata(JsonElement root)
diff --git a/src/Packwright.Core/Parsers/SonyEnginePackageAccess.cs b/src/Packwright.Core/Parsers/SonyEnginePackageAccess.cs
deleted file mode 100644
index 6ad7449..0000000
--- a/src/Packwright.Core/Parsers/SonyEnginePackageAccess.cs
+++ /dev/null
@@ -1,476 +0,0 @@
-using System.Buffers.Binary;
-using Packwright.Core.Builders;
-using ProsperoPkgTool.Containers;
-using ProsperoPkgTool.Crypto;
-
-namespace Packwright.Core.Parsers;
-
-///
-/// Adapts the vendored, validated ProsperoPkgTool 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.
-///
-internal sealed class SonyEnginePackageAccess : IDisposable
-{
- private readonly string _passcode;
- private readonly string _contentId;
- private IReadOnlyList? _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; }
-
- ///
- /// 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.
- ///
- public IReadOnlyList Entries
- {
- get { EnsureDecoded(); return _entries!; }
- }
-
- public string? DecodeError
- {
- get { EnsureDecoded(); return _decodeError; }
- }
-
- ///
- /// True when 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.
- ///
- public bool DecodeUnsupported
- {
- get { EnsureDecoded(); return _decodeUnsupported; }
- }
-
- public IEnumerable Files => Entries.Where(entry => !entry.IsDirectory);
-
- /// Parses the CNT/FIH metadata only. The inner image is decoded lazily on first use.
- public static SonyEnginePackageAccess Open(string packagePath, string? passcode)
- {
- string fullPath = Path.GetFullPath(packagePath);
- ProsperoPackageInspection inspection = ProsperoPackageReader.Read(fullPath);
- return FromInspection(fullPath, inspection, passcode);
- }
-
- ///
- /// 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.
- ///
- 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);
- }
-
- ///
- /// Decrypts a protected CNT entry (the 0x80000000 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.
- ///
- 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.");
- }
-
- /// The 32-byte finalized entry meta row the key seed is bound to (six big-endian fields, then 8 zero bytes).
- 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;
- }
-
- /// Known plaintext magics for protected entries, so a wrong passcode is rejected rather
- /// than returning garbage. Unknown ids are accepted as-is.
- 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
- };
-
- ///
- /// 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.
- ///
- 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);
- }
- }
-
- ///
- /// 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.
- ///
- 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();
- }
- }
-
- ///
- /// 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.
- ///
- 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();
-
- /// Delegating stream that notifies its owner once, when disposed, so the shared package
- /// handle can be released after the last concurrent reader finishes.
- 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 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 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); }
- }
-}
diff --git a/src/Packwright.Core/Services/GameFileSystem.cs b/src/Packwright.Core/Services/GameFileSystem.cs
index ece575d..ec8f801 100644
--- a/src/Packwright.Core/Services/GameFileSystem.cs
+++ b/src/Packwright.Core/Services/GameFileSystem.cs
@@ -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 _segments;
- private readonly SonyPfsCryptoContext? _cryptoContext;
- private readonly SonyEnginePackageAccess? _engine;
+ private readonly Ps5PfsCryptoContext? _cryptoContext;
+ private readonly Ps5EnginePackageAccess? _engine;
private readonly Dictionary? _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(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(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;
}
diff --git a/src/Packwright.Core/Services/PackageReaderVerification.cs b/src/Packwright.Core/Services/PackageReaderVerification.cs
index 72c24be..e1379d2 100644
--- a/src/Packwright.Core/Services/PackageReaderVerification.cs
+++ b/src/Packwright.Core/Services/PackageReaderVerification.cs
@@ -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
}
///
-/// 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.
///
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 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);
}
}
diff --git a/src/Packwright.Core/Services/Ps5DdsEncoder.cs b/src/Packwright.Core/Services/Ps5DdsEncoder.cs
new file mode 100644
index 0000000..caca3d4
--- /dev/null
+++ b/src/Packwright.Core/Services/Ps5DdsEncoder.cs
@@ -0,0 +1,167 @@
+using System.Buffers.Binary;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace Packwright.Core.Services;
+
+///
+/// Encodes a picture as the BC7 texture (.dds ) 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.
+///
+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 image = Image.Load(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 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 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 block, Span destination)
+ {
+ Span 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 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 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 endpoint, Span quantized, out int pbit, Span 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 e0, ReadOnlySpan e1, ReadOnlySpan 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;
+ }
+}
diff --git a/src/Packwright.Core/Services/Ps5DetailsLoader.cs b/src/Packwright.Core/Services/Ps5DetailsLoader.cs
index ecc3f34..d7390b9 100644
--- a/src/Packwright.Core/Services/Ps5DetailsLoader.cs
+++ b/src/Packwright.Core/Services/Ps5DetailsLoader.cs
@@ -12,7 +12,7 @@ public sealed class Ps5DetailsLoader
private static Ps5GameDetails Load(Ps5GameInfo game, CancellationToken cancellationToken,
IProgress? 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();
- 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 errors,
IProgress? 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 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
///
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);
}
diff --git a/src/Packwright.Core/Services/Ps5DiskSpace.cs b/src/Packwright.Core/Services/Ps5DiskSpace.cs
index c0b3881..fd68086 100644
--- a/src/Packwright.Core/Services/Ps5DiskSpace.cs
+++ b/src/Packwright.Core/Services/Ps5DiskSpace.cs
@@ -1,8 +1,8 @@
-using ProsperoPkgTool.Containers;
+using System.Globalization;
namespace Packwright.Core.Services;
-/// Free-space preflight outcome, mirroring the engine without exposing its types.
+/// Free-space preflight outcome.
public enum Ps5DiskSpaceStatus
{
Ok,
@@ -14,69 +14,97 @@ public enum Ps5DiskSpaceStatus
public readonly record struct Ps5DiskSpaceCheck(Ps5DiskSpaceStatus Status, string Message);
///
-/// Thin facade over the engine's 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).
///
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);
+
/// Estimates and checks free space for a package build.
/// Source payload upper bound (dump total, or image file length).
/// Destination package path.
- /// Workspace folder, or null for the system temp folder.
- 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));
- }
+ /// Not used by the builder; kept so callers do not need to change.
+ public static Ps5DiskSpaceCheck Check(long rawPayloadBytes, string outputPath, string? tempDirectory) =>
+ Evaluate([new Requirement(RootOf(outputPath), PackageSize(rawPayloadBytes), "output package")]);
///
- /// 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.
///
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();
+ 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);
}
- /// True when the exception is the engine's insufficient-free-space failure.
- public static bool IsInsufficient(Exception exception) => exception is ProsperoInsufficientSpaceException;
+ /// True when the exception is an out-of-disk-space failure.
+ public static bool IsInsufficient(Exception exception) =>
+ exception is IOException io && (io.HResult & 0xFFFF) is 112 or 39; // ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL
- /// The engine's free-space message for an insufficient-space exception.
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 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);
}
diff --git a/src/Packwright.Core/Services/Ps5LibraryScanner.cs b/src/Packwright.Core/Services/Ps5LibraryScanner.cs
index c0c84c8..6818519 100644
--- a/src/Packwright.Core/Services/Ps5LibraryScanner.cs
+++ b/src/Packwright.Core/Services/Ps5LibraryScanner.cs
@@ -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);
}
+ ///
+ /// 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.
+ ///
+ private static void ComputeDumpSizes(List games, CancellationToken cancellationToken)
+ {
+ List 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(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;
diff --git a/src/Packwright.Core/Services/Ps5MetadataSuggester.cs b/src/Packwright.Core/Services/Ps5MetadataSuggester.cs
index c68e2e7..3ef8e23 100644
--- a/src/Packwright.Core/Services/Ps5MetadataSuggester.cs
+++ b/src/Packwright.Core/Services/Ps5MetadataSuggester.cs
@@ -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;
diff --git a/src/Packwright.Core/Services/Ps5PackageExtraction.cs b/src/Packwright.Core/Services/Ps5PackageExtraction.cs
new file mode 100644
index 0000000..d7ec609
--- /dev/null
+++ b/src/Packwright.Core/Services/Ps5PackageExtraction.cs
@@ -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
+{
+ ///
+ /// Extracts the application filesystem of a package to , which must not exist.
+ /// The passcode is only needed for the protected metadata entries; the files themselves are not encrypted.
+ ///
+ public static Task ExtractAsync(string packagePath, string destination,
+ string passcode = Ps5DebugPackageCredentials.DefaultPasscode,
+ IProgress? 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 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? forward = progress is null
+ ? null
+ : new Progress(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);
+}
diff --git a/src/Packwright.Core/Services/SonyPackageImageConversion.cs b/src/Packwright.Core/Services/Ps5PackageImageConversion.cs
similarity index 83%
rename from src/Packwright.Core/Services/SonyPackageImageConversion.cs
rename to src/Packwright.Core/Services/Ps5PackageImageConversion.cs
index fb5318a..0db2db3 100644
--- a/src/Packwright.Core/Services/SonyPackageImageConversion.cs
+++ b/src/Packwright.Core/Services/Ps5PackageImageConversion.cs
@@ -6,23 +6,11 @@ namespace Packwright.Core.Services;
///
/// 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
-/// sce_sys/param.json , artwork and trophies) is materialized to a temporary tree after
-/// dropping the container's internal bookkeeping files, then rebuilt in the target format.
+/// sce_sys/param.json , artwork and trophies) is materialized to a temporary tree unchanged, with
+/// no file left out, then rebuilt in the target format.
///
-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 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 ConvertAsync(string packagePath, string outputPath,
Ps5ImageConversionTarget target, bool overwrite = false,
IProgress? 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));
diff --git a/src/Packwright.Core/Services/Ps5PackageMetadataEditor.cs b/src/Packwright.Core/Services/Ps5PackageMetadataEditor.cs
new file mode 100644
index 0000000..87d58ce
--- /dev/null
+++ b/src/Packwright.Core/Services/Ps5PackageMetadataEditor.cs
@@ -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;
+
+///
+/// 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.
+///
+public static class Ps5PackageMetadataEditor
+{
+ ///
+ /// Writes from 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).
+ ///
+ public static Task TryEditAsync(string sourcePath, string targetPath, Func patchParam, byte[]? iconPng,
+ string passcode, IProgress? 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 { [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(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(); }
+ catch (Exception ex) when (ex is System.Text.Json.JsonException or InvalidOperationException or FormatException) { return null; }
+ }
+}
diff --git a/src/Packwright.Core/Services/Ps5ParamJson.cs b/src/Packwright.Core/Services/Ps5ParamJson.cs
new file mode 100644
index 0000000..e146175
--- /dev/null
+++ b/src/Packwright.Core/Services/Ps5ParamJson.cs
@@ -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;
+
+///
+/// Small, targeted edits of a title's sce_sys/param.json . 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.
+///
+public static class Ps5ParamJson
+{
+ // requiredSystemSoftwareVersion is never raised above this (firmware 9.00)
+ private const ulong RequiredSystemSoftwareVersionCap = 0x0900000000000000UL;
+
+ public static byte[] ApplyDrmType(ReadOnlySpan source, string drmType)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(drmType);
+ JsonObject root = Parse(source);
+ root["applicationDrmType"] = drmType;
+ return Serialize(root);
+ }
+
+ /// Stamps a full 64-bit executable SDK id: sdkVersion becomes its package version and the required firmware is raised to match.
+ public static byte[] ApplySdkOverride(ReadOnlySpan 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);
+ }
+
+ /// The value of a top-level string property, or null.
+ public static string? ReadString(ReadOnlySpan source, string name)
+ {
+ try { return Parse(source)[name]?.GetValue(); }
+ catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or FormatException) { return null; }
+ }
+
+ private static JsonObject Parse(ReadOnlySpan 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 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();
+ 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}";
+}
diff --git a/src/Packwright.Core/Services/SonyPkgGameReader.cs b/src/Packwright.Core/Services/Ps5PkgGameReader.cs
similarity index 88%
rename from src/Packwright.Core/Services/SonyPkgGameReader.cs
rename to src/Packwright.Core/Services/Ps5PkgGameReader.cs
index 209ac19..7f79ab8 100644
--- a/src/Packwright.Core/Services/SonyPkgGameReader.cs
+++ b/src/Packwright.Core/Services/Ps5PkgGameReader.cs
@@ -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.
///
- 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);
diff --git a/src/Packwright.Core/Services/Ps5SdkVersions.cs b/src/Packwright.Core/Services/Ps5SdkVersions.cs
index 687944a..09b024d 100644
--- a/src/Packwright.Core/Services/Ps5SdkVersions.cs
+++ b/src/Packwright.Core/Services/Ps5SdkVersions.cs
@@ -1,10 +1,8 @@
-using ProsperoPkgTool.Content;
-
namespace Packwright.Core.Services;
///
-/// 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.
///
public static class Ps5SdkVersions
{
@@ -12,9 +10,19 @@ public static class Ps5SdkVersions
/// Known SDK releases in ascending major order.
public static IReadOnlyList 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),
+ ];
/// The executable SDK id at , or null when out of range.
public static ulong? ExecutableVersionAt(int index) =>
diff --git a/src/Packwright.Core/Services/SonyPackageExtraction.cs b/src/Packwright.Core/Services/SonyPackageExtraction.cs
deleted file mode 100644
index 8fbe21b..0000000
--- a/src/Packwright.Core/Services/SonyPackageExtraction.cs
+++ /dev/null
@@ -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 ExtractAsync(string packagePath, string destination,
- string passcode = SonyDebugPackageCredentials.DefaultPasscode,
- IProgress? 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;
- }
-}
diff --git a/src/Packwright.Core/Tasks/PackageTaskTypes.cs b/src/Packwright.Core/Tasks/PackageTaskTypes.cs
index e724565..f29cb9f 100644
--- a/src/Packwright.Core/Tasks/PackageTaskTypes.cs
+++ b/src/Packwright.Core/Tasks/PackageTaskTypes.cs
@@ -3,7 +3,7 @@ namespace Packwright.Core.Tasks;
/// Stable task type identifiers used for display and future persistence/restore routing.
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";
diff --git a/src/Packwright.Core/Tasks/QueuedPackageTask.cs b/src/Packwright.Core/Tasks/QueuedPackageTask.cs
index f41a0a1..e6e05e0 100644
--- a/src/Packwright.Core/Tasks/QueuedPackageTask.cs
+++ b/src/Packwright.Core/Tasks/QueuedPackageTask.cs
@@ -118,25 +118,9 @@ public static class PackageTaskPlans
new("Finalize", "finaliz")
];
- ///
- /// 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.
- ///
- 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")
- ];
-
/// Selects the build-package step plan for a dump folder or an image file.
- /// True when an image source is extracted to a staging folder first.
- 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 =
[
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenBitWriter.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenBitWriter.cs
new file mode 100644
index 0000000..635ea82
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenBitWriter.cs
@@ -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;
+
+/// Accumulates bits MSB-first into bytes; one instance per bitstream direction.
+internal sealed class KrakenBitWriter
+{
+ private readonly List _bytes = new();
+ private uint _cur;
+ private int _curBits;
+
+ /// Number of whole bytes produced once the trailing partial byte is flushed.
+ public int ByteLength => _bytes.Count + (_curBits > 0 ? 1 : 0);
+
+ /// Writes the low bits of , MSB-first.
+ public void WriteBits(uint value, int n)
+ {
+ for (int i = n - 1; i >= 0; i--)
+ WriteBit((value >> i) & 1);
+ }
+
+ /// Writes zero bits.
+ 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;
+ }
+ }
+
+ /// Writes the Elias-gamma length-stream-size prefix for (>= 0).
+ public void WriteLenStreamSizePrefix(int size)
+ {
+ uint field = (uint)size + 1;
+ int fieldBits = 32 - BitOperations.LeadingZeroCount(field);
+ WriteZeros(fieldBits - 1);
+ WriteBits(field, fieldBits);
+ }
+
+ /// Writes a Kraken length code for (the u32 length-stream value).
+ public void WriteLength(uint value)
+ {
+ uint field = value + 64;
+ int fieldBits = 32 - BitOperations.LeadingZeroCount(field);
+ WriteZeros(fieldBits - 7);
+ WriteBits(field, fieldBits);
+ }
+
+ ///
+ /// Encodes distance (>= 8): returns the packed-offset command byte and
+ /// writes the associated extra bits. Only the < 0xF0 bucket (window <= 256 KiB) is produced.
+ ///
+ 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);
+ }
+
+ /// Flushes any partial byte (zero-padded) and returns the produced bytes in write order.
+ public byte[] ToBytes()
+ {
+ if (_curBits > 0)
+ {
+ _bytes.Add((byte)(_cur << (8 - _curBits)));
+ _cur = 0;
+ _curBits = 0;
+ }
+ return _bytes.ToArray();
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenDecoder.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenDecoder.cs
new file mode 100644
index 0000000..3b658a3
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenDecoder.cs
@@ -0,0 +1,1472 @@
+// LibProsperoPkg - A library for building and inspecting PS5 packages.
+// Copyright (C) 2026 SvenGDK
+//
+// ---------------------------------------------------------------------------------------------------
+// Kraken (newLZ) decoder for PFSv3 Kraken blocks: entropy-coded
+// literals/commands/offsets/lengths, the post-seed 0x80 "excess" framing, and both literal models,
+// including the excess-substream length escapes and continuation-byte excess-count parse.
+//
+// Portions of the decode logic carry a GPLv3 attribution; see NOTICE. LibProsperoPkg as a whole is
+// licensed under the GNU GPLv3; section 13 of the GPLv3 expressly permits conveying a work that
+// links/combines GPLv3-covered code. Uses byte[]/int indices only.
+//
+// Supported chunk-decode (DecodeBytes) array types: 0 (raw / memcpy-short) and 2/4 (Huffman, both
+// code-length encodings + single/triple stream split). Types 1 (TANS), 3 (RLE) and 5 (recursive /
+// multi-array) are reported as unsupported; level-7 nwonly blocks observed here use only
+// raw + Huffman, but the dispatcher surfaces any unsupported type cleanly rather than silently failing.
+// ---------------------------------------------------------------------------------------------------
+#nullable enable
+using System;
+using System.Buffers.Binary;
+using System.Numerics;
+
+namespace LibProsperoPkg.PFS.Compression.Oodle;
+
+/// The outcome of a Kraken decode attempt.
+internal enum KrakenDecodeStatus
+{
+ /// The block decoded successfully into the destination buffer.
+ Success = 0,
+
+ /// The block's framing, sizes or arrays were inconsistent (corrupt or unexpected input).
+ Malformed = 1,
+
+ /// An entropy array used a coding the managed decoder does not implement (TANS/RLE/recursive).
+ UnsupportedEntropy = 2,
+
+ /// The chunk used an excess/control form the managed decoder does not implement.
+ UnsupportedExcessMode = 3,
+}
+
+///
+/// A complete managed Kraken (newLZ) chunk decoder. Decodes one PFS block (one or two internal
+/// newLZ chunks) into a caller-supplied destination buffer. This is the production decoder used by
+/// to read both this library's own output and compatible encoded blocks.
+///
+internal static class KrakenDecoder
+{
+ private const int ChunkMax = 0x20000; // a single newLZ chunk decodes at most 128 KiB
+ private const int SeedSize = 8;
+
+ // Boundary flag byte bits (the on-disk id=3 entry's per-chunk markers).
+ // The low nibble describes the first sub-chunk, the high nibble the second: a 256 KiB block is two
+ // 128 KiB sub-chunks whose types are INDEPENDENT (newLZ or bare-entropy), so each is decoded on its
+ // own per these bits. These bits cover newLZ, entropy, and mixed inputs and align with the
+ // frame rebuilder's bit-38 newLZ flag.
+ // The flag byte is two independent 4-bit groups, one per sub-chunk: bit 0 literal model,
+ // bit 1 LZ enable, bit 2 restart (decode at output position 0), bit 3 verbatim copy — and
+ // bits 4..7 the same four for sub-chunk 1. Sub-chunk 0 always decodes at position 0, so its
+ // restart bit carries no extra information; the verbatim-copy bits agree with the authoritative
+ // size test (compressed length == uncompressed length) and are not read.
+ private const int Chunk0SubLitBit = 0x01; // chunk0 literal model: set = sub/delta (mode 0), clear = raw (mode 1)
+ private const int Chunk0NewLzBit = 0x02; // chunk0 is newLZ; clear = bare-entropy array
+ private const int Chunk1SubLitBit = 0x10; // chunk1 literal model (same encoding as chunk0)
+ private const int Chunk1NewLzBit = 0x20; // chunk1 is newLZ; clear = bare-entropy array
+ private const int Chunk1RestartBit = 0x40; // chunk1 decodes at output position 0 instead of ChunkMax
+
+ ///
+ /// Decodes a whole PFS block (the section-7 payload) into (sized to the
+ /// block's exact uncompressed length). is the block's on-disk boundary
+ /// flag byte; is the first sub-chunk's compressed size (from the
+ /// boundary size hint) and is only consulted when the block spans two sub-chunks (its uncompressed
+ /// size exceeds one 128 KiB chunk). A two-chunk block's sub-chunks have INDEPENDENT types — each is
+ /// newLZ or a bare-entropy Kraken_DecodeBytes array selected by
+ /// (chunk0: bit 0x02 ; chunk1: bit 0x20 ) — so they are decoded separately, the second
+ /// seedless and able to back-reference the first. The literal model for each newLZ sub-chunk comes
+ /// from its low bit (chunk0 0x01 , chunk1 0x10 ): set = sub/delta, clear = raw.
+ ///
+ public static KrakenDecodeStatus DecodeBlock(
+ ReadOnlySpan payload, int flags, int firstChunkComp, Span dst)
+ {
+ if (dst.Length <= 0)
+ return payload.Length == 0 ? KrakenDecodeStatus.Success : KrakenDecodeStatus.Malformed;
+
+ byte[] src = payload.ToArray();
+ byte[] outBuf = new byte[dst.Length];
+
+ // Sub-chunk geometry. The size hint carries sub-chunk 0's compressed length for every block,
+ // so both sub-chunks are located the same way whether or not the block is split.
+ int chunk0Dst = dst.Length < ChunkMax ? dst.Length : ChunkMax;
+ int chunk1Dst = dst.Length - chunk0Dst;
+ int chunk0Comp = chunk1Dst == 0 ? src.Length : firstChunkComp;
+ if (chunk0Comp <= 0 || chunk0Comp > src.Length)
+ return KrakenDecodeStatus.Malformed;
+ int chunk1Comp = src.Length - chunk0Comp;
+ if (chunk1Dst == 0 && chunk1Comp != 0)
+ return KrakenDecodeStatus.Malformed;
+
+ KrakenDecodeStatus st = DecodeSubChunk(
+ src, 0, chunk0Comp, outBuf, 0, chunk0Dst,
+ lzEnable: (flags & Chunk0NewLzBit) != 0,
+ restart: true,
+ literalMode: (flags & Chunk0SubLitBit) != 0 ? 0 : 1);
+ if (st != KrakenDecodeStatus.Success)
+ return st;
+
+ if (chunk1Dst > 0)
+ {
+ // Sub-chunk 1 normally continues the same output window, so a newLZ sub-chunk 1 may
+ // back-reference sub-chunk 0's bytes; the restart bit instead makes it decode as its own
+ // window at position 0, with its own seed and no back-references.
+ st = DecodeSubChunk(
+ src, chunk0Comp, chunk1Comp, outBuf, chunk0Dst, chunk1Dst,
+ lzEnable: (flags & Chunk1NewLzBit) != 0,
+ restart: (flags & Chunk1RestartBit) != 0,
+ literalMode: (flags & Chunk1SubLitBit) != 0 ? 0 : 1);
+ if (st != KrakenDecodeStatus.Success)
+ return st;
+ }
+
+ outBuf.AsSpan(0, dst.Length).CopyTo(dst);
+ return st;
+ }
+
+ ///
+ /// Decodes one sub-chunk into at . A sub-chunk
+ /// whose compressed length equals its uncompressed length is a verbatim copy; otherwise it is a
+ /// newLZ sub-chunk when is set and a single entropy array when it is not.
+ /// selects a fresh output window (own seed, no back-references) instead of
+ /// continuing the block's window.
+ ///
+ private static KrakenDecodeStatus DecodeSubChunk(
+ byte[] src, int srcStart, int srcLen, byte[] outBuf, int dstStart, int dstLen,
+ bool lzEnable, bool restart, int literalMode)
+ {
+ if (srcLen < 0 || srcStart + srcLen > src.Length || dstLen < 0 || dstStart + dstLen > outBuf.Length)
+ return KrakenDecodeStatus.Malformed;
+ if (srcLen == dstLen)
+ {
+ Array.Copy(src, srcStart, outBuf, dstStart, dstLen);
+ return KrakenDecodeStatus.Success;
+ }
+ return lzEnable
+ ? DecodeChunk(src, srcStart, srcLen, outBuf, dstStart, dstLen, withSeed: restart, literalMode)
+ : DecodeBareEntropyBlock(src, srcStart, srcLen, outBuf, dstStart, dstLen);
+ }
+
+ ///
+ /// Decodes one bare-entropy array: bytes at
+ /// are a single Kraken_DecodeBytes array (raw type-0 or Huffman type-2/4) that expands to
+ /// exactly bytes written at in
+ /// — no seed, no excess framing, no LZ table, no back-references. A block
+ /// larger than one 128 KiB chunk is encoded as two such arrays back-to-back (each independent).
+ /// The array must consume its whole source span.
+ ///
+ private static KrakenDecodeStatus DecodeBareEntropyBlock(
+ byte[] src, int srcStart, int srcLen, byte[] outBuf, int dstStart, int dstLen)
+ {
+ if (srcLen < 0 || srcStart + srcLen > src.Length || dstLen < 0 || dstStart + dstLen > outBuf.Length)
+ return KrakenDecodeStatus.Malformed;
+ int n = DecodeBytes(src, srcStart, srcStart + srcLen, dstLen, out byte[] dec, out int decCount);
+ if (n < 0)
+ return n == -2 ? KrakenDecodeStatus.UnsupportedEntropy : KrakenDecodeStatus.Malformed;
+ if (n != srcLen || decCount != dstLen)
+ return KrakenDecodeStatus.Malformed;
+ Array.Copy(dec, 0, outBuf, dstStart, dstLen);
+ return KrakenDecodeStatus.Success;
+ }
+
+ /// Holds the four decoded streams for one newLZ chunk.
+ private sealed class LzTable
+ {
+ public byte[] LitStream = Array.Empty();
+ public int LitStreamSize;
+ public byte[] CmdStream = Array.Empty();
+ public int CmdStreamSize;
+ public int[] OffsStream = Array.Empty();
+ public int OffsStreamSize;
+ public int[] LenStream = Array.Empty();
+ public int LenStreamSize;
+ }
+
+ ///
+ /// Decodes one newLZ chunk covering output range
+ /// [ , dstStart + ) within the shared block
+ /// buffer . Matches may reference any earlier byte in .
+ ///
+ private static KrakenDecodeStatus DecodeChunk(
+ byte[] src, int srcStart, int srcLen, byte[] dst, int dstStart, int dstSize, bool withSeed, int literalMode)
+ {
+ if (dstSize <= 0 || dstStart + dstSize > dst.Length || srcLen < 0 || srcStart + srcLen > src.Length)
+ return KrakenDecodeStatus.Malformed;
+
+ int srcEnd = srcStart + srcLen;
+ int sp = srcStart;
+ int offset = withSeed ? 0 : dstStart; // "offset" = dst - dst_start (0 only for the seeded chunk)
+
+ var table = new LzTable();
+ int rc = ReadLzTable(src, ref sp, srcEnd, dst, dstStart, dstSize, offset, table);
+ if (rc < 0)
+ return rc == -2 ? KrakenDecodeStatus.UnsupportedEntropy : KrakenDecodeStatus.Malformed;
+
+ bool ok = ProcessLzRuns(literalMode, dst, dstStart, dstSize, offset, table);
+ return ok ? KrakenDecodeStatus.Success : KrakenDecodeStatus.Malformed;
+ }
+
+ // ===========================================================================================
+ // Kraken_ReadLzTable (+ the seed copy, the excess framing, and the multi-table offset mode)
+ // ===========================================================================================
+ private static int ReadLzTable(byte[] src, ref int sp, int srcEnd, byte[] dst, int dstStart, int dstSize, int offset, LzTable lzt)
+ {
+ // Seeded chunk: the first 8 output bytes are a raw COPY_64 seed.
+ if (offset == 0)
+ {
+ if (srcEnd - sp < SeedSize)
+ return -1;
+ Array.Copy(src, sp, dst, dstStart, SeedSize);
+ sp += SeedSize;
+ }
+
+ // Excess framing: encoded chunks set the post-seed control byte's 0x80 bit.
+ bool excessFlag = false;
+ int excessCount = 0;
+ if (sp < srcEnd && (src[sp] & 0x80) != 0)
+ {
+ byte flag = src[sp++];
+ if ((flag & 0xC0) != 0x80)
+ return -1; // reserved flag bit set
+ excessFlag = true;
+ excessCount = flag & 0x3F;
+ if (excessCount > 0x1F)
+ {
+ if (sp >= srcEnd)
+ return -1; // truncated excess byte count
+ excessCount += src[sp++] * 0x20; // continuation byte
+ }
+ srcEnd -= excessCount; // trailing excess substream lives at [srcEnd, srcEnd+excessCount)
+ if (srcEnd < sp)
+ return -1;
+ }
+
+ // Lit stream (bounded by dst_size).
+ int n = DecodeBytes(src, sp, srcEnd, dstSize, out byte[] lit, out int litCount);
+ if (n < 0) return n;
+ sp += n;
+ lzt.LitStream = lit; lzt.LitStreamSize = litCount;
+
+ // Command stream (bounded by dst_size).
+ n = DecodeBytes(src, sp, srcEnd, dstSize, out byte[] cmd, out int cmdCount);
+ if (n < 0) return n;
+ sp += n;
+ lzt.CmdStream = cmd; lzt.CmdStreamSize = cmdCount;
+
+ if (srcEnd - sp < 3)
+ return -1;
+
+ int offsScaling = 0;
+ byte[] packedOffsExtra = Array.Empty();
+ byte[] packedOffs;
+ int offsCount;
+
+ if ((src[sp] & 0x80) != 0)
+ {
+ // Distances coded with two tables.
+ offsScaling = src[sp] - 127;
+ sp++;
+ n = DecodeBytes(src, sp, srcEnd, cmdCount, out packedOffs, out offsCount);
+ if (n < 0) return n;
+ sp += n;
+ if (offsScaling != 1)
+ {
+ n = DecodeBytes(src, sp, srcEnd, offsCount, out packedOffsExtra, out int extraCount);
+ if (n < 0) return n;
+ if (extraCount != offsCount) return -1;
+ sp += n;
+ }
+ }
+ else
+ {
+ n = DecodeBytes(src, sp, srcEnd, cmdCount, out packedOffs, out offsCount);
+ if (n < 0) return n;
+ sp += n;
+ }
+ lzt.OffsStreamSize = offsCount;
+
+ // Packed litlen stream (bounded by dst_size / 4).
+ n = DecodeBytes(src, sp, srcEnd, dstSize >> 2, out byte[] packedLitLen, out int lenCount);
+ if (n < 0) return n;
+ sp += n;
+ lzt.LenStreamSize = lenCount;
+
+ lzt.OffsStream = new int[offsCount];
+ lzt.LenStream = new int[lenCount];
+
+ return UnpackOffsets(src, sp, srcEnd, excessFlag, excessCount,
+ packedOffs, offsCount, offsScaling, packedOffsExtra,
+ packedLitLen, lenCount, lzt.OffsStream, lzt.LenStream)
+ ? sp
+ : -1;
+ }
+
+ // ===========================================================================================
+ // Kraken_UnpackOffsets (dual meet-in-the-middle bit reader, traditional + scaled offsets,
+ // in-band OR excess-substream length escapes)
+ // ===========================================================================================
+ private static bool UnpackOffsets(
+ byte[] src, int bsBegin, int bsEnd, bool excessFlag, int excessCount,
+ byte[] packedOffs, int offsCount, int offsScaling, byte[] packedOffsExtra,
+ byte[] packedLitLen, int lenCount, int[] offsStream, int[] lenStream)
+ {
+ var a = RefBitReader.Forward(src, bsBegin, bsEnd);
+ var b = RefBitReader.Backward(src, bsBegin, bsEnd);
+
+ int u32LenStreamSize = 0;
+ if (!excessFlag)
+ {
+ if (b.Bits < 0x2000) return false;
+ int nn = 31 - Bsr(b.Bits);
+ b.BitPos += nn; b.Bits <<= nn; b.RefillB();
+ nn++;
+ u32LenStreamSize = (int)((b.Bits >> (32 - nn)) - 1);
+ b.BitPos += nn; b.Bits <<= nn; b.RefillB();
+ }
+ else
+ {
+ for (int q = 0; q < lenCount; q++)
+ if (packedLitLen[q] == 255) u32LenStreamSize++;
+ }
+
+ if (offsScaling == 0)
+ {
+ int i = 0;
+ while (i < offsCount)
+ {
+ offsStream[i] = -(int)a.ReadDistance(packedOffs[i]); i++;
+ if (i >= offsCount) break;
+ offsStream[i] = -(int)b.ReadDistanceB(packedOffs[i]); i++;
+ }
+ }
+ else
+ {
+ int i = 0;
+ while (i < offsCount)
+ {
+ uint cmd = packedOffs[i];
+ if ((cmd >> 3) > 26) return false;
+ uint off = ((8u + (cmd & 7)) << (int)(cmd >> 3)) | a.ReadMoreThan24Bits((int)(cmd >> 3));
+ offsStream[i] = 8 - (int)off; i++;
+ if (i >= offsCount) break;
+ cmd = packedOffs[i];
+ if ((cmd >> 3) > 26) return false;
+ off = ((8u + (cmd & 7)) << (int)(cmd >> 3)) | b.ReadMoreThan24BitsB((int)(cmd >> 3));
+ offsStream[i] = 8 - (int)off; i++;
+ }
+ if (offsScaling != 1)
+ CombineScaledOffsets(offsStream, offsCount, offsScaling, packedOffsExtra);
+ }
+
+ if (u32LenStreamSize > 512)
+ return false;
+ var u32 = new uint[u32LenStreamSize];
+
+ if (excessFlag)
+ {
+ // The length-escape u32 values live in a SEPARATE dual bitstream over the trailing
+ // excess substream [bsEnd, bsEnd+excessCount), read with the same fwd/bwd alternation.
+ var ea = RefBitReader.Forward(src, bsEnd, bsEnd + excessCount);
+ var eb = RefBitReader.Backward(src, bsEnd, bsEnd + excessCount);
+ int i = 0;
+ for (; i + 1 < u32LenStreamSize; i += 2)
+ {
+ if (!ea.ReadLength(out u32[i])) return false;
+ if (!eb.ReadLengthB(out u32[i + 1])) return false;
+ }
+ if (i < u32LenStreamSize)
+ {
+ if (!ea.ReadLength(out u32[i])) return false;
+ }
+ // The excess seam is allowed to differ; only the main bitstream seam is asserted.
+ }
+ else
+ {
+ int i = 0;
+ for (; i + 1 < u32LenStreamSize; i += 2)
+ {
+ if (!a.ReadLength(out u32[i])) return false;
+ if (!b.ReadLengthB(out u32[i + 1])) return false;
+ }
+ if (i < u32LenStreamSize)
+ {
+ if (!a.ReadLength(out u32[i])) return false;
+ }
+ }
+
+ int aSeam = a.P - ((24 - a.BitPos) >> 3);
+ int bSeam = b.P + ((24 - b.BitPos) >> 3);
+ if (aSeam != bSeam && !excessFlag)
+ return false;
+
+ int u = 0;
+ for (int i = 0; i < lenCount; i++)
+ {
+ uint v = packedLitLen[i];
+ if (v == 255)
+ {
+ if (u >= u32LenStreamSize) return false;
+ v = u32[u++] + 255;
+ }
+ lenStream[i] = (int)v + 3;
+ }
+ return u == u32LenStreamSize;
+ }
+
+ private static void CombineScaledOffsets(int[] offs, int count, int scale, byte[] extra)
+ {
+ for (int i = 0; i < count; i++)
+ offs[i] = (sbyte)extra[i] - offs[i] * scale;
+ }
+
+ // ===========================================================================================
+ // Kraken_DecodeBytes (dispatcher) — returns srcUsed, or -1 malformed / -2 unsupported type
+ // ===========================================================================================
+ private static int DecodeBytes(byte[] src, int sp, int srcEnd, int outputCap, out byte[] output, out int decodedSize)
+ {
+ output = Array.Empty();
+ decodedSize = 0;
+ int srcOrg = sp;
+
+ if (srcEnd - sp < 2)
+ return -1;
+
+ int chunkType = (src[sp] >> 4) & 0x7;
+ int srcSize, dstSize;
+
+ if (chunkType == 0)
+ {
+ if (src[sp] >= 0x80)
+ {
+ srcSize = ((src[sp] << 8) | src[sp + 1]) & 0xFFF; // memcpy-short, 12-bit length
+ sp += 2;
+ }
+ else
+ {
+ if (srcEnd - sp < 3) return -1;
+ srcSize = (src[sp] << 16) | (src[sp + 1] << 8) | src[sp + 2];
+ if ((srcSize & ~0x3FFFF) != 0) return -1;
+ sp += 3;
+ }
+ if (srcSize > outputCap || srcEnd - sp < srcSize) return -1;
+ output = new byte[srcSize];
+ Array.Copy(src, sp, output, 0, srcSize);
+ decodedSize = srcSize;
+ return sp + srcSize - srcOrg;
+ }
+
+ // Entropy modes: the first bytes carry src_size + dst_size.
+ if (src[sp] >= 0x80)
+ {
+ if (srcEnd - sp < 3) return -1;
+ uint bits = (uint)((src[sp] << 16) | (src[sp + 1] << 8) | src[sp + 2]);
+ srcSize = (int)(bits & 0x3FF);
+ dstSize = (int)(srcSize + ((bits >> 10) & 0x3FF) + 1);
+ sp += 3;
+ }
+ else
+ {
+ if (srcEnd - sp < 5) return -1;
+ uint bits = (uint)((src[sp + 1] << 24) | (src[sp + 2] << 16) | (src[sp + 3] << 8) | src[sp + 4]);
+ srcSize = (int)(bits & 0x3FFFF);
+ dstSize = (int)((((bits >> 18) | ((uint)src[sp] << 14)) & 0x3FFFF) + 1);
+ if (srcSize >= dstSize) return -1;
+ sp += 5;
+ }
+ if (srcEnd - sp < srcSize || dstSize > outputCap) return -1;
+
+ output = new byte[dstSize];
+ int used;
+ switch (chunkType)
+ {
+ case 2:
+ case 4:
+ used = DecodeBytesType12(src, sp, srcSize, output, dstSize, chunkType >> 1);
+ break;
+ default:
+ // 1 (TANS), 3 (RLE), 5 (recursive/multi-array): not emitted by the supported nwonly lvl-7 path here.
+ return -2;
+ }
+ if (used != srcSize) return -1;
+ decodedSize = dstSize;
+ return sp + srcSize - srcOrg;
+ }
+
+ // ===========================================================================================
+ // Huffman: Kraken_DecodeBytes_Type12 + code-length readers + LUT + 3-stream scalar core
+ // ===========================================================================================
+ private static readonly uint[] CodePrefixOrg =
+ { 0x0, 0x0, 0x2, 0x6, 0xE, 0x1E, 0x3E, 0x7E, 0xFE, 0x1FE, 0x2FE, 0x3FE };
+
+ private static int DecodeBytesType12(byte[] src, int srcStart, int srcSize, byte[] output, int outputSize, int type)
+ {
+ int srcEnd = srcStart + srcSize;
+ var bits = RefBitReader.Forward(src, srcStart, srcEnd);
+
+ uint[] codePrefix = (uint[])CodePrefixOrg.Clone();
+ byte[] syms = new byte[1280];
+
+ int numSyms;
+ if (bits.ReadBitNoRefill() == 0)
+ numSyms = HuffReadCodeLengthsOld(ref bits, syms, codePrefix);
+ else if (bits.ReadBitNoRefill() == 0)
+ numSyms = HuffReadCodeLengthsNew(ref bits, syms, codePrefix);
+ else
+ return -1;
+
+ if (numSyms < 1)
+ return -1;
+
+ int sp = bits.P - ((24 - bits.BitPos) / 8);
+
+ if (numSyms == 1)
+ {
+ // A single-symbol array carries no payload streams: the whole compressed array is the
+ // code-length header. Report the full source size consumed, matching the caller's contract.
+ byte fill = syms[0];
+ for (int i = 0; i < outputSize; i++) output[i] = fill;
+ return srcSize;
+ }
+
+ var lut = new NewHuffLut();
+ if (!HuffMakeLut(CodePrefixOrg, codePrefix, lut, syms))
+ return -1;
+
+ var rev = new NewHuffLut();
+ ReverseBitsArray2048(lut.Bits2Len, rev.Bits2Len);
+ ReverseBitsArray2048(lut.Bits2Sym, rev.Bits2Sym);
+
+ if (type == 1)
+ {
+ if (sp + 3 > srcEnd) return -1;
+ int splitMid = src[sp] | (src[sp + 1] << 8);
+ sp += 2;
+ var hr = new HuffReader(src, output)
+ {
+ OutOff = 0,
+ OutEnd = outputSize,
+ Src = sp,
+ SrcEnd = srcEnd,
+ SrcMidOrg = sp + splitMid,
+ SrcMid = sp + splitMid,
+ };
+ if (!DecodeBytesCore(ref hr, rev)) return -1;
+ }
+ else
+ {
+ if (sp + 6 > srcEnd) return -1;
+ int halfOut = (outputSize + 1) >> 1;
+ int splitMid = src[sp] | (src[sp + 1] << 8) | (src[sp + 2] << 16);
+ sp += 3;
+ if (splitMid > srcEnd - sp) return -1;
+ int srcMid = sp + splitMid;
+ int splitLeft = src[sp] | (src[sp + 1] << 8);
+ sp += 2;
+ if (srcMid - sp < splitLeft + 2 || srcEnd - srcMid < 3) return -1;
+ int splitRight = src[srcMid] | (src[srcMid + 1] << 8);
+ if (srcEnd - (srcMid + 2) < splitRight + 2) return -1;
+
+ var hr1 = new HuffReader(src, output)
+ {
+ OutOff = 0,
+ OutEnd = halfOut,
+ Src = sp,
+ SrcEnd = srcMid,
+ SrcMidOrg = sp + splitLeft,
+ SrcMid = sp + splitLeft,
+ };
+ if (!DecodeBytesCore(ref hr1, rev)) return -1;
+
+ var hr2 = new HuffReader(src, output)
+ {
+ OutOff = halfOut,
+ OutEnd = outputSize,
+ Src = srcMid + 2,
+ SrcEnd = srcEnd,
+ SrcMidOrg = srcMid + 2 + splitRight,
+ SrcMid = srcMid + 2 + splitRight,
+ };
+ if (!DecodeBytesCore(ref hr2, rev)) return -1;
+ }
+ return srcSize;
+ }
+
+ private sealed class NewHuffLut
+ {
+ public readonly byte[] Bits2Len = new byte[2048 + 16];
+ public readonly byte[] Bits2Sym = new byte[2048 + 16];
+ }
+
+ private static bool HuffMakeLut(uint[] prefixOrg, uint[] prefixCur, NewHuffLut lut, byte[] syms)
+ {
+ uint currslot = 0;
+ for (uint i = 1; i < 11; i++)
+ {
+ uint start = prefixOrg[i];
+ uint count = prefixCur[i] - start;
+ if (count != 0)
+ {
+ uint stepsize = 1u << (int)(11 - i);
+ uint numToSet = count << (int)(11 - i);
+ if (currslot + numToSet > 2048) return false;
+ FillByte(lut.Bits2Len, (int)currslot, (byte)i, (int)numToSet);
+ int p = (int)currslot;
+ for (uint j = 0; j != count; j++, p += (int)stepsize)
+ FillByte(lut.Bits2Sym, p, syms[start + j], (int)stepsize);
+ currslot += numToSet;
+ }
+ }
+ if (prefixCur[11] - prefixOrg[11] != 0)
+ {
+ uint numToSet = prefixCur[11] - prefixOrg[11];
+ if (currslot + numToSet > 2048) return false;
+ FillByte(lut.Bits2Len, (int)currslot, 11, (int)numToSet);
+ Array.Copy(syms, (int)prefixOrg[11], lut.Bits2Sym, (int)currslot, (int)numToSet);
+ currslot += numToSet;
+ }
+ return currslot == 2048;
+ }
+
+ private static void FillByte(byte[] dst, int off, byte v, int n)
+ {
+ for (int i = 0; i < n; i++) dst[off + i] = v;
+ }
+
+ // output[i] = input[reverse-low-11-bits(i)] (the SIMD ReverseBitsArray2048 is an 11-bit
+ // index reversal converting the MSB-first LUT into the LSB-first form the 3-stream core reads).
+ private static void ReverseBitsArray2048(byte[] input, byte[] output)
+ {
+ for (int i = 0; i < 2048; i++)
+ {
+ int j = Reverse11(i);
+ output[i] = input[j];
+ }
+ }
+
+ private static int Reverse11(int v)
+ {
+ int r = 0;
+ for (int k = 0; k < 11; k++)
+ {
+ r = (r << 1) | (v & 1);
+ v >>= 1;
+ }
+ return r;
+ }
+
+ private static int HuffReadCodeLengthsOld(ref RefBitReader bits, byte[] syms, uint[] codePrefix)
+ {
+ if (bits.ReadBitNoRefill() != 0)
+ {
+ int sym = 0, codelen, numSymbols = 0;
+ int avgBitsX4 = 32;
+ int forcedBits = (int)bits.ReadBitsNoRefill(2);
+ uint thres = 1u << (31 - (int)(20u >> forcedBits));
+ int n;
+ bool skip = bits.ReadBit() != 0;
+
+ while (true)
+ {
+ if (!skip)
+ {
+ if ((bits.Bits & 0xFF000000) == 0) return -1;
+ sym += (int)bits.ReadBitsNoRefill(2 * (Clz(bits.Bits) + 1)) - 2 + 1;
+ if (sym >= 256) break;
+ }
+ skip = false;
+
+ bits.Refill();
+ if ((bits.Bits & 0xFF000000) == 0) return -1;
+ n = (int)bits.ReadBitsNoRefill(2 * (Clz(bits.Bits) + 1)) - 2 + 1;
+ if (sym + n > 256) return -1;
+ bits.Refill();
+ numSymbols += n;
+ do
+ {
+ if (bits.Bits < thres) return -1;
+ int lz = Clz(bits.Bits);
+ int v = (int)bits.ReadBitsNoRefill(lz + forcedBits + 1) + ((lz - 1) << forcedBits);
+ codelen = (-(v & 1) ^ (v >> 1)) + ((avgBitsX4 + 2) >> 2);
+ if (codelen < 1 || codelen > 11) return -1;
+ avgBitsX4 = codelen + ((3 * avgBitsX4 + 2) >> 2);
+ bits.Refill();
+ syms[codePrefix[codelen]++] = (byte)sym++;
+ } while (--n != 0);
+
+ if (sym == 256) break;
+ }
+ return (sym == 256 && numSymbols >= 2) ? numSymbols : -1;
+ }
+ else
+ {
+ int numSymbols = (int)bits.ReadBitsNoRefill(8);
+ if (numSymbols == 0) return -1;
+ if (numSymbols == 1)
+ {
+ syms[0] = (byte)bits.ReadBitsNoRefill(8);
+ }
+ else
+ {
+ int codelenBits = (int)bits.ReadBitsNoRefill(3);
+ if (codelenBits > 4) return -1;
+ for (int i = 0; i < numSymbols; i++)
+ {
+ bits.Refill();
+ int sym = (int)bits.ReadBitsNoRefill(8);
+ int codelen = (int)bits.ReadBitsNoRefillZero(codelenBits) + 1;
+ if (codelen > 11) return -1;
+ syms[codePrefix[codelen]++] = (byte)sym;
+ }
+ }
+ return numSymbols;
+ }
+ }
+
+ private static int HuffReadCodeLengthsNew(ref RefBitReader bits, byte[] syms, uint[] codePrefix)
+ {
+ int forcedBits = (int)bits.ReadBitsNoRefill(2);
+ int numSymbols = (int)bits.ReadBitsNoRefill(8) + 1;
+ int fluff = bits.ReadFluff(numSymbols);
+
+ byte[] codeLen = new byte[512 + 16];
+ var br2 = new RefBitReader2
+ {
+ B = bits.B,
+ BitPos = (bits.BitPos - 24) & 7,
+ PEnd = bits.Bound,
+ P = bits.P - ((24 - bits.BitPos + 7) >> 3),
+ };
+
+ if (!DecodeGolombRiceLengths(codeLen, numSymbols + fluff, ref br2)) return -1;
+ for (int z = 0; z < 16; z++) codeLen[numSymbols + fluff + z] = 0;
+ if (!DecodeGolombRiceBits(codeLen, numSymbols, forcedBits, ref br2)) return -1;
+
+ // Reset the bit decoder onto br2's position.
+ bits.BitPos = 24;
+ bits.P = br2.P;
+ bits.Bits = 0;
+ bits.Refill();
+ bits.Bits <<= br2.BitPos;
+ bits.BitPos += br2.BitPos;
+
+ uint runningSum = 0x1e;
+ for (int i = 0; i < numSymbols; i++)
+ {
+ int v = codeLen[i];
+ v = -(v & 1) ^ (v >> 1);
+ int cl = v + (int)(runningSum >> 2) + 1;
+ if (cl < 1 || cl > 11) return -1;
+ codeLen[i] = (byte)cl;
+ runningSum = (uint)((int)runningSum + v);
+ }
+
+ var range = new HuffRange[128];
+ int ranges = HuffConvertToRanges(range, numSymbols, fluff, codeLen, numSymbols, ref bits);
+ if (ranges <= 0) return -1;
+
+ int cp = 0;
+ for (int i = 0; i < ranges; i++)
+ {
+ int sym = range[i].Symbol;
+ int nn = range[i].Num;
+ do
+ {
+ syms[codePrefix[codeLen[cp++]]++] = (byte)sym++;
+ } while (--nn != 0);
+ }
+ return numSymbols;
+ }
+
+ private struct HuffRange { public int Symbol; public int Num; }
+
+ private static int HuffConvertToRanges(HuffRange[] range, int numSymbols, int p, byte[] symlen, int symlenOff, ref RefBitReader bits)
+ {
+ int numRanges = p >> 1, v, symIdx = 0;
+
+ if ((p & 1) != 0)
+ {
+ bits.Refill();
+ v = symlen[symlenOff++];
+ if (v >= 8) return -1;
+ symIdx = (int)bits.ReadBitsNoRefill(v + 1) + (1 << (v + 1)) - 1;
+ }
+ int symsUsed = 0;
+
+ for (int i = 0; i < numRanges; i++)
+ {
+ bits.Refill();
+ v = symlen[symlenOff];
+ if (v >= 9) return -1;
+ int num = (int)bits.ReadBitsNoRefillZero(v) + (1 << v);
+ v = symlen[symlenOff + 1];
+ if (v >= 8) return -1;
+ int space = (int)bits.ReadBitsNoRefill(v + 1) + (1 << (v + 1)) - 1;
+ range[i].Symbol = symIdx;
+ range[i].Num = num;
+ symsUsed += num;
+ symIdx += num + space;
+ symlenOff += 2;
+ }
+
+ if (symIdx >= 256 || symsUsed >= numSymbols || symIdx + numSymbols - symsUsed > 256)
+ return -1;
+
+ range[numRanges].Symbol = symIdx;
+ range[numRanges].Num = numSymbols - symsUsed;
+ return numRanges + 1;
+ }
+
+ // 3-stream scalar Huffman core (scalar tail only — no SIMD fast path).
+ private static bool DecodeBytesCore(ref HuffReader hr, NewHuffLut lut)
+ {
+ byte[] B = hr.B;
+ byte[] outp = hr.Out;
+ byte[] b2len = lut.Bits2Len;
+ byte[] b2sym = lut.Bits2Sym;
+
+ int src = hr.Src;
+ uint srcBits = hr.SrcBits;
+ int srcBitpos = hr.SrcBitpos;
+ int srcMid = hr.SrcMid;
+ uint srcMidBits = hr.SrcMidBits;
+ int srcMidBitpos = hr.SrcMidBitpos;
+ int srcEnd = hr.SrcEnd;
+ uint srcEndBits = hr.SrcEndBits;
+ int srcEndBitpos = hr.SrcEndBitpos;
+
+ int dst = hr.OutOff;
+ int dstEnd = hr.OutEnd;
+
+ if (src > srcMid)
+ return false;
+
+ int k, n;
+ for (; ; )
+ {
+ if (dst >= dstEnd) break;
+
+ if (srcMid - src <= 1)
+ {
+ if (srcMid - src == 1) srcBits |= (uint)B[src] << srcBitpos;
+ }
+ else
+ {
+ srcBits |= (uint)(B[src] | (B[src + 1] << 8)) << srcBitpos;
+ }
+ k = (int)(srcBits & 0x7FF);
+ n = b2len[k];
+ srcBitpos -= n;
+ srcBits >>= n;
+ outp[dst++] = b2sym[k];
+ src += (7 - srcBitpos) >> 3;
+ srcBitpos &= 7;
+
+ if (dst < dstEnd)
+ {
+ if (srcEnd - srcMid <= 1)
+ {
+ if (srcEnd - srcMid == 1)
+ {
+ srcEndBits |= (uint)B[srcMid] << srcEndBitpos;
+ srcMidBits |= (uint)B[srcMid] << srcMidBitpos;
+ }
+ }
+ else
+ {
+ uint vv = (uint)(B[srcEnd - 2] | (B[srcEnd - 1] << 8));
+ srcEndBits |= (((vv >> 8) | (vv << 8)) & 0xFFFF) << srcEndBitpos;
+ srcMidBits |= (uint)(B[srcMid] | (B[srcMid + 1] << 8)) << srcMidBitpos;
+ }
+ k = (int)(srcEndBits & 0x7FF);
+ n = b2len[k];
+ outp[dst++] = b2sym[k];
+ srcEndBitpos -= n;
+ srcEndBits >>= n;
+ srcEnd -= (7 - srcEndBitpos) >> 3;
+ srcEndBitpos &= 7;
+ if (dst < dstEnd)
+ {
+ k = (int)(srcMidBits & 0x7FF);
+ n = b2len[k];
+ outp[dst++] = b2sym[k];
+ srcMidBitpos -= n;
+ srcMidBits >>= n;
+ srcMid += (7 - srcMidBitpos) >> 3;
+ srcMidBitpos &= 7;
+ }
+ }
+ if (src > srcMid || srcMid > srcEnd)
+ return false;
+ }
+ if (src != hr.SrcMidOrg || srcEnd != srcMid)
+ return false;
+ return true;
+ }
+
+ private struct HuffReader
+ {
+ public byte[] B;
+ public byte[] Out;
+ public int Src, SrcEnd, SrcMid, SrcMidOrg;
+ public uint SrcBits = 0, SrcMidBits = 0, SrcEndBits = 0;
+ public int SrcBitpos = 0, SrcMidBitpos = 0, SrcEndBitpos = 0;
+ public int OutOff, OutEnd;
+ public HuffReader(byte[] b, byte[] o) : this()
+ {
+ B = b; Out = o;
+ }
+ }
+
+ // ===========================================================================================
+ // Golomb-Rice length/bit decode.
+ // ===========================================================================================
+ private struct RefBitReader2 { public byte[] B; public int P; public int PEnd; public int BitPos; }
+
+ private static readonly uint[] RiceVal = BuildRiceVal();
+ private static readonly byte[] RiceLen = BuildRiceLen();
+
+ private static bool DecodeGolombRiceLengths(byte[] dst, int size, ref RefBitReader2 br)
+ {
+ int p = br.P, pEnd = br.PEnd;
+ int dstPos = 0, dstEnd = size;
+ if (p >= pEnd) return false;
+
+ int count = -br.BitPos;
+ uint v = (uint)(At(br.B, p++) & (255 >> br.BitPos));
+ for (; ; )
+ {
+ if (v == 0)
+ {
+ count += 8;
+ }
+ else
+ {
+ uint x = RiceVal[v];
+ uint lo = (uint)count + (x & 0x0F0F0F0F);
+ WriteLE32(dst, dstPos, lo);
+ WriteLE32(dst, dstPos + 4, (x >> 4) & 0x0F0F0F0F);
+ dstPos += RiceLen[v];
+ if (dstPos >= dstEnd) break;
+ count = (int)(x >> 28);
+ }
+ if (p >= pEnd) return false;
+ v = At(br.B, p++);
+ }
+ if (dstPos > dstEnd)
+ {
+ int nn = dstPos - dstEnd;
+ do { v &= v - 1; } while (--nn != 0);
+ }
+ int bitpos = 0;
+ if ((v & 1) == 0)
+ {
+ p--;
+ int q = Bsf(v);
+ bitpos = 8 - q;
+ }
+ br.P = p;
+ br.BitPos = bitpos;
+ return true;
+ }
+
+ private static bool DecodeGolombRiceBits(byte[] dst, int size, int bitcount, ref RefBitReader2 br)
+ {
+ if (bitcount == 0) return true;
+ int dstPos = 0, dstEnd = size;
+ int p = br.P;
+ int bitpos = br.BitPos;
+
+ int bitsRequired = bitpos + bitcount * size;
+ int bytesRequired = (bitsRequired + 7) >> 3;
+ if (bytesRequired > br.PEnd - p) return false;
+
+ br.P = p + (bitsRequired >> 3);
+ br.BitPos = bitsRequired & 7;
+
+ ulong bak = ReadLE64(dst, dstEnd);
+
+ if (bitcount == 1)
+ {
+ do
+ {
+ ulong bits = (byte)(BSwap32(ReadLE32(br.B, p)) >> (24 - bitpos));
+ p += 1;
+ bits = (bits | (bits << 28)) & 0xF0000000Ful;
+ bits = (bits | (bits << 14)) & 0x3000300030003ul;
+ bits = (bits | (bits << 7)) & 0x0101010101010101ul;
+ ulong cur = ReadLE64(dst, dstPos);
+ WriteLE64(dst, dstPos, cur * 2 + BSwap64(bits));
+ dstPos += 8;
+ } while (dstPos < dstEnd);
+ }
+ else if (bitcount == 2)
+ {
+ do
+ {
+ ulong bits = (ushort)(BSwap32(ReadLE32(br.B, p)) >> (16 - bitpos));
+ p += 2;
+ bits = (bits | (bits << 24)) & 0xFF000000FFul;
+ bits = (bits | (bits << 12)) & 0xF000F000F000Ful;
+ bits = (bits | (bits << 6)) & 0x0303030303030303ul;
+ ulong cur = ReadLE64(dst, dstPos);
+ WriteLE64(dst, dstPos, cur * 4 + BSwap64(bits));
+ dstPos += 8;
+ } while (dstPos < dstEnd);
+ }
+ else // bitcount == 3
+ {
+ do
+ {
+ ulong bits = (ulong)((BSwap32(ReadLE32(br.B, p)) >> (8 - bitpos)) & 0xFFFFFF);
+ p += 3;
+ bits = (bits | (bits << 20)) & 0xFFF00000FFFul;
+ bits = (bits | (bits << 10)) & 0x3F003F003F003Ful;
+ bits = (bits | (bits << 5)) & 0x0707070707070707ul;
+ ulong cur = ReadLE64(dst, dstPos);
+ WriteLE64(dst, dstPos, cur * 8 + BSwap64(bits));
+ dstPos += 8;
+ } while (dstPos < dstEnd);
+ }
+ WriteLE64(dst, dstEnd, bak);
+ return true;
+ }
+
+ // ===========================================================================================
+ // Kraken_ProcessLzRuns (Type1 = raw literals, Type0 = sub/delta literals)
+ // ===========================================================================================
+ private static bool ProcessLzRuns(int mode, byte[] dst, int dstStart, int dstSize, int offset, LzTable lzt)
+ {
+ int dstEnd = dstStart + dstSize;
+ int start = dstStart + (offset == 0 ? SeedSize : 0);
+ int dstBaseStart = dstStart - offset; // dst_start = dst - offset
+ if (mode == 1)
+ return ProcessLzRunsType1(lzt, dst, start, dstEnd, dstBaseStart);
+ if (mode == 0)
+ return ProcessLzRunsType0(lzt, dst, start, dstEnd, dstBaseStart);
+ return false;
+ }
+
+ private static bool ProcessLzRunsType1(LzTable lzt, byte[] dst, int dstPos, int dstEnd, int dstStart)
+ {
+ byte[] cmd = lzt.CmdStream; int cmdPos = 0, cmdEnd = lzt.CmdStreamSize;
+ int[] len = lzt.LenStream; int lenPos = 0, lenEnd = lzt.LenStreamSize;
+ byte[] lit = lzt.LitStream; int litPos = 0, litEnd = lzt.LitStreamSize;
+ int[] offs = lzt.OffsStream; int offsPos = 0, offsEnd = lzt.OffsStreamSize;
+
+ Span recent = stackalloc int[7];
+ recent[3] = -8; recent[4] = -8; recent[5] = -8;
+
+ while (cmdPos < cmdEnd)
+ {
+ uint f = cmd[cmdPos++];
+ uint litlen = f & 3;
+ int offsIndex = (int)(f >> 6);
+ uint matchlen = (f >> 2) & 0xF;
+
+ if (litlen == 3)
+ {
+ if (lenPos >= lenEnd) return false;
+ litlen = (uint)len[lenPos++];
+ }
+ recent[6] = offsPos < offsEnd ? offs[offsPos] : 0;
+
+ if (litlen != 0)
+ {
+ if (litPos + litlen > (uint)litEnd || dstPos + litlen > (uint)dstEnd) return false;
+ for (uint c = 0; c < litlen; c++) dst[dstPos + c] = lit[litPos + c];
+ dstPos += (int)litlen; litPos += (int)litlen;
+ }
+
+ int offset = recent[offsIndex + 3];
+ recent[offsIndex + 3] = recent[offsIndex + 2];
+ recent[offsIndex + 2] = recent[offsIndex + 1];
+ recent[offsIndex + 1] = recent[offsIndex + 0];
+ recent[3] = offset;
+
+ offsPos += ((offsIndex + 1) & 4) >> 2;
+
+ if (dstPos + offset < dstStart) return false;
+ int copyFrom = dstPos + offset;
+
+ uint actual;
+ if (matchlen != 15)
+ {
+ actual = matchlen + 2;
+ }
+ else
+ {
+ if (lenPos >= lenEnd) return false;
+ actual = 14 + (uint)len[lenPos++];
+ }
+ if (dstPos + actual > (uint)dstEnd) return false;
+ for (uint c = 0; c < actual; c++) dst[dstPos + c] = dst[copyFrom + c];
+ dstPos += (int)actual;
+ }
+
+ if (offsPos != offsEnd || lenPos != lenEnd) return false;
+ int finalLen = dstEnd - dstPos;
+ if (finalLen != litEnd - litPos) return false;
+ for (int c = 0; c < finalLen; c++) dst[dstPos + c] = lit[litPos + c];
+ return true;
+ }
+
+ private static bool ProcessLzRunsType0(LzTable lzt, byte[] dst, int dstPos, int dstEnd, int dstStart)
+ {
+ byte[] cmd = lzt.CmdStream; int cmdPos = 0, cmdEnd = lzt.CmdStreamSize;
+ int[] len = lzt.LenStream; int lenPos = 0, lenEnd = lzt.LenStreamSize;
+ byte[] lit = lzt.LitStream; int litPos = 0, litEnd = lzt.LitStreamSize;
+ int[] offs = lzt.OffsStream; int offsPos = 0, offsEnd = lzt.OffsStreamSize;
+
+ Span recent = stackalloc int[7];
+ recent[3] = -8; recent[4] = -8; recent[5] = -8;
+ int lastOffset = -8;
+
+ while (cmdPos < cmdEnd)
+ {
+ uint f = cmd[cmdPos++];
+ uint litlen = f & 3;
+ int offsIndex = (int)(f >> 6);
+ uint matchlen = (f >> 2) & 0xF;
+
+ if (litlen == 3)
+ {
+ if (lenPos >= lenEnd) return false;
+ litlen = (uint)len[lenPos++];
+ }
+ recent[6] = offsPos < offsEnd ? offs[offsPos] : 0;
+
+ if (litlen != 0)
+ {
+ if (litPos + litlen > (uint)litEnd || dstPos + litlen > (uint)dstEnd) return false;
+ for (uint c = 0; c < litlen; c++)
+ dst[dstPos + c] = (byte)(lit[litPos + c] + dst[dstPos + (int)c + lastOffset]);
+ dstPos += (int)litlen; litPos += (int)litlen;
+ }
+
+ int offset = recent[offsIndex + 3];
+ recent[offsIndex + 3] = recent[offsIndex + 2];
+ recent[offsIndex + 2] = recent[offsIndex + 1];
+ recent[offsIndex + 1] = recent[offsIndex + 0];
+ recent[3] = offset;
+ lastOffset = offset;
+
+ offsPos += ((offsIndex + 1) & 4) >> 2;
+
+ if (dstPos + offset < dstStart) return false;
+ int copyFrom = dstPos + offset;
+
+ uint actual;
+ if (matchlen != 15)
+ {
+ actual = matchlen + 2;
+ }
+ else
+ {
+ if (lenPos >= lenEnd) return false;
+ actual = 14 + (uint)len[lenPos++];
+ }
+ if (dstPos + actual > (uint)dstEnd) return false;
+ for (uint c = 0; c < actual; c++) dst[dstPos + c] = dst[copyFrom + c];
+ dstPos += (int)actual;
+ }
+
+ if (offsPos != offsEnd || lenPos != lenEnd) return false;
+ int finalLen = dstEnd - dstPos;
+ if (finalLen != litEnd - litPos) return false;
+ for (int c = 0; c < finalLen; c++)
+ dst[dstPos + c] = (byte)(lit[litPos + c] + dst[dstPos + c + lastOffset]);
+ return true;
+ }
+
+ // ===========================================================================================
+ // RefBitReader — MSB-first bit reader (24-bit refill window), forward & backward.
+ // ===========================================================================================
+ private struct RefBitReader
+ {
+ public byte[] B;
+ public int P; // current byte index
+ public int Bound; // forward: high bound (exclusive); backward: low bound (inclusive)
+ public uint Bits;
+ public int BitPos;
+ private bool _bwd;
+
+ public static RefBitReader Forward(byte[] b, int start, int end)
+ {
+ var r = new RefBitReader { B = b, P = start, Bound = end, Bits = 0, BitPos = 24, _bwd = false };
+ r.RefillF();
+ return r;
+ }
+
+ public static RefBitReader Backward(byte[] b, int low, int high)
+ {
+ var r = new RefBitReader { B = b, P = high, Bound = low, Bits = 0, BitPos = 24, _bwd = true };
+ r.RefillB();
+ return r;
+ }
+
+ public void Refill() { if (_bwd) RefillB(); else RefillF(); }
+
+ public void RefillF()
+ {
+ while (BitPos > 0)
+ {
+ Bits |= (uint)(P < Bound ? B[P] : 0) << BitPos;
+ BitPos -= 8;
+ P++;
+ }
+ }
+
+ public void RefillB()
+ {
+ while (BitPos > 0)
+ {
+ P--;
+ Bits |= (uint)(P >= Bound ? B[P] : 0) << BitPos;
+ BitPos -= 8;
+ }
+ }
+
+ public int ReadBit()
+ {
+ Refill();
+ int r = (int)(Bits >> 31);
+ Bits <<= 1; BitPos += 1;
+ return r;
+ }
+
+ public int ReadBitNoRefill()
+ {
+ int r = (int)(Bits >> 31);
+ Bits <<= 1; BitPos += 1;
+ return r;
+ }
+
+ public uint ReadBitsNoRefill(int n)
+ {
+ uint r = Bits >> (32 - n);
+ Bits <<= n; BitPos += n;
+ return r;
+ }
+
+ public uint ReadBitsNoRefillZero(int n)
+ {
+ uint r = Bits >> 1 >> (31 - n);
+ Bits <<= n; BitPos += n;
+ return r;
+ }
+
+ public int ReadFluff(int numSymbols)
+ {
+ if (numSymbols == 256) return 0;
+ int x = 257 - numSymbols;
+ if (x > numSymbols) x = numSymbols;
+ x *= 2;
+ int y = Bsr((uint)(x - 1)) + 1;
+ uint v = Bits >> (32 - y);
+ uint z = (uint)((1 << y) - x);
+ if ((v >> 1) >= z)
+ {
+ Bits <<= y; BitPos += y;
+ return (int)(v - z);
+ }
+ else
+ {
+ Bits <<= (y - 1); BitPos += (y - 1);
+ return (int)(v >> 1);
+ }
+ }
+
+ public uint ReadMoreThan24Bits(int n)
+ {
+ uint rv;
+ if (n <= 24) rv = ReadBitsNoRefillZero(n);
+ else { rv = ReadBitsNoRefill(24) << (n - 24); RefillF(); rv += ReadBitsNoRefill(n - 24); }
+ RefillF();
+ return rv;
+ }
+
+ public uint ReadMoreThan24BitsB(int n)
+ {
+ uint rv;
+ if (n <= 24) rv = ReadBitsNoRefillZero(n);
+ else { rv = ReadBitsNoRefill(24) << (n - 24); RefillB(); rv += ReadBitsNoRefill(n - 24); }
+ RefillB();
+ return rv;
+ }
+
+ public uint ReadDistance(uint v)
+ {
+ uint w, m, rv; int n;
+ if (v < 0xF0)
+ {
+ n = (int)(v >> 4) + 4;
+ w = BitOperations.RotateLeft(Bits | 1, n);
+ BitPos += n;
+ m = (2u << n) - 1;
+ Bits = w & ~m;
+ rv = ((w & m) << 4) + (v & 0xF) - 248;
+ }
+ else
+ {
+ n = (int)(v - 0xF0) + 4;
+ w = BitOperations.RotateLeft(Bits | 1, n);
+ BitPos += n;
+ m = (2u << n) - 1;
+ Bits = w & ~m;
+ rv = 8322816 + ((w & m) << 12);
+ RefillF();
+ rv += Bits >> 20;
+ BitPos += 12;
+ Bits <<= 12;
+ }
+ RefillF();
+ return rv;
+ }
+
+ public uint ReadDistanceB(uint v)
+ {
+ uint w, m, rv; int n;
+ if (v < 0xF0)
+ {
+ n = (int)(v >> 4) + 4;
+ w = BitOperations.RotateLeft(Bits | 1, n);
+ BitPos += n;
+ m = (2u << n) - 1;
+ Bits = w & ~m;
+ rv = ((w & m) << 4) + (v & 0xF) - 248;
+ }
+ else
+ {
+ n = (int)(v - 0xF0) + 4;
+ w = BitOperations.RotateLeft(Bits | 1, n);
+ BitPos += n;
+ m = (2u << n) - 1;
+ Bits = w & ~m;
+ rv = 8322816 + ((w & m) << 12);
+ RefillB();
+ rv += Bits >> 20;
+ BitPos += 12;
+ Bits <<= 12;
+ }
+ RefillB();
+ return rv;
+ }
+
+ public bool ReadLength(out uint v)
+ {
+ v = 0;
+ int n = 31 - Bsr(Bits);
+ if (n > 12) return false;
+ BitPos += n; Bits <<= n; RefillF();
+ n += 7;
+ BitPos += n;
+ v = (Bits >> (32 - n)) - 64;
+ Bits <<= n; RefillF();
+ return true;
+ }
+
+ public bool ReadLengthB(out uint v)
+ {
+ v = 0;
+ int n = 31 - Bsr(Bits);
+ if (n > 12) return false;
+ BitPos += n; Bits <<= n; RefillB();
+ n += 7;
+ BitPos += n;
+ v = (Bits >> (32 - n)) - 64;
+ Bits <<= n; RefillB();
+ return true;
+ }
+ }
+
+ // ===========================================================================================
+ // Bit / byte helpers
+ // ===========================================================================================
+ private static int Bsr(uint x) => x == 0 ? 0 : 31 - BitOperations.LeadingZeroCount(x);
+ private static int Bsf(uint x) => x == 0 ? 0 : BitOperations.TrailingZeroCount(x);
+ private static int Clz(uint x) => x == 0 ? 31 : BitOperations.LeadingZeroCount(x);
+
+ private static byte At(byte[] b, int i) => (uint)i < (uint)b.Length ? b[i] : (byte)0;
+
+ private static uint ReadLE32(byte[] b, int off)
+ {
+ uint r = 0;
+ for (int i = 0; i < 4; i++)
+ if ((uint)(off + i) < (uint)b.Length) r |= (uint)b[off + i] << (8 * i);
+ return r;
+ }
+
+ private static ulong ReadLE64(byte[] b, int off)
+ {
+ ulong r = 0;
+ for (int i = 0; i < 8; i++)
+ if ((uint)(off + i) < (uint)b.Length) r |= (ulong)b[off + i] << (8 * i);
+ return r;
+ }
+
+ private static void WriteLE32(byte[] b, int off, uint v)
+ {
+ for (int i = 0; i < 4; i++)
+ if ((uint)(off + i) < (uint)b.Length) b[off + i] = (byte)(v >> (8 * i));
+ }
+
+ private static void WriteLE64(byte[] b, int off, ulong v)
+ {
+ for (int i = 0; i < 8; i++)
+ if ((uint)(off + i) < (uint)b.Length) b[off + i] = (byte)(v >> (8 * i));
+ }
+
+ private static uint BSwap32(uint v) => BinaryPrimitives.ReverseEndianness(v);
+ private static ulong BSwap64(ulong v) => BinaryPrimitives.ReverseEndianness(v);
+
+ private static uint[] BuildRiceVal()
+ {
+ return new uint[256]
+ {
+ 0x80000000, 0x00000007, 0x10000006, 0x00000006, 0x20000005, 0x00000105, 0x10000005, 0x00000005,
+ 0x30000004, 0x00000204, 0x10000104, 0x00000104, 0x20000004, 0x00010004, 0x10000004, 0x00000004,
+ 0x40000003, 0x00000303, 0x10000203, 0x00000203, 0x20000103, 0x00010103, 0x10000103, 0x00000103,
+ 0x30000003, 0x00020003, 0x10010003, 0x00010003, 0x20000003, 0x01000003, 0x10000003, 0x00000003,
+ 0x50000002, 0x00000402, 0x10000302, 0x00000302, 0x20000202, 0x00010202, 0x10000202, 0x00000202,
+ 0x30000102, 0x00020102, 0x10010102, 0x00010102, 0x20000102, 0x01000102, 0x10000102, 0x00000102,
+ 0x40000002, 0x00030002, 0x10020002, 0x00020002, 0x20010002, 0x01010002, 0x10010002, 0x00010002,
+ 0x30000002, 0x02000002, 0x11000002, 0x01000002, 0x20000002, 0x00000012, 0x10000002, 0x00000002,
+ 0x60000001, 0x00000501, 0x10000401, 0x00000401, 0x20000301, 0x00010301, 0x10000301, 0x00000301,
+ 0x30000201, 0x00020201, 0x10010201, 0x00010201, 0x20000201, 0x01000201, 0x10000201, 0x00000201,
+ 0x40000101, 0x00030101, 0x10020101, 0x00020101, 0x20010101, 0x01010101, 0x10010101, 0x00010101,
+ 0x30000101, 0x02000101, 0x11000101, 0x01000101, 0x20000101, 0x00000111, 0x10000101, 0x00000101,
+ 0x50000001, 0x00040001, 0x10030001, 0x00030001, 0x20020001, 0x01020001, 0x10020001, 0x00020001,
+ 0x30010001, 0x02010001, 0x11010001, 0x01010001, 0x20010001, 0x00010011, 0x10010001, 0x00010001,
+ 0x40000001, 0x03000001, 0x12000001, 0x02000001, 0x21000001, 0x01000011, 0x11000001, 0x01000001,
+ 0x30000001, 0x00000021, 0x10000011, 0x00000011, 0x20000001, 0x00001001, 0x10000001, 0x00000001,
+ 0x70000000, 0x00000600, 0x10000500, 0x00000500, 0x20000400, 0x00010400, 0x10000400, 0x00000400,
+ 0x30000300, 0x00020300, 0x10010300, 0x00010300, 0x20000300, 0x01000300, 0x10000300, 0x00000300,
+ 0x40000200, 0x00030200, 0x10020200, 0x00020200, 0x20010200, 0x01010200, 0x10010200, 0x00010200,
+ 0x30000200, 0x02000200, 0x11000200, 0x01000200, 0x20000200, 0x00000210, 0x10000200, 0x00000200,
+ 0x50000100, 0x00040100, 0x10030100, 0x00030100, 0x20020100, 0x01020100, 0x10020100, 0x00020100,
+ 0x30010100, 0x02010100, 0x11010100, 0x01010100, 0x20010100, 0x00010110, 0x10010100, 0x00010100,
+ 0x40000100, 0x03000100, 0x12000100, 0x02000100, 0x21000100, 0x01000110, 0x11000100, 0x01000100,
+ 0x30000100, 0x00000120, 0x10000110, 0x00000110, 0x20000100, 0x00001100, 0x10000100, 0x00000100,
+ 0x60000000, 0x00050000, 0x10040000, 0x00040000, 0x20030000, 0x01030000, 0x10030000, 0x00030000,
+ 0x30020000, 0x02020000, 0x11020000, 0x01020000, 0x20020000, 0x00020010, 0x10020000, 0x00020000,
+ 0x40010000, 0x03010000, 0x12010000, 0x02010000, 0x21010000, 0x01010010, 0x11010000, 0x01010000,
+ 0x30010000, 0x00010020, 0x10010010, 0x00010010, 0x20010000, 0x00011000, 0x10010000, 0x00010000,
+ 0x50000000, 0x04000000, 0x13000000, 0x03000000, 0x22000000, 0x02000010, 0x12000000, 0x02000000,
+ 0x31000000, 0x01000020, 0x11000010, 0x01000010, 0x21000000, 0x01001000, 0x11000000, 0x01000000,
+ 0x40000000, 0x00000030, 0x10000020, 0x00000020, 0x20000010, 0x00001010, 0x10000010, 0x00000010,
+ 0x30000000, 0x00002000, 0x10001000, 0x00001000, 0x20000000, 0x00100000, 0x10000000, 0x00000000,
+ };
+ }
+
+ private static byte[] BuildRiceLen()
+ {
+ return new byte[256]
+ {
+ 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
+ 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
+ 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
+ 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
+ 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
+ 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
+ 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
+ 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8,
+ };
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenHuffmanArrayEncoder.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenHuffmanArrayEncoder.cs
new file mode 100644
index 0000000..66421c8
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenHuffmanArrayEncoder.cs
@@ -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;
+
+///
+/// Encodes a byte array into a Kraken entropy "array" (Huffman, chunk-type 2) that
+/// decodes to the original bytes. Returns null when an entropy array
+/// would not be smaller than the raw form (caller should then store the array raw, type 0).
+///
+internal static class KrakenHuffmanArrayEncoder
+{
+ private const int MaxCodeLen = 11;
+
+ /// Largest payload the five-byte compressed array header can describe.
+ private const int MaxArrayPayload = 0x3FFFF;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ internal sealed class Prepared
+ {
+ internal CostInputs Cost;
+ internal byte[] LengthOfSymbol = Array.Empty();
+ internal ushort[] ReversedCode = Array.Empty();
+ internal byte[] Header = Array.Empty();
+
+ /// True when every byte is the same symbol, which has its own eight-byte form.
+ internal bool SingleSymbol;
+
+ /// The only symbol present, when is set.
+ internal byte Symbol;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Payload bytes the one-symbol form carries.
+ private const int SingleSymbolPayload = 3;
+
+ /// Total bytes the one-symbol form occupies.
+ internal const int SingleSymbolArrayLength = 5 + SingleSymbolPayload;
+
+ ///
+ /// Attempts to Huffman-encode as a single entropy array. Returns the
+ /// on-disk array bytes (header + payload) or null if not representable.
+ ///
+ public static byte[]? TryEncode(ReadOnlySpan data) => TryEncode(data, out _);
+
+ ///
+ /// As , and also reports the cost-model inputs the
+ /// caller needs to decide between this form and the uncompressed one.
+ ///
+ public static byte[]? TryEncode(ReadOnlySpan data, out CostInputs cost)
+ {
+ Prepared? prepared = Prepare(data, out cost);
+ return prepared is null ? null : Finish(data, prepared);
+ }
+
+ ///
+ /// Builds the code assignment and the code-length header for and reports the
+ /// cost-model inputs, without packing the symbol streams. Returns null when the array is not
+ /// representable in this form.
+ ///
+ internal static Prepared? Prepare(ReadOnlySpan data, out CostInputs cost)
+ {
+ cost = default;
+ int d = data.Length;
+ if (d < 2 || d > 0x3FFFF) return null;
+
+ // Histogram.
+ Span 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,
+ };
+ }
+
+ /// Packs the symbol streams and returns the finished array, or null when it cannot be framed.
+ internal static byte[]? Finish(ReadOnlySpan 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 freq)
+ {
+ var result = new byte[256];
+ var present = new List();
+ 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();
+ 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();
+ }
+
+ // (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();
+ }
+
+ // Kraft completeness guard: a valid prefix code satisfies sum 2^-len == 1 over present symbols.
+ private static bool ValidateKraft(byte[] result, List 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 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 _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 _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();
+ }
+ }
+
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenOptimalCost.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenOptimalCost.cs
new file mode 100644
index 0000000..190409d
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenOptimalCost.cs
@@ -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;
+
+///
+/// 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
+/// bits * 32 (the 1/32-bit fixed-point the parser compares).
+///
+internal static class KrakenOptimalCost
+{
+ /// Alphabet size every newLZ entropy array is built over (asserted == 256 by the format).
+ internal const int Alphabet = 256;
+
+ ///
+ /// 65-entry unsigned-16 mantissa table. Entry
+ /// k = round( log2(1 + k/64) * 8192 ) ; Table[0] = 0 (log2 1), Table[64] = 0x2000
+ /// (log2 2 = one full bit, 8192). Interpolated by .
+ ///
+ 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,
+ };
+
+ ///
+ /// bitlen(x) = x == 0 ? 0 : 32 - clz(x) . The index of
+ /// the most-significant set bit plus one.
+ ///
+ internal static int BitLen(uint x) => 32 - BitOperations.LeadingZeroCount(x);
+
+ ///
+ /// Fixed-point log2 of the reciprocal, scaled by
+ /// 2^13: returns round( log2(2^32 / x) * 8192 ) . Exact for powers of two
+ /// (Log2Fix(2^k) = (32 - k) * 8192 ); table-interpolated otherwise. must be
+ /// non-zero (the caller always passes count*4+1 >= 1 ).
+ ///
+ 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;
+ }
+
+ ///
+ /// Builds a per-symbol bit-cost table (units = bits*32)
+ /// from a symbol histogram, then applies the entropy-flatten clamp.
+ ///
+ /// Symbol counts, length (256).
+ /// Destination cost table, length (256); overwritten.
+ /// Per-table additive bias (from passinfo_to_codecost ).
+ /// Entropy-flatten threshold (expected in [7*32, 32*8] = [224, 256]).
+ internal static void HistoToCodeCost(ReadOnlySpan histo, Span 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
+ // -----------------------------------------------------------------------------------------------
+
+ ///
+ /// The per-pass quantized cost tables (codecosts , serialized size ~0x1808 bytes). Every
+ /// table is a 256-entry bits*32 cost array built by . Byte
+ /// offsets of the serialized layout are noted for each field.
+ ///
+ internal sealed class CodeCosts
+ {
+ /// codecosts[0]: literal model — 1 = raw literals, otherwise "sub" (delta) literals.
+ internal int LitMode;
+ /// codecosts+0x04: literal-delta mask — 0 (raw) or 0xff (sub).
+ internal int LitSubMask;
+ /// codecosts+0x08: literal-byte cost table, indexed by (byte - (ref & mask)) & 0xff .
+ internal readonly int[] Lit = new int[Alphabet];
+ /// codecosts+0x408: command/packet cost table, indexed by the newLZ command byte.
+ internal readonly int[] Packet = new int[Alphabet];
+ /// codecosts+0x808: offset alt-modulo (offset coding mode; 0 = standard bucket reader).
+ internal int OffsetAltModulo;
+ /// codecosts+0x80c: offset-bucket cost table, indexed by the packed-offset bucket byte.
+ internal readonly int[] OffsetBucket = new int[Alphabet];
+ /// codecosts+0xc0c: offset alt cost table (only built when > 1).
+ internal readonly int[] OffsetAlt = new int[Alphabet];
+ /// codecosts+0x100c: length cost table; [0..0xFE] per-length, [0xFF] = escape base.
+ internal readonly int[] Length = new int[Alphabet];
+ }
+
+ ///
+ /// Builds the five cost tables from a pass
+ /// histogram block via with the fixed per-table biases (threshold 0xff).
+ /// The block is int-indexed:
+ /// [0..0xFF] lit-raw histo, [0x100..0x1FF] lit-sub histo, [0x200..0x2FF] packet,
+ /// [0x300..0x3FF] length, [0x400] offset_alt_modulo, [0x401..0x500] offset-bucket,
+ /// [0x501..0x600] offset-alt. is the carried codecosts[0] flag.
+ ///
+ internal static CodeCosts BuildFromPassinfo(ReadOnlySpan 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;
+ }
+
+ ///
+ /// Cost of a -byte literal run that
+ /// starts at in , against the most-recent offset
+ /// (>= 8). Each byte costs Lit[(buf[p] - (buf[p-lo] & mask)) & 0xff] .
+ ///
+ internal static int CostLiterals(ReadOnlySpan 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;
+ }
+
+ ///
+ /// Cost of a single literal at
+ /// against the most-recent offset (>= 8). In raw mode (LitSubMask == 0 )
+ /// this is simply Lit[buf[pos]] ; in sub mode it is the delta against buf[pos-lo] .
+ ///
+ internal static int CostAddLiteral(ReadOnlySpan 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];
+ }
+
+ ///
+ /// Packed-offset bucket byte for (>= 8), identical to
+ /// KrakenBitWriter.WriteDistance for the
+ /// standard window (bucket < 0xF0, dist <= ~256 KiB). Outputs the extra-bit count in
+ /// .
+ ///
+ 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;
+ }
+
+ ///
+ /// Standard mode-0 offset path: OffsetBucket[bucket] +
+ /// extraBits*32 (+0xc for the > 0x7FFF07 huge-offset bucket). Valid for the newLZ window the
+ /// nwonly encoder emits (offset coding mode 0).
+ ///
+ 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;
+ }
+
+ /// Elias-gamma bit count: 2*floor(log2(v+1)) + 1 .
+ internal static int VarBitsEliasGamma(int val) => (BitLen((uint)(val + 1)) - 1) * 2 + 1;
+
+ /// Length-escape bit count: VarBitsEliasGamma(val >> nbits) + nbits .
+ internal static int CountBits(uint val, int nbits) => VarBitsEliasGamma((int)(val >> nbits)) + nbits;
+
+ ///
+ /// Length cost: Length[v] for v < 0xFF ; otherwise the escape base
+ /// Length[0xFF] + CountBits(v - 0xFF, 6)*32 .
+ ///
+ 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;
+ }
+
+ ///
+ /// Cost of a repeat-offset match of length
+ /// with literal field (0..3) and recent-offset
+ /// index (0..2). Short match (ml < 17 ) → one packet-table lookup;
+ /// long match → CostLen(ml-17) plus the escape packet entry.
+ ///
+ 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];
+ }
+
+ ///
+ /// Packet/length cost of a new-offset match
+ /// (offset index 3) of length with literal field .
+ /// The caller adds for the new distance separately.
+ ///
+ 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];
+ }
+
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenSuffixTrieMatcher.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenSuffixTrieMatcher.cs
new file mode 100644
index 0000000..2bcec32
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/KrakenSuffixTrieMatcher.cs
@@ -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;
+
+///
+/// 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.
+///
+internal sealed class KrakenSuffixTrieMatcher
+{
+ /// Pairs reported per position.
+ public const int PairsPerPosition = 4;
+
+ /// Ints per position in the match table: one length and one offset per pair.
+ 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;
+
+ /// Creates a matcher over , which must be one whole block.
+ 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];
+ }
+
+ ///
+ /// Fills for every position of the block. The array must hold
+ /// data.Length * IntsPerPosition ints.
+ ///
+ 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);
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/Oodle/OodleKrakenEncoder.cs b/src/Packwright.Pkg/PFS/Compression/Oodle/OodleKrakenEncoder.cs
new file mode 100644
index 0000000..3edca43
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/Oodle/OodleKrakenEncoder.cs
@@ -0,0 +1,2406 @@
+// LibProsperoPkg - A library for building and inspecting PS5 packages.
+// Copyright (C) 2026 SvenGDK
+//
+// ---------------------------------------------------------------------------------------------------
+// Kraken (newLZ) encoder producing standard, decoder-valid
+// newLZ chunks accepted by the PS5 Kraken decompressor. The PS5 block decompressor is the
+// acceptance check. This file emits the documented chunk structure. GPLv3.
+//
+// A PFS block (up to blockSize, default 256 KiB) is encoded as ONE or TWO headerless newLZ chunks,
+// because a single newLZ chunk decodes at most 128 KiB (0x20000). blockSize 0x40000 is therefore
+// exactly two chunks; the block-boundary table flags a two-chunk block with the 0x20 bit (flag 0x26
+// instead of 0x06) and stores the first chunk's compressed size in its saturating size hint, exactly
+// as the frame rebuilder expects when it splits the block
+// back into per-chunk OodleLZ frames.
+//
+// CHUNK FORMAT ("excess mode", post-seed control byte high bit set):
+//
+// first chunk:[8-byte raw seed][ctrl][lit raw][cmd raw][offs raw][litlen raw][dual bitstream][excess]
+// second chunk:[ctrl][lit raw][cmd raw][offs raw][litlen raw][dual bitstream][excess]
+//
+// * seed = the first 8 bytes of the first chunk (COPY_64); decoding starts at dst+8. The second
+// chunk has NO seed — it is decoded at a non-zero output offset and its matches may
+// reference back into the first chunk's already-decoded output.
+// * ctrl byte = 0x80 | (excessByteCount & 0x3F), with a continuation byte when the low six bits would
+// * exceed 0x1F. excessByteCount is the length of the trailing excess sub-stream that carries
+// * the literal-length escape values (0 when no literal run exceeds 257).
+// * 4 raw arrays, each a DecodeBytes chunk-type-0 raw block: a 3-byte big-endian length header (high
+// bit clear, len <= 0x3FFFF) followed by that many raw bytes. Order: lit, cmd, packed_offs,
+// packed_litlen.
+// * dual bitstream = forward bytes ++ reverse(backward bytes); carries ONLY the offset extra (E) bits,
+// alternating new-offset #0 -> forward, #1 -> backward, #2 -> forward,...
+// * excess sub-stream (only when excessByteCount > 0) = the chunk's trailing excessByteCount bytes:
+// a forward writer (even-index literal-length escapes) ++ reverse(backward writer, odd-index escapes),
+// each value written with WriteLength; the decoder's ea/eb readers consume them in the same order.
+//
+// Command byte f: litfield = f & 3 (== 3 -> packed_litlen carries litlen-3); matchfield = (f >> 2) & 0xF
+// (<= 14 -> inline matchlen = matchfield + 2; == 15 -> packed_litlen carries matchlen-17);
+// offs_index = f >> 6 (== 3 -> new offset taken from packed_offs + E bits).
+//
+// CONSTRAINTS THAT MAKE A CHUNK DECODER-VALID:
+// 1. Every chunk MUST end with a literal tail; a match may never cover the final 8 bytes of a chunk.
+// 2. Minimum new-offset distance is 8 (recent offsets initialise to -8; 1..7 are not encodable).
+// 3. matchlen is capped at 271 (split into consecutive same-distance pieces) so packed_litlen never
+// needs a match-length escape. A literal run longer than 257 is encoded as follows: packed_litlen stores a
+// 255 marker and the (litlen-258) value is emitted into the trailing excess sub-stream. At most 512
+// escapes per chunk (the decoder's u32 length-stream cap), else the block is stored raw.
+// ---------------------------------------------------------------------------------------------------
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace LibProsperoPkg.PFS.Compression.Oodle;
+
+///
+/// The result of compressing one PFS block: the section-7 payload plus the metadata the boundary
+/// table needs (whether the block was split into two chunks, and the first chunk's compressed size).
+///
+/// The form a block's sub-chunk takes on disk.
+internal enum KrakenSubChunkForm
+{
+ /// A coded LZ sub-chunk.
+ Lz,
+
+ /// A single entropy array covering the whole sub-chunk, with no LZ table.
+ BareEntropy,
+
+ /// The sub-chunk's uncompressed bytes, copied.
+ Verbatim,
+}
+
+internal readonly struct EncodedBlock
+{
+ /// The bytes that land in section 7 for this block (one or two concatenated chunks).
+ public readonly byte[] Payload;
+
+ /// True when the block is two chunks (boundary flag 0x26); false for a single chunk (0x06).
+ public readonly bool MultiChunk;
+
+ /// The compressed size of the first chunk (the value stored, minus one, in the size hint).
+ public readonly int FirstChunkCompSize;
+
+ /// The literal model the first sub-chunk used: 0 = sub/delta, 1 = raw.
+ public readonly int Chunk0LitMode;
+
+ /// The literal model the second sub-chunk used, or 1 when the block has one sub-chunk.
+ public readonly int Chunk1LitMode;
+
+ /// The form the first sub-chunk took.
+ public readonly KrakenSubChunkForm Chunk0Form;
+
+ /// The form the second sub-chunk took; meaningless when the block has one sub-chunk.
+ public readonly KrakenSubChunkForm Chunk1Form;
+
+ public EncodedBlock(byte[] payload, bool multiChunk, int firstChunkCompSize,
+ int chunk0LitMode = 1, int chunk1LitMode = 1,
+ KrakenSubChunkForm chunk0Form = KrakenSubChunkForm.Lz,
+ KrakenSubChunkForm chunk1Form = KrakenSubChunkForm.Lz)
+ {
+ Payload = payload;
+ MultiChunk = multiChunk;
+ FirstChunkCompSize = firstChunkCompSize;
+ Chunk0LitMode = chunk0LitMode;
+ Chunk1LitMode = chunk1LitMode;
+ Chunk0Form = chunk0Form;
+ Chunk1Form = chunk1Form;
+ }
+}
+
+///
+/// Managed Kraken (newLZ) encoder. Compresses a PFS block into one or two headerless newLZ "excess
+/// mode" chunks that round-trip through the PS5 block decompressor.
+///
+/// Length escapes. A literal run longer than 257 bytes (packed value litLen-3 >= 255 ) and
+/// a match longer than (packed value matchLen-17 >= 255 ) are carried by a
+/// 0xFF packed_litlen marker plus a u32 escape value. Every escape in a chunk is collected in packed_litlen
+/// order and written into a trailing excess sub-stream, split even index -> forward writer / odd index
+/// -> backward writer and laid out forward ++ reverse(backward) , mirroring the paired readers the
+/// decoder uses. The post-seed control byte is 0x80 | low6(excessByteCount) with a continuation byte
+/// when the count exceeds 0x1F. Over-long matches are split into <= pieces so in
+/// practice only literal runs escape; a chunk needing more than escapes falls back
+/// to the stored path.
+///
+///
+/// No match may start in the last (16) bytes of a chunk. The newLZ release
+/// parse loop enforces match_zone_end - to_ptr >= lrl where match_zone_end = chunk_end - 16 :
+/// the literal-run end (= match start) must be <= chunk_end - 16. A match that starts later drifts the
+/// decode pointer and the block is rejected with error -1007. The parser therefore stops emitting matches
+/// past chunkEnd - NoMatchZone and flushes the remainder as the trailing literal run; a store-raw
+/// safety net rejects any chunk that would still violate it.
+///
+/// Returns null when compression is not worthwhile (caller stores the block).
+///
+internal static class OodleKrakenEncoder
+{
+ private const int ChunkMax = 0x20000; // a single newLZ chunk decodes at most 128 KiB
+ private const int MinChunk = 128; // a coded chunk must expand to at least this many bytes
+ private const int TinyChunk = 0x20; // a shorter sub-chunk is copied without any comparison
+ private const int MinMatch = 4;
+ private const int MinRepMatch = 2; // a repeat-offset match costs no offset, so length 2 can pay
+ private const int MinDistance = 8; // recent-offset init is -8; shorter distances are illegal
+ private const int LiteralTail = 8; // a match may extend to chunkEnd-8, so >=8 trailing literals remain
+ private const int NoMatchZone = 16; // a match may not START in the last 16 bytes of a chunk (match_zone_end = chunkEnd-16)
+ private const int MaxMatch = 271; // split path caps matchlen-17 <= 254 (packed_litlen < 255); a lone over-long match instead uses the single match-length escape
+ private const int MaxArrayLength = 0x3FFFF; // DecodeBytes raw 3-byte size limit
+ private const int MaxChainWalk = 128;
+ private const int HashBits = 17;
+ private const int HashSize = 1 << HashBits;
+ private const byte CtrlExcessMode = 0x80; // post-seed control byte:0x80 | low6(excessCount), plus a continuation byte when excessCount > 0x1F
+ private const int MaxEscapes = 512; // the decoder caps u32_len_stream_size (the count of length escapes) at 512
+
+ private readonly struct Command
+ {
+ public readonly int LitStart;
+ public readonly int LitLen;
+ public readonly int Distance;
+ public readonly int MatchLen;
+ public readonly int OffsIndex; // 0/1/2 = reuse a recent offset (no offset emitted); 3 = new offset
+
+ public Command(int litStart, int litLen, int distance, int matchLen, int offsIndex)
+ {
+ LitStart = litStart;
+ LitLen = litLen;
+ Distance = distance;
+ MatchLen = matchLen;
+ OffsIndex = offsIndex;
+ }
+ }
+
+ ///
+ /// Compresses (one PFS block) into a section-7 payload, or returns null
+ /// when the block must be stored whole.
+ ///
+ public static EncodedBlock? EncodeBlock(ReadOnlySpan data) => EncodeBlock(data, useHuffmanArrays: false);
+
+ ///
+ /// Compresses (one PFS block) into a section-7 payload, or returns null when
+ /// the block must be stored whole. The block is split into sub-chunks of at most
+ /// uncompressed bytes, and each sub-chunk independently takes the cheapest of
+ /// three forms: an LZ sub-chunk, a single entropy array over the whole sub-chunk, or a verbatim copy.
+ /// When is set the streams inside an LZ sub-chunk are entropy
+ /// coded wherever that wins the array score.
+ ///
+ public static EncodedBlock? EncodeBlock(ReadOnlySpan data, bool useHuffmanArrays)
+ {
+ int n = data.Length;
+ // A block below the minimum coded chunk size is never coded at all.
+ if (n < MinChunk)
+ return null;
+ // A larger block cannot be described by the single first-sub-chunk size hint.
+ if (n > 2 * ChunkMax)
+ return null;
+
+ var head = new int[HashSize];
+ var prev = new int[n];
+ head.AsSpan().Fill(-1);
+
+ // The match table is built once over the whole block and sliced by the sub-chunk parsers, so a
+ // second sub-chunk's matches can reach back into the first. Matches never leave the block.
+ int[]? savedTable = MatchTable;
+ MatchTable = BuildMatchTable(data);
+ CarriedPassinfo = null;
+ CarriedLitMode = -1;
+ PendingCarryPassinfo = null;
+ try
+ {
+ int chunk0End = n < ChunkMax ? n : ChunkMax;
+ bool blockConstant = IsConstantRun(data);
+ EncodedSubChunk sub0 = EncodeSubChunk(data, head, prev, 0, chunk0End, isFirst: true, useHuffmanArrays, blockConstant);
+ if (sub0.Payload is null)
+ return null;
+ if (chunk0End == n)
+ {
+ // One sub-chunk. A single verbatim sub-chunk is exactly a stored block, so it is left to
+ // the stored path.
+ if (sub0.Form == KrakenSubChunkForm.Verbatim || sub0.Payload.Length >= n)
+ return null;
+ return new EncodedBlock(sub0.Payload, multiChunk: false, sub0.Payload.Length,
+ sub0.LitMode, 1, sub0.Form, KrakenSubChunkForm.Verbatim);
+ }
+
+ EncodedSubChunk sub1 = EncodeSubChunk(data, head, prev, chunk0End, n, isFirst: false, useHuffmanArrays, blockConstant);
+ if (sub1.Payload is null)
+ return null;
+ // The frame header cannot express a block whose sub-chunks are both verbatim, and such a
+ // block never beats storing anyway.
+ if (sub0.Form == KrakenSubChunkForm.Verbatim && sub1.Form == KrakenSubChunkForm.Verbatim)
+ return null;
+ if (sub0.Payload.Length > ChunkMax)
+ return null;
+
+ var payload = new byte[sub0.Payload.Length + sub1.Payload.Length];
+ Buffer.BlockCopy(sub0.Payload, 0, payload, 0, sub0.Payload.Length);
+ Buffer.BlockCopy(sub1.Payload, 0, payload, sub0.Payload.Length, sub1.Payload.Length);
+ if (payload.Length >= n)
+ return null;
+ return new EncodedBlock(payload, multiChunk: true, sub0.Payload.Length,
+ sub0.LitMode, sub1.LitMode, sub0.Form, sub1.Form);
+ }
+ finally
+ {
+ MatchTable = savedTable;
+ CarriedPassinfo = null;
+ CarriedLitMode = -1;
+ PendingCarryPassinfo = null;
+ }
+ }
+
+ /// One encoded sub-chunk: its container payload, the form it took and its literal model.
+ private readonly struct EncodedSubChunk
+ {
+ public readonly byte[]? Payload;
+ public readonly KrakenSubChunkForm Form;
+ public readonly int LitMode;
+
+ public EncodedSubChunk(byte[]? payload, KrakenSubChunkForm form, int litMode)
+ {
+ Payload = payload;
+ Form = form;
+ LitMode = litMode;
+ }
+ }
+
+ /// Decode-time weight of a verbatim copy, in the units the array score model uses.
+ private static float CopyDecodeTime(int length) => length * 0.0625f;
+
+ ///
+ /// The deadline the whole-sub-chunk entropy array has to meet. It is not applied to the arrays
+ /// inside an LZ sub-chunk.
+ ///
+ private static float WholeChunkDeadline(int length) => length * 0.57f - 50.0f;
+
+ ///
+ /// Encodes one sub-chunk in whichever of the three forms scores best. The order is fixed. When the
+ /// whole block is one repeated byte, every sub-chunk long enough to beat the eight-byte array takes
+ /// it and the rest are copied. Otherwise a sub-chunk below bytes is copied
+ /// without any comparison, a sub-chunk that is itself one repeated byte becomes the eight-byte array
+ /// without any comparison, and everything else is scored across the LZ form, the whole-sub-chunk
+ /// entropy array and the verbatim copy. Ties move away from the LZ form and toward the copy.
+ ///
+ private static EncodedSubChunk EncodeSubChunk(ReadOnlySpan data, int[] head, int[] prev,
+ int chunkStart, int chunkEnd, bool isFirst, bool useHuffmanArrays, bool blockConstant)
+ {
+ int length = chunkEnd - chunkStart;
+ ReadOnlySpan chunk = data.Slice(chunkStart, length);
+
+ if (blockConstant)
+ {
+ return length > ConstantRunArrayLength
+ ? new EncodedSubChunk(ConstantRunArray(chunk[0], length), KrakenSubChunkForm.BareEntropy, 1)
+ : new EncodedSubChunk(chunk.ToArray(), KrakenSubChunkForm.Verbatim, 1);
+ }
+
+ if (length < TinyChunk)
+ return new EncodedSubChunk(chunk.ToArray(), KrakenSubChunkForm.Verbatim, 1);
+
+ if (IsConstantRun(chunk))
+ return new EncodedSubChunk(ConstantRunArray(chunk[0], length), KrakenSubChunkForm.BareEntropy, 1);
+
+ float copyScore = length + 3.0f + ArrayLambda * CopyDecodeTime(length);
+
+ byte[]? lz = null;
+ int lzLitMode = 1;
+ float lzScore = float.MaxValue;
+ if (length > MinChunk)
+ {
+ lz = EncodeChunk(data, head, prev, chunkStart, chunkEnd, withSeed: isFirst, useHuffmanArrays,
+ out lzLitMode, out float lzArrayScore, allowOptimal: true);
+ if (lz is not null && lz.Length < length)
+ lzScore = lzArrayScore + 3.0f;
+ else
+ lz = null;
+ }
+
+ // The whole-sub-chunk array is offered the better of the two other forms as its ceiling, and it
+ // additionally has to decode within the deadline.
+ float arrayBudget = lzScore < copyScore ? lzScore : copyScore;
+ float bareScore = float.MaxValue;
+ byte[]? bare = useHuffmanArrays ? TryWholeChunkArray(chunk, arrayBudget, out bareScore) : null;
+
+ if (lz is not null && lzScore < copyScore && lzScore <= bareScore)
+ return new EncodedSubChunk(lz, KrakenSubChunkForm.Lz, lzLitMode);
+ if (bare is null || copyScore <= bareScore)
+ return new EncodedSubChunk(chunk.ToArray(), KrakenSubChunkForm.Verbatim, 1);
+ return new EncodedSubChunk(bare, KrakenSubChunkForm.BareEntropy, 1);
+ }
+
+ private static bool IsConstantRun(ReadOnlySpan chunk)
+ {
+ byte first = chunk[0];
+ for (int i = 1; i < chunk.Length; i++)
+ if (chunk[i] != first)
+ return false;
+ return true;
+ }
+
+ /// Bytes the constant-run form occupies; a sub-chunk this short is copied instead.
+ private const int ConstantRunArrayLength = KrakenHuffmanArrayEncoder.SingleSymbolArrayLength;
+
+ /// The eight-byte form a constant run takes, as a whole sub-chunk.
+ private static byte[] ConstantRunArray(byte value, int length)
+ => KrakenHuffmanArrayEncoder.BuildSingleSymbolArray(value, length);
+
+ ///
+ /// Encodes the whole sub-chunk as a single entropy array, or returns null when it does not beat
+ /// , misses the decode deadline, or does not come out below the raw size.
+ ///
+ private static byte[]? TryWholeChunkArray(ReadOnlySpan chunk, float budget, out float score)
+ {
+ score = float.MaxValue;
+ int length = chunk.Length;
+ var prepared = KrakenHuffmanArrayEncoder.Prepare(chunk, out KrakenHuffmanArrayEncoder.CostInputs cost);
+ if (prepared is null)
+ return null;
+ if (prepared.SingleSymbol)
+ {
+ // A constant sub-chunk is handled before this point, so this only sees a constant array
+ // reached through some other path; score it with its own model.
+ float memsetScore = ArrayLambda * CopyDecodeTime(length) + KrakenHuffmanArrayEncoder.SingleSymbolArrayLength;
+ if (memsetScore >= (budget < length + RawArrayHeader ? budget : length + RawArrayHeader))
+ return null;
+ score = memsetScore;
+ return KrakenHuffmanArrayEncoder.Finish(chunk, prepared);
+ }
+ float time = ArrayDecodeTime(length, cost.NonZeroSymbols, cost.AlphabetRuns,
+ (float)cost.CodeLengthBits / length);
+ if (time > WholeChunkDeadline(length))
+ return null;
+ float cap = budget < length + RawArrayHeader ? budget : length + RawArrayHeader;
+ float overhead = ArrayLambda * time + ArrayCompressedBias;
+ float estimate = ((cost.CodeLengthBits + 7) >> 3) + 13 + cost.HeaderBytes;
+ if (estimate + overhead >= cap)
+ return null;
+ byte[]? huff = KrakenHuffmanArrayEncoder.Finish(chunk, prepared);
+ if (huff is null || huff.Length >= length)
+ return null;
+ score = huff.Length + overhead;
+ return score < length + RawArrayHeader ? huff : null;
+ }
+
+ ///
+ /// The per-position match table for the block currently being encoded: four length/offset pairs per
+ /// position, shared by the greedy pre-passes and the forward DP.
+ ///
+ [ThreadStatic] internal static int[]? MatchTable;
+
+ private static int[] BuildMatchTable(ReadOnlySpan data)
+ {
+ var block = data.ToArray();
+ var table = new int[Math.Max(1, block.Length) * KrakenSuffixTrieMatcher.IntsPerPosition];
+ new KrakenSuffixTrieMatcher(block).Run(table);
+ return table;
+ }
+
+ ///
+ /// Encodes the output range [ , ) of
+ /// as one newLZ chunk. The shared /
+ /// hash chain lets a later chunk reference matches in an earlier one. When
+ /// is false the chunk omits the 8-byte COPY_64 seed (used for any
+ /// chunk decoded at a non-zero output offset).
+ ///
+ private static byte[]? EncodeChunk(ReadOnlySpan data, int[] head, int[] prev,
+ int chunkStart, int chunkEnd, bool withSeed, bool useHuffmanArrays, out int litMode,
+ out float score, bool allowOptimal = false)
+ {
+ litMode = 1;
+ score = float.MaxValue;
+ int matchLimit = chunkEnd - LiteralTail; // a match may EXTEND up to here, so >= LiteralTail trailing literals remain
+ // The newLZ decoder forbids a match from STARTING in the last 16 bytes of a chunk. Its release
+ // parse loop (the algorithm) enforces `match_zone_end - to_ptr >= lrl` with
+ // match_zone_end = chunk_end - 16: the match start
+ // (= literal-run end) must be <= chunk_end - 16. A match that starts later drifts the decode
+ // pointer and the block is rejected with decompression error -1007, even
+ // though our own (lenient) decoder round-trips it. So the last legal match-start is chunkEnd-NoMatchZone.
+ int matchStartLimit = chunkEnd - NoMatchZone;
+ int firstMatchPos = withSeed ? chunkStart + 8 : chunkStart; // seed is the first 8 bytes
+ // The seed bytes are valid copy sources (the decoder emits them before the first command), so
+ // index them in the hash chain. Without this the parser cannot find a match that references the
+ // first 8 bytes of a seeded chunk and the first match is delayed by up to 8 literals, which is
+ // exactly how the parser handles a periodic block (e.g. a distance-16 match at position 16 referencing
+ // position 0). The seedless second chunk needs nothing here — its predecessors are already indexed.
+ if (withSeed)
+ {
+ for (int p = chunkStart; p < firstMatchPos; p++)
+ Insert(data, p, head, prev);
+ }
+ // Literals before the first match begin where matching begins (the seed is emitted separately).
+ // A single-chunk block (allowOptimal) has no cross-chunk backward dependency, so it is
+ // parsed with the Optimal3 multi-candidate selection: greedy mml=4/3/8 plus one seeded
+ // forward-DP, choosing the candidate with the smallest real entropy-coded emit size.
+ // Because plain greedy is included as a candidate, this cannot enlarge the block. The
+ // forward-DP needs mml=3 (UseDpMml3) to surface length-3 matches. Multi-chunk blocks stay
+ // on the shared-chain greedy so the seedless second chunk can still reference the first.
+ //
+ // The matcher is a bounded suffix-trie; this managed DP is O(n^2) in the block length,
+ // so the optimal parse is limited to genuinely small standalone blocks (ProductionOptimalMaxBlock)
+ // — the small config/text system files stored as per-file standalone blocks —
+ // keeping build times fast. Larger single-chunk blocks fall back to the linear greedy parse.
+ bool blockOptimalSmall = allowOptimal && ProductionOptimalSingleChunk
+ && (chunkEnd - chunkStart) <= ProductionOptimalMaxBlock;
+ // Large single-chunk blocks: windowed Optimal3 DP (near-linear, seeded). Best-of-{greedy,DP}
+ // by real emit size so it never enlarges the block versus the greedy fallback.
+ int blockLen = chunkEnd - chunkStart;
+ bool blockOptimalWindowed = allowOptimal && ProductionWindowedOptimal
+ && blockLen > ProductionOptimalMaxBlock && blockLen <= ProductionOptimalWindowedMaxBlock;
+ bool optimal = UseOptimalParse || blockOptimalSmall || blockOptimalWindowed;
+ List commands;
+ if (optimal)
+ {
+ bool savedMml3 = UseDpMml3, savedWin = UseWindowedParse, savedFront = UseFrontierGate, savedTiny = UseTinyOffsetRemap;
+ UseDpMml3 = true;
+ // Enable windowing plus long-match skipping for the large-block path while leaving the
+ // monolithic small-block path untouched. The long-match skip replaces the earlier
+ // cross-position frontier gate, which over-suppressed candidates; with the skip enabled,
+ // disabling the frontier gate keeps the windowed DP aligned with the intended parse.
+ if (blockOptimalWindowed && !UseOptimalParse) { UseWindowedParse = true; UseFrontierGate = false; UseTinyOffsetRemap = true; }
+ try
+ {
+ commands = ParseOptimal(data, head, prev, firstMatchPos, firstMatchPos, matchLimit, matchStartLimit,
+ chunkStart, chunkEnd, withSeed, useHuffmanArrays);
+ }
+ finally { UseDpMml3 = savedMml3; UseWindowedParse = savedWin; UseFrontierGate = savedFront; UseTinyOffsetRemap = savedTiny; }
+ }
+ else
+ {
+ commands = Parse(data, head, prev, firstMatchPos, firstMatchPos, matchLimit, matchStartLimit);
+ }
+ if (commands.Count == 0)
+ return null;
+
+ // The literal model is chosen per chunk and signalled out of band by the boundary-table flag
+ // bit (sub-chunk 0 bit 0x01, sub-chunk 1 bit 0x10), so the chosen model travels back to the
+ // container writer alongside the bytes.
+ byte[]? chunkBytes = EmitChunkFromCommands(data, commands, chunkStart, chunkEnd, withSeed,
+ useHuffmanArrays, litMode: -1, out litMode, out score);
+ if (PendingCarryPassinfo is not null && chunkBytes is not null)
+ {
+ CarriedPassinfo = PendingCarryPassinfo;
+ CarriedLitMode = litMode;
+ }
+ PendingCarryPassinfo = null;
+ return chunkBytes;
+ }
+
+ ///
+ /// Builds the newLZ stream arrays from an already-computed parse and assembles the chunk bytes
+ /// (entropy-coding the lit/cmd/length arrays when beneficial). Factored out of
+ /// so a diagnostic can measure the actual entropy-coded emit size of an externally supplied parse
+ /// (its size vs the DP's) — the metric the level-7 producer actually minimises in its final
+ /// greedy-vs-DP selection (actual-emit-size pick). Returns null when the parse needs a store-raw
+ /// fallback (a literal run over MaxLitRun or a match starting inside the no-match zone).
+ ///
+ private static byte[]? EmitChunkFromCommands(ReadOnlySpan data, List commands,
+ int chunkStart, int chunkEnd, bool withSeed, bool useHuffmanArrays, int litMode,
+ out int chosenLitMode)
+ => EmitChunkFromCommands(data, commands, chunkStart, chunkEnd, withSeed, useHuffmanArrays,
+ litMode, out chosenLitMode, out _);
+
+ ///
+ /// As the other overload, and also reports the chunk's score: the arrays contribute the score the
+ /// array writer assigned them and every other byte contributes itself.
+ ///
+ private static byte[]? EmitChunkFromCommands(ReadOnlySpan data, List commands,
+ int chunkStart, int chunkEnd, bool withSeed, bool useHuffmanArrays, int litMode,
+ out int chosenLitMode, out float score)
+ {
+ chosenLitMode = 1;
+ score = 0.0f;
+ int matchStartLimit = chunkEnd - NoMatchZone;
+ // Store-raw safety net: a match may not start inside the no-match zone (the last 16 bytes of the
+ // chunk). The parser already enforces this, so in practice this never triggers.
+ foreach (var cmd in commands)
+ {
+ if (cmd.MatchLen > 0 && cmd.LitStart + cmd.LitLen > matchStartLimit)
+ return null;
+ }
+
+ var litRaw = new List(chunkEnd - chunkStart); // mode 1:raw literals
+ var litSub = new List(chunkEnd - chunkStart); // mode 0:sub/delta literals (byte - dst[p+lastOffset])
+ BuildLiteralStreams(data, commands, chunkEnd, litRaw, litSub);
+ var cmdStream = new List();
+ var packedOffs = new List();
+ var packedLitLen = new List();
+ var forward = new KrakenBitWriter();
+ var backward = new KrakenBitWriter();
+ // Length-escape u32 values (one per 0xFF packed_litlen marker), collected in packed_litlen order.
+ // They are written into the trailing excess sub-stream after the main streams are assembled.
+ var escapeValues = new List();
+
+ // Local helper: append a length's packed value to packed_litlen, escaping when it reaches 255.
+ // A literal run longer than 257 bytes (packed value litLen-3 >= 255) escapes; matches are split
+ // into <= MaxMatch pieces below, so their packed length (piece-17 <= 254) never escapes.
+ void EmitLen(int packedValue)
+ {
+ if (packedValue < 255)
+ {
+ packedLitLen.Add((byte)packedValue);
+ }
+ else
+ {
+ packedLitLen.Add(255); // u32 length-escape marker
+ escapeValues.Add((uint)(packedValue - 255)); // decoder: length value = u32 + 255
+ }
+ }
+
+ int offsetIndex = 0;
+ foreach (var c in commands)
+ {
+ // Literal streams (litRaw/litSub) were built up-front by BuildLiteralStreams; this loop only
+ // assembles the command/offset/length streams. The encoder emits exactly ONE command
+ // per parse command regardless of match length: a match longer than 16 sets matchField=15 and
+ // transmits (matchLen-17) through the shared length stream, escaping to the trailing excess
+ // sub-stream when the packed value reaches 255 (matchLen >= 272). A trailing command with
+ // MatchLen==0 (the final literal run) contributes no command byte -- the decoder copies its
+ // literals in the tail loop. Splitting an over-long match into rep0 continuation pieces would
+ // still round-trip through our lenient decoder, but it is NOT what the encoder emits, so the
+ // block bytes would differ (extra command bytes, no escape) -- hence one command, one escape.
+ if (c.MatchLen == 0)
+ continue;
+
+ int curLit = c.LitLen;
+ int litField = curLit >= 3 ? 3 : curLit;
+ int matchField = c.MatchLen <= 16 ? c.MatchLen - 2 : 15;
+ cmdStream.Add((byte)((c.OffsIndex << 6) | (matchField << 2) | litField));
+
+ if (litField == 3)
+ EmitLen(curLit - 3);
+
+ if (c.OffsIndex == 3)
+ {
+ // Only a new offset is transmitted: one packed_offs byte plus its E bits in the dual
+ // bitstream, alternating forward/backward per new offset. Recent-offset reuse
+ // (index 0/1/2) writes nothing here.
+ byte v = (offsetIndex & 1) == 0
+ ? forward.WriteDistance(c.Distance)
+ : backward.WriteDistance(c.Distance);
+ packedOffs.Add(v);
+ offsetIndex++;
+ }
+
+ if (matchField == 15)
+ EmitLen(c.MatchLen - 17);
+ }
+
+ if (escapeValues.Count > MaxEscapes)
+ return null; // more length escapes than the decoder accepts; store raw instead
+
+ // Both literal models hold the same number of literals; the array-length guard is mode-independent.
+ if (litRaw.Count > MaxArrayLength || cmdStream.Count > MaxArrayLength ||
+ packedOffs.Count > MaxArrayLength || packedLitLen.Count > MaxArrayLength)
+ {
+ return null; // arrays too large for the raw 3-byte header; store raw instead
+ }
+
+ // Literal model selection (newLZ "sub" vs "raw"): the only chunk array that depends on the
+ // model. The encoder keeps whichever
+ // yields the smaller entropy-coded literal array; litMode forces it (0 = sub, 1 = raw) for
+ // diagnostics, -1 = auto (ChooseLitMode = the validated decision).
+ chosenLitMode = litMode >= 0 ? litMode : ChooseLitMode(litRaw, litSub, useHuffmanArrays);
+ List lit = chosenLitMode == 0 ? litSub : litRaw;
+
+ byte[] forwardBytes = forward.ToBytes();
+ byte[] backwardBytes = backward.ToBytes();
+
+ var outBuf = new List(chunkEnd - chunkStart);
+ if (withSeed)
+ {
+ for (int i = 0; i < 8; i++)
+ outBuf.Add(data[chunkStart + i]); // seed
+ }
+ // Trailing excess sub-stream = the length-escape u32 values, split even index -> forward writer,
+ // odd index -> backward writer, then laid out forward ++ reverse(backward) exactly like the main
+ // bitstream so the decoder's paired readers meet.
+ var excessF = new KrakenBitWriter();
+ var excessB = new KrakenBitWriter();
+ for (int i = 0; i < escapeValues.Count; i++)
+ {
+ if ((i & 1) == 0) excessF.WriteLength(escapeValues[i]);
+ else excessB.WriteLength(escapeValues[i]);
+ }
+ byte[] excessFwd = excessF.ToBytes();
+ byte[] excessBwd = excessB.ToBytes();
+ int excessCount = excessFwd.Length + excessBwd.Length;
+ if (excessCount > 0x1FFF)
+ return null; // continuation byte cannot encode this many excess bytes; store raw instead
+
+ // Post-seed control byte: 0x80 | low6(excessCount). When excessCount > 0x1F a continuation byte
+ // carries the high bits: low6 = 0x20 | (excessCount & 0x1F), continuation = (excessCount >> 5) - 1
+ // (decoder: excessCount = low6 + continuation * 0x20).
+ if (excessCount <= 0x1F)
+ {
+ outBuf.Add((byte)(CtrlExcessMode | excessCount));
+ }
+ else
+ {
+ outBuf.Add((byte)(CtrlExcessMode | 0x20 | (excessCount & 0x1F)));
+ outBuf.Add((byte)((excessCount >> 5) - 1));
+ }
+ int arrayStart = outBuf.Count;
+ score += WriteArray(outBuf, lit, useHuffmanArrays);
+ score += WriteArray(outBuf, cmdStream, useHuffmanArrays);
+ // packed_offs: Huffman-code this array when beneficial (single-table offset mode).
+ // The offset-mode reader inspects this array's first byte for bit 0x80 (set = two-table scaling
+ // byte). KrakenHuffmanArrayEncoder always emits the 5-byte long-form entropy header whose first
+ // byte is (chunkType<<4)|... = 0x20..0x2F for chunkType 2 (bit 0x80 CLEAR), and a raw array's
+ // first byte is (len>>16) <= 3 (also clear), so either form stays in single-table mode.
+ // A previous validity-only version forced this raw; Huffman form is needed for text/data offsets.
+ score += WriteArray(outBuf, packedOffs, useHuffmanArrays);
+ score += WriteArray(outBuf, packedLitLen, useHuffmanArrays);
+ int arrayBytes = outBuf.Count - arrayStart;
+ // main dual bitstream region = forward ++ reverse(backward)
+ outBuf.AddRange(forwardBytes);
+ for (int i = backwardBytes.Length - 1; i >= 0; i--)
+ outBuf.Add(backwardBytes[i]);
+ // trailing excess sub-stream = forward ++ reverse(backward)
+ outBuf.AddRange(excessFwd);
+ for (int i = excessBwd.Length - 1; i >= 0; i--)
+ outBuf.Add(excessBwd[i]);
+
+ // Every byte outside the four arrays contributes its own size to the score.
+ score += outBuf.Count - arrayBytes;
+ return outBuf.ToArray();
+ }
+
+ private static void WriteRawArray(List outBuf, List array)
+ {
+ int len = array.Count;
+ outBuf.Add((byte)((len >> 16) & 0xFF)); // high bit clear (len <= 0x3FFFF), chunk type 0
+ outBuf.Add((byte)((len >> 8) & 0xFF));
+ outBuf.Add((byte)(len & 0xFF));
+ outBuf.AddRange(array);
+ }
+
+ // Builds the raw and sub (delta) literal byte streams for a parse. subLastOffset tracks r0 (the
+ // active offset for the current run's literals): init -8, updated to -Distance after each match.
+ // The decoder applies the old lastOffset to a command's literals and only updates it after them
+ // (ProcessLzRunsType0), so literals use the PREVIOUS command's offset; the trailing run after the
+ // final match uses the last chosen offset. Shared by EmitChunkFromCommands and ChooseLitMode so
+ // the litMode decision scores exactly the bytes the emit would write.
+ private static void BuildLiteralStreams(ReadOnlySpan data, List commands,
+ int chunkEnd, List litRaw, List litSub)
+ {
+ int subLastOffset = -8;
+ foreach (var c in commands)
+ {
+ for (int i = 0; i < c.LitLen; i++)
+ {
+ int p = c.LitStart + i;
+ litRaw.Add(data[p]);
+ litSub.Add((byte)(data[p] - data[p + subLastOffset]));
+ }
+ if (c.MatchLen > 0)
+ subLastOffset = -c.Distance;
+ }
+ var last = commands[^1];
+ int tailStart = last.LitStart + last.LitLen + last.MatchLen;
+ for (int i = tailStart; i < chunkEnd; i++)
+ {
+ litRaw.Add(data[i]);
+ litSub.Add((byte)(data[i] - data[i + subLastOffset]));
+ }
+ }
+
+ ///
+ /// Chooses the chunk's literal model: 0 = sub/delta, 1 = raw. The sub candidate is offered first and
+ /// becomes the budget the raw candidate must beat, so three rules apply in order: fewer than 32
+ /// literals always take raw; a sub array that does not encode smaller than the literal count is
+ /// discarded and raw is taken; otherwise raw wins only when it is strictly smaller, so an exact tie
+ /// keeps sub. The sizes are the exact byte lengths the array emitter would write.
+ ///
+ internal static int ChooseLitMode(List litRaw, List litSub, bool useHuffmanArrays)
+ {
+ int n = litSub.Count;
+ if (n < 32) return 1;
+ float budget = float.MaxValue;
+ PutArray(litSub, useHuffmanArrays, ref budget, out _, out int subSize);
+ if (subSize >= n) return 1;
+ // The raw candidate is offered the sub candidate's score as its ceiling, so it replaces sub only
+ // when it is strictly cheaper; the writer declines outright on a tie.
+ return PutArray(litRaw, useHuffmanArrays, ref budget, out _, out _) ? 1 : 0;
+ }
+
+ // Convenience overload: choose the litMode a parse would emit, used to seed the DP cost build
+ // with the greedy/seed parse's litMode so the winning greedy's choice becomes codecosts[0].
+ private static int LitModeForParse(ReadOnlySpan data, List commands,
+ int chunkEnd, bool useHuffmanArrays)
+ {
+ if (commands.Count == 0) return 1;
+ var litRaw = new List(chunkEnd);
+ var litSub = new List(chunkEnd);
+ BuildLiteralStreams(data, commands, chunkEnd, litRaw, litSub);
+ return ChooseLitMode(litRaw, litSub, useHuffmanArrays);
+ }
+
+
+ ///
+ /// Space-speed weight in the array method decision. It multiplies the estimated decode time of an
+ /// entropy-coded array, so a larger value biases the decision toward the uncompressed form.
+ ///
+ private const float ArrayLambda = 0.04f;
+
+ /// Fixed cost added to an entropy-coded array's score.
+ private const float ArrayCompressedBias = 5.0f;
+
+ /// Header bytes an uncompressed array carries.
+ private const int RawArrayHeader = 3;
+
+ /// An array this short or shorter is written uncompressed without building a histogram.
+ private const int MinEntropyArray = 32;
+
+ ///
+ /// Estimated decode time of a three-stream entropy array of bytes over an
+ /// alphabet of symbols in contiguous
+ /// runs, whose codes average bits.
+ ///
+ private static float ArrayDecodeTime(int length, int nonZeroSymbols, int alphabetRuns, float averageBits)
+ {
+ float x = averageBits - 6.0f;
+ float f = x <= 0.0f ? 0.25f : x >= 1.0f ? 0.375f : 0.25f + x * 0.125f;
+ return f * length + 20.0f + nonZeroSymbols * 1.0f + alphabetRuns * 4.0f;
+ }
+
+ ///
+ /// The array writer's decision, scored the way the format producer scores it. The incoming
+ /// is the score this array has to beat; the uncompressed form scores
+ /// length + 3 and caps the budget. The entropy form is accepted only when its estimated score
+ /// is strictly below that capped budget, so an exact tie keeps the incumbent. On acceptance the
+ /// budget is lowered to the entropy form's real score; when the entropy form loses and the incoming
+ /// budget was already no worse than the uncompressed form, the writer declines to write anything.
+ ///
+ /// The bytes to write, the length written, and whether anything was written at all.
+ private static bool PutArray(List array, bool useHuffman, ref float budget,
+ out byte[]? entropyForm, out int writtenLength)
+ {
+ entropyForm = null;
+ int n = array.Count;
+ float rawScore = n + RawArrayHeader;
+ float cap = budget < rawScore ? budget : rawScore;
+
+ if (useHuffman && n > MinEntropyArray)
+ {
+ var source = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(array);
+ var prepared = KrakenHuffmanArrayEncoder.Prepare(source,
+ out KrakenHuffmanArrayEncoder.CostInputs cost);
+ if (prepared is not null && prepared.SingleSymbol)
+ {
+ float memsetScore = ArrayLambda * CopyDecodeTime(n) + KrakenHuffmanArrayEncoder.SingleSymbolArrayLength;
+ if (memsetScore < cap)
+ {
+ budget = memsetScore;
+ entropyForm = KrakenHuffmanArrayEncoder.Finish(source, prepared);
+ writtenLength = KrakenHuffmanArrayEncoder.SingleSymbolArrayLength;
+ return true;
+ }
+ prepared = null;
+ }
+ if (prepared is not null)
+ {
+ float time = ArrayDecodeTime(n, cost.NonZeroSymbols, cost.AlphabetRuns,
+ (float)cost.CodeLengthBits / n);
+ float overhead = ArrayLambda * time + ArrayCompressedBias;
+ // The gate uses an upper-bound estimate of the coded size, never the final size, so the
+ // symbol streams are only packed once the form has already won.
+ float estimate = ((cost.CodeLengthBits + 7) >> 3) + 13 + cost.HeaderBytes;
+ if (estimate + overhead < cap)
+ {
+ byte[]? huff = KrakenHuffmanArrayEncoder.Finish(source, prepared);
+ if (huff is not null)
+ {
+ float score = huff.Length + overhead;
+ if (score < rawScore)
+ {
+ budget = score;
+ entropyForm = huff;
+ writtenLength = huff.Length;
+ return true;
+ }
+ }
+ }
+ }
+ }
+
+ if (budget <= rawScore)
+ {
+ writtenLength = 0;
+ return false;
+ }
+ budget = rawScore;
+ writtenLength = n + RawArrayHeader;
+ return true;
+ }
+
+ /// The byte length the emitter writes for with an open budget.
+ private static int EncodedArraySize(List array, bool useHuffman)
+ {
+ float budget = float.MaxValue;
+ PutArray(array, useHuffman, ref budget, out _, out int length);
+ return length;
+ }
+
+ ///
+ /// Writes in whichever form selects: an entropy
+ /// (Huffman) array when it wins the space-speed score against the uncompressed form, otherwise the
+ /// uncompressed form. The literal, command, offset and length streams are all read back through the
+ /// same array reader, so either form is accepted in any of them.
+ ///
+ private static float WriteArray(List outBuf, List array, bool useHuffman)
+ {
+ float budget = float.MaxValue;
+ PutArray(array, useHuffman, ref budget, out byte[]? huff, out _);
+ if (huff is not null)
+ outBuf.AddRange(huff);
+ else
+ WriteRawArray(outBuf, array);
+ return budget;
+ }
+
+ ///
+ /// Value-model lazy parse over the output range. Each candidate match is scored as
+ /// len*4 - (isRep ? 0 : bitlen(dist)+2) , preferring repeat offsets because they transmit no offset and preferring the shortest new distance on value ties.
+ /// A two-step lazy look-ahead defers the match start when the next one or two positions pass the configured value-delta gates, then emits the selected command without starting matches past or extending them past .
+ ///
+ private static List Parse(ReadOnlySpan data, int[] head, int[] prev,
+ int startPos, int litStart0, int matchLimit, int matchStartLimit)
+ {
+ // The chain is only read by the value-model selector; the match-table selector ignores it.
+ bool chainUsed = !UseExactGreedy;
+ var commands = new List();
+
+ int litStart = litStart0;
+ int pos = startPos;
+ // A match may not start past chunkEnd-16 (the match_zone_end). Everything after the last
+ // legal match start is emitted as the trailing literal run, which the decoder copies verbatim.
+ int maxLast = matchStartLimit;
+
+ // The decoder's three recent offsets (distances), most-recent first, move-to-front. The
+ // decoder initialises recent[3..5] = -8, i.e. distance 8.
+ int r0 = MinDistance, r1 = MinDistance, r2 = MinDistance;
+
+ // Hash-chain watermark: positions [startPos, inserted) are present in the chain. We insert
+ // every position exactly once, in order, as the parse passes it (so a match at p can only
+ // reference earlier positions — the decoder is causal).
+ int inserted = startPos;
+
+ while (pos <= maxLast)
+ {
+ if (chainUsed) inserted = InsertUpTo(data, head, prev, inserted, pos);
+ Cand cur = UseExactGreedy
+ ? FindMatchExact(data, pos, head, prev, matchLimit, r0, r1, r2, pos - litStart)
+ : FindMatch(data, pos, head, prev, matchLimit, r0, r1, r2);
+ if (!cur.Valid)
+ {
+ pos++;
+ continue;
+ }
+
+ // 2-step lazy look-ahead: defer the match start while a strictly better match appears.
+ while (pos + 1 <= maxLast)
+ {
+ if (chainUsed) inserted = InsertUpTo(data, head, prev, inserted, pos + 1);
+ Cand m1 = UseExactGreedy
+ ? FindMatchExact(data, pos + 1, head, prev, matchLimit, r0, r1, r2, (pos + 1) - litStart)
+ : FindMatch(data, pos + 1, head, prev, matchLimit, r0, r1, r2);
+ if (m1.Value - cur.Value - 4 >= 1) // pos+1 strictly better after the +4 bias
+ {
+ pos++;
+ cur = m1;
+ continue;
+ }
+ if (pos + 2 > maxLast)
+ break;
+ if (chainUsed) inserted = InsertUpTo(data, head, prev, inserted, pos + 2);
+ Cand m2 = UseExactGreedy
+ ? FindMatchExact(data, pos + 2, head, prev, matchLimit, r0, r1, r2, (pos + 2) - litStart)
+ : FindMatch(data, pos + 2, head, prev, matchLimit, r0, r1, r2);
+ if (m2.Value - cur.Value - 4 >= 4) // pos+2 better by the larger threshold
+ {
+ pos += 2;
+ cur = m2;
+ continue;
+ }
+ break;
+ }
+
+ // At a chunk's very first parse position every recent offset is still the initial distance,
+ // so a repeat-offset match there decodes the same through slot 0 or slot 1. The greedy
+ // selects slot 1, which changes only the command byte, and that feeds the histogram the
+ // optimal pass is seeded from. The forward DP carries no such rule.
+ int idx = cur.Idx;
+ if (idx == 0 && pos == startPos && r0 == MinDistance && r1 == r0)
+ idx = 1;
+ commands.Add(new Command(litStart, pos - litStart, cur.Dist, cur.Len, idx));
+ UpdateRecent(ref r0, ref r1, ref r2, idx, cur.Dist);
+ int end = pos + cur.Len;
+ if (chainUsed) inserted = InsertUpTo(data, head, prev, inserted, Math.Min(end, maxLast + 1));
+ pos = end;
+ litStart = end;
+ }
+
+ return commands;
+ }
+
+ /// Bit length of computed as 32 - leadingZeroCount(v) .
+ private static int BitLen(uint v) =>
+ v == 0 ? 0 : 32 - System.Numerics.BitOperations.LeadingZeroCount(v);
+
+ ///
+ /// A candidate match. 0/1/2 means repeat-offset reuse, 3 means new offset, and -1 means none.
+ /// is len*4 - (isRep ? 0 : bitlen(dist)+2) ; a none candidate scores a large negative value so it never wins a comparison.
+ ///
+ private readonly struct Cand
+ {
+ public readonly int Len;
+ public readonly int Dist;
+ public readonly int Idx;
+ public Cand(int len, int dist, int idx) { Len = len; Dist = dist; Idx = idx; }
+ public bool Valid => Idx >= 0;
+ public int Value => Idx < 0 ? -0x40000000 : Len * 4 - (Idx < 3 ? 0 : BitLen((uint)Dist) + 2);
+ }
+
+ ///
+ /// Per-position value-model match finder for the greedy parser.
+ /// It returns the highest-value match at given the three recent offsets, preferring repeat-offset reuse on ties and otherwise the shortest-distance new offset on a value tie.
+ ///
+ private static Cand FindMatch(ReadOnlySpan data, int pos, int[] head, int[] prev,
+ int matchLimit, int r0, int r1, int r2)
+ {
+ // Repeat-offset candidate: longest reuse among the three recent distances (value = len*4).
+ int repLen0 = RepMatchLength(data, pos, r0, matchLimit);
+ int repLen1 = RepMatchLength(data, pos, r1, matchLimit);
+ int repLen2 = RepMatchLength(data, pos, r2, matchLimit);
+ int bestRepLen = repLen0, bestRepIdx = 0;
+ if (repLen1 > bestRepLen) { bestRepLen = repLen1; bestRepIdx = 1; }
+ if (repLen2 > bestRepLen) { bestRepLen = repLen2; bestRepIdx = 2; }
+
+ // New-offset candidate via the shared hash chain, scored by value (shortest distance wins ties
+ // because the chain is walked most-recent-first and we keep the first strictly-greater value).
+ int effMml = GreedyMmlOverride > 0 ? GreedyMmlOverride : MinMatch;
+ int bestNewLen = 0, bestNewDist = 0, bestNewValue = -0x40000000;
+ uint h = Hash(data, pos);
+ int cand = head[h];
+ int walk = 0;
+ while (cand >= 0 && walk < MaxChainWalk)
+ {
+ int dist = pos - cand;
+ if (dist >= MinDistance)
+ {
+ int len = MatchLength(data, cand, pos, matchLimit);
+ if (len >= effMml)
+ {
+ int val = len * 4 - (BitLen((uint)dist) + 2);
+ if (val > bestNewValue)
+ {
+ bestNewValue = val;
+ bestNewLen = len;
+ bestNewDist = dist;
+ }
+ }
+ }
+ cand = prev[cand];
+ walk++;
+ }
+
+ bool repViable = bestRepLen >= MinRepMatch;
+ bool newViable = bestNewLen >= effMml;
+ int repValue = repViable ? bestRepLen * 4 : -0x40000000;
+ int newValue = newViable ? bestNewValue : -0x40000000;
+ if (!repViable && !newViable)
+ return new Cand(0, 0, -1);
+ // Repeat reuse wins ties (its offset is free and seeds future cheap reps).
+ if (repValue >= newValue)
+ return new Cand(bestRepLen, bestRepIdx == 0 ? r0 : bestRepIdx == 1 ? r1 : r2, bestRepIdx);
+ return new Cand(bestNewLen, bestNewDist, 3);
+ }
+
+ // ===========================================================================================
+ // GREEDY SELECTOR — implementation of the per-position match heuristic and its decision
+ // helpers. Replaces the value-model FindMatch when UseExactGreedy is set so Tally(the greedy
+ // parse) == Tally(the greedy selector). The 2-step lazy main loop (Parse) is unchanged; only
+ // the per-position selector it calls switches.
+ // ===========================================================================================
+
+ // algorithm short-match offset thresholds [ml]: a new match of length ml < 6 is
+ // allowed only when off < threshold[ml]. ml is always >= 3 (the asserted minimum).
+ private static readonly int[] NormalMatchOffsetThreshold = { 0, 0, 0, 0x4000, 0x20000, 0x100000 };
+
+ ///
+ /// Exact greedy per-position selector used when is enabled.
+ /// It searches repeat offsets first, then scans the four-pair Pareto new-match frontier through and , and finally chooses between repeat and new matches with .
+ ///
+ private static Cand FindMatchExact(ReadOnlySpan data, int pos, int[] head, int[] prev,
+ int matchLimit, int r0, int r1, int r2, int lrl)
+ {
+ // Rep search over the three recent distances (loi 0,1,2). Strict '<' ⇒ the lowest loi wins ties.
+ int rl0 = RepExtendMml2(data, pos, r0, matchLimit);
+ int rl1 = RepExtendMml2(data, pos, r1, matchLimit);
+ int rl2 = RepExtendMml2(data, pos, r2, matchLimit);
+ int bestRepLen = -1, bestRepIdx = -1;
+ if (bestRepLen < rl0) { bestRepLen = rl0; bestRepIdx = 0; }
+ if (bestRepLen < rl1) { bestRepLen = rl1; bestRepIdx = 1; }
+ if (bestRepLen < rl2) { bestRepLen = rl2; bestRepIdx = 2; }
+
+ // A rep of length >= 4 short-circuits: the selector returns it immediately with no new-match search.
+ if (bestRepLen >= 4)
+ return new Cand(bestRepLen, bestRepIdx == 0 ? r0 : bestRepIdx == 1 ? r1 : r2, bestRepIdx);
+
+ int mml = GreedyMmlOverride > 0 ? GreedyMmlOverride : MinMatch;
+ if (lrl > 0x37)
+ {
+ if (bestRepLen < 3) bestRepLen = 0; // a short (len-2) rep is dropped after a long literal run
+ mml += 1;
+ }
+
+ // New-match candidates = the 4-pair Pareto frontier (length-descending) fed by the
+ // suffix-trie match finder. No synthetic off-8 candidate is included; the selector reads
+ // only matchTable[0..3].
+ Span cl = stackalloc int[4];
+ Span co = stackalloc int[4];
+ int np = FindParetoPairs(data, pos, matchLimit, cl, co);
+ int bestNewLen = 0, bestNewOff = 0;
+ for (int i = 0; i < np; i++)
+ {
+ int ml = cl[i], off = co[i];
+ if (ml < mml) break; // length-descending ⇒ every later pair is shorter still
+ int useLen = ml, useOff = off;
+ if (UseTinyOffsetRemap && off < MinDistance)
+ {
+ // A sub-8 distance is rounded up to a codeable one, the match length is
+ // recomputed at the rounded distance, and the candidate is kept only if it still reaches mml.
+ int rounded = RoundUpTiny(off);
+ if (rounded == 0 || rounded > pos) continue; // <= absPos (valid backref) else skip
+ int rlen = MatchLength(data, pos - rounded, pos, matchLimit);
+ if (rlen < mml) continue; // <= recomputed len else skip
+ useLen = rlen;
+ useOff = rounded;
+ }
+ if (IsAllowedNormalMatch(useLen, useOff) && IsNormalMatchBetter(useLen, useOff, bestNewLen, bestNewOff))
+ {
+ bestNewLen = useLen;
+ bestNewOff = useOff;
+ }
+ }
+
+ // Final pick: keep the rep unless the new match is clearly longer. When the rep is
+ // kept its length is >= 2 (TakeNewOverRep returns false only for repLen >= 2), so bestRepIdx is valid.
+ if (!TakeNewOverRep(bestRepLen, bestNewLen, bestNewOff))
+ return new Cand(bestRepLen, bestRepIdx == 0 ? r0 : bestRepIdx == 1 ? r1 : r2, bestRepIdx);
+ return bestNewLen > 0 ? new Cand(bestNewLen, bestNewOff, 3) : new Cand(0, 0, -1);
+ }
+
+ ///
+ /// Returns the new-match candidates at from the block match table, in the
+ /// table's own order (length descending, offset descending). Lengths that overrun the chunk's
+ /// match-end limit are capped; candidates that collapse onto the same capped tail end are dropped so
+ /// only the longest-uncapped distance survives, and sub-minimum distances are left for the caller to
+ /// round up.
+ ///
+ private static int FindParetoPairs(ReadOnlySpan data, int pos, int matchLimit,
+ Span outLen, Span outDist)
+ {
+ int[]? table = MatchTable;
+ if (table is null)
+ return 0;
+ int recordMml = GreedyMmlOverride > 0 ? GreedyMmlOverride : MinMatch;
+ int baseIndex = pos * KrakenSuffixTrieMatcher.IntsPerPosition;
+ int tailEmit = -1;
+ int outN = 0;
+ for (int i = 0; i < KrakenSuffixTrieMatcher.PairsPerPosition && outN < outLen.Length; i++)
+ {
+ int clen = table[baseIndex + i * 2];
+ if (clen == 0) break;
+ int cdst = table[baseIndex + i * 2 + 1];
+ if (cdst >= MinDistance && clen > matchLimit - pos)
+ {
+ clen = matchLimit - pos;
+ if (clen < recordMml) continue;
+ if (clen == tailEmit) continue;
+ tailEmit = clen;
+ }
+ outLen[outN] = clen;
+ outDist[outN] = cdst;
+ outN++;
+ }
+ return outN;
+ }
+
+ ///
+ /// Rep-match length with the mml2 quantization:0 for fewer than 2 matching leading
+ /// bytes, exactly 2 or 3 for a 2- or 3-byte lead, or the full forward length for a 4+-byte lead. The raw
+ /// count already yields 2/3/full for those cases; only a raw count of 1 must be clamped to 0 (a rep is
+ /// never length 1).
+ ///
+ private static int RepExtendMml2(ReadOnlySpan data, int pos, int dist, int limit)
+ {
+ int rl = RepMatchLength(data, pos, dist, limit);
+ return rl < 2 ? 0 : rl;
+ }
+
+ ///
+ /// New-match comparator used by the exact greedy selector.
+ /// Longer matches win, equal lengths prefer the closer offset, and a one-byte length gain must stay within the configured offset growth bound.
+ ///
+ private static bool IsNormalMatchBetter(int newml, int newoff, int bestml, int bestoff)
+ {
+ if (newml < bestml) return false;
+ if (newml == bestml) return newoff < bestoff;
+ if (newml >= bestml + 2) return true;
+ return (newoff >> 7) <= bestoff; // newml == bestml + 1
+ }
+
+ ///
+ /// Returns whether a new-offset match should replace the best repeat-offset match.
+ /// Repeat matches are favored unless the new match is sufficiently longer for its offset range; repeat lengths below two always lose.
+ ///
+ private static bool TakeNewOverRep(int repLen, int newLen, int newOff)
+ {
+ if (repLen < 2) return true;
+ if (newLen < repLen + 2) return false;
+ if (newLen < repLen + 3 && newOff >= 0x400) return false;
+ if (newLen < repLen + 4 && newOff >= 0x10000) return false;
+ return true;
+ }
+
+ ///
+ /// Returns whether a new-offset match is allowed by the short-match offset ceiling.
+ /// Lengths below six use ; lengths six and above are always allowed in the supported window size.
+ ///
+ private static bool IsAllowedNormalMatch(int ml, int off)
+ {
+ if (ml < 6) return off < NormalMatchOffsetThreshold[ml];
+ return true;
+ }
+
+ /// Inserts every position in [inserted, target) into the hash chain, in order.
+ private static int InsertUpTo(ReadOnlySpan data, int[] head, int[] prev, int inserted, int target)
+ {
+ while (inserted < target)
+ {
+ Insert(data, inserted, head, prev);
+ inserted++;
+ }
+ return inserted;
+ }
+
+ ///
+ /// Applies the decoder's recent-offset move-to-front update for the chosen offset index. Index
+ /// 0/1/2 promotes that recent distance to the front; index 3 pushes a brand-new distance to the
+ /// front and evicts the oldest. This exactly mirrors the recent[] shuffle in
+ /// 's LZ-run loop.
+ ///
+ private static void UpdateRecent(ref int r0, ref int r1, ref int r2, int idx, int dist)
+ {
+ switch (idx)
+ {
+ case 0:
+ break; // [r0, r1, r2] unchanged
+ case 1:
+ (r0, r1) = (r1, r0); // [r1, r0, r2]
+ break;
+ case 2:
+ { int t = r2; r2 = r1; r1 = r0; r0 = t; } // [r2, r0, r1]
+ break;
+ default:
+ r2 = r1; r1 = r0; r0 = dist; // new offset:[dist, r0, r1]
+ break;
+ }
+ }
+
+ ///
+ /// Length of a repeat-offset match at bytes back from ,
+ /// capped so the match ends at or before . Returns 0 when the source would
+ /// fall before the start of the buffer.
+ ///
+ private static int RepMatchLength(ReadOnlySpan data, int pos, int dist, int limit)
+ {
+ int src = pos - dist;
+ if (src < 0)
+ return 0;
+ int len = 0;
+ int max = limit - pos;
+ while (len < max && data[src + len] == data[pos + len])
+ len++;
+ return len;
+ }
+
+ private static void Insert(ReadOnlySpan data, int pos, int[] head, int[] prev)
+ {
+ uint h = Hash(data, pos);
+ prev[pos] = head[h];
+ head[h] = pos;
+ }
+
+ private static uint Hash(ReadOnlySpan data, int pos)
+ {
+ // The mml=3 greedy pre-pass needs length-3 matches, so positions that share only 3 bytes must
+ // collide; a 3-byte hash makes them. The 4-byte hash is the production default.
+ if (GreedyHashBytesOverride == 3)
+ {
+ uint x3 = (uint)(data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16));
+ return (x3 * 506832829u) >> (32 - HashBits);
+ }
+ uint x = (uint)(data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16) | (data[pos + 3] << 24));
+ return (x * 2654435761u) >> (32 - HashBits);
+ }
+
+ /// Match length quantized to a four-byte minimum: shorter runs report nothing.
+ private static int MatchLengthMinimum4(ReadOnlySpan data, int a, int b, int limit)
+ {
+ if (a < 0) return 0;
+ int len = MatchLength(data, a, b, limit);
+ return len >= 4 ? len : 0;
+ }
+
+ ///
+ /// Match length quantized to a three-byte minimum: four or more bytes report the full run, exactly
+ /// three report three, anything shorter reports nothing.
+ ///
+ private static int MatchLengthMinimum3(ReadOnlySpan data, int a, int b, int limit)
+ {
+ if (a < 0) return 0;
+ int len = MatchLength(data, a, b, limit);
+ if (len >= 4) return len;
+ return len >= 3 ? 3 : 0;
+ }
+
+ private static int MatchLength(ReadOnlySpan data, int a, int b, int limit)
+ {
+ int len = 0;
+ int max = limit - b;
+ while (len < max && data[a + len] == data[b + len])
+ len++;
+ return len;
+ }
+
+ // ===========================================================================================
+ // Optimal3 (Kraken level-7) forward cost DP. Opt-in: the production default remains the
+ // value-model greedy Parse; when UseOptimalParse is set, EncodeChunk routes here.
+ // KrakenOptimalCost supplies the integer cost model consumed by the DP.
+ // ===========================================================================================
+
+ ///
+ /// When true, uses instead of the greedy path.
+ /// Default false keeps the production encode path on the greedy parser unless a caller explicitly enables the optimal parse.
+ ///
+ internal static bool UseOptimalParse;
+
+ ///
+ /// When true, eligible single-chunk blocks may use the multi-candidate optimal parse instead of always using the greedy parser.
+ /// Default true lets standalone blocks choose the smallest emitted candidate while multi-chunk blocks keep the shared-chain greedy path.
+ ///
+ internal static bool ProductionOptimalSingleChunk = true;
+
+ ///
+ /// Maximum single-chunk block size, in bytes, allowed to use the monolithic Optimal3 DP.
+ /// Default 0x1000 limits that quadratic parse to small standalone blocks; larger eligible blocks use the greedy or windowed path.
+ ///
+ internal static int ProductionOptimalMaxBlock = 0x1000;
+
+ ///
+ /// When true, single-chunk blocks larger than and not larger than may use the windowed Optimal3 DP.
+ /// Default true allows large standalone blocks to try the near-linear windowed parse and still select the smaller emitted result against greedy.
+ ///
+ internal static bool ProductionWindowedOptimal = true;
+
+ ///
+ /// Maximum single-chunk block size, in bytes, allowed to use the windowed Optimal3 DP.
+ /// Default 0x40000 covers a full PFSC block, so every single-chunk block above is eligible.
+ ///
+ internal static int ProductionOptimalWindowedMaxBlock = 0x40000;
+
+ ///
+ /// With disabled, true makes collect candidates from the bounded two-row cache instead of only walking the hash chain.
+ /// Default true configures the CTMF path for diagnostic runs; the default suffix-trie path bypasses it.
+ ///
+ internal static bool UseCtmfFinder = true;
+
+ ///
+ /// Overrides the number of ways per CTMF row when nonzero; zero uses the built-in 16-way row depth.
+ /// Default 0 keeps the standard CTMF row depth.
+ ///
+ internal static int CtmfWaysOverride = 0;
+
+ ///
+ /// When true on the CTMF path, unions CTMF candidates with the hash-chain candidates at the same position.
+ /// Default false keeps the CTMF candidate set bounded unless contributes strictly longer matches.
+ ///
+ internal static bool UseChainUnion = false;
+
+ ///
+ /// When true on the CTMF path, the hash chain supplements CTMF candidates only with matches longer than the best CTMF match at that position.
+ /// Default false leaves CTMF results unsupplemented unless full union is enabled by .
+ ///
+ internal static bool UseLrmSupplement = false;
+
+ ///
+ /// When true, uses a full-history Pareto candidate walk that keeps up to four longest length-improving matches per position.
+ /// Default true makes use that full-history finder instead of the bounded CTMF path.
+ ///
+ internal static bool UseSuffixTrieFinder = true;
+
+ ///
+ /// When true, the CTMF query skips row A and uses only row B.
+ /// Default false queries both CTMF rows.
+ ///
+ internal static bool CtmfRowAOff = false;
+
+ ///
+ /// Overrides the CTMF table-bit count when greater than zero; zero computes the bit count from the input size.
+ /// Default 0 uses automatic CTMF sizing.
+ ///
+ internal static int CtmfBitsOverride = 0;
+
+ ///
+ /// Thread-local greedy minimum-match override; zero uses .
+ /// Default 0 keeps the normal greedy minimum of four bytes unless a pre-pass sets another value.
+ ///
+ [ThreadStatic] internal static int GreedyMmlOverride;
+
+ ///
+ /// Thread-local override for the greedy hash width; zero uses the normal four-byte hash in .
+ /// Default 0 keeps the standard hash, while selected short-match passes set three-byte hashing so can verify length-three candidates.
+ ///
+ [ThreadStatic] internal static int GreedyHashBytesOverride;
+
+ ///
+ /// When true with , match relaxation also evaluates an immediate rep0 continuation through .
+ /// Default true lets the DP propagate that bundled continuation; disabling it restricts arrivals to the primary match only.
+ ///
+ internal static bool UseFaef0 = true;
+
+ ///
+ /// When true, the greedy path uses instead of the value-model selector.
+ /// Default false keeps production greedy parsing on the existing value-model selector unless a diagnostic or optimal pre-pass enables it.
+ ///
+ [ThreadStatic] internal static bool UseExactGreedy;
+
+ ///
+ /// When true, the greedy seed histogram is passed through before code-cost tables are built for a single DP pass.
+ /// Default false leaves the seed histogram unchanged except in code paths that apply decay directly.
+ ///
+ [ThreadStatic] internal static bool UseSeedDecay;
+
+ ///
+ /// When true, builds and queries the DP candidate chain with a three-byte minimum-match configuration.
+ /// Default false keeps DP candidate discovery on the normal four-byte minimum-match path.
+ ///
+ [ThreadStatic] internal static bool UseDpMml3;
+
+ ///
+ /// Minimum match length used by the optional three-byte DP candidate path.
+ /// The constant value is 3 .
+ ///
+ private const int DpMml3 = 3;
+
+ ///
+ /// When true, fills sublength arrivals only when accepts the match length window.
+ /// Default false preserves unconditional sublength filling in the production path.
+ ///
+ [ThreadStatic] internal static bool UseSublenGate;
+
+ ///
+ /// When true, the LRL-source loop stops after a relaxed match at the position reaches the optimal-skip length.
+ /// Default true prevents additional source arrivals at that position; disabling it relaxes every LRL source.
+ ///
+ internal static bool UseLrlExitGate = true;
+
+ ///
+ /// A parse position whose longest relaxed match reaches the optimal-skip length advances directly to
+ /// that match frontier, skipping the intermediate positions the long match swallows.
+ ///
+ private const bool UseLongMatchSkip = true;
+
+ ///
+ /// When true, a windowed re-anchor accepts an equal-cost committed arrival by using <= instead of strict < .
+ /// Default false keeps strict improvement as the re-anchor condition.
+ ///
+ internal static bool UseReanchorTie;
+
+ ///
+ /// When true, repeat-offset relaxations that do not extend past the current DP frontier are suppressed.
+ /// Default false allows those repeat relaxations to be considered.
+ ///
+ internal static bool UseRepFrontierGate;
+
+ ///
+ /// High bound used by the sublength-fill gate at level 7.
+ /// The constant value is 128 .
+ ///
+ private const int SublenFillThreshold = 128;
+
+ ///
+ /// The sublen-fill predicate: fill is allowed iff (uint)(len − lo) < range
+ /// (cfg {lo, range} from the helper-style {A, B−A} ). Unsigned compare folds
+ /// the lower bound (len < lo underflows to a huge value ⇒ false) into a single branch.
+ ///
+ private static bool SublenFillAllowed(int lo, int range, int len) => (uint)(len - lo) < (uint)range;
+
+ ///
+ /// When true, equal-cost writes in and replace the existing arrival.
+ /// Default false keeps the first equal-cost arrival and only replaces it on a strictly lower cost.
+ ///
+ [ThreadStatic] internal static bool UseTieBreakLast;
+
+ ///
+ /// When true, runs the adaptive windowed optimal parse instead of one monolithic full-chunk DP pass.
+ /// Default false keeps the classic single-pass DP unless production block selection enables the windowed path.
+ ///
+ [ThreadStatic] internal static bool UseWindowedParse;
+
+
+ ///
+ /// When true, long new-match relaxations that do not extend past are suppressed.
+ /// Default false leaves frontier suppression disabled until a windowed parse path enables it.
+ ///
+ [ThreadStatic] internal static bool UseFrontierGate;
+
+ ///
+ /// Furthest position reached by any relaxation in the current windowed DP segment.
+ /// Default thread-local value 0 is updated only while is active.
+ ///
+ [ThreadStatic] internal static int DpMaxReached;
+
+ ///
+ /// Commit stride, in bytes, used by the windowed parse.
+ /// The constant value is 0x100 .
+ ///
+ private const int WindowCommitStride = 0x100;
+
+ ///
+ /// Maximum lookahead, in bytes, for a windowed parse segment before an overflow commit is forced.
+ /// The constant value is 0x1000 .
+ ///
+ private const int WindowLookahead = 0x1000;
+
+ ///
+ /// When true, sub- new-match candidates are rounded up with and rechecked before normal candidate gates run.
+ /// Default false leaves tiny-distance candidates unavailable to the production path.
+ ///
+ [ThreadStatic] internal static bool UseTinyOffsetRemap;
+
+ ///
+ /// Lookup table used by to map distances 1..7 to offset44-codeable distances that preserve the period.
+ /// Index 0 is unused.
+ ///
+ private static readonly int[] RoundUpTinyTable = { 0, 8, 8, 9, 8, 10, 12, 14 };
+
+ /// Rounds a tiny distance (1..7) up to its offset44-codeable equivalent via .
+ private static int RoundUpTiny(int dist) => RoundUpTinyTable[dist];
+
+ /// A forward-DP arrival: the cheapest way to reach a buffer position (the 0x28-byte record).
+ private struct Arrival
+ {
+ public int Cost; // 0x7fffffff = unreached
+ public int R0, R1, R2; // the three recent offsets at this arrival
+ public int Ml, Lrl, Src; // command that produced it: Lrl literals at Src, then a match of Ml
+ public int Idx, Dist; // OffsIndex (0/1/2 rep, 3 new) and the chosen distance
+ public int ContLrl, ContMl; // field [6]: a free rep0 continuation bundled into this arrival
+ // (ContLrl literals then a rep0 match of ContMl at distance R0); 0 = none
+ }
+
+ ///
+ /// Optimal3 level-7 parse selection. Runs greedy pre-passes at mml 4, 3, and 8, then one DP pass
+ /// seeded from the best greedy histogram after seed decay. The final parse is the candidate with the
+ /// smallest real entropy-coded emit size rather than the integer DP cost proxy.
+ ///
+ private static List ParseOptimal(ReadOnlySpan data, int[] head, int[] prev,
+ int startPos, int litStart0, int matchLimit, int matchStartLimit,
+ int chunkStart, int chunkEnd, bool withSeed, bool useHuffmanArrays)
+ {
+ // Real entropy-coded emit size of a candidate parse (the shipping back-end, litMode=1 raw). A parse
+ // that falls back to store-raw (null) scores int.MaxValue so it is never selected over a valid one.
+ int RealEmit(ReadOnlySpan d, List cand)
+ {
+ if (cand.Count == 0) return int.MaxValue;
+ byte[]? b = EmitChunkFromCommands(d, cand, chunkStart, chunkEnd, withSeed, useHuffmanArrays, litMode: -1, out _);
+ return b?.Length ?? int.MaxValue;
+ }
+
+ // One greedy pre-pass at the given minimum-match length / hash width, on a PRIVATE causal chain
+ // (Parse advances whatever chain it is given, so each pre-pass needs its own). The mml=3 pass uses a
+ // 3-byte hash so positions sharing only three bytes collide and length-3 matches become visible —
+ // exactly the greedy pre-pass configuration.
+ List Greedy(ReadOnlySpan d, int mml, int hashBytes)
+ {
+ int sMml = GreedyMmlOverride, sHb = GreedyHashBytesOverride; bool sEx = UseExactGreedy, sTiny = UseTinyOffsetRemap;
+ GreedyMmlOverride = mml; GreedyHashBytesOverride = hashBytes; UseExactGreedy = true; UseTinyOffsetRemap = true;
+ try
+ {
+ // The selector reads the block's match table, which already covers the whole block, so
+ // this pre-pass carries no chain of its own.
+ return Parse(d, Array.Empty(), Array.Empty(), startPos, litStart0, matchLimit, matchStartLimit);
+ }
+ finally { GreedyMmlOverride = sMml; GreedyHashBytesOverride = sHb; UseExactGreedy = sEx; UseTinyOffsetRemap = sTiny; }
+ }
+
+ var g4 = Greedy(data, 4, 0);
+ if (g4.Count == 0)
+ return g4; // no legal parse (store-raw handled upstream)
+
+ var best = g4;
+ int bestEmit = RealEmit(data, g4);
+ foreach (var g in new[] { Greedy(data, 3, 3), Greedy(data, 8, 0) })
+ {
+ int e = RealEmit(data, g);
+ if (e < bestEmit) { bestEmit = e; best = g; }
+ }
+ var bestGreedy = best;
+
+ // ONE DP pass seeded from the best greedy's histogram, decayed per 256-entry region
+ // (table[i] = (table[i] >> 4) + 1). The DP's cost model is this
+ // decayed histogram; its output competes with the best greedy purely on real emit size.
+ int[] dpHead = UseSuffixTrieFinder ? Array.Empty() : new int[HashSize];
+ int[] dpPrev = UseSuffixTrieFinder ? Array.Empty() : new int[data.Length];
+ int seedLitMode = LitModeForParse(data, bestGreedy, chunkEnd, useHuffmanArrays);
+ // Seed passinfo tallies the best greedy with inc=1. The windowed re-tally accumulates
+ // with a separate increment (see ForwardDp).
+ int[] passinfo = Tally(bestGreedy, data, startPos, 1);
+ int[]? carried = CarriedPassinfo;
+ if (carried is null) DecaySeedPassinfo(passinfo);
+ else BlendSeedPassinfo(passinfo, carried, CarriedLitMode == seedLitMode);
+ var cc = KrakenOptimalCost.BuildFromPassinfo(passinfo, seedLitMode);
+ // When the adaptive windowed parse is enabled, thread the (decayed) seed passinfo + its lit mode into
+ // the DP: it re-tallies each committed 256-stride segment onto this cumulative passinfo and rebuilds
+ // the cost tables per commit. Null (default) ⇒ the classic single monolithic pass runs unchanged.
+ int[]? adapt = UseWindowedParse ? passinfo : null;
+ var dp = ForwardDp(data, dpHead, dpPrev, startPos, matchLimit, matchStartLimit, cc, adapt, seedLitMode);
+ int dpEmit = RealEmit(data, dp);
+ // Only the optimal-wins exit carries its end-of-chunk histogram set to the next chunk.
+ PendingCarryPassinfo = null;
+ if (dpEmit < bestEmit) { best = dp; PendingCarryPassinfo = passinfo; }
+
+ return best;
+ }
+
+ ///
+ /// Total cost (bits×32) of a command list under , accumulated exactly as the
+ /// forward DP accumulates an arrival (literal run + LRL escape + rep/new match) plus the trailing
+ /// literal run to . Used to pick the cheapest parse across passes.
+ ///
+ private static long CostOfParse(List cmds, ReadOnlySpan data, int start, int chunkEnd,
+ KrakenOptimalCost.CodeCosts cc)
+ {
+ long cost = 0;
+ int r0 = MinDistance, r1 = MinDistance, r2 = MinDistance;
+ int endPos = start;
+ foreach (var c in cmds)
+ {
+ int lo = r0 < MinDistance ? MinDistance : r0;
+ cost += KrakenOptimalCost.CostLiterals(data, c.LitStart, c.LitLen, lo, cc);
+ int litField = c.LitLen < 3 ? c.LitLen : 3;
+ if (c.LitLen > 2) cost += KrakenOptimalCost.CostLen(cc, c.LitLen - 3);
+ if (c.OffsIndex < 3)
+ cost += KrakenOptimalCost.CostLoMatch(cc, litField, c.MatchLen, c.OffsIndex);
+ else
+ cost += KrakenOptimalCost.CostOffset(c.Distance, cc) +
+ KrakenOptimalCost.CostNormalMatch(cc, litField, c.MatchLen);
+ UpdateRecent(ref r0, ref r1, ref r2, c.OffsIndex, c.Distance);
+ endPos = c.LitStart + c.LitLen + c.MatchLen;
+ }
+ int tlo = r0 < MinDistance ? MinDistance : r0;
+ cost += KrakenOptimalCost.CostLiterals(data, endPos, chunkEnd - endPos, tlo, cc);
+ return cost;
+ }
+
+ ///
+ /// The histogram set the previous chunk of the same block ended with, and the literal model it
+ /// emitted. A chunk after the first seeds its cost model by blending these into its own greedy
+ /// tally instead of decaying that tally alone. Reset for every block; never carried between blocks,
+ /// and never set when the greedy parse beat the optimal one.
+ ///
+ [ThreadStatic] internal static int[]? CarriedPassinfo;
+
+ /// The literal model the previous chunk of the block emitted, or -1 when nothing is carried.
+ [ThreadStatic] internal static int CarriedLitMode;
+
+ /// Set by the optimal parse when its output won, so the emit path knows to carry its state.
+ [ThreadStatic] internal static int[]? PendingCarryPassinfo;
+
+ ///
+ /// Blends the carried histogram set into this chunk's greedy tally. Literal histograms blend only
+ /// when the literal model is unchanged, otherwise they decay on their own. When the offset-table
+ /// count changed the chunk's own offset tallies are replaced by the carried ones and decayed.
+ ///
+ private static void BlendSeedPassinfo(int[] pi, int[] carried, bool litModeUnchanged)
+ {
+ static void Decay(int[] p, int start)
+ {
+ for (int i = 0; i < 0x100; i++) p[start + i] = (p[start + i] >> 4) + 1;
+ }
+ static void Blend(int[] dst, int[] src, int start)
+ {
+ for (int i = 0; i < 0x100; i++) dst[start + i] = ((dst[start + i] + src[start + i]) >> 5) + 1;
+ }
+
+ if (!litModeUnchanged) { Decay(pi, 0x000); Decay(pi, 0x100); }
+ else { Blend(pi, carried, 0x000); Blend(pi, carried, 0x100); }
+ Blend(pi, carried, 0x200);
+ Blend(pi, carried, 0x300);
+ if (pi[0x400] == carried[0x400])
+ {
+ Blend(pi, carried, 0x401);
+ if (pi[0x400] > 1) Blend(pi, carried, 0x501);
+ }
+ else
+ {
+ Array.Copy(carried, 0x401, pi, 0x401, 0x100);
+ Array.Copy(carried, 0x501, pi, 0x501, 0x100);
+ pi[0x400] = carried[0x400];
+ Decay(pi, 0x401);
+ if (pi[0x400] > 1) Decay(pi, 0x501);
+ }
+ }
+
+ ///
+ /// Applies per-region decay to seed passinfo before a DP pass builds its code-cost tables.
+ /// Each 256-entry histogram region is transformed with (count >> 4) + SeedDecayBase , while the offset-alt modulo scalar at index 0x400 is preserved.
+ ///
+ internal static void DecaySeedPassinfo(int[] pi)
+ {
+ int b = SeedDecayBase;
+ void Decay(int[] p, int start, int bias, bool blend)
+ {
+ for (int i = 0; i < 0x100; i++) p[start + i] = (p[start + i] >> 4) + b;
+ }
+ Decay(pi, 0x000, 0x00, blend: true); // literals raw
+ Decay(pi, 0x100, 0x00, blend: true); // literals sub
+ Decay(pi, 0x401, 0x24, blend: true); // offset bucket
+ if (pi[0x400] > 1)
+ Decay(pi, 0x501, 0x00, blend: true); // offset alt (only when modulo > 1)
+ Decay(pi, 0x200, 0x12, blend: true); // packet
+ Decay(pi, 0x300, 0x0c, blend: true); // length
+ }
+
+ ///
+ /// Thread-local override for the uniform base added by after seed histogram decay; zero uses the property default.
+ /// Default 0 leaves using the built-in seed-decay base.
+ ///
+ [ThreadStatic] internal static int SeedDecayBaseOverride;
+ internal static int SeedDecayBase => SeedDecayBaseOverride != 0 ? SeedDecayBaseOverride : 1;
+
+ private static int[] Tally(List commands, ReadOnlySpan data, int start, int inc = 2, int chunkEnd = -1)
+ {
+ int r0 = MinDistance, r1 = MinDistance, r2 = MinDistance;
+ return TallyRecents(commands, data, start, inc, chunkEnd, ref r0, ref r1, ref r2);
+ }
+
+ ///
+ /// Tally variant that threads recent-offset state in and out across committed windows.
+ /// Threading keeps the sub-literal histogram based on the carried recent offset instead of resetting to the initial offsets for each window.
+ ///
+ private static int[] TallyRecents(List commands, ReadOnlySpan data, int start, int inc,
+ int chunkEnd, ref int r0, ref int r1, ref int r2)
+ {
+ var pi = new int[0x601];
+ int lastEnd = start;
+ foreach (var cmd in commands)
+ {
+ int p = cmd.LitStart;
+ for (int i = 0; i < cmd.LitLen; i++)
+ {
+ int b = data[p + i];
+ pi[b] += inc;
+ int refIdx = p + i - r0;
+ int refb = refIdx >= 0 ? data[refIdx] : 0;
+ pi[0x100 + ((b - refb) & 0xff)] += inc;
+ }
+ if (cmd.LitLen >= 3)
+ pi[0x300 + Math.Min(cmd.LitLen - 3, 0xff)] += inc;
+
+ int litField = cmd.LitLen < 3 ? cmd.LitLen : 3;
+ int ml = cmd.MatchLen;
+ bool isRep = cmd.OffsIndex < 3;
+ int matchByte = ml < 0x11 ? ml - 2 : 0xf;
+ int off3 = isRep ? cmd.OffsIndex * 0x40 : 0xc0;
+ pi[0x200 + litField + matchByte * 4 + off3] += inc;
+ if (ml >= 0x11)
+ pi[0x300 + Math.Min(ml - 0x11, 0xff)] += inc;
+ if (!isRep)
+ pi[0x401 + KrakenOptimalCost.OffsetBucketByte(cmd.Distance, out _)] += inc;
+
+ UpdateRecent(ref r0, ref r1, ref r2, cmd.OffsIndex, cmd.Distance);
+ lastEnd = cmd.LitStart + cmd.LitLen + cmd.MatchLen;
+ }
+ // Greedy SEED only (chunkEnd >= 0): the trailing literals after the last match (to chunk end)
+ // are tallied into the literal histograms only (no packet/length/offset event). Seed passinfo
+ // is built this way; the DP re-tally (inc=2, chunkEnd=-1) does not add a tail.
+ if (chunkEnd > lastEnd)
+ {
+ for (int p = lastEnd; p < chunkEnd; p++)
+ {
+ int b = data[p];
+ pi[b] += inc;
+ int refIdx = p - r0;
+ int refb = refIdx >= 0 ? data[refIdx] : 0;
+ pi[0x100 + ((b - refb) & 0xff)] += inc;
+ }
+ }
+ return pi;
+ }
+
+ ///
+ /// One forward-DP pass (the single-TLL algorithm). Fills the arrival table left to right
+ /// with the anchor/accumulated-literal bookkeeping, the lrl loop (0..3, the 4th = the full run from
+ /// the anchor), rep relaxation (longest-first gating) and new-match relaxation (new-minimum-base gate,
+ /// length-desc candidates, break once a candidate is no longer than the longest rep), then backtraces
+ /// the cheapest reachable arrival plus its trailing literals into a command list.
+ ///
+ private static List ForwardDp(ReadOnlySpan data, int[] dpHead, int[] dpPrev,
+ int startPos, int matchLimit, int matchStartLimit, KrakenOptimalCost.CodeCosts cc,
+ int[]? adaptPassinfo = null, int adaptLitMode = 1)
+ {
+ // Level-7 suffix-trie match finder mml=3 (firstbytes=2): build the DP chain AND its
+ // candidate query with a 3-byte hash so positions sharing only 3 bytes collide and length-3 matches
+ // become visible. Restored in finally so the thread-static never leaks past the DP.
+ int saveDpHb = GreedyHashBytesOverride;
+ if (UseDpMml3) GreedyHashBytesOverride = DpMml3;
+ try
+ {
+ int chunkEnd = matchLimit + LiteralTail;
+ int maxArr = matchLimit; // a match can end at most here
+ var arr = new Arrival[maxArr + 1];
+ for (int i = 0; i <= maxArr; i++) arr[i].Cost = int.MaxValue;
+ arr[startPos] = new Arrival
+ {
+ Cost = 0,
+ R0 = MinDistance,
+ R1 = MinDistance,
+ R2 = MinDistance,
+ Src = startPos,
+ Idx = -1,
+ };
+
+ // Seed-index the private chain with the chunk's seed bytes [startPos-LiteralTail, startPos) so the
+ // DP can find matches that reference the 8-byte COPY_64 seed (e.g. a match that backward-extends
+ // into the seed). EncodeChunk indexes these into the shared greedy chain; the DP's private chain
+ // must mirror that or it underfinds the very first backward-extended match.
+ bool chainUsed = !UseSuffixTrieFinder;
+ if (chainUsed) dpHead.AsSpan().Fill(-1);
+ // Index the full pre-chunk history [0, startPos): for a first/single chunk this is the seed
+ // region [0,8); for a second chunk it is the entire first chunk, so the DP finds cross-chunk
+ // back-matches (the private chain must mirror the shared greedy chain, or chunk1 underfinds).
+ int inserted = 0;
+
+ // Cache-table finder bits N = clamp(ceil_log2(len), 18, 24). Even tiny buffers use
+ // N=18 (a 2^18 zero-init table), so the 16-way windows plus check bits have the same
+ // layout regardless of input size.
+ int ctmfBits;
+ {
+ int len = data.Length < 1 ? 1 : data.Length;
+ int cl = 0;
+ while (cl < 24 && (1 << cl) < len) cl++; // ceil_log2(len), capped (no overflow)
+ ctmfBits = cl < 18 ? 18 : cl;
+ }
+ if (CtmfBitsOverride > 0) ctmfBits = CtmfBitsOverride;
+ Ctmf? ctmf = UseCtmfFinder && !UseSuffixTrieFinder ? new Ctmf(ctmfBits, data.Length) : null;
+ int ctmfIns = inserted;
+
+ Span cml = stackalloc int[8];
+ Span cdist = stackalloc int[8];
+ Span coff = stackalloc int[8];
+
+ // Adaptive windowed parse (num_tlls==1 / lvl7): advance in 256-byte
+ // commit strides with a 4096-byte lookahead window, committing at a clean re-anchor (frontier fully
+ // behind pos) or a window overflow, and re-tallying the committed segment + rebuilding the cost model
+ // after every commit. When adaptPassinfo is null the classic single monolithic full-chunk pass runs
+ // unchanged.
+ bool windowed = UseWindowedParse && adaptPassinfo != null;
+ int end = matchStartLimit;
+ int segStart = startPos;
+ List? outCmds = windowed ? new List() : null;
+ // Cumulative recent-offset state for the windowed re-tally. Threading it across committed
+ // windows prevents a per-window reset from drifting the sub-literal histogram.
+ int tr0 = MinDistance, tr1 = MinDistance, tr2 = MinDistance;
+
+ int anchor = startPos;
+ long accLit = 0;
+ int prevReach = startPos;
+
+ while (true)
+ {
+ int windowEnd = end;
+ if (windowed)
+ {
+ anchor = segStart;
+ accLit = 0;
+ // Frontier starts one commit-stride ahead (the maxreached=min(segStart+256,end),
+ // snapped to end when within the no-match zone); relaxations grow it via DpMaxReached.
+ int mr = segStart + WindowCommitStride;
+ if (mr > end) mr = end;
+ if (end - NoMatchZone <= mr) mr = end;
+ DpMaxReached = mr;
+ windowEnd = segStart + WindowLookahead;
+ if (windowEnd > end) windowEnd = end;
+ if (end - NoMatchZone <= windowEnd) windowEnd = end;
+ // Reset stale forward arrivals left by the PREVIOUS window's lookahead: they were relaxed
+ // under the previous window's codecost and must be recomputed under the new (rebuilt) one.
+ // arr[segStart] (the committed boundary cost) is preserved. Only the range the previous
+ // window actually reached can hold a stale arrival, so the clear stops there.
+ if (segStart > startPos)
+ {
+ int clearEnd = prevReach < maxArr ? prevReach : maxArr;
+ for (int i = segStart + 1; i <= clearEnd; i++) arr[i].Cost = int.MaxValue;
+ }
+ }
+ int commitEnd = -1; // -1 ⇒ the pos loop ran to matchStartLimit ⇒ final segment
+
+ for (int pos = segStart; pos <= matchStartLimit; pos++)
+ {
+ if (windowed)
+ {
+ if (DpMaxReached > windowEnd) { commitEnd = DpMaxReached; break; } // window overflow
+ if (pos >= end) { commitEnd = -1; break; } // final commit at pos==end
+ }
+ if (pos > segStart)
+ {
+ int alo = arr[anchor].R0 < MinDistance ? MinDistance : arr[anchor].R0;
+ accLit += KrakenOptimalCost.CostAddLiteral(data, pos - 1, alo, cc);
+ if (arr[pos].Cost != int.MaxValue)
+ {
+ int run0 = pos - anchor;
+ long viaAnchor = (long)arr[anchor].Cost +
+ (run0 > 2 ? KrakenOptimalCost.CostLen(cc, run0 - 3) : 0) + accLit;
+ if (UseReanchorTie ? arr[pos].Cost <= viaAnchor : arr[pos].Cost < viaAnchor)
+ {
+ anchor = pos;
+ accLit = 0;
+ }
+ }
+ }
+
+ if (chainUsed) inserted = InsertUpTo(data, dpHead, dpPrev, inserted, pos);
+ if (ctmf != null)
+ while (ctmfIns < inserted) { ctmf.Insert(data, ctmfIns); ctmfIns++; }
+ int nc = FindCandidates(data, pos, dpHead, dpPrev, matchLimit, cc, cml, cdist, coff, ctmf);
+
+ long minBase = long.MaxValue;
+ // longestReach: the longest rep/new match relaxed at this position across all LRL
+ // iterations. Once it reaches 1< 2) baseCost += KrakenOptimalCost.CostLen(cc, run - 3);
+
+ int sr0 = arr[src].R0, sr1 = arr[src].R1, sr2 = arr[src].R2;
+
+ // Rep matches, longest-first: a shorter rep than one already relaxed is never tried.
+ int longestlo = 0;
+ for (int loi = 0; loi < 3; loi++)
+ {
+ int rdist = loi == 0 ? sr0 : loi == 1 ? sr1 : sr2;
+ if (rdist < MinDistance) continue;
+ int repLen = RepMatchLength(data, pos, rdist, matchLimit);
+ if (repLen < MinRepMatch || repLen <= longestlo) continue;
+ longestlo = repLen;
+
+ int n1, n2;
+ if (loi == 0) { n1 = sr1; n2 = sr2; }
+ else if (loi == 1) { n1 = sr0; n2 = sr2; }
+ else { n1 = sr0; n2 = sr1; }
+
+ // Frontier domination gate: a rep whose reach does not exceed the frontier already
+ // established by an earlier position (pos+repLen <= DpMaxReached) is an arrival that
+ // lands inside a committed long match. Such positions are not re-processed after a
+ // frontier-extending match, which prevents off-path rep sublengths from drifting the
+ // segment cadence.
+ bool repDominated = UseRepFrontierGate && windowed && (long)pos + repLen <= DpMaxReached;
+ if (!repDominated)
+ {
+ // Full-length relax always; fill rep sublengths only when repLen < 128. A match
+ // >= 1< longestReach) longestReach = longestlo;
+
+ // Once any relaxed match (rep or, from a prior lrl, new) reaches >= 1<= SublenFillThreshold) break;
+
+ // New-offset matches only when this lrl iteration set a new minimum base. On an EXACT base
+ // tie the DP still relaxes the (later, longer-LRL) run so its earlier-source path can win the
+ // arrival tie (see UseTieBreakLast): the gate becomes <= and RelaxNew keeps the last equal.
+ // Skip the new-match relax entirely once a >=128 match already reaches here.
+ if (longestReach < SublenFillThreshold &&
+ (UseTieBreakLast ? baseCost <= minBase : baseCost < minBase))
+ {
+ minBase = baseCost;
+ for (int i = 0; i < nc; i++)
+ {
+ int cm = cml[i];
+ if (cm <= longestlo) break; // candidates are length-desc
+ int d = cdist[i];
+ if (d == sr0 || d == sr1 || d == sr2) continue; // a recent distance is a rep, not new
+ // Cross-position frontier gate: a long match (cm >= 1<= SublenFillThreshold && pos + cm <= DpMaxReached)
+ continue;
+ if (cm > longestReach) longestReach = cm; // ref line ~958: local_2a4 = max(local_2a4, ml)
+ long baseOff = baseCost + coff[i];
+ // Full-length relax always; fill new-match sublengths only when cm < 128.
+ int newFloor = UseDpMml3 ? 3 : 4;
+ int newLo = newFloor + 1;
+ RelaxNew(arr, pos, cm, baseOff, litField, d, sr0, sr1, run, src, cc);
+ if (SublenFillAllowed(newLo, SublenFillThreshold - newLo, cm))
+ for (int m = newFloor; m < cm; m++)
+ RelaxNew(arr, pos, m, baseOff, litField, d, sr0, sr1, run, src, cc);
+
+ // Value the immediate rep0 continuation off this full-length new match.
+ if (UseFaef0)
+ {
+ long costAtE = baseOff + KrakenOptimalCost.CostNormalMatch(cc, litField, cm);
+ Faef0(arr, data, pos + cm, costAtE, cm, run, 3, d, src,
+ d, sr0, sr1, d, matchLimit, cc);
+ }
+ }
+ }
+ }
+ // Long-match skip: after processing a position, if the longest match relaxed there reaches
+ // the optimal-skip length (1<= SublenFillThreshold)
+ {
+ int skipTarget = pos + longestReach; // = longestml_arrival_pos, <= DpMaxReached
+ if (skipTarget > matchStartLimit) skipTarget = matchStartLimit;
+ if (skipTarget > pos && arr[skipTarget].Cost != int.MaxValue)
+ {
+ anchor = skipTarget;
+ accLit = 0;
+ {
+ // the long match reaches the frontier exactly → commit the segment at the match end
+ commitEnd = skipTarget;
+ break;
+ }
+ }
+ }
+ }
+ if (!windowed)
+ {
+ int bestEndNw = EstarSelect(arr, data, startPos, matchLimit, chunkEnd, cc);
+ if (bestEndNw < 0) return new List();
+ var revNw = BackTraceSeg(arr, startPos, bestEndNw, maxArr);
+ return revNw ?? new List();
+ }
+ bool finalSeg = commitEnd < 0 || commitEnd >= end;
+ if (finalSeg)
+ {
+ int bestEndF = EstarSelect(arr, data, segStart, matchLimit, chunkEnd, cc);
+ if (bestEndF > segStart)
+ {
+ var segF = BackTraceSeg(arr, segStart, bestEndF, maxArr);
+ if (segF != null) outCmds!.AddRange(segF);
+ }
+ return outCmds!;
+ }
+
+ prevReach = DpMaxReached + SublenFillThreshold;
+ if (prevReach > maxArr) prevReach = maxArr;
+ var seg = BackTraceSeg(arr, segStart, commitEnd, maxArr);
+ if (seg == null) return outCmds!; // trace failed → return the committed prefix (ParseOptimal reselects)
+ outCmds!.AddRange(seg);
+ // Re-tally the committed segment into the cumulative passinfo and rebuild the cost tables (the
+ // lvl7 rebuild interval = 1 ⇒ rebuild on every commit).
+ int[] tal = TallyRecents(seg, data, segStart, 2, -1, ref tr0, ref tr1, ref tr2);
+ int nAdapt = adaptPassinfo!.Length < tal.Length ? adaptPassinfo.Length : tal.Length;
+ for (int i = 0; i < nAdapt; i++) adaptPassinfo[i] += tal[i];
+ cc = KrakenOptimalCost.BuildFromPassinfo(adaptPassinfo, adaptLitMode);
+ segStart = commitEnd;
+ if (segStart >= end)
+ {
+ int bestEndT = EstarSelect(arr, data, segStart, matchLimit, chunkEnd, cc);
+ if (bestEndT > segStart)
+ {
+ var tail = BackTraceSeg(arr, segStart, bestEndT, maxArr);
+ if (tail != null) outCmds!.AddRange(tail);
+ }
+ return outCmds!;
+ }
+ } // end while(true) — process the next segment
+ }
+ finally { GreedyHashBytesOverride = saveDpHb; }
+ }
+
+ ///
+ /// E* selection: the cheapest reachable arrival in (from, matchLimit] plus its trailing
+ /// literal run to . Returns the winning end index, or -1 if none is reachable.
+ ///
+ private static int EstarSelect(Arrival[] arr, ReadOnlySpan data, int from, int matchLimit,
+ int chunkEnd, KrakenOptimalCost.CodeCosts cc)
+ {
+ int bestEnd = -1;
+ long bestTotal = long.MaxValue;
+ for (int e = from + 1; e <= matchLimit; e++)
+ {
+ if (arr[e].Cost == int.MaxValue) continue;
+ int lo = arr[e].R0 < MinDistance ? MinDistance : arr[e].R0;
+ long total = (long)arr[e].Cost + KrakenOptimalCost.CostLiterals(data, e, chunkEnd - e, lo, cc);
+ if (total < bestTotal)
+ {
+ bestTotal = total;
+ bestEnd = e;
+ }
+ }
+ return bestEnd;
+ }
+
+ ///
+ /// Backward-traces the arrival chain from to , emitting one
+ /// per arrival (a two-step bundle emits its free rep0 continuation first). Returns the
+ /// commands in forward order, or null if the chain does not cleanly reach .
+ ///
+ private static List? BackTraceSeg(Arrival[] arr, int from, int to, int maxArr)
+ {
+ var rev = new List();
+ int p2 = to;
+ int guard = 0;
+ while (p2 != from && guard++ <= maxArr)
+ {
+ Arrival a = arr[p2];
+ if (a.Ml <= 0 || a.Src < from || a.Src >= p2)
+ break;
+ if (a.ContMl > 0)
+ {
+ // Two-step arrival: a primary match then a free rep0 continuation. Emit later-first
+ // (continuation, then primary); the rep0 reuses the primary's distance (idx 0).
+ int sCont = a.Src + a.Lrl + a.Ml; // end of primary = start of continuation literals
+ rev.Add(new Command(sCont, a.ContLrl, a.R0, a.ContMl, 0));
+ }
+ rev.Add(new Command(a.Src, a.Lrl, a.Dist, a.Ml, a.Idx));
+ p2 = a.Src;
+ }
+ if (p2 != from)
+ return null; // trace did not reach the origin → discard
+ rev.Reverse();
+ return rev;
+ }
+
+ private static void RelaxRep(Arrival[] arr, int pos, int ml, int loi, long baseCost, int litField,
+ int r0, int r1, int r2, int run, int src, KrakenOptimalCost.CodeCosts cc)
+ {
+ long total = baseCost + KrakenOptimalCost.CostLoMatch(cc, litField, ml, loi);
+ int dst = pos + ml;
+ if (UseWindowedParse && dst > DpMaxReached) DpMaxReached = dst;
+ if (UseTieBreakLast ? total <= arr[dst].Cost : total < arr[dst].Cost)
+ {
+ arr[dst].Cost = (int)total;
+ arr[dst].R0 = r0; arr[dst].R1 = r1; arr[dst].R2 = r2;
+ arr[dst].Ml = ml; arr[dst].Lrl = run; arr[dst].Src = src;
+ arr[dst].Idx = loi; arr[dst].Dist = r0;
+ arr[dst].ContLrl = 0; arr[dst].ContMl = 0;
+ }
+ }
+
+ private static void RelaxNew(Arrival[] arr, int pos, int ml, long baseWithOff, int litField,
+ int dist, int sr0, int sr1, int run, int src, KrakenOptimalCost.CodeCosts cc)
+ {
+ long total = baseWithOff + KrakenOptimalCost.CostNormalMatch(cc, litField, ml);
+ int dst = pos + ml;
+ if (UseWindowedParse && dst > DpMaxReached) DpMaxReached = dst;
+ if (UseTieBreakLast ? total <= arr[dst].Cost : total < arr[dst].Cost)
+ {
+ arr[dst].Cost = (int)total;
+ arr[dst].R0 = dist; arr[dst].R1 = sr0; arr[dst].R2 = sr1;
+ arr[dst].Ml = ml; arr[dst].Lrl = run; arr[dst].Src = src;
+ arr[dst].Idx = 3; arr[dst].Dist = dist;
+ arr[dst].ContLrl = 0; arr[dst].ContMl = 0;
+ }
+ }
+
+ ///
+ /// Rep0-continuation pre-relaxation for the forward DP.
+ /// Given a primary match and its post-match front offset, it evaluates an immediate rep0 continuation and records the bundled continuation in and when that arrival is cheaper.
+ ///
+ private static void Faef0(Arrival[] arr, ReadOnlySpan data, int s, long costAtE,
+ int primaryMl, int primaryLrl, int primaryIdx, int primaryDist, int src,
+ int pr0, int pr1, int pr2, int contDist, int matchLimit,
+ KrakenOptimalCost.CodeCosts cc)
+ {
+ int d = contDist;
+ if (s + 3 > matchLimit) return; // forward-extend window must lie inside the match zone
+ if (d < MinDistance || s + 1 - d < 0) return;
+
+ // backExt: 2 if both s+1 and s+2 match their -d back-refs; 1 if only s+2; 0 otherwise.
+ int backExt = data[s + 2] == data[s + 2 - d] ? (data[s + 1] == data[s + 1 - d] ? 2 : 1) : 0;
+ int fwd = RepMatchLength(data, s + 3, d, matchLimit);
+ int lrl2 = 3 - backExt;
+ int ml2 = fwd + backExt;
+ if (ml2 < MinRepMatch) return; // guard: only when 1 < ml2
+
+ long cost = costAtE + KrakenOptimalCost.CostLiterals(data, s, lrl2, d, cc);
+ int litField2 = lrl2;
+ if (lrl2 > 2) { litField2 = 3; cost += KrakenOptimalCost.CostLen(cc, 0); }
+ cost += KrakenOptimalCost.CostLoMatch(cc, litField2, ml2, 0); // loi 0 = rep0
+
+ int e2 = s + lrl2 + ml2; // == s + 3 + fwd, always <= matchLimit
+ if (UseWindowedParse && e2 > DpMaxReached) DpMaxReached = e2;
+ if (UseTieBreakLast ? cost <= arr[e2].Cost : cost < arr[e2].Cost)
+ {
+ arr[e2].Cost = (int)cost;
+ arr[e2].R0 = pr0; arr[e2].R1 = pr1; arr[e2].R2 = pr2;
+ arr[e2].Ml = primaryMl; arr[e2].Lrl = primaryLrl; arr[e2].Src = src;
+ arr[e2].Idx = primaryIdx; arr[e2].Dist = primaryDist;
+ arr[e2].ContLrl = lrl2; arr[e2].ContMl = ml2;
+ }
+ }
+
+ ///
+ /// Collects up to four longest distinct-distance new-offset candidates from the causal hash chain plus
+ /// a forced distance-8 (init offset) candidate, sorted by length descending, caching each one's
+ /// . Approximates the find_all_matches 4-pair table.
+ ///
+ private static int FindCandidates(ReadOnlySpan data, int pos, int[] dpHead, int[] dpPrev,
+ int matchLimit, KrakenOptimalCost.CodeCosts cc, Span cml, Span cdist, Span coff,
+ Ctmf? ctmf)
+ {
+ int n = 0;
+ if (UseSuffixTrieFinder)
+ {
+ int[]? table = MatchTable;
+ if (table is null) return 0;
+ int recMin = UseDpMml3 ? DpMml3 : MinMatch;
+ int baseIndex = pos * KrakenSuffixTrieMatcher.IntsPerPosition;
+ int tailEmit = -1;
+ for (int i = 0; i < KrakenSuffixTrieMatcher.PairsPerPosition; i++)
+ {
+ int clen = table[baseIndex + i * 2];
+ if (clen == 0) break;
+ int cdst = table[baseIndex + i * 2 + 1];
+ if (cdst < MinDistance)
+ {
+ // A distance below the minimum is rounded up to the nearest codeable one that keeps
+ // the period, and the length is recomputed there; a candidate that no longer reaches
+ // the minimum match length is dropped.
+ int rounded = RoundUpTiny(cdst);
+ if (rounded == 0 || rounded > pos) continue;
+ int rlen = MatchLengthMinimum4(data, pos - rounded, pos, matchLimit);
+ if (rlen < recMin) continue;
+ clen = rlen;
+ cdst = rounded;
+ }
+ else if (clen > matchLimit - pos)
+ {
+ // The table ranks by the untruncated length; a match may only extend to the chunk's
+ // match-end limit, so cap it here and keep the farther distance.
+ clen = matchLimit - pos;
+ }
+ if (clen < recMin) continue;
+ if (clen == tailEmit) continue;
+ if (pos + clen >= matchLimit) tailEmit = clen;
+ n = InsertCandidate(cml, cdist, n, clen, cdst, 4);
+ }
+ int forced = MatchLengthMinimum3(data, pos - MinDistance, pos, matchLimit);
+ if (pos >= MinDistance && forced >= recMin)
+ n = InsertCandidate(cml, cdist, n, forced, MinDistance, cml.Length);
+ for (int i = 0; i < n; i++)
+ coff[i] = KrakenOptimalCost.CostOffset(cdist[i], cc);
+ return n;
+ }
+ int chunkEnd = matchLimit + LiteralTail;
+ int bestCtmfLen = 0;
+ if (ctmf != null)
+ {
+ // The cache-table finder emits strictly-length-improving (len,dist) pairs scanning row A then
+ // row B (shared monotonic prevml), already distinct/ascending in length. Feed them longest-first
+ // into the length-descending candidate set.
+ Span qlen = stackalloc int[40];
+ Span qdist = stackalloc int[40];
+ int qn = ctmf.Query(data, pos, matchLimit, qlen, qdist);
+ for (int i = qn - 1; i >= 0; i--)
+ {
+ n = InsertCandidate(cml, cdist, n, qlen[i], qdist[i], 4);
+ if (qlen[i] > bestCtmfLen) bestCtmfLen = qlen[i];
+ }
+ }
+ if (ctmf == null || UseChainUnion || UseLrmSupplement)
+ {
+ // Strictly-longer-only when supplementing the CTMF (the LRM-equivalent path); full union otherwise.
+ bool supplement = ctmf != null && !UseChainUnion;
+ uint h = Hash(data, pos);
+ int cand = dpHead[h];
+ int walk = 0;
+ while (cand >= 0 && walk < MaxChainWalk)
+ {
+ int dist = pos - cand;
+ if (dist >= MinDistance)
+ {
+ int len = MatchLength(data, cand, pos, matchLimit);
+ if (len >= MinMatch && (!supplement || len > bestCtmfLen))
+ n = InsertCandidate(cml, cdist, n, len, dist, 4);
+ }
+ cand = dpPrev[cand];
+ walk++;
+ }
+ }
+ int off8 = RepMatchLength(data, pos, MinDistance, matchLimit);
+ if (off8 >= MinMatch)
+ n = InsertCandidate(cml, cdist, n, off8, MinDistance, cml.Length);
+ for (int i = 0; i < n; i++)
+ coff[i] = KrakenOptimalCost.CostOffset(cdist[i], cc);
+ return n;
+ }
+
+ /// Inserts (len,dist) into the length-descending candidate arrays, deduping by distance, capped at .
+ private static int InsertCandidate(Span cml, Span cdist, int n, int len, int dist, int cap)
+ {
+ for (int k = 0; k < n; k++)
+ if (cdist[k] == dist)
+ return n; // keep the first (longest) occurrence of this distance
+ int posi = 0;
+ while (posi < n && cml[posi] >= len) posi++;
+ if (posi >= cap)
+ return n;
+ int last = n < cap ? n : cap - 1;
+ for (int k = last; k > posi; k--) { cml[k] = cml[k - 1]; cdist[k] = cdist[k - 1]; }
+ cml[posi] = len; cdist[posi] = dist;
+ return n < cap ? n + 1 : n;
+ }
+
+ ///
+ /// Two-row, 16-way check-bit cache-table match finder used by the CTMF candidate path.
+ /// Each row scans newest-first, emits strictly length-improving matches, then the row outputs are sorted, deduplicated by length, and capped to four candidates.
+ ///
+ private sealed class Ctmf
+ {
+ private const uint Mul4 = 0xB7A56463u; // row-A 4-byte context multiplier (shared mul >> 32)
+ private const ulong Mul8 = 0xCF1BBCDCB7A56463UL; // row-B 8-byte context multiplier
+ private const int Ways = 16; // 16 entries per row window
+ private const uint PosMask = 0x03FFFFFFu; // entry low 26 bits = position
+ private const uint CheckMask = 0xFC000000u; // entry top 6 bits = check
+
+ private readonly int _bits;
+ private readonly int _ways; // window length (16 = faithful; CtmfWaysOverride probes retention depth)
+ private readonly int _maskA; // (1<= 4 index bits above the 16-way window
+ _ways = CtmfWaysOverride > 0 ? CtmfWaysOverride : Ways;
+ _maskA = (1 << _bits) - Ways; // index stays 16-aligned; only the window length varies
+ _shiftB = 64 - _bits;
+ _table = new uint[(1 << _bits) + 64]; // memset 0; +64 pads windows longer than 16
+ }
+
+ private static uint Read32(ReadOnlySpan d, int p)
+ {
+ uint v = 0; int n = d.Length;
+ for (int i = 0; i < 4; i++) { int q = p + i; if ((uint)q < (uint)n) v |= (uint)d[q] << (8 * i); }
+ return v;
+ }
+
+ private static ulong Read64(ReadOnlySpan d, int p)
+ {
+ ulong v = 0; int n = d.Length;
+ for (int i = 0; i < 8; i++) { int q = p + i; if ((uint)q < (uint)n) v |= (ulong)d[q] << (8 * i); }
+ return v;
+ }
+
+ private static uint Rotl32(uint v, int r) { r &= 31; return r == 0 ? v : (v << r) | (v >> (32 - r)); }
+
+ // row-A / hash1 = rotl32(low32(read32(ptr) * 0xB7A56463), bits ); the top 6 bits are the stored check.
+ private uint Hash1(ReadOnlySpan d, int p) => Rotl32(Read32(d, p) * Mul4, _bits);
+ private int RowA(uint h1) => (int)(h1 & (uint)_maskA);
+ // row-B index = ((read64(ptr) * 0xCF1BBCDCB7A56463) >> (64-bits)) & ~0xF (16-aligned, in [0, 2^bits)).
+ private int RowB(ReadOnlySpan d, int p) => (int)((uint)((Read64(d, p) * Mul8) >> _shiftB) & ~(uint)0xF);
+
+ /// Inserts position into both hash rows (16-way FIFO shift, newest at slot 0).
+ public void Insert(ReadOnlySpan d, int p)
+ {
+ uint h1 = Hash1(d, p);
+ uint entry = ((uint)p & PosMask) | (h1 & CheckMask);
+ InsertRow(RowA(h1), entry);
+ InsertRow(RowB(d, p), entry);
+ }
+
+ private void InsertRow(int row, uint entry)
+ {
+ uint[] t = _table;
+ for (int k = Ways - 1; k > 0; k--) t[row + k] = t[row + k - 1];
+ t[row] = entry;
+ }
+
+ ///
+ /// Scans row A then row B (newest-first), emitting strictly-length-improving (len,dist) pairs into
+ /// / with a shared monotonic prevml
+ /// across both rows. Returns the pair count (ascending, distinct lengths == the post sort+dedup set).
+ ///
+ public int Query(ReadOnlySpan d, int pos, int matchLimit, Span outLen, Span outDist)
+ {
+ int n = 0;
+ uint h1 = Hash1(d, pos);
+ uint check = h1 & CheckMask;
+ if (!CtmfRowAOff)
+ {
+ int prevmlA = 0; // reset the threshold to 0 at the start of each row
+ n = ScanRow(d, pos, matchLimit, RowA(h1), check, ref prevmlA, outLen, outDist, n);
+ }
+ int prevmlB = 0; // row B starts its strictly-increasing subsequence fresh (NOT continuing row A)
+ n = ScanRow(d, pos, matchLimit, RowB(d, pos), check, ref prevmlB, outLen, outDist, n);
+ return n;
+ }
+
+ private int ScanRow(ReadOnlySpan d, int pos, int matchLimit, int row, uint check,
+ ref int prevml, Span outLen, Span outDist, int n)
+ {
+ uint[] t = _table;
+ for (int w = 0; w < Ways; w++)
+ {
+ uint entry = t[row + w];
+ if ((entry & CheckMask) != check) continue; // check-bit reject (also drops empty slots)
+ int epos = (int)(entry & PosMask);
+ int dist = (int)(((uint)(pos - 1 - epos) & PosMask) + 1);
+ if (dist < MinDistance) continue;
+ if (dist > pos) continue; // in-window: dist <= (ptr - base) = pos
+ int cand = pos - dist;
+ if (Read32(d, cand) != Read32(d, pos)) continue; // 4-byte verify
+ int len = MatchLength(d, cand, pos, matchLimit);
+ if (len > prevml) // strictly longer (assert "len > prevml")
+ {
+ if (n < outLen.Length) { outLen[n] = len; outDist[n] = dist; n++; }
+ prevml = len;
+ }
+ }
+ return n;
+ }
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/ProsperoCompressedPfsFile.cs b/src/Packwright.Pkg/PFS/Compression/ProsperoCompressedPfsFile.cs
new file mode 100644
index 0000000..209751c
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/ProsperoCompressedPfsFile.cs
@@ -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;
+
+///
+/// One block of a parsed .
+///
+public readonly struct PfsBlock
+{
+ /// The zero-based block index.
+ public int Index { get; init; }
+
+ /// The absolute byte offset of this block's compressed data within the container file.
+ public long CompressedOffset { get; init; }
+
+ /// The compressed size, in bytes, of this block within the container.
+ public int CompressedSize { get; init; }
+
+ /// The logical (uncompressed) byte offset this block expands to.
+ public long UncompressedOffset { get; init; }
+
+ /// The uncompressed size, in bytes, this block expands to.
+ public int UncompressedSize { get; init; }
+
+ ///
+ /// The block's stored SHA3-256 digest (32 bytes), taken over the uncompressed block bytes.
+ /// Verify a decoded block with .
+ ///
+ public ReadOnlyMemory Hash { get; init; }
+
+ /// The raw compressed bytes of this block (a slice of the container buffer).
+ public ReadOnlyMemory CompressedData { get; init; }
+
+ ///
+ /// true when the block is stored uncompressed ( equals
+ /// ), as the format does for incompressible data; otherwise the
+ /// block is a Kraken bitstream.
+ ///
+ public bool IsStored => CompressedSize == UncompressedSize;
+
+ ///
+ /// true when a compressed block is split into two newLZ chunks (boundary flag 0x26
+ /// rather than 0x06 ), which happens for blocks larger than 128 KiB. The first chunk's
+ /// compressed size is then .
+ ///
+ public bool IsMultiChunk { get; init; }
+
+ ///
+ /// true when a compressed block is a single bare-entropy array rather than an LZ stream
+ /// (boundary flag bit 0x02 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 false for stored and newLZ blocks.
+ ///
+ public bool IsBareEntropy { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public int FirstChunkCompressedSize { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public int Chunk0UncompressedSize { get; init; }
+
+ ///
+ /// The uncompressed size in bytes of the block's second sub-chunk, or zero when the block has one
+ /// sub-chunk.
+ ///
+ public int Chunk1UncompressedSize { get; init; }
+
+ ///
+ /// 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): 1 = raw literals,
+ /// 0 = sub/delta literals. Irrelevant for stored blocks.
+ ///
+ public int LiteralMode { get; init; }
+
+ ///
+ /// 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
+ /// : chunk0 newLZ = bit 0x02 (literal model
+ /// bit 0x01 ); chunk1 newLZ = bit 0x20 (literal model bit 0x10 ), bare-entropy
+ /// chunk1 = bit 0x40 . Zero for stored blocks.
+ ///
+ public int Flags { get; init; }
+
+ ///
+ /// The pre-compression shuffle applied to this block. Containers produced without region hints
+ /// (all known default-output containers) always use .
+ ///
+ ///
+ /// The per-block storage location of a non- pattern has not
+ /// been characterized, so this reader
+ /// reports . Do not rely on it to detect shuffled blocks.
+ ///
+ public ProsperoPfsShufflePattern ShufflePattern => ProsperoPfsShufflePattern.None;
+}
+
+///
+/// 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 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.
+///
+public sealed class ProsperoCompressedPfsFile
+{
+ /// The 4-byte container magic, 'P','F','S','C' (little-endian 0x43534650 ).
+ 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 _buffer;
+
+ private ProsperoCompressedPfsFile(ReadOnlyMemory buffer) => _buffer = buffer;
+
+ /// The container format version ( or ).
+ public ProsperoPfsCompressionFormat Version { get; private init; }
+
+ /// The compression algorithm recorded in the header (always for PS5).
+ public ProsperoCompressionAlgorithm Algorithm { get; private init; }
+
+ ///
+ /// The Kraken compression level recorded in the header (encode-parameter byte 0x11 ).
+ /// Accepted levels are 0..9 and the fast range -4..-1 ; default-output containers use level 7.
+ ///
+ public int CompressionLevel { get; private init; }
+
+ /// The Kraken sliding-window bits recorded in the header (encode-parameter byte 0x12 , normally 18).
+ public int WindowBits { get; private init; }
+
+ /// The logical block size, in bytes (256 KiB for PS5 v3).
+ public int BlockSize { get; private init; }
+
+ /// The total uncompressed size, in bytes, of the payload the container expands to.
+ public long UncompressedSize { get; private init; }
+
+ /// The total compressed size, in bytes, of the whole container file (including metadata).
+ public long TotalCompressedSize { get; private init; }
+
+ /// The header's 32-byte SHA3-256 file digest (offset 0x28 ).
+ public ReadOnlyMemory FileDigest { get; private init; }
+
+ /// The 20-byte revision hash recorded in the metadata (section id=1), or empty when absent.
+ public ReadOnlyMemory GitHash { get; private init; }
+
+ /// The absolute byte offset where compressed block data begins (the metadata size).
+ public long DataOffset { get; private init; }
+
+ /// The parsed per-block table.
+ public IReadOnlyList Blocks { get; private init; } = Array.Empty();
+
+ // 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;
+
+ ///
+ /// Recomputes the file-level SHA3-256 digest from the parsed metadata and compares it to the
+ /// value stored at header offset 0x28 . Returns true when they match.
+ ///
+ ///
+ /// This is the file-level integrity check performed when opening a container: SHA3-256(header32 || shuffleSection || boundarySection || blockHashSection) .
+ ///
+ /// SHA3-256 is unavailable on this host.
+ public bool VerifyFileDigest()
+ {
+ ReadOnlySpan 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);
+ }
+
+ ///
+ /// Decompresses the whole container back to its original payload. Stored blocks are copied
+ /// verbatim; compressed blocks are decoded with the Kraken codec
+ /// ( ), which reads both this library's own output and
+ /// finalized blocks (entropy-coded arrays + the post-seed excess framing). The result has
+ /// length .
+ ///
+ /// The reconstructed uncompressed payload.
+ ///
+ /// A block is malformed or uses an unsupported entropy/excess form.
+ ///
+ 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 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;
+ }
+
+ ///
+ /// Returns true when begins with a PS5 PFSv2/PFSv3 compression
+ /// container, distinguishing it from the zlib "PFSC" image (which stores 0 in the
+ /// version field).
+ ///
+ public static bool IsPfsCompressed(ReadOnlySpan 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;
+ }
+
+ /// Parses a PS5 PFSv2/PFSv3 compression container from .
+ /// is null.
+ /// The buffer is not a valid PS5 PFS compression container.
+ public static ProsperoCompressedPfsFile Parse(byte[] buffer)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ return Parse(buffer.AsMemory());
+ }
+
+ /// Parses a PS5 PFSv2/PFSv3 compression container from .
+ /// The buffer is not a valid PS5 PFS compression container.
+ public static ProsperoCompressedPfsFile Parse(ReadOnlyMemory buffer)
+ {
+ ReadOnlySpan 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 sections = ReadSectionDirectory(span);
+
+ long dataOffset = sections.TryGetValue(SectionBlockData, out var dataSec) ? dataSec.Offset : 0;
+ var blocks = ReadBlocks(buffer, span, sections, dataOffset);
+
+ ReadOnlyMemory gitHash = ReadOnlyMemory.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 ReadSectionDirectory(ReadOnlySpan span)
+ {
+ var sections = new Dictionary();
+
+ // 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 ReadBlocks(
+ ReadOnlyMemory buffer,
+ ReadOnlySpan span,
+ Dictionary sections,
+ long dataOffset)
+ {
+ var blocks = new List();
+ 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 hash = ReadOnlyMemory.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;
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/ProsperoPfsCompressionTypes.cs b/src/Packwright.Pkg/PFS/Compression/ProsperoPfsCompressionTypes.cs
new file mode 100644
index 0000000..a61cc83
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/ProsperoPfsCompressionTypes.cs
@@ -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;
+
+///
+/// Compression algorithm recorded in the PFS compressed-file header.
+/// Only is supported for compressed-file creation.
+///
+public enum ProsperoCompressionAlgorithm
+{
+ /// Fast Zlib. Not supported for PFS compressed-file creation.
+ QuickZ = 0,
+
+ /// High-quality Zlib. Not supported for PFS compressed-file creation.
+ Zlib = 1,
+
+ /// Kraken. The only supported algorithm for PFS compressed-file creation.
+ Kraken = 2,
+}
+
+///
+/// PFS compression file-format version recorded in the container header.
+///
+///
+/// This is the version of the compression container , not the PFS filesystem superblock
+/// version. Only and are valid for PS5.
+///
+public enum ProsperoPfsCompressionFormat
+{
+ /// PFSv0. Deprecated.
+ Version0 = 0,
+
+ /// PFSv1. Deprecated.
+ Version1 = 1,
+
+ /// PFSv2. A PS5 compression file format.
+ Version2 = 2,
+
+ ///
+ /// PFSv3. The default 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. naps_pkg_layout.dat ).
+ ///
+ Version3 = 3,
+}
+
+///
+/// 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.
+///
+public enum ProsperoPfsShufflePattern
+{
+ /// An invalid shuffle pattern.
+ Invalid = -1,
+
+ /// No shuffle is applied.
+ None = 0,
+
+ /// Tap 2x 4 bytes with an 8 byte stride.
+ Shuffle44 = 1,
+
+ /// Tap 2x 2 bytes then 4 bytes with an 8 byte stride.
+ Shuffle224 = 2,
+
+ /// Tap 2x 1 bytes then 6 bytes with an 8 byte stride.
+ Shuffle116 = 3,
+
+ /// Tap 8x 1 bytes with an 8 byte stride.
+ Shuffle11111111 = 4,
+
+ /// Tap 8 bytes, 2x 2 bytes then 4 bytes with a 16 byte stride.
+ Shuffle8224 = 5,
+
+ /// Tap 2x 1 bytes, 6 bytes, 2x 2 bytes then 4 bytes with a 16 byte stride.
+ Shuffle116224 = 6,
+
+ /// Tap 2x 1 bytes, 6 bytes, 2x 1 bytes then 6 bytes with a 16 byte stride.
+ Shuffle116116 = 7,
+
+ /// Tap 4x 4 bytes with a 16 byte stride.
+ Shuffle4444 = 8,
+
+ /// Tap 2x 8 bytes with a 16 byte stride.
+ Shuffle88 = 9,
+
+ /// Tap 8 bytes then 2x 4 bytes with a 16 byte stride.
+ Shuffle844 = 10,
+
+ /// Tap 2 bytes then 6 bytes with an 8 byte stride.
+ Shuffle26 = 11,
+
+ /// Tap 2 bytes, 6 bytes, 2 bytes then 6 bytes with a 16 byte stride.
+ Shuffle2626 = 12,
+}
+
+///
+/// Constants for PFS Kraken compression.
+///
+public static class ProsperoPfsCompressionConstants
+{
+ /// Number of sliding-window bits used for Kraken (the only valid value).
+ public const uint KrakenWindowBits = 18;
+}
diff --git a/src/Packwright.Pkg/PFS/Compression/ProsperoPfsDigest.cs b/src/Packwright.Pkg/PFS/Compression/ProsperoPfsDigest.cs
new file mode 100644
index 0000000..1481681
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/Compression/ProsperoPfsDigest.cs
@@ -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;
+
+///
+/// Computes the SHA3-256 digests the PS5 PFSv3 compression container uses for its
+/// per-block hashes (the id=4 metadata section) and its file-level digest (header
+/// offset 0x28 ).
+///
+///
+/// SHA3-256 is the hash primitive used throughout the PFS compression format. Per-block
+/// hashes are taken over the uncompressed block bytes, which is what
+/// returns. The host runtime must
+/// support SHA3-256; query before use.
+///
+public static class ProsperoPfsDigest
+{
+ /// The length, in bytes, of a SHA3-256 digest (256 bits).
+ public const int DigestLength = 32;
+
+ ///
+ /// Gets a value indicating whether the host runtime and operating system provide SHA3-256.
+ ///
+ public static bool IsSupported => true;
+
+ ///
+ /// Computes the SHA3-256 digest of a single PFS block's uncompressed bytes, matching the
+ /// per-block hash stored in the container's id=4 metadata section.
+ ///
+ /// The raw (pre-compression, post-deshuffle) block bytes.
+ /// The 32-byte SHA3-256 digest.
+ /// SHA3-256 is unavailable on this host.
+ public static byte[] ComputeBlockDigest(ReadOnlySpan uncompressedBlock)
+ {
+ EnsureSupported();
+ return Packwright.Pkg.Util.Sha3.Hash(uncompressedBlock);
+ }
+
+ ///
+ /// Computes the SHA3-256 digest of into .
+ ///
+ /// The bytes to hash.
+ /// A span of at least bytes.
+ /// The number of bytes written (always ).
+ /// SHA3-256 is unavailable on this host.
+ public static int ComputeBlockDigest(ReadOnlySpan data, Span destination)
+ {
+ EnsureSupported();
+ Packwright.Pkg.Util.Sha3.Hash(data, destination);
+ return DigestLength;
+ }
+
+ ///
+ /// Returns true when equals the SHA3-256 digest of
+ /// , using a fixed-time comparison.
+ ///
+ /// The raw (pre-compression) block bytes.
+ /// The 32-byte digest read from the container metadata.
+ public static bool VerifyBlockDigest(ReadOnlySpan uncompressedBlock, ReadOnlySpan expected)
+ {
+ if (expected.Length != DigestLength)
+ return false;
+ EnsureSupported();
+ Span actual = stackalloc byte[DigestLength];
+ Packwright.Pkg.Util.Sha3.Hash(uncompressedBlock, actual);
+ return CryptographicOperations.FixedTimeEquals(actual, expected);
+ }
+
+ private static void EnsureSupported()
+ {
+
+ }
+
+ /// The length, in bytes, of the header parameter slice (container[0x08..0x20] ) the file digest hashes.
+ public const int FileDigestHeaderParamsLength = 0x18;
+
+ ///
+ /// Computes the container's file-level SHA3-256 digest (stored at header offset 0x28 ),
+ /// using the target format's file-digest algorithm.
+ ///
+ ///
+ /// Exactly (24) bytes copied verbatim from the container
+ /// header at offsets 0x08..0x20 (block size, encode parameters, and uncompressed size).
+ ///
+ /// The shuffle-pattern metadata section bytes (directory id=2).
+ /// The block-boundary table bytes (directory id=3).
+ /// The per-block hash table bytes (directory id=4).
+ /// The 32-byte SHA3-256 file digest.
+ ///
+ /// The preimage is header32 || shuffleSection || boundarySection || blockHashSection , where
+ /// header32 is a 32-byte, 8-byte-aligned little-endian struct:
+ /// {u32 1; u32 blockSize; u32 encodeParam0C; u32 0 (pad); u64 encodeParam10; u64 uncompressedSize} .
+ /// This layout applies to independent encoder outputs (compressed and stored blocks).
+ ///
+ /// is not 24 bytes.
+ /// SHA3-256 is unavailable on this host.
+ public static byte[] ComputeFileDigest(
+ ReadOnlySpan headerParams,
+ ReadOnlySpan shuffleSection,
+ ReadOnlySpan boundarySection,
+ ReadOnlySpan 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 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;
+ }
+
+ ///
+ /// Returns true when equals the file digest computed by
+ /// , using a fixed-time comparison.
+ ///
+ public static bool VerifyFileDigest(
+ ReadOnlySpan headerParams,
+ ReadOnlySpan shuffleSection,
+ ReadOnlySpan boundarySection,
+ ReadOnlySpan blockHashSection,
+ ReadOnlySpan expected)
+ {
+ if (expected.Length != DigestLength)
+ return false;
+ byte[] actual = ComputeFileDigest(headerParams, shuffleSection, boundarySection, blockHashSection);
+ return CryptographicOperations.FixedTimeEquals(actual, expected);
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoPfsImage.cs b/src/Packwright.Pkg/PFS/ProsperoPfsImage.cs
new file mode 100644
index 0000000..5e72aa2
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoPfsImage.cs
@@ -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;
+
+/// The PFS superblock fields relevant to image encryption.
+public sealed class ProsperoPfsImageInfo
+{
+ /// Superblock version (2 = PS5).
+ public required long Version { get; init; }
+
+ /// The raw PFS mode flag word from the superblock.
+ public required ushort Mode { get; init; }
+
+ /// Filesystem block size in bytes (typically 0x10000).
+ public required uint BlockSize { get; init; }
+
+ /// The 16-byte header crypto seed (all-zero when the image carries none).
+ public required byte[] Seed { get; init; }
+
+ /// True when the superblock declares the image AES-XTS encrypted.
+ public bool Encrypted => (Mode & ProsperoPfsImage.EncryptedModeFlag) != 0;
+
+ /// True when the superblock declares the image HMAC signed.
+ public bool Signed => (Mode & ProsperoPfsImage.SignedModeFlag) != 0;
+}
+
+/// Options controlling a PFS image encrypt/decrypt operation.
+public sealed class ProsperoPfsImageOptions
+{
+ ///
+ /// The 32-byte EKPFS. When null the all-zero EKPFS is used — the standard
+ /// package key that pairs with the all-zero passcode.
+ ///
+ public byte[]? Ekpfs { get; set; }
+
+ ///
+ /// The 16-byte header seed. When null 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.
+ ///
+ public byte[]? Seed { get; set; }
+
+ ///
+ /// When true, derive the encryption key from HMAC(EKPFS, seed) first (the
+ /// new_crypt path / the newCrypt scheme). Defaults to the classic path.
+ ///
+ public bool NewCrypt { get; set; }
+}
+
+/// The outcome of an encrypt/decrypt operation.
+public sealed class ProsperoPfsImageResult
+{
+ /// The path the image was written to.
+ public required string OutputPath { get; init; }
+
+ /// Total image size in bytes.
+ public required long ImageSize { get; init; }
+
+ /// Block size used (bytes).
+ public required uint BlockSize { get; init; }
+
+ /// Number of XTS sectors transformed.
+ public required long SectorsTransformed { get; init; }
+
+ /// The 16-byte seed the keys were derived from.
+ public required byte[] Seed { get; init; }
+}
+
+///
+/// PFS image AES-XTS encryptor/decryptor for PS5. See the file header for the scheme.
+///
+public static class ProsperoPfsImage
+{
+ /// AES-XTS sector size used by the PFS image crypto.
+ public const int XtsSectorSize = 0x1000;
+
+ /// The PFS mode bit that marks an image AES-XTS encrypted.
+ public const ushort EncryptedModeFlag = 0x4;
+
+ /// The PFS mode bit that marks an image HMAC signed.
+ 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
+
+ /// The all-zero EKPFS — the standard package key.
+ public static byte[] ZeroEkpfs => new byte[32];
+
+ ///
+ /// Reads the PFS superblock of and reports the fields
+ /// relevant to encryption (version, mode, block size, seed).
+ ///
+ 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],
+ };
+ }
+
+ ///
+ /// Returns true when the PFS image at has the encrypted
+ /// mode flag set in its superblock (i.e. its filesystem sectors are AES-XTS encrypted).
+ ///
+ /// Path to the PFS image to inspect.
+ /// true if the superblock declares the image encrypted; otherwise false .
+ public static bool IsEncrypted(string imagePath) => Inspect(imagePath).Encrypted;
+
+ ///
+ /// 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.
+ ///
+ public static ProsperoPfsImageResult EncryptInPlace(
+ string imagePath, ProsperoPfsImageOptions? options = null, Action? logger = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
+ return Transform(imagePath, imagePath, encrypt: true, options, logger);
+ }
+
+ ///
+ /// Encrypts a prepared (plaintext) PFS image, writing the result to
+ /// (the input is left untouched).
+ ///
+ public static ProsperoPfsImageResult Encrypt(
+ string inputImagePath, string outputPath, ProsperoPfsImageOptions? options = null, Action? logger = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(inputImagePath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ CopyTo(inputImagePath, outputPath);
+ return Transform(outputPath, outputPath, encrypt: true, options, logger);
+ }
+
+ ///
+ /// 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 .
+ ///
+ public static ProsperoPfsImageResult DecryptInPlace(
+ string imagePath, ProsperoPfsImageOptions? options = null, Action? logger = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
+ return Transform(imagePath, imagePath, encrypt: false, options, logger);
+ }
+
+ ///
+ /// Decrypts an encrypted PFS image, writing the plaintext result to
+ /// (the input is left untouched).
+ ///
+ public static ProsperoPfsImageResult Decrypt(
+ string inputImagePath, string outputPath, ProsperoPfsImageOptions? options = null, Action? logger = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(inputImagePath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ CopyTo(inputImagePath, outputPath);
+ return Transform(outputPath, outputPath, encrypt: false, options, logger);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 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? 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 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 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 */ }
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoPfsReader.cs b/src/Packwright.Pkg/PFS/ProsperoPfsReader.cs
new file mode 100644
index 0000000..b11c5ec
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoPfsReader.cs
@@ -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;
+
+///
+/// Class allowing parallel readonly access to a PFS archive
+///
+public class ProsperoPfsReader
+{
+ ///
+ /// Represents a file or directory in a PFS image.
+ ///
+ 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;
+ }
+ ///
+ /// Represents a directory in a PFS image.
+ ///
+ public class Dir : Node
+ {
+ public List children = new List();
+ 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 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;
+ }
+ }
+ }
+ ///
+ /// Represents a file in a PFS image.
+ ///
+ 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)
+ { }
+ /// Backing memory reader over the PFS image.
+ /// Superblock pfs_flags selecting the crypto scheme (e.g. the newCrypt bit).
+ /// Image key used to derive the per-image XTS keys, or null for a plaintext image.
+ /// Explicit XTS tweak key, or null to derive it from .
+ /// Explicit XTS data key, or null to derive it from .
+ /// 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.
+ /// 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.
+ 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 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 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>();
+ 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
+ };
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoPfsStructs.cs b/src/Packwright.Pkg/PFS/ProsperoPfsStructs.cs
new file mode 100644
index 0000000..8059f96
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoPfsStructs.cs
@@ -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;
+
+///
+/// PFS mode flags.
+///
+[Flags]
+public enum ProsperoPfsMode : ushort
+{
+ None = 0,
+ Signed = 0x1,
+ Is64Bit = 0x2,
+ Encrypted = 0x4,
+ UnknownFlagAlwaysSet = 0x8,
+}
+///
+/// Represents a PFS image suberblock.
+///
+public class ProsperoPfsHeader
+{
+ /// PFS superblock version for PS5 images.
+ 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;
+ ///
+ /// This is always 1 for some reason.
+ ///
+ 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;
+ }
+}
+
+///
+/// Inode mode flags including user/group/other permissions.
+///
+[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,
+}
+
+///
+/// Inode flags for special PFS features
+///
+[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
+}
+
+///
+/// Base class for inodes. Inodes can be signed or unsigned, and 32 or 64 bit.
+///
+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);
+ }
+ ///
+ /// The index of this inode in the block of inodes.
+ ///
+ public uint Number;
+ ///
+ /// Default is 555 octal.
+ ///
+ public ProsperoInodeMode Mode = (ProsperoInodeMode)0x16D;
+ ///
+ /// Number of links to this file in the filesystem.
+ /// 1 for regular files, 1 + 1 for every subdirectory for dirs.
+ ///
+ 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 DirectBlocks { get; }
+ public abstract IList 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;
+ }
+};
+
+///
+/// 32-bit unsigned inodes
+///
+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 DirectBlocks => db;
+ public override IList 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;
+ }
+};
+///
+/// Data structure used in signed 32 bit PFS images
+///
+public struct ProsperoBlockSig
+{
+ public byte[] sig;
+ public int block;
+}
+///
+/// Data structure used in signed 64 bit PFS images, and in any signed PFS header.
+///
+public struct ProsperoBlockSig64
+{
+ public byte[] sig;
+ public long block;
+}
+///
+/// Signed 32-bit inode
+///
+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 DirectBlocks => db.Select(d => d.block).ToList();
+ public override IList 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;
+ }
+};
+
+///
+/// Signed 64-bit inode
+///
+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 DirectBlocks => db.Select(d => (int)d.block).ToList();
+ public override IList 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;
+ }
+};
+
+///
+/// Represents a PFS dirent. Directories are stored ondisk as blocks of dirents.
+///
+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;
+ }
+}
+///
+/// Describes the nature of the dirent
+///
+public enum ProsperoDirentType : int
+{
+ ///
+ /// A regular file
+ ///
+ File = 2,
+ ///
+ /// A directory
+ ///
+ Directory = 3,
+ ///
+ /// The special "." file that points to the current dir
+ ///
+ Dot = 4,
+ ///
+ /// The special ".." file that points to the parent dir
+ ///
+ DotDot = 5
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoPfscReader.cs b/src/Packwright.Pkg/PFS/ProsperoPfscReader.cs
new file mode 100644
index 0000000..f9d3833
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoPfscReader.cs
@@ -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;
+
+///
+/// This wraps a Memory mapped file view of a PFSC file so that you can access it
+/// as though it were uncompressed.
+///
+public class ProsperoPfscReader : IMemoryReader
+{
+ public const int Magic = 0x43534650;
+ private IMemoryAccessor _accessor;
+ private PFSCHdr hdr;
+ private long[] sectorMap;
+
+ ///
+ /// Creates a PFSCReader
+ ///
+ /// An IMemoryAccessor containing the PFSC file
+ /// Thrown when the accessor is not a view of a PFSC file.
+ 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);
+ }
+
+ ///
+ /// Creates a PFSCReader
+ ///
+ /// A ViewAccessor containing the PFSC file
+ /// Thrown when the accessor is not a view of a PFSC file.
+ public ProsperoPfscReader(MemoryMappedViewAccessor va) : this(new MemoryMappedViewAccessor_(va))
+ { }
+
+ public ProsperoPfscReader(IMemoryReader r) : this(new MemoryAccessor(r))
+ { }
+
+ public int SectorSize => hdr.BlockSz;
+
+ /// The logical (decompressed) length in bytes represented by this PFSC image.
+ public long DataLength => hdr.DataLength;
+
+ ///
+ /// Reads the sector at the given index into the given byte array.
+ ///
+ /// sector index (multiply by SectorSize to get the byte offset)
+ /// byte array where sector will be written
+ 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 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;
+ }
+ }
+
+ ///
+ /// Read `count` bytes at location `src` into the writeable Stream `dest`
+ ///
+ /// Byte offset into PFSC
+ /// Number of bytes to read
+ /// Output stream
+ public void Read(long src, long count, Stream dest)
+ {
+ Read(src, count, dest.Write);
+ }
+
+ ///
+ /// Read `count` bytes at location `src` into the byte array at offset `offset`
+ ///
+ /// Byte offset into PFSC
+ /// Output byte array
+ /// Offset into byte array
+ /// Number of bytes to read
+ 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;
+ }
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoPs5InnerImageReader.cs b/src/Packwright.Pkg/PFS/ProsperoPs5InnerImageReader.cs
new file mode 100644
index 0000000..a1d8245
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoPs5InnerImageReader.cs
@@ -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;
+
+/// A regular file discovered in a reconstructed inner mount.
+public sealed class ProsperoPs5InnerFileEntry
+{
+ /// User-root-relative path (e.g. sce_sys/keystone or application.ps.bundle ).
+ public required string Path { get; init; }
+
+ /// Byte offset of the file's (already decoded) data within the reconstructed mount.
+ public required ulong LogicalOffset { get; init; }
+
+ /// File size in bytes.
+ public required long Size { get; init; }
+}
+
+/// The reconstructed inner PFS mount plus the offset of its superblock.
+public sealed class ProsperoPs5InnerMountResult
+{
+ /// The uncompressed inner PFS mount bytes (walk with ).
+ public required byte[] Mount { get; init; }
+
+ ///
+ /// Byte offset of the PFS superblock within . A nwonly inner image is data-first,
+ /// so the superblock is at the metadata-region base (not offset 0).
+ ///
+ public required long SuperblockOffset { get; init; }
+}
+
+///
+/// Reconstructs the uncompressed inner PFS mount of a PS5 nwonly package from its data-first
+/// pfs_image.dat and the sibling naps_pkg_layout.dat . The result is a plaintext PFS
+/// image whose superblock is at the metadata-region base that can walk.
+///
+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
+
+ ///
+ /// 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.
+ ///
+ /// The on-disk data-first pfs_image.dat bytes.
+ /// The naps_pkg_layout.dat bytes (its CblockInfo drives the decode).
+ ///
+ /// The uncompressed mount size (Ndblock·64K). Pass 0 to derive it from the naps: the mount end is the
+ /// largest 64K-aligned fidx boundary (trailing typed sentinels are not block-aligned).
+ ///
+ public static ProsperoPs5InnerMountResult ReconstructMount(
+ ReadOnlySpan innerImage, ReadOnlySpan naps, long mountSize = 0)
+ {
+ NapsLayoutDocument doc = ProsperoNapsLayout.Parse(naps);
+ IReadOnlyList 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 };
+ }
+
+ ///
+ /// Parses the reconstructed inner mount's PS5 metadata (superblock + inode table + dirents) and returns
+ /// every regular file under uroot with its user-root-relative path, logical offset and size. The
+ /// file bytes are mount[LogicalOffset .. LogicalOffset+Size] (already decoded by
+ /// ). The PS5 inner inode layout (LogicalOffset@0x60, parent@0x6c)
+ /// differs from the layout reads, so this does not use it.
+ ///
+ public static IReadOnlyList ReadFileTree(byte[] mount, long metaBase)
+ {
+ ReadOnlySpan 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 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();
+ WalkDir(mount, blockSize, nodes, 0, "", underUroot: false, files, new HashSet());
+ 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 files, HashSet 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];
+}
diff --git a/src/Packwright.Pkg/PFS/ProsperoXtsDecryptReader.cs b/src/Packwright.Pkg/PFS/ProsperoXtsDecryptReader.cs
new file mode 100644
index 0000000..b51e9fd
--- /dev/null
+++ b/src/Packwright.Pkg/PFS/ProsperoXtsDecryptReader.cs
@@ -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;
+
+///
+/// Provides XTS decryption on an IMemoryReader
+///
+public class ProsperoXtsDecryptReader : IMemoryReader
+{
+ private byte[] dataKey;
+ private byte[] tweakKey;
+ ///
+ /// Size of each encryption sector
+ ///
+ private uint sectorSize;
+ ///
+ /// Sector at and after which the encryption is active
+ ///
+ private uint cryptStartSector;
+ private IMemoryReader reader;
+ private static byte[] zeroes = new byte[16];
+
+ ///
+ /// Creates an AES-XTS-128 stream.
+ /// Reads will decrypt data.
+ ///
+ 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;
+ }
+
+ ///
+ /// Precondition: activeSector is set
+ /// Postconditions:
+ /// - sectorOffset is reset to 0
+ /// - sectorBuf[] is filled with decrypted sector
+ /// - position is updated
+ ///
+ 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],
+ };
+ }
+
+ ///
+ /// Creates a single-block AES-128-ECB engine (no padding) used as the primitive for
+ /// the manual XTS transform. Uses the modern factory.
+ ///
+ 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.
+ }
+}
diff --git a/src/Packwright.Pkg/PKG/ProsperoNapsLayout.cs b/src/Packwright.Pkg/PKG/ProsperoNapsLayout.cs
new file mode 100644
index 0000000..bbd909b
--- /dev/null
+++ b/src/Packwright.Pkg/PKG/ProsperoNapsLayout.cs
@@ -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;
+
+///
+/// Section counts of a naps_pkg_layout.dat structure. Every count is the number of records
+/// in the matching section; the strides are fixed and validated (see ).
+///
+/// Files in the package (m_numFilesMinus1 + 1 ).
+/// Oodle codec selector (m_compressionType ).
+/// Encryption keys (m_numKeysMinus1 + 1 ).
+/// Shuffle-pattern entries (0 = no shuffling).
+/// Uncompressed blocks (m_numUBlocks ).
+/// Outer-block entries (m_numOuterBlocks ).
+/// CblockInfo entries (m_numCblockInfoMinus2 + 2 ).
+public readonly record struct NapsLayoutCounts(
+ int NumFiles,
+ byte CompressionType,
+ int NumKeys,
+ int NumShufflePatterns,
+ int NumUBlocks,
+ int NumOuterBlocks,
+ int NumCblockInfo)
+{
+ ///
+ /// Number of 10-byte CblockInfoOffsetByUblockIdxCompressed (u2c) entries: (m_numUBlocks + 8) >> 3 .
+ /// Each entry covers a group of 8 uncompressed blocks (a uint24 base plus 7 per-ublock deltas); the
+ /// +8 (rather than +7 ) adds a trailing group whenever the block count is a multiple of 8.
+ ///
+ public int NumU2cEntries => ((NumUBlocks + 8) >> 3);
+
+ ///
+ /// Number of 6-byte UncompressedOffsetStartByFileIdx (fidx) entries. There is one entry per
+ /// file plus one trailing sentinel (the total uncompressed size), i.e. m_numFilesMinus1 + 2
+ /// which equals + 1.
+ ///
+ public int NumFileOffsetEntries => NumFiles + 1;
+}
+
+/// Byte offset and size of one NAPS section inside the layout blob.
+/// Offset from the start of the blob.
+/// Section size in bytes.
+/// Per-entry stride in bytes.
+/// Number of entries.
+public readonly record struct NapsSection(long Offset, long Size, int Stride, int Count);
+
+/// Map of all NAPS sections in their on-disk order.
+/// The 16-byte [PackageLayout_NAPS] header.
+/// 8-byte outer-block entries.
+/// 8-byte shuffle-pattern entries.
+/// 6-byte per-file uncompressed-offset entries.
+/// 10-byte u2c entries.
+/// 9-byte CblockInfo entries.
+/// Total layout size (end of the last section).
+public readonly record struct NapsSectionMap(
+ NapsSection Header,
+ NapsSection OuterBlockDigest,
+ NapsSection ShufflePattern,
+ NapsSection UncompressedOffsetStartByFileIdx,
+ NapsSection CblockInfoOffsetByUblockIdxCompressed,
+ NapsSection CblockInfo,
+ long TotalSize);
+
+///
+/// A 6-byte UncompressedOffsetStartByFileIdx (fidx) entry: a 40-bit little-endian
+/// uncompressed start offset followed by a 1-byte type (validated layout
+/// offset[0..5) LE + type[5] ). The last real entry carries the total inner logical size with
+/// type=0x40 ; a fixed trailer sentinel follows the per-file entries.
+///
+/// The per-file type byte (byte 5 of the entry).
+/// 40-bit little-endian uncompressed start offset (bytes 0..4).
+public readonly record struct NapsFileOffsetEntry(byte Type, ulong UncompressedOffsetStart);
+
+///
+/// A 10-byte CblockInfoOffsetByUblockIdxCompressed (u2c) entry. Layout matches the
+/// dump format u2c[..] : m_infoOffset9BBase=%06lx, m_deltaFromBase=%02x ×7
+/// (uint24 base + seven delta bytes). Each entry covers 8 uncompressed blocks; the per-ublock
+/// start_cblockinfo_index is .
+///
+/// uint24 base index into the CblockInfo section.
+/// Seven per-ublock deltas added to .
+public readonly record struct NapsU2cEntry(uint InfoOffset9BBase, byte[] DeltaFromBase)
+{
+ ///
+ /// Derived start_cblockinfo_index for each of the 8 uncompressed blocks this entry covers.
+ /// Index 0 is the base itself; indices 1..7 add the matching delta byte.
+ ///
+ public IReadOnlyList 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;
+ }
+ }
+}
+
+///
+/// A 9-byte CblockInfo 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).
+///
+public readonly record struct NapsCblockInfoEntry
+{
+ /// Raw 9 bytes of the record, always preserved verbatim.
+ public byte[] Raw { get; init; }
+
+ /// True for the run-base format, false for the per-block format.
+ public bool IsRunBase { get; init; }
+
+ // Non-run-base fields (valid when IsRunBase is false).
+
+ /// m_coffsetStartMod256K (18 bits). Model bit-offset.
+ public uint CoffsetStartMod256K { get; init; }
+
+ /// m_uoffsetStart (18 bits). Model bit-offset.
+ public uint UoffsetStart { get; init; }
+
+ /// m_clenEvenMinus1 (17 bits). Model bit-offset.
+ public uint ClenEvenMinus1 { get; init; }
+
+ /// m_even flag. Model bit-offset.
+ public byte Even { get; init; }
+
+ /// m_odd flag. Model bit-offset.
+ public byte Odd { get; init; }
+
+ /// m_KdePredictor (3 bits). Model bit-offset.
+ public byte KdePredictor { get; init; }
+
+ /// m_shuffleIdx (4 bits). Model bit-offset.
+ public byte ShuffleIdx { get; init; }
+
+ // Run-base fields (valid when IsRunBase is true).
+
+ /// m_coffsetEndMod256K (18 bits). Model bit-offset.
+ public uint CoffsetEndMod256K { get; init; }
+
+ /// m_tweakIdxStart (up to 28 bits). Model bit-offset.
+ public uint TweakIdxStart { get; init; }
+
+ /// m_keyTableIdx . Model bit-offset.
+ public byte KeyTableIdx { get; init; }
+
+ /// m_coffsetStart256K (24 bits). Model bit-offset.
+ public uint CoffsetStart256K { get; init; }
+}
+
+/// A fully parsed naps_pkg_layout.dat blob (raw section slices + decoded entries).
+public sealed class NapsLayoutDocument
+{
+ /// Section counts.
+ public required NapsLayoutCounts Counts { get; init; }
+
+ /// Section offset/size map.
+ public required NapsSectionMap Map { get; init; }
+
+ /// Raw 8-byte outer-block entries (values are key-gated; preserved verbatim).
+ public required IReadOnlyList OuterBlockDigests { get; init; }
+
+ /// Raw 8-byte shuffle-pattern entries.
+ public required IReadOnlyList ShufflePatterns { get; init; }
+
+ /// Decoded 6-byte per-file uncompressed-offset entries.
+ public required IReadOnlyList FileOffsets { get; init; }
+
+ /// Decoded 10-byte u2c entries.
+ public required IReadOnlyList CblockInfoOffsetByUblock { get; init; }
+
+ /// Decoded 9-byte CblockInfo entries.
+ public required IReadOnlyList CblockInfos { get; init; }
+}
+
+///
+/// Decoder for the PS5 naps_pkg_layout.dat (PackageLayout_NAPS ) structure.
+/// Decodes existing bytes; never produces or fabricates the key-gated streaming values. See the file
+/// header for the exact confirmed-vs-model boundary.
+///
+public static class ProsperoNapsLayout
+{
+ /// On-disk file name of the NAPS layout structure.
+ public const string FileName = "naps_pkg_layout.dat";
+
+ /// Size of the [PackageLayout_NAPS] header (two packed 64-bit words).
+ public const int HeaderSize = 16;
+
+ /// Stride of an OuterBlockDigest entry (validated: (=8*%lld) ).
+ public const int OuterBlockDigestStride = 8;
+
+ /// Stride of a ShufflePattern entry (validated: (=8*%lld) ).
+ public const int ShufflePatternStride = 8;
+
+ /// Stride of an UncompressedOffsetStartByFileIdx entry (validated: (=6*%lld) ).
+ public const int FileOffsetStride = 6;
+
+ /// Stride of a CblockInfoOffsetByUblockIdxCompressed entry (validated: (=10*%lld) ).
+ public const int U2cStride = 10;
+
+ /// Stride of a CblockInfo entry (validated: (=9*%lld) ).
+ public const int CblockInfoStride = 9;
+
+ // ---- Header (MODEL packing) ------------------------------------------------------------------
+
+ ///
+ /// Decode the 16-byte header into section counts. The field names come from the
+ /// dump-format strings (m_numFilesMinus1 , m_numKeysMinus1 ,
+ /// m_numShufflePatterns , m_numUBlocks , m_numOuterBlocks ,
+ /// m_numCblockInfoMinus2 ): NumFiles=7, compType=2, Keys=1, Shuffle=0, UBlocks=19,
+ /// OuterBlocks=5, CblockInfo=45 all decode and re-encode to the exact header bytes.
+ ///
+ public static NapsLayoutCounts DecodeHeader(ReadOnlySpan 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);
+ }
+
+ /// Encode section counts back into a 16-byte header (inverse of ).
+ 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) -----------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ public static NapsSectionMap SectionMap(NapsLayoutCounts counts)
+ => SectionMap(counts, counts.NumFileOffsetEntries);
+
+ ///
+ /// Section map with an EXPLICIT fidx (UncompressedOffsetStartByFileIdx) entry count. The fidx count is
+ /// NOT a fixed function of — 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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 NumFiles+1 as a fallback when the blob is
+ /// padded or the arithmetic is not exact.
+ ///
+ 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 ---------------------------------------------------------------------------
+
+ /// Decode a 6-byte fidx entry (validated layout: 40-bit LE offset + 1 type byte).
+ public static NapsFileOffsetEntry DecodeFileOffsetEntry(ReadOnlySpan 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);
+ }
+
+ /// Encode a 6-byte fidx entry (inverse of ).
+ 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;
+ }
+
+ /// Decode a 10-byte u2c entry (validated layout: uint24 base + 7 delta bytes).
+ public static NapsU2cEntry DecodeU2cEntry(ReadOnlySpan 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);
+ }
+
+ /// Encode a 10-byte u2c entry (inverse of ).
+ 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;
+ }
+
+ ///
+ /// Decode a 9-byte CblockInfo entry. The round-trip re-encodes every CblockInfo record of a
+ /// naps_pkg_layout.dat exactly: the discriminator
+ /// (m_isRunBase 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 .
+ ///
+ public static NapsCblockInfoEntry DecodeCblockInfoEntry(ReadOnlySpan 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 --------------------------------------------------------------------------
+
+ ///
+ /// Parse a full