From 9928e998a40806a3dad9d23e5f4c10b2ab349cf0 Mon Sep 17 00:00:00 2001 From: Arsenic <55256796+danthespal@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:17:46 +0300 Subject: [PATCH 01/28] Add AMPR asset pack support (WIP) Port of ampr_emu ampr_pack.py: LZ4 1.9.4 native shim, AMPRPAK4 formats, TOML config and default preset, builder, verify/unpack/list/inspect, runtime-config, remove-sources, ampr CLI verb, GUI panel, parity tests. Parked: packs only work for titles that read assets through the AMPR API; Unity titles (ordinary file I/O) fail with the default ampr_emu build. --- README.md | 73 +- native/CMakeLists.txt | 7 +- native/build.ps1 | 2 +- native/build.sh | 2 +- native/lz4/LICENSE | 24 + native/lz4/ORIGIN.md | 9 + native/lz4/lz4.c | 2722 +++++++++++++++++ native/lz4/lz4.h | 842 +++++ native/lz4/lz4hc.c | 1631 ++++++++++ native/lz4/lz4hc.h | 413 +++ native/mkpfs_lz4.c | 71 + .../AMPRPack/AMPRBlockCompressor.cs | 66 + src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs | 86 + src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs | 471 +++ src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 746 +++++ src/MkPFS.Build/AMPRPack/AMPRPackLayout.cs | 350 +++ .../AMPRPack/AMPRPackMaintenance.cs | 289 ++ src/MkPFS.Build/AMPRPack/AMPRPackPattern.cs | 521 ++++ src/MkPFS.Build/AMPRPack/AMPRPackPlanner.cs | 348 +++ src/MkPFS.Build/AMPRPack/AMPRToml.cs | 70 + src/MkPFS.Build/AMPRPack/PythonValue.cs | 192 ++ src/MkPFS.Build/AmprIndex.cs | 14 + src/MkPFS.Build/MkPFS.Build.csproj | 4 + src/MkPFS.Cli/Commands/AmprCommand.cs | 442 +++ src/MkPFS.Cli/MkPFSCli.cs | 1 + src/MkPFS.Cli/Output/CliContext.cs | 9 + src/MkPFS.Core/AMPR/AMPRAssetPath.cs | 139 + src/MkPFS.Core/AMPR/AMPRBuildId.cs | 26 + src/MkPFS.Core/AMPR/AMPRChunkCrcs.cs | 109 + src/MkPFS.Core/AMPR/AMPRDataHeader.cs | 104 + src/MkPFS.Core/AMPR/AMPRGlob.cs | 179 ++ src/MkPFS.Core/AMPR/AMPRIndexReader.cs | 114 + src/MkPFS.Core/AMPR/AMPRPackException.cs | 23 + src/MkPFS.Core/AMPR/AMPRPackFormat.cs | 191 ++ src/MkPFS.Core/AMPR/AMPRPackManifest.cs | 542 ++++ src/MkPFS.Core/AMPR/AMPRPackReader.cs | 188 ++ src/MkPFS.Core/AMPR/AMPRPackRecords.cs | 181 ++ src/MkPFS.Core/AMPR/AMPRPackTools.cs | 360 +++ src/MkPFS.Core/AMPR/AMPRRuntimeSettings.cs | 128 + src/MkPFS.Core/AMPR/AMPRSize.cs | 102 + src/MkPFS.Core/AMPR/AMPRStringTable.cs | 44 + src/MkPFS.Core/Compression/LZ4Codec.cs | 108 + src/MkPFS.Core/Compression/LZ4Native.cs | 59 + .../Util/PythonCodePointComparer.cs | 37 + src/MkPFS.Core/Util/PythonSortedJson.cs | 150 + src/MkPFS.Core/Util/PythonText.cs | 201 ++ src/MkPFS.Gui/Localization/Strings.resx | 34 + src/MkPFS.Gui/Localization/Strings_de.resx | 34 + src/MkPFS.Gui/Localization/Strings_es.resx | 34 + src/MkPFS.Gui/Localization/Strings_fr.resx | 34 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 34 + src/MkPFS.Gui/Localization/Strings_ro.resx | 34 + .../ViewModels/AmprPackPanelViewModel.cs | 180 ++ src/MkPFS.Gui/ViewModels/Icons.cs | 1 + .../ViewModels/MainWindowViewModel.cs | 4 +- src/MkPFS.Gui/Views/PanelView.axaml | 1 + src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 39 + .../Views/Panels/AmprPackForm.axaml.cs | 13 + tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 160 + tests/MkPFS.Gui.Tests/MainWindowTests.cs | 2 +- tests/MkPFS.Parity/AMPRCliParityTests.cs | 111 + .../AMPRPackBuilderParityTests.cs | 207 ++ .../MkPFS.Parity/AMPRPackConfigParityTests.cs | 42 + .../MkPFS.Parity/AMPRPackFormatParityTests.cs | 126 + .../AMPRPackOperationsParityTests.cs | 218 ++ tests/MkPFS.Parity/LZ4VectorTests.cs | 82 + tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs | 100 + .../MkPFS.Tests/AMPR/AMPRPackBuilderTests.cs | 185 ++ tests/MkPFS.Tests/AMPR/AMPRPackConfigTests.cs | 204 ++ tests/MkPFS.Tests/AMPR/AMPRPackFormatTests.cs | 348 +++ .../AMPR/AMPRPackOperationsTests.cs | 170 + tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 98 + tests/MkPFS.Tests/Build/AmprIndexTests.cs | 24 + tests/MkPFS.Tests/Compression/LZ4Tests.cs | 108 + tools/oracle/README.md | 55 + tools/oracle/build_ampr_goldens.py | 423 +++ 76 files changed, 15488 insertions(+), 7 deletions(-) create mode 100644 native/lz4/LICENSE create mode 100644 native/lz4/ORIGIN.md create mode 100644 native/lz4/lz4.c create mode 100644 native/lz4/lz4.h create mode 100644 native/lz4/lz4hc.c create mode 100644 native/lz4/lz4hc.h create mode 100644 native/mkpfs_lz4.c create mode 100644 src/MkPFS.Build/AMPRPack/AMPRBlockCompressor.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackLayout.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackMaintenance.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackPattern.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRPackPlanner.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRToml.cs create mode 100644 src/MkPFS.Build/AMPRPack/PythonValue.cs create mode 100644 src/MkPFS.Cli/Commands/AmprCommand.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRAssetPath.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRBuildId.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRChunkCrcs.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRDataHeader.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRGlob.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRIndexReader.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackException.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackFormat.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackManifest.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackReader.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackRecords.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRPackTools.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRRuntimeSettings.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRSize.cs create mode 100644 src/MkPFS.Core/AMPR/AMPRStringTable.cs create mode 100644 src/MkPFS.Core/Compression/LZ4Codec.cs create mode 100644 src/MkPFS.Core/Compression/LZ4Native.cs create mode 100644 src/MkPFS.Core/Util/PythonCodePointComparer.cs create mode 100644 src/MkPFS.Core/Util/PythonSortedJson.cs create mode 100644 src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs create mode 100644 src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml create mode 100644 src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml.cs create mode 100644 tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs create mode 100644 tests/MkPFS.Parity/AMPRCliParityTests.cs create mode 100644 tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs create mode 100644 tests/MkPFS.Parity/AMPRPackConfigParityTests.cs create mode 100644 tests/MkPFS.Parity/AMPRPackFormatParityTests.cs create mode 100644 tests/MkPFS.Parity/AMPRPackOperationsParityTests.cs create mode 100644 tests/MkPFS.Parity/LZ4VectorTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPackBuilderTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPackConfigTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPackFormatTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPackOperationsTests.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs create mode 100644 tests/MkPFS.Tests/Compression/LZ4Tests.cs create mode 100644 tools/oracle/build_ampr_goldens.py diff --git a/README.md b/README.md index 6ff1871..72a3ab6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ offline PFSC block repair ported from PS5 Game Compressor. - **Repair**: find and fix compressed blocks the PS5 may decode wrongly (images made with ISA-L). - **APR Emu**: copies your AMPR Emu libraries into `fakelib/` of APR titles (`--ampr-libs`) and builds `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. +- **AMPR packs**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte + like ampr_emu's `ampr_pack.py`. - **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. @@ -62,6 +64,7 @@ for the parser's built-in help. | `tree ` | folder or image path | outer tree | List files and directories. | | `unpack ` | image path, destination directory | — | Extract an image. | | `repair ` | single-file `.ffpfsc` path | repairs risky blocks | Repair PFSC blocks that a PS5 may decode incorrectly. | +| `ampr ` | see [`ampr`](#ampr-asset-packs) | JSON on standard output | Build and manage AMPR Emu LZ4 asset packs. | ### Common examples @@ -240,6 +243,10 @@ and sizes (paths compared case-insensitively, modification times ignored) and re turns this on when the chosen folder already has an index. `--no-ampr-index` packs the existing index untouched, even when it no longer matches. +A folder that also holds AMPR packs (`ampr_assets.index`, see [`ampr`](#ampr-asset-packs)) always keeps its +`ampr_emu.index`, with a warning: the packs address files by their row in that index, and a rebuilt index +renumbers the rows so the emulator fails every packed read. `--ampr-force-regen` still rebuilds it. + | Option | Default | Meaning | |---|---|---| | `--ampr-libs ` | none | Folder holding `libSceAmpr.sprx` (required) and `libScePlayGo.sprx` (optional) to copy into `fakelib/`. | @@ -248,6 +255,56 @@ untouched, even when it no longer matches. | `--ampr-skip-regen-if-exists` | off | Keep an existing index while it lists exactly the folder's files and sizes; rebuild it otherwise. | | `--ampr-force-regen` | off | Regenerate an existing AMPR index. | +### `ampr` (asset packs) + +AMPR Emu (`libSceAmpr.sprx`) can serve selected `/app0` files from seekable LZ4 packs: the pack +manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, and an offline CRC sidecar +`ampr_assets.index.crc`. The packs sit next to the game files inside a normal image; the PS5 kernel never +sees LZ4. `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, +the same JSON on standard output, and byte-identical packs. + +The released emulator only serves files the game reads through AMPR. Files read another way (for +example `mmap`) must stay loose, so keep the source files until the packed game is tested, and remove +them only after that with `remove-sources --confirm`. + +| Subcommand | Required options | Purpose | +|---|---|---| +| `pack` | `--root --ampr-index --output ` | Build the manifest, volumes, CRC sidecar, and optional runtime settings. | +| `verify` | `--index ` | Decode every chunk and check its CRC; `--root ` also compares every byte with the source. | +| `unpack` | `--index --output ` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | +| `list` | `--index ` | One line per file (`--json` for details). | +| `inspect` | `--index ` | Manifest summary, volumes, and runtime settings. | +| `runtime-config` | `--index --config ` | Replace `.runtime` from a `[runtime]` section without repacking. | +| `remove-sources` | `--index --root ` | Show which sources the packs replace; with `--confirm`, verify everything and delete them. Also `remove-packed-sources`. | + +`pack` options: + +| Option | Default | Meaning | +|---|---|---| +| `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | +| `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | +| `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | +| `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | +| `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | +| `--self-contained` | off | Never auto-loose selected files; incompressible blocks are stored uncompressed. | +| `--require-packed ` | none; repeatable | Fail if a matching file would stay loose. | +| `--allow-missing` | off | Leave selected files that are missing from `--root` loose. | +| `--no-progress` | off | Hide progress on standard error. | + +The TOML format is ampr_emu's (see its `tools/ampr_pack.example.toml`). A minimal config: + +```toml +[pack] +default_action = "compress" # LZ4 HC level 12, 64 KiB blocks + +[[rule]] # last match wins: keep modules and system files loose +action = "loose" +include = ["eboot.bin", "**/*.prx", "**/*.sprx", "sce_sys/**", "sce_module/**", "fakelib/**"] +``` + +`ampr_pack.py` has no built-in exclusions, so a config without that last rule packs `eboot.bin` and +modules too; `remove-sources` then refuses to run. Errors print `error: ` and exit with code 2. + ### Reading and extracting images Encrypted read commands accept `--ekpfs-key <64-hex>` (default: all-zero key) and `--new-crypt` @@ -313,6 +370,8 @@ dotnet run --project src/MkPFS.Gui -c Release settings for the zlib level, CPU cores, block size, and when to keep blocks uncompressed. - The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its offset, stored size, and largest back-reference distance. +- The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. + Packing without a TOML uses `--preset default`. ## Differences from Python MkPFS @@ -371,7 +430,7 @@ Studio Installer folder (`C:\Program Files (x86)\Microsoft Visual Studio\Install | `src/MkPFS.Repair` | PFSC repair (Game Compressor port) | | `src/MkPFS.Cli` | `mkpfs` command line | | `src/MkPFS.Gui` | `mkpfs-gui` desktop app (Avalonia) | -| `native/` | zlib 1.3.1 and the `mkpfs_zlib` shim | +| `native/` | zlib 1.3.1, lz4 1.9.4, and the `mkpfs_zlib` shim | | `tests/MkPFS.Tests` | Unit tests | | `tests/MkPFS.Parity` | Byte-for-byte tests against the Python oracle corpus | | `tests/MkPFS.Gui.Tests` | GUI view model and headless UI tests | @@ -386,6 +445,13 @@ out at `../MkPFS` and `uv` installed: uv run --project ../MkPFS python tools/oracle/build_goldens.py --check ``` +The `ampr` tests use a second corpus made by ampr_emu's `ampr_pack.py` (checked out at `../ampr_emu`; +needs python-lz4 4.4.5): + +```bash +python tools/oracle/build_ampr_goldens.py --check +``` + ### Releases Push a version tag to publish a release. The workflow reruns CI, then uploads the archives, @@ -404,12 +470,15 @@ git push origin v2.0.0 - [MkPFS](https://github.com/PSBrew/MkPFS) by PSBrew: the Python original this port follows. - PS5 Game Compressor by Juma Sayeh: the PFSC repair logic. -- Drakmor's [APR Emu](https://github.com/drakmor/ampr_emu): the `ampr_emu.index` format. +- Drakmor's [APR Emu](https://github.com/drakmor/ampr_emu): the `ampr_emu.index` format and the asset-pack + format and tools that `ampr` ports. - Drakmor's [PlayGo stub](https://github.com/drakmor/pgo_stub): `libScePlayGo.sprx`. Third-party components: - [zlib](https://zlib.net) 1.3.1 (zlib license) +- [LZ4](https://github.com/lz4/lz4) 1.9.4 (BSD-2-Clause) +- [Tomlyn](https://github.com/xoofx/Tomlyn) (BSD-2-Clause) - [Avalonia](https://avaloniaui.net), [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet), [System.CommandLine](https://github.com/dotnet/command-line-api), and [Spectre.Console](https://spectreconsole.net) (MIT) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index a86c0f7..3106cde 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -19,8 +19,13 @@ add_library(mkpfs_zlib SHARED zlib/trees.c zlib/uncompr.c zlib/zutil.c + mkpfs_lz4.c + lz4/lz4.c + lz4/lz4hc.c ) -target_include_directories(mkpfs_zlib PRIVATE zlib) +target_include_directories(mkpfs_zlib PRIVATE zlib lz4) +# lz4.h marks its API visibility("default") on GCC/Clang; keep it hidden like zlib. +target_compile_definitions(mkpfs_zlib PRIVATE LZ4LIB_VISIBILITY=) if(MSVC) target_compile_definitions(mkpfs_zlib PRIVATE _CRT_SECURE_NO_DEPRECATE _CRT_NONSTDC_NO_DEPRECATE) diff --git a/native/build.ps1 b/native/build.ps1 index 8d31bc7..bc9e36e 100644 --- a/native/build.ps1 +++ b/native/build.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Build native/mkpfs_zlib (zlib 1.3.1 + shim) for Windows into artifacts/native//. + Build native/mkpfs_zlib (zlib 1.3.1 + lz4 1.9.4 + shims) for Windows into artifacts/native//. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File native/build.ps1 -Rid win-x64 #> diff --git a/native/build.sh b/native/build.sh index 2b6d78f..9ba7537 100644 --- a/native/build.sh +++ b/native/build.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Build native/mkpfs_zlib (zlib 1.3.1 + shim) for Linux/macOS into artifacts/native//. +# Build native/mkpfs_zlib (zlib 1.3.1 + lz4 1.9.4 + shims) for Linux/macOS into artifacts/native//. # Usage: sh native/build.sh linux-x64|linux-arm64|osx-x64|osx-arm64 [Release] set -eu diff --git a/native/lz4/LICENSE b/native/lz4/LICENSE new file mode 100644 index 0000000..4884916 --- /dev/null +++ b/native/lz4/LICENSE @@ -0,0 +1,24 @@ +LZ4 Library +Copyright (c) 2011-2020, Yann Collet +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/lz4/ORIGIN.md b/native/lz4/ORIGIN.md new file mode 100644 index 0000000..70eedc0 --- /dev/null +++ b/native/lz4/ORIGIN.md @@ -0,0 +1,9 @@ +lz4 1.9.4 + +Source: https://github.com/lz4/lz4/tree/v1.9.4 +Commit: 5ff839680134437dbf4678f3d0c7b371d84f4964 + +Files `lib/lz4.c`, `lib/lz4.h`, `lib/lz4hc.c`, `lib/lz4hc.h` and `lib/LICENSE` (BSD-2-Clause), unchanged. +They are identical (apart from line endings) to `lz4libs/` in python-lz4 v4.4.5, the encoder of the AMPR +pack oracle (ampr_emu `tools/ampr_pack.py`). lz4 1.10.0 changes HC levels 1 and 2, so it is not used. +The decoder accepts blocks from any lz4 version; the ampr_emu runtime decodes with 1.10.0. diff --git a/native/lz4/lz4.c b/native/lz4/lz4.c new file mode 100644 index 0000000..654bfdf --- /dev/null +++ b/native/lz4/lz4.c @@ -0,0 +1,2722 @@ +/* + LZ4 - Fast LZ compression algorithm + Copyright (C) 2011-2020, Yann Collet. + + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + You can contact the author at : + - LZ4 homepage : http://www.lz4.org + - LZ4 source repository : https://github.com/lz4/lz4 +*/ + +/*-************************************ +* Tuning parameters +**************************************/ +/* + * LZ4_HEAPMODE : + * Select how default compression functions will allocate memory for their hash table, + * in memory stack (0:default, fastest), or in memory heap (1:requires malloc()). + */ +#ifndef LZ4_HEAPMODE +# define LZ4_HEAPMODE 0 +#endif + +/* + * LZ4_ACCELERATION_DEFAULT : + * Select "acceleration" for LZ4_compress_fast() when parameter value <= 0 + */ +#define LZ4_ACCELERATION_DEFAULT 1 +/* + * LZ4_ACCELERATION_MAX : + * Any "acceleration" value higher than this threshold + * get treated as LZ4_ACCELERATION_MAX instead (fix #876) + */ +#define LZ4_ACCELERATION_MAX 65537 + + +/*-************************************ +* CPU Feature Detection +**************************************/ +/* LZ4_FORCE_MEMORY_ACCESS + * By default, access to unaligned memory is controlled by `memcpy()`, which is safe and portable. + * Unfortunately, on some target/compiler combinations, the generated assembly is sub-optimal. + * The below switch allow to select different access method for improved performance. + * Method 0 (default) : use `memcpy()`. Safe and portable. + * Method 1 : `__packed` statement. It depends on compiler extension (ie, not portable). + * This method is safe if your compiler supports it, and *generally* as fast or faster than `memcpy`. + * Method 2 : direct access. This method is portable but violate C standard. + * It can generate buggy code on targets which assembly generation depends on alignment. + * But in some circumstances, it's the only known way to get the most performance (ie GCC + ARMv6) + * See https://fastcompression.blogspot.fr/2015/08/accessing-unaligned-memory.html for details. + * Prefer these methods in priority order (0 > 1 > 2) + */ +#ifndef LZ4_FORCE_MEMORY_ACCESS /* can be defined externally */ +# if defined(__GNUC__) && \ + ( defined(__ARM_ARCH_6__) || defined(__ARM_ARCH_6J__) || defined(__ARM_ARCH_6K__) \ + || defined(__ARM_ARCH_6Z__) || defined(__ARM_ARCH_6ZK__) || defined(__ARM_ARCH_6T2__) ) +# define LZ4_FORCE_MEMORY_ACCESS 2 +# elif (defined(__INTEL_COMPILER) && !defined(_WIN32)) || defined(__GNUC__) +# define LZ4_FORCE_MEMORY_ACCESS 1 +# endif +#endif + +/* + * LZ4_FORCE_SW_BITCOUNT + * Define this parameter if your target system or compiler does not support hardware bit count + */ +#if defined(_MSC_VER) && defined(_WIN32_WCE) /* Visual Studio for WinCE doesn't support Hardware bit count */ +# undef LZ4_FORCE_SW_BITCOUNT /* avoid double def */ +# define LZ4_FORCE_SW_BITCOUNT +#endif + + + +/*-************************************ +* Dependency +**************************************/ +/* + * LZ4_SRC_INCLUDED: + * Amalgamation flag, whether lz4.c is included + */ +#ifndef LZ4_SRC_INCLUDED +# define LZ4_SRC_INCLUDED 1 +#endif + +#ifndef LZ4_STATIC_LINKING_ONLY +#define LZ4_STATIC_LINKING_ONLY +#endif + +#ifndef LZ4_DISABLE_DEPRECATE_WARNINGS +#define LZ4_DISABLE_DEPRECATE_WARNINGS /* due to LZ4_decompress_safe_withPrefix64k */ +#endif + +#define LZ4_STATIC_LINKING_ONLY /* LZ4_DISTANCE_MAX */ +#include "lz4.h" +/* see also "memory routines" below */ + + +/*-************************************ +* Compiler Options +**************************************/ +#if defined(_MSC_VER) && (_MSC_VER >= 1400) /* Visual Studio 2005+ */ +# include /* only present in VS2005+ */ +# pragma warning(disable : 4127) /* disable: C4127: conditional expression is constant */ +# pragma warning(disable : 6237) /* disable: C6237: conditional expression is always 0 */ +#endif /* _MSC_VER */ + +#ifndef LZ4_FORCE_INLINE +# ifdef _MSC_VER /* Visual Studio */ +# define LZ4_FORCE_INLINE static __forceinline +# else +# if defined (__cplusplus) || defined (__STDC_VERSION__) && __STDC_VERSION__ >= 199901L /* C99 */ +# ifdef __GNUC__ +# define LZ4_FORCE_INLINE static inline __attribute__((always_inline)) +# else +# define LZ4_FORCE_INLINE static inline +# endif +# else +# define LZ4_FORCE_INLINE static +# endif /* __STDC_VERSION__ */ +# endif /* _MSC_VER */ +#endif /* LZ4_FORCE_INLINE */ + +/* LZ4_FORCE_O2 and LZ4_FORCE_INLINE + * gcc on ppc64le generates an unrolled SIMDized loop for LZ4_wildCopy8, + * together with a simple 8-byte copy loop as a fall-back path. + * However, this optimization hurts the decompression speed by >30%, + * because the execution does not go to the optimized loop + * for typical compressible data, and all of the preamble checks + * before going to the fall-back path become useless overhead. + * This optimization happens only with the -O3 flag, and -O2 generates + * a simple 8-byte copy loop. + * With gcc on ppc64le, all of the LZ4_decompress_* and LZ4_wildCopy8 + * functions are annotated with __attribute__((optimize("O2"))), + * and also LZ4_wildCopy8 is forcibly inlined, so that the O2 attribute + * of LZ4_wildCopy8 does not affect the compression speed. + */ +#if defined(__PPC64__) && defined(__LITTLE_ENDIAN__) && defined(__GNUC__) && !defined(__clang__) +# define LZ4_FORCE_O2 __attribute__((optimize("O2"))) +# undef LZ4_FORCE_INLINE +# define LZ4_FORCE_INLINE static __inline __attribute__((optimize("O2"),always_inline)) +#else +# define LZ4_FORCE_O2 +#endif + +#if (defined(__GNUC__) && (__GNUC__ >= 3)) || (defined(__INTEL_COMPILER) && (__INTEL_COMPILER >= 800)) || defined(__clang__) +# define expect(expr,value) (__builtin_expect ((expr),(value)) ) +#else +# define expect(expr,value) (expr) +#endif + +#ifndef likely +#define likely(expr) expect((expr) != 0, 1) +#endif +#ifndef unlikely +#define unlikely(expr) expect((expr) != 0, 0) +#endif + +/* Should the alignment test prove unreliable, for some reason, + * it can be disabled by setting LZ4_ALIGN_TEST to 0 */ +#ifndef LZ4_ALIGN_TEST /* can be externally provided */ +# define LZ4_ALIGN_TEST 1 +#endif + + +/*-************************************ +* Memory routines +**************************************/ + +/*! LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION : + * Disable relatively high-level LZ4/HC functions that use dynamic memory + * allocation functions (malloc(), calloc(), free()). + * + * Note that this is a compile-time switch. And since it disables + * public/stable LZ4 v1 API functions, we don't recommend using this + * symbol to generate a library for distribution. + * + * The following public functions are removed when this symbol is defined. + * - lz4 : LZ4_createStream, LZ4_freeStream, + * LZ4_createStreamDecode, LZ4_freeStreamDecode, LZ4_create (deprecated) + * - lz4hc : LZ4_createStreamHC, LZ4_freeStreamHC, + * LZ4_createHC (deprecated), LZ4_freeHC (deprecated) + * - lz4frame, lz4file : All LZ4F_* functions + */ +#if defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +# define ALLOC(s) lz4_error_memory_allocation_is_disabled +# define ALLOC_AND_ZERO(s) lz4_error_memory_allocation_is_disabled +# define FREEMEM(p) lz4_error_memory_allocation_is_disabled +#elif defined(LZ4_USER_MEMORY_FUNCTIONS) +/* memory management functions can be customized by user project. + * Below functions must exist somewhere in the Project + * and be available at link time */ +void* LZ4_malloc(size_t s); +void* LZ4_calloc(size_t n, size_t s); +void LZ4_free(void* p); +# define ALLOC(s) LZ4_malloc(s) +# define ALLOC_AND_ZERO(s) LZ4_calloc(1,s) +# define FREEMEM(p) LZ4_free(p) +#else +# include /* malloc, calloc, free */ +# define ALLOC(s) malloc(s) +# define ALLOC_AND_ZERO(s) calloc(1,s) +# define FREEMEM(p) free(p) +#endif + +#if ! LZ4_FREESTANDING +# include /* memset, memcpy */ +#endif +#if !defined(LZ4_memset) +# define LZ4_memset(p,v,s) memset((p),(v),(s)) +#endif +#define MEM_INIT(p,v,s) LZ4_memset((p),(v),(s)) + + +/*-************************************ +* Common Constants +**************************************/ +#define MINMATCH 4 + +#define WILDCOPYLENGTH 8 +#define LASTLITERALS 5 /* see ../doc/lz4_Block_format.md#parsing-restrictions */ +#define MFLIMIT 12 /* see ../doc/lz4_Block_format.md#parsing-restrictions */ +#define MATCH_SAFEGUARD_DISTANCE ((2*WILDCOPYLENGTH) - MINMATCH) /* ensure it's possible to write 2 x wildcopyLength without overflowing output buffer */ +#define FASTLOOP_SAFE_DISTANCE 64 +static const int LZ4_minLength = (MFLIMIT+1); + +#define KB *(1 <<10) +#define MB *(1 <<20) +#define GB *(1U<<30) + +#define LZ4_DISTANCE_ABSOLUTE_MAX 65535 +#if (LZ4_DISTANCE_MAX > LZ4_DISTANCE_ABSOLUTE_MAX) /* max supported by LZ4 format */ +# error "LZ4_DISTANCE_MAX is too big : must be <= 65535" +#endif + +#define ML_BITS 4 +#define ML_MASK ((1U<=1) +# include +#else +# ifndef assert +# define assert(condition) ((void)0) +# endif +#endif + +#define LZ4_STATIC_ASSERT(c) { enum { LZ4_static_assert = 1/(int)(!!(c)) }; } /* use after variable declarations */ + +#if defined(LZ4_DEBUG) && (LZ4_DEBUG>=2) +# include + static int g_debuglog_enable = 1; +# define DEBUGLOG(l, ...) { \ + if ((g_debuglog_enable) && (l<=LZ4_DEBUG)) { \ + fprintf(stderr, __FILE__ ": "); \ + fprintf(stderr, __VA_ARGS__); \ + fprintf(stderr, " \n"); \ + } } +#else +# define DEBUGLOG(l, ...) {} /* disabled */ +#endif + +static int LZ4_isAligned(const void* ptr, size_t alignment) +{ + return ((size_t)ptr & (alignment -1)) == 0; +} + + +/*-************************************ +* Types +**************************************/ +#include +#if defined(__cplusplus) || (defined (__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L) /* C99 */) +# include + typedef uint8_t BYTE; + typedef uint16_t U16; + typedef uint32_t U32; + typedef int32_t S32; + typedef uint64_t U64; + typedef uintptr_t uptrval; +#else +# if UINT_MAX != 4294967295UL +# error "LZ4 code (when not C++ or C99) assumes that sizeof(int) == 4" +# endif + typedef unsigned char BYTE; + typedef unsigned short U16; + typedef unsigned int U32; + typedef signed int S32; + typedef unsigned long long U64; + typedef size_t uptrval; /* generally true, except OpenVMS-64 */ +#endif + +#if defined(__x86_64__) + typedef U64 reg_t; /* 64-bits in x32 mode */ +#else + typedef size_t reg_t; /* 32-bits in x32 mode */ +#endif + +typedef enum { + notLimited = 0, + limitedOutput = 1, + fillOutput = 2 +} limitedOutput_directive; + + +/*-************************************ +* Reading and writing into memory +**************************************/ + +/** + * LZ4 relies on memcpy with a constant size being inlined. In freestanding + * environments, the compiler can't assume the implementation of memcpy() is + * standard compliant, so it can't apply its specialized memcpy() inlining + * logic. When possible, use __builtin_memcpy() to tell the compiler to analyze + * memcpy() as if it were standard compliant, so it can inline it in freestanding + * environments. This is needed when decompressing the Linux Kernel, for example. + */ +#if !defined(LZ4_memcpy) +# if defined(__GNUC__) && (__GNUC__ >= 4) +# define LZ4_memcpy(dst, src, size) __builtin_memcpy(dst, src, size) +# else +# define LZ4_memcpy(dst, src, size) memcpy(dst, src, size) +# endif +#endif + +#if !defined(LZ4_memmove) +# if defined(__GNUC__) && (__GNUC__ >= 4) +# define LZ4_memmove __builtin_memmove +# else +# define LZ4_memmove memmove +# endif +#endif + +static unsigned LZ4_isLittleEndian(void) +{ + const union { U32 u; BYTE c[4]; } one = { 1 }; /* don't use static : performance detrimental */ + return one.c[0]; +} + + +#if defined(LZ4_FORCE_MEMORY_ACCESS) && (LZ4_FORCE_MEMORY_ACCESS==2) +/* lie to the compiler about data alignment; use with caution */ + +static U16 LZ4_read16(const void* memPtr) { return *(const U16*) memPtr; } +static U32 LZ4_read32(const void* memPtr) { return *(const U32*) memPtr; } +static reg_t LZ4_read_ARCH(const void* memPtr) { return *(const reg_t*) memPtr; } + +static void LZ4_write16(void* memPtr, U16 value) { *(U16*)memPtr = value; } +static void LZ4_write32(void* memPtr, U32 value) { *(U32*)memPtr = value; } + +#elif defined(LZ4_FORCE_MEMORY_ACCESS) && (LZ4_FORCE_MEMORY_ACCESS==1) + +/* __pack instructions are safer, but compiler specific, hence potentially problematic for some compilers */ +/* currently only defined for gcc and icc */ +typedef union { U16 u16; U32 u32; reg_t uArch; } __attribute__((packed)) LZ4_unalign; + +static U16 LZ4_read16(const void* ptr) { return ((const LZ4_unalign*)ptr)->u16; } +static U32 LZ4_read32(const void* ptr) { return ((const LZ4_unalign*)ptr)->u32; } +static reg_t LZ4_read_ARCH(const void* ptr) { return ((const LZ4_unalign*)ptr)->uArch; } + +static void LZ4_write16(void* memPtr, U16 value) { ((LZ4_unalign*)memPtr)->u16 = value; } +static void LZ4_write32(void* memPtr, U32 value) { ((LZ4_unalign*)memPtr)->u32 = value; } + +#else /* safe and portable access using memcpy() */ + +static U16 LZ4_read16(const void* memPtr) +{ + U16 val; LZ4_memcpy(&val, memPtr, sizeof(val)); return val; +} + +static U32 LZ4_read32(const void* memPtr) +{ + U32 val; LZ4_memcpy(&val, memPtr, sizeof(val)); return val; +} + +static reg_t LZ4_read_ARCH(const void* memPtr) +{ + reg_t val; LZ4_memcpy(&val, memPtr, sizeof(val)); return val; +} + +static void LZ4_write16(void* memPtr, U16 value) +{ + LZ4_memcpy(memPtr, &value, sizeof(value)); +} + +static void LZ4_write32(void* memPtr, U32 value) +{ + LZ4_memcpy(memPtr, &value, sizeof(value)); +} + +#endif /* LZ4_FORCE_MEMORY_ACCESS */ + + +static U16 LZ4_readLE16(const void* memPtr) +{ + if (LZ4_isLittleEndian()) { + return LZ4_read16(memPtr); + } else { + const BYTE* p = (const BYTE*)memPtr; + return (U16)((U16)p[0] + (p[1]<<8)); + } +} + +static void LZ4_writeLE16(void* memPtr, U16 value) +{ + if (LZ4_isLittleEndian()) { + LZ4_write16(memPtr, value); + } else { + BYTE* p = (BYTE*)memPtr; + p[0] = (BYTE) value; + p[1] = (BYTE)(value>>8); + } +} + +/* customized variant of memcpy, which can overwrite up to 8 bytes beyond dstEnd */ +LZ4_FORCE_INLINE +void LZ4_wildCopy8(void* dstPtr, const void* srcPtr, void* dstEnd) +{ + BYTE* d = (BYTE*)dstPtr; + const BYTE* s = (const BYTE*)srcPtr; + BYTE* const e = (BYTE*)dstEnd; + + do { LZ4_memcpy(d,s,8); d+=8; s+=8; } while (d= 16. */ +LZ4_FORCE_INLINE void +LZ4_wildCopy32(void* dstPtr, const void* srcPtr, void* dstEnd) +{ + BYTE* d = (BYTE*)dstPtr; + const BYTE* s = (const BYTE*)srcPtr; + BYTE* const e = (BYTE*)dstEnd; + + do { LZ4_memcpy(d,s,16); LZ4_memcpy(d+16,s+16,16); d+=32; s+=32; } while (d= dstPtr + MINMATCH + * - there is at least 8 bytes available to write after dstEnd */ +LZ4_FORCE_INLINE void +LZ4_memcpy_using_offset(BYTE* dstPtr, const BYTE* srcPtr, BYTE* dstEnd, const size_t offset) +{ + BYTE v[8]; + + assert(dstEnd >= dstPtr + MINMATCH); + + switch(offset) { + case 1: + MEM_INIT(v, *srcPtr, 8); + break; + case 2: + LZ4_memcpy(v, srcPtr, 2); + LZ4_memcpy(&v[2], srcPtr, 2); +#if defined(_MSC_VER) && (_MSC_VER <= 1933) /* MSVC 2022 ver 17.3 or earlier */ +# pragma warning(push) +# pragma warning(disable : 6385) /* warning C6385: Reading invalid data from 'v'. */ +#endif + LZ4_memcpy(&v[4], v, 4); +#if defined(_MSC_VER) && (_MSC_VER <= 1933) /* MSVC 2022 ver 17.3 or earlier */ +# pragma warning(pop) +#endif + break; + case 4: + LZ4_memcpy(v, srcPtr, 4); + LZ4_memcpy(&v[4], srcPtr, 4); + break; + default: + LZ4_memcpy_using_offset_base(dstPtr, srcPtr, dstEnd, offset); + return; + } + + LZ4_memcpy(dstPtr, v, 8); + dstPtr += 8; + while (dstPtr < dstEnd) { + LZ4_memcpy(dstPtr, v, 8); + dstPtr += 8; + } +} +#endif + + +/*-************************************ +* Common functions +**************************************/ +static unsigned LZ4_NbCommonBytes (reg_t val) +{ + assert(val != 0); + if (LZ4_isLittleEndian()) { + if (sizeof(val) == 8) { +# if defined(_MSC_VER) && (_MSC_VER >= 1800) && (defined(_M_AMD64) && !defined(_M_ARM64EC)) && !defined(LZ4_FORCE_SW_BITCOUNT) +/*-************************************************************************************************* +* ARM64EC is a Microsoft-designed ARM64 ABI compatible with AMD64 applications on ARM64 Windows 11. +* The ARM64EC ABI does not support AVX/AVX2/AVX512 instructions, nor their relevant intrinsics +* including _tzcnt_u64. Therefore, we need to neuter the _tzcnt_u64 code path for ARM64EC. +****************************************************************************************************/ +# if defined(__clang__) && (__clang_major__ < 10) + /* Avoid undefined clang-cl intrinsics issue. + * See https://github.com/lz4/lz4/pull/1017 for details. */ + return (unsigned)__builtin_ia32_tzcnt_u64(val) >> 3; +# else + /* x64 CPUS without BMI support interpret `TZCNT` as `REP BSF` */ + return (unsigned)_tzcnt_u64(val) >> 3; +# endif +# elif defined(_MSC_VER) && defined(_WIN64) && !defined(LZ4_FORCE_SW_BITCOUNT) + unsigned long r = 0; + _BitScanForward64(&r, (U64)val); + return (unsigned)r >> 3; +# elif (defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 3) || \ + ((__GNUC__ == 3) && (__GNUC_MINOR__ >= 4))))) && \ + !defined(LZ4_FORCE_SW_BITCOUNT) + return (unsigned)__builtin_ctzll((U64)val) >> 3; +# else + const U64 m = 0x0101010101010101ULL; + val ^= val - 1; + return (unsigned)(((U64)((val & (m - 1)) * m)) >> 56); +# endif + } else /* 32 bits */ { +# if defined(_MSC_VER) && (_MSC_VER >= 1400) && !defined(LZ4_FORCE_SW_BITCOUNT) + unsigned long r; + _BitScanForward(&r, (U32)val); + return (unsigned)r >> 3; +# elif (defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 3) || \ + ((__GNUC__ == 3) && (__GNUC_MINOR__ >= 4))))) && \ + !defined(__TINYC__) && !defined(LZ4_FORCE_SW_BITCOUNT) + return (unsigned)__builtin_ctz((U32)val) >> 3; +# else + const U32 m = 0x01010101; + return (unsigned)((((val - 1) ^ val) & (m - 1)) * m) >> 24; +# endif + } + } else /* Big Endian CPU */ { + if (sizeof(val)==8) { +# if (defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 3) || \ + ((__GNUC__ == 3) && (__GNUC_MINOR__ >= 4))))) && \ + !defined(__TINYC__) && !defined(LZ4_FORCE_SW_BITCOUNT) + return (unsigned)__builtin_clzll((U64)val) >> 3; +# else +#if 1 + /* this method is probably faster, + * but adds a 128 bytes lookup table */ + static const unsigned char ctz7_tab[128] = { + 7, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 6, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, + }; + U64 const mask = 0x0101010101010101ULL; + U64 const t = (((val >> 8) - mask) | val) & mask; + return ctz7_tab[(t * 0x0080402010080402ULL) >> 57]; +#else + /* this method doesn't consume memory space like the previous one, + * but it contains several branches, + * that may end up slowing execution */ + static const U32 by32 = sizeof(val)*4; /* 32 on 64 bits (goal), 16 on 32 bits. + Just to avoid some static analyzer complaining about shift by 32 on 32-bits target. + Note that this code path is never triggered in 32-bits mode. */ + unsigned r; + if (!(val>>by32)) { r=4; } else { r=0; val>>=by32; } + if (!(val>>16)) { r+=2; val>>=8; } else { val>>=24; } + r += (!val); + return r; +#endif +# endif + } else /* 32 bits */ { +# if (defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 3) || \ + ((__GNUC__ == 3) && (__GNUC_MINOR__ >= 4))))) && \ + !defined(LZ4_FORCE_SW_BITCOUNT) + return (unsigned)__builtin_clz((U32)val) >> 3; +# else + val >>= 8; + val = ((((val + 0x00FFFF00) | 0x00FFFFFF) + val) | + (val + 0x00FF0000)) >> 24; + return (unsigned)val ^ 3; +# endif + } + } +} + + +#define STEPSIZE sizeof(reg_t) +LZ4_FORCE_INLINE +unsigned LZ4_count(const BYTE* pIn, const BYTE* pMatch, const BYTE* pInLimit) +{ + const BYTE* const pStart = pIn; + + if (likely(pIn < pInLimit-(STEPSIZE-1))) { + reg_t const diff = LZ4_read_ARCH(pMatch) ^ LZ4_read_ARCH(pIn); + if (!diff) { + pIn+=STEPSIZE; pMatch+=STEPSIZE; + } else { + return LZ4_NbCommonBytes(diff); + } } + + while (likely(pIn < pInLimit-(STEPSIZE-1))) { + reg_t const diff = LZ4_read_ARCH(pMatch) ^ LZ4_read_ARCH(pIn); + if (!diff) { pIn+=STEPSIZE; pMatch+=STEPSIZE; continue; } + pIn += LZ4_NbCommonBytes(diff); + return (unsigned)(pIn - pStart); + } + + if ((STEPSIZE==8) && (pIn<(pInLimit-3)) && (LZ4_read32(pMatch) == LZ4_read32(pIn))) { pIn+=4; pMatch+=4; } + if ((pIn<(pInLimit-1)) && (LZ4_read16(pMatch) == LZ4_read16(pIn))) { pIn+=2; pMatch+=2; } + if ((pIn compression run slower on incompressible data */ + + +/*-************************************ +* Local Structures and types +**************************************/ +typedef enum { clearedTable = 0, byPtr, byU32, byU16 } tableType_t; + +/** + * This enum distinguishes several different modes of accessing previous + * content in the stream. + * + * - noDict : There is no preceding content. + * - withPrefix64k : Table entries up to ctx->dictSize before the current blob + * blob being compressed are valid and refer to the preceding + * content (of length ctx->dictSize), which is available + * contiguously preceding in memory the content currently + * being compressed. + * - usingExtDict : Like withPrefix64k, but the preceding content is somewhere + * else in memory, starting at ctx->dictionary with length + * ctx->dictSize. + * - usingDictCtx : Everything concerning the preceding content is + * in a separate context, pointed to by ctx->dictCtx. + * ctx->dictionary, ctx->dictSize, and table entries + * in the current context that refer to positions + * preceding the beginning of the current compression are + * ignored. Instead, ctx->dictCtx->dictionary and ctx->dictCtx + * ->dictSize describe the location and size of the preceding + * content, and matches are found by looking in the ctx + * ->dictCtx->hashTable. + */ +typedef enum { noDict = 0, withPrefix64k, usingExtDict, usingDictCtx } dict_directive; +typedef enum { noDictIssue = 0, dictSmall } dictIssue_directive; + + +/*-************************************ +* Local Utils +**************************************/ +int LZ4_versionNumber (void) { return LZ4_VERSION_NUMBER; } +const char* LZ4_versionString(void) { return LZ4_VERSION_STRING; } +int LZ4_compressBound(int isize) { return LZ4_COMPRESSBOUND(isize); } +int LZ4_sizeofState(void) { return sizeof(LZ4_stream_t); } + + +/*-**************************************** +* Internal Definitions, used only in Tests +*******************************************/ +#if defined (__cplusplus) +extern "C" { +#endif + +int LZ4_compress_forceExtDict (LZ4_stream_t* LZ4_dict, const char* source, char* dest, int srcSize); + +int LZ4_decompress_safe_forceExtDict(const char* source, char* dest, + int compressedSize, int maxOutputSize, + const void* dictStart, size_t dictSize); +int LZ4_decompress_safe_partial_forceExtDict(const char* source, char* dest, + int compressedSize, int targetOutputSize, int dstCapacity, + const void* dictStart, size_t dictSize); +#if defined (__cplusplus) +} +#endif + +/*-****************************** +* Compression functions +********************************/ +LZ4_FORCE_INLINE U32 LZ4_hash4(U32 sequence, tableType_t const tableType) +{ + if (tableType == byU16) + return ((sequence * 2654435761U) >> ((MINMATCH*8)-(LZ4_HASHLOG+1))); + else + return ((sequence * 2654435761U) >> ((MINMATCH*8)-LZ4_HASHLOG)); +} + +LZ4_FORCE_INLINE U32 LZ4_hash5(U64 sequence, tableType_t const tableType) +{ + const U32 hashLog = (tableType == byU16) ? LZ4_HASHLOG+1 : LZ4_HASHLOG; + if (LZ4_isLittleEndian()) { + const U64 prime5bytes = 889523592379ULL; + return (U32)(((sequence << 24) * prime5bytes) >> (64 - hashLog)); + } else { + const U64 prime8bytes = 11400714785074694791ULL; + return (U32)(((sequence >> 24) * prime8bytes) >> (64 - hashLog)); + } +} + +LZ4_FORCE_INLINE U32 LZ4_hashPosition(const void* const p, tableType_t const tableType) +{ + if ((sizeof(reg_t)==8) && (tableType != byU16)) return LZ4_hash5(LZ4_read_ARCH(p), tableType); + return LZ4_hash4(LZ4_read32(p), tableType); +} + +LZ4_FORCE_INLINE void LZ4_clearHash(U32 h, void* tableBase, tableType_t const tableType) +{ + switch (tableType) + { + default: /* fallthrough */ + case clearedTable: { /* illegal! */ assert(0); return; } + case byPtr: { const BYTE** hashTable = (const BYTE**)tableBase; hashTable[h] = NULL; return; } + case byU32: { U32* hashTable = (U32*) tableBase; hashTable[h] = 0; return; } + case byU16: { U16* hashTable = (U16*) tableBase; hashTable[h] = 0; return; } + } +} + +LZ4_FORCE_INLINE void LZ4_putIndexOnHash(U32 idx, U32 h, void* tableBase, tableType_t const tableType) +{ + switch (tableType) + { + default: /* fallthrough */ + case clearedTable: /* fallthrough */ + case byPtr: { /* illegal! */ assert(0); return; } + case byU32: { U32* hashTable = (U32*) tableBase; hashTable[h] = idx; return; } + case byU16: { U16* hashTable = (U16*) tableBase; assert(idx < 65536); hashTable[h] = (U16)idx; return; } + } +} + +LZ4_FORCE_INLINE void LZ4_putPositionOnHash(const BYTE* p, U32 h, + void* tableBase, tableType_t const tableType, + const BYTE* srcBase) +{ + switch (tableType) + { + case clearedTable: { /* illegal! */ assert(0); return; } + case byPtr: { const BYTE** hashTable = (const BYTE**)tableBase; hashTable[h] = p; return; } + case byU32: { U32* hashTable = (U32*) tableBase; hashTable[h] = (U32)(p-srcBase); return; } + case byU16: { U16* hashTable = (U16*) tableBase; hashTable[h] = (U16)(p-srcBase); return; } + } +} + +LZ4_FORCE_INLINE void LZ4_putPosition(const BYTE* p, void* tableBase, tableType_t tableType, const BYTE* srcBase) +{ + U32 const h = LZ4_hashPosition(p, tableType); + LZ4_putPositionOnHash(p, h, tableBase, tableType, srcBase); +} + +/* LZ4_getIndexOnHash() : + * Index of match position registered in hash table. + * hash position must be calculated by using base+index, or dictBase+index. + * Assumption 1 : only valid if tableType == byU32 or byU16. + * Assumption 2 : h is presumed valid (within limits of hash table) + */ +LZ4_FORCE_INLINE U32 LZ4_getIndexOnHash(U32 h, const void* tableBase, tableType_t tableType) +{ + LZ4_STATIC_ASSERT(LZ4_MEMORY_USAGE > 2); + if (tableType == byU32) { + const U32* const hashTable = (const U32*) tableBase; + assert(h < (1U << (LZ4_MEMORY_USAGE-2))); + return hashTable[h]; + } + if (tableType == byU16) { + const U16* const hashTable = (const U16*) tableBase; + assert(h < (1U << (LZ4_MEMORY_USAGE-1))); + return hashTable[h]; + } + assert(0); return 0; /* forbidden case */ +} + +static const BYTE* LZ4_getPositionOnHash(U32 h, const void* tableBase, tableType_t tableType, const BYTE* srcBase) +{ + if (tableType == byPtr) { const BYTE* const* hashTable = (const BYTE* const*) tableBase; return hashTable[h]; } + if (tableType == byU32) { const U32* const hashTable = (const U32*) tableBase; return hashTable[h] + srcBase; } + { const U16* const hashTable = (const U16*) tableBase; return hashTable[h] + srcBase; } /* default, to ensure a return */ +} + +LZ4_FORCE_INLINE const BYTE* +LZ4_getPosition(const BYTE* p, + const void* tableBase, tableType_t tableType, + const BYTE* srcBase) +{ + U32 const h = LZ4_hashPosition(p, tableType); + return LZ4_getPositionOnHash(h, tableBase, tableType, srcBase); +} + +LZ4_FORCE_INLINE void +LZ4_prepareTable(LZ4_stream_t_internal* const cctx, + const int inputSize, + const tableType_t tableType) { + /* If the table hasn't been used, it's guaranteed to be zeroed out, and is + * therefore safe to use no matter what mode we're in. Otherwise, we figure + * out if it's safe to leave as is or whether it needs to be reset. + */ + if ((tableType_t)cctx->tableType != clearedTable) { + assert(inputSize >= 0); + if ((tableType_t)cctx->tableType != tableType + || ((tableType == byU16) && cctx->currentOffset + (unsigned)inputSize >= 0xFFFFU) + || ((tableType == byU32) && cctx->currentOffset > 1 GB) + || tableType == byPtr + || inputSize >= 4 KB) + { + DEBUGLOG(4, "LZ4_prepareTable: Resetting table in %p", cctx); + MEM_INIT(cctx->hashTable, 0, LZ4_HASHTABLESIZE); + cctx->currentOffset = 0; + cctx->tableType = (U32)clearedTable; + } else { + DEBUGLOG(4, "LZ4_prepareTable: Re-use hash table (no reset)"); + } + } + + /* Adding a gap, so all previous entries are > LZ4_DISTANCE_MAX back, + * is faster than compressing without a gap. + * However, compressing with currentOffset == 0 is faster still, + * so we preserve that case. + */ + if (cctx->currentOffset != 0 && tableType == byU32) { + DEBUGLOG(5, "LZ4_prepareTable: adding 64KB to currentOffset"); + cctx->currentOffset += 64 KB; + } + + /* Finally, clear history */ + cctx->dictCtx = NULL; + cctx->dictionary = NULL; + cctx->dictSize = 0; +} + +/** LZ4_compress_generic() : + * inlined, to ensure branches are decided at compilation time. + * Presumed already validated at this stage: + * - source != NULL + * - inputSize > 0 + */ +LZ4_FORCE_INLINE int LZ4_compress_generic_validated( + LZ4_stream_t_internal* const cctx, + const char* const source, + char* const dest, + const int inputSize, + int* inputConsumed, /* only written when outputDirective == fillOutput */ + const int maxOutputSize, + const limitedOutput_directive outputDirective, + const tableType_t tableType, + const dict_directive dictDirective, + const dictIssue_directive dictIssue, + const int acceleration) +{ + int result; + const BYTE* ip = (const BYTE*) source; + + U32 const startIndex = cctx->currentOffset; + const BYTE* base = (const BYTE*) source - startIndex; + const BYTE* lowLimit; + + const LZ4_stream_t_internal* dictCtx = (const LZ4_stream_t_internal*) cctx->dictCtx; + const BYTE* const dictionary = + dictDirective == usingDictCtx ? dictCtx->dictionary : cctx->dictionary; + const U32 dictSize = + dictDirective == usingDictCtx ? dictCtx->dictSize : cctx->dictSize; + const U32 dictDelta = (dictDirective == usingDictCtx) ? startIndex - dictCtx->currentOffset : 0; /* make indexes in dictCtx comparable with index in current context */ + + int const maybe_extMem = (dictDirective == usingExtDict) || (dictDirective == usingDictCtx); + U32 const prefixIdxLimit = startIndex - dictSize; /* used when dictDirective == dictSmall */ + const BYTE* const dictEnd = dictionary ? dictionary + dictSize : dictionary; + const BYTE* anchor = (const BYTE*) source; + const BYTE* const iend = ip + inputSize; + const BYTE* const mflimitPlusOne = iend - MFLIMIT + 1; + const BYTE* const matchlimit = iend - LASTLITERALS; + + /* the dictCtx currentOffset is indexed on the start of the dictionary, + * while a dictionary in the current context precedes the currentOffset */ + const BYTE* dictBase = (dictionary == NULL) ? NULL : + (dictDirective == usingDictCtx) ? + dictionary + dictSize - dictCtx->currentOffset : + dictionary + dictSize - startIndex; + + BYTE* op = (BYTE*) dest; + BYTE* const olimit = op + maxOutputSize; + + U32 offset = 0; + U32 forwardH; + + DEBUGLOG(5, "LZ4_compress_generic_validated: srcSize=%i, tableType=%u", inputSize, tableType); + assert(ip != NULL); + /* If init conditions are not met, we don't have to mark stream + * as having dirty context, since no action was taken yet */ + if (outputDirective == fillOutput && maxOutputSize < 1) { return 0; } /* Impossible to store anything */ + if ((tableType == byU16) && (inputSize>=LZ4_64Klimit)) { return 0; } /* Size too large (not within 64K limit) */ + if (tableType==byPtr) assert(dictDirective==noDict); /* only supported use case with byPtr */ + assert(acceleration >= 1); + + lowLimit = (const BYTE*)source - (dictDirective == withPrefix64k ? dictSize : 0); + + /* Update context state */ + if (dictDirective == usingDictCtx) { + /* Subsequent linked blocks can't use the dictionary. */ + /* Instead, they use the block we just compressed. */ + cctx->dictCtx = NULL; + cctx->dictSize = (U32)inputSize; + } else { + cctx->dictSize += (U32)inputSize; + } + cctx->currentOffset += (U32)inputSize; + cctx->tableType = (U32)tableType; + + if (inputSizehashTable, tableType, base); + ip++; forwardH = LZ4_hashPosition(ip, tableType); + + /* Main Loop */ + for ( ; ; ) { + const BYTE* match; + BYTE* token; + const BYTE* filledIp; + + /* Find a match */ + if (tableType == byPtr) { + const BYTE* forwardIp = ip; + int step = 1; + int searchMatchNb = acceleration << LZ4_skipTrigger; + do { + U32 const h = forwardH; + ip = forwardIp; + forwardIp += step; + step = (searchMatchNb++ >> LZ4_skipTrigger); + + if (unlikely(forwardIp > mflimitPlusOne)) goto _last_literals; + assert(ip < mflimitPlusOne); + + match = LZ4_getPositionOnHash(h, cctx->hashTable, tableType, base); + forwardH = LZ4_hashPosition(forwardIp, tableType); + LZ4_putPositionOnHash(ip, h, cctx->hashTable, tableType, base); + + } while ( (match+LZ4_DISTANCE_MAX < ip) + || (LZ4_read32(match) != LZ4_read32(ip)) ); + + } else { /* byU32, byU16 */ + + const BYTE* forwardIp = ip; + int step = 1; + int searchMatchNb = acceleration << LZ4_skipTrigger; + do { + U32 const h = forwardH; + U32 const current = (U32)(forwardIp - base); + U32 matchIndex = LZ4_getIndexOnHash(h, cctx->hashTable, tableType); + assert(matchIndex <= current); + assert(forwardIp - base < (ptrdiff_t)(2 GB - 1)); + ip = forwardIp; + forwardIp += step; + step = (searchMatchNb++ >> LZ4_skipTrigger); + + if (unlikely(forwardIp > mflimitPlusOne)) goto _last_literals; + assert(ip < mflimitPlusOne); + + if (dictDirective == usingDictCtx) { + if (matchIndex < startIndex) { + /* there was no match, try the dictionary */ + assert(tableType == byU32); + matchIndex = LZ4_getIndexOnHash(h, dictCtx->hashTable, byU32); + match = dictBase + matchIndex; + matchIndex += dictDelta; /* make dictCtx index comparable with current context */ + lowLimit = dictionary; + } else { + match = base + matchIndex; + lowLimit = (const BYTE*)source; + } + } else if (dictDirective == usingExtDict) { + if (matchIndex < startIndex) { + DEBUGLOG(7, "extDict candidate: matchIndex=%5u < startIndex=%5u", matchIndex, startIndex); + assert(startIndex - matchIndex >= MINMATCH); + assert(dictBase); + match = dictBase + matchIndex; + lowLimit = dictionary; + } else { + match = base + matchIndex; + lowLimit = (const BYTE*)source; + } + } else { /* single continuous memory segment */ + match = base + matchIndex; + } + forwardH = LZ4_hashPosition(forwardIp, tableType); + LZ4_putIndexOnHash(current, h, cctx->hashTable, tableType); + + DEBUGLOG(7, "candidate at pos=%u (offset=%u \n", matchIndex, current - matchIndex); + if ((dictIssue == dictSmall) && (matchIndex < prefixIdxLimit)) { continue; } /* match outside of valid area */ + assert(matchIndex < current); + if ( ((tableType != byU16) || (LZ4_DISTANCE_MAX < LZ4_DISTANCE_ABSOLUTE_MAX)) + && (matchIndex+LZ4_DISTANCE_MAX < current)) { + continue; + } /* too far */ + assert((current - matchIndex) <= LZ4_DISTANCE_MAX); /* match now expected within distance */ + + if (LZ4_read32(match) == LZ4_read32(ip)) { + if (maybe_extMem) offset = current - matchIndex; + break; /* match found */ + } + + } while(1); + } + + /* Catch up */ + filledIp = ip; + while (((ip>anchor) & (match > lowLimit)) && (unlikely(ip[-1]==match[-1]))) { ip--; match--; } + + /* Encode Literals */ + { unsigned const litLength = (unsigned)(ip - anchor); + token = op++; + if ((outputDirective == limitedOutput) && /* Check output buffer overflow */ + (unlikely(op + litLength + (2 + 1 + LASTLITERALS) + (litLength/255) > olimit)) ) { + return 0; /* cannot compress within `dst` budget. Stored indexes in hash table are nonetheless fine */ + } + if ((outputDirective == fillOutput) && + (unlikely(op + (litLength+240)/255 /* litlen */ + litLength /* literals */ + 2 /* offset */ + 1 /* token */ + MFLIMIT - MINMATCH /* min last literals so last match is <= end - MFLIMIT */ > olimit))) { + op--; + goto _last_literals; + } + if (litLength >= RUN_MASK) { + int len = (int)(litLength - RUN_MASK); + *token = (RUN_MASK<= 255 ; len-=255) *op++ = 255; + *op++ = (BYTE)len; + } + else *token = (BYTE)(litLength< olimit)) { + /* the match was too close to the end, rewind and go to last literals */ + op = token; + goto _last_literals; + } + + /* Encode Offset */ + if (maybe_extMem) { /* static test */ + DEBUGLOG(6, " with offset=%u (ext if > %i)", offset, (int)(ip - (const BYTE*)source)); + assert(offset <= LZ4_DISTANCE_MAX && offset > 0); + LZ4_writeLE16(op, (U16)offset); op+=2; + } else { + DEBUGLOG(6, " with offset=%u (same segment)", (U32)(ip - match)); + assert(ip-match <= LZ4_DISTANCE_MAX); + LZ4_writeLE16(op, (U16)(ip - match)); op+=2; + } + + /* Encode MatchLength */ + { unsigned matchCode; + + if ( (dictDirective==usingExtDict || dictDirective==usingDictCtx) + && (lowLimit==dictionary) /* match within extDict */ ) { + const BYTE* limit = ip + (dictEnd-match); + assert(dictEnd > match); + if (limit > matchlimit) limit = matchlimit; + matchCode = LZ4_count(ip+MINMATCH, match+MINMATCH, limit); + ip += (size_t)matchCode + MINMATCH; + if (ip==limit) { + unsigned const more = LZ4_count(limit, (const BYTE*)source, matchlimit); + matchCode += more; + ip += more; + } + DEBUGLOG(6, " with matchLength=%u starting in extDict", matchCode+MINMATCH); + } else { + matchCode = LZ4_count(ip+MINMATCH, match+MINMATCH, matchlimit); + ip += (size_t)matchCode + MINMATCH; + DEBUGLOG(6, " with matchLength=%u", matchCode+MINMATCH); + } + + if ((outputDirective) && /* Check output buffer overflow */ + (unlikely(op + (1 + LASTLITERALS) + (matchCode+240)/255 > olimit)) ) { + if (outputDirective == fillOutput) { + /* Match description too long : reduce it */ + U32 newMatchCode = 15 /* in token */ - 1 /* to avoid needing a zero byte */ + ((U32)(olimit - op) - 1 - LASTLITERALS) * 255; + ip -= matchCode - newMatchCode; + assert(newMatchCode < matchCode); + matchCode = newMatchCode; + if (unlikely(ip <= filledIp)) { + /* We have already filled up to filledIp so if ip ends up less than filledIp + * we have positions in the hash table beyond the current position. This is + * a problem if we reuse the hash table. So we have to remove these positions + * from the hash table. + */ + const BYTE* ptr; + DEBUGLOG(5, "Clearing %u positions", (U32)(filledIp - ip)); + for (ptr = ip; ptr <= filledIp; ++ptr) { + U32 const h = LZ4_hashPosition(ptr, tableType); + LZ4_clearHash(h, cctx->hashTable, tableType); + } + } + } else { + assert(outputDirective == limitedOutput); + return 0; /* cannot compress within `dst` budget. Stored indexes in hash table are nonetheless fine */ + } + } + if (matchCode >= ML_MASK) { + *token += ML_MASK; + matchCode -= ML_MASK; + LZ4_write32(op, 0xFFFFFFFF); + while (matchCode >= 4*255) { + op+=4; + LZ4_write32(op, 0xFFFFFFFF); + matchCode -= 4*255; + } + op += matchCode / 255; + *op++ = (BYTE)(matchCode % 255); + } else + *token += (BYTE)(matchCode); + } + /* Ensure we have enough space for the last literals. */ + assert(!(outputDirective == fillOutput && op + 1 + LASTLITERALS > olimit)); + + anchor = ip; + + /* Test end of chunk */ + if (ip >= mflimitPlusOne) break; + + /* Fill table */ + LZ4_putPosition(ip-2, cctx->hashTable, tableType, base); + + /* Test next position */ + if (tableType == byPtr) { + + match = LZ4_getPosition(ip, cctx->hashTable, tableType, base); + LZ4_putPosition(ip, cctx->hashTable, tableType, base); + if ( (match+LZ4_DISTANCE_MAX >= ip) + && (LZ4_read32(match) == LZ4_read32(ip)) ) + { token=op++; *token=0; goto _next_match; } + + } else { /* byU32, byU16 */ + + U32 const h = LZ4_hashPosition(ip, tableType); + U32 const current = (U32)(ip-base); + U32 matchIndex = LZ4_getIndexOnHash(h, cctx->hashTable, tableType); + assert(matchIndex < current); + if (dictDirective == usingDictCtx) { + if (matchIndex < startIndex) { + /* there was no match, try the dictionary */ + matchIndex = LZ4_getIndexOnHash(h, dictCtx->hashTable, byU32); + match = dictBase + matchIndex; + lowLimit = dictionary; /* required for match length counter */ + matchIndex += dictDelta; + } else { + match = base + matchIndex; + lowLimit = (const BYTE*)source; /* required for match length counter */ + } + } else if (dictDirective==usingExtDict) { + if (matchIndex < startIndex) { + assert(dictBase); + match = dictBase + matchIndex; + lowLimit = dictionary; /* required for match length counter */ + } else { + match = base + matchIndex; + lowLimit = (const BYTE*)source; /* required for match length counter */ + } + } else { /* single memory segment */ + match = base + matchIndex; + } + LZ4_putIndexOnHash(current, h, cctx->hashTable, tableType); + assert(matchIndex < current); + if ( ((dictIssue==dictSmall) ? (matchIndex >= prefixIdxLimit) : 1) + && (((tableType==byU16) && (LZ4_DISTANCE_MAX == LZ4_DISTANCE_ABSOLUTE_MAX)) ? 1 : (matchIndex+LZ4_DISTANCE_MAX >= current)) + && (LZ4_read32(match) == LZ4_read32(ip)) ) { + token=op++; + *token=0; + if (maybe_extMem) offset = current - matchIndex; + DEBUGLOG(6, "seq.start:%i, literals=%u, match.start:%i", + (int)(anchor-(const BYTE*)source), 0, (int)(ip-(const BYTE*)source)); + goto _next_match; + } + } + + /* Prepare next loop */ + forwardH = LZ4_hashPosition(++ip, tableType); + + } + +_last_literals: + /* Encode Last Literals */ + { size_t lastRun = (size_t)(iend - anchor); + if ( (outputDirective) && /* Check output buffer overflow */ + (op + lastRun + 1 + ((lastRun+255-RUN_MASK)/255) > olimit)) { + if (outputDirective == fillOutput) { + /* adapt lastRun to fill 'dst' */ + assert(olimit >= op); + lastRun = (size_t)(olimit-op) - 1/*token*/; + lastRun -= (lastRun + 256 - RUN_MASK) / 256; /*additional length tokens*/ + } else { + assert(outputDirective == limitedOutput); + return 0; /* cannot compress within `dst` budget. Stored indexes in hash table are nonetheless fine */ + } + } + DEBUGLOG(6, "Final literal run : %i literals", (int)lastRun); + if (lastRun >= RUN_MASK) { + size_t accumulator = lastRun - RUN_MASK; + *op++ = RUN_MASK << ML_BITS; + for(; accumulator >= 255 ; accumulator-=255) *op++ = 255; + *op++ = (BYTE) accumulator; + } else { + *op++ = (BYTE)(lastRun< 0); + DEBUGLOG(5, "LZ4_compress_generic: compressed %i bytes into %i bytes", inputSize, result); + return result; +} + +/** LZ4_compress_generic() : + * inlined, to ensure branches are decided at compilation time; + * takes care of src == (NULL, 0) + * and forward the rest to LZ4_compress_generic_validated */ +LZ4_FORCE_INLINE int LZ4_compress_generic( + LZ4_stream_t_internal* const cctx, + const char* const src, + char* const dst, + const int srcSize, + int *inputConsumed, /* only written when outputDirective == fillOutput */ + const int dstCapacity, + const limitedOutput_directive outputDirective, + const tableType_t tableType, + const dict_directive dictDirective, + const dictIssue_directive dictIssue, + const int acceleration) +{ + DEBUGLOG(5, "LZ4_compress_generic: srcSize=%i, dstCapacity=%i", + srcSize, dstCapacity); + + if ((U32)srcSize > (U32)LZ4_MAX_INPUT_SIZE) { return 0; } /* Unsupported srcSize, too large (or negative) */ + if (srcSize == 0) { /* src == NULL supported if srcSize == 0 */ + if (outputDirective != notLimited && dstCapacity <= 0) return 0; /* no output, can't write anything */ + DEBUGLOG(5, "Generating an empty block"); + assert(outputDirective == notLimited || dstCapacity >= 1); + assert(dst != NULL); + dst[0] = 0; + if (outputDirective == fillOutput) { + assert (inputConsumed != NULL); + *inputConsumed = 0; + } + return 1; + } + assert(src != NULL); + + return LZ4_compress_generic_validated(cctx, src, dst, srcSize, + inputConsumed, /* only written into if outputDirective == fillOutput */ + dstCapacity, outputDirective, + tableType, dictDirective, dictIssue, acceleration); +} + + +int LZ4_compress_fast_extState(void* state, const char* source, char* dest, int inputSize, int maxOutputSize, int acceleration) +{ + LZ4_stream_t_internal* const ctx = & LZ4_initStream(state, sizeof(LZ4_stream_t)) -> internal_donotuse; + assert(ctx != NULL); + if (acceleration < 1) acceleration = LZ4_ACCELERATION_DEFAULT; + if (acceleration > LZ4_ACCELERATION_MAX) acceleration = LZ4_ACCELERATION_MAX; + if (maxOutputSize >= LZ4_compressBound(inputSize)) { + if (inputSize < LZ4_64Klimit) { + return LZ4_compress_generic(ctx, source, dest, inputSize, NULL, 0, notLimited, byU16, noDict, noDictIssue, acceleration); + } else { + const tableType_t tableType = ((sizeof(void*)==4) && ((uptrval)source > LZ4_DISTANCE_MAX)) ? byPtr : byU32; + return LZ4_compress_generic(ctx, source, dest, inputSize, NULL, 0, notLimited, tableType, noDict, noDictIssue, acceleration); + } + } else { + if (inputSize < LZ4_64Klimit) { + return LZ4_compress_generic(ctx, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, byU16, noDict, noDictIssue, acceleration); + } else { + const tableType_t tableType = ((sizeof(void*)==4) && ((uptrval)source > LZ4_DISTANCE_MAX)) ? byPtr : byU32; + return LZ4_compress_generic(ctx, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, noDict, noDictIssue, acceleration); + } + } +} + +/** + * LZ4_compress_fast_extState_fastReset() : + * A variant of LZ4_compress_fast_extState(). + * + * Using this variant avoids an expensive initialization step. It is only safe + * to call if the state buffer is known to be correctly initialized already + * (see comment in lz4.h on LZ4_resetStream_fast() for a definition of + * "correctly initialized"). + */ +int LZ4_compress_fast_extState_fastReset(void* state, const char* src, char* dst, int srcSize, int dstCapacity, int acceleration) +{ + LZ4_stream_t_internal* ctx = &((LZ4_stream_t*)state)->internal_donotuse; + if (acceleration < 1) acceleration = LZ4_ACCELERATION_DEFAULT; + if (acceleration > LZ4_ACCELERATION_MAX) acceleration = LZ4_ACCELERATION_MAX; + + if (dstCapacity >= LZ4_compressBound(srcSize)) { + if (srcSize < LZ4_64Klimit) { + const tableType_t tableType = byU16; + LZ4_prepareTable(ctx, srcSize, tableType); + if (ctx->currentOffset) { + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, 0, notLimited, tableType, noDict, dictSmall, acceleration); + } else { + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, 0, notLimited, tableType, noDict, noDictIssue, acceleration); + } + } else { + const tableType_t tableType = ((sizeof(void*)==4) && ((uptrval)src > LZ4_DISTANCE_MAX)) ? byPtr : byU32; + LZ4_prepareTable(ctx, srcSize, tableType); + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, 0, notLimited, tableType, noDict, noDictIssue, acceleration); + } + } else { + if (srcSize < LZ4_64Klimit) { + const tableType_t tableType = byU16; + LZ4_prepareTable(ctx, srcSize, tableType); + if (ctx->currentOffset) { + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, dstCapacity, limitedOutput, tableType, noDict, dictSmall, acceleration); + } else { + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, dstCapacity, limitedOutput, tableType, noDict, noDictIssue, acceleration); + } + } else { + const tableType_t tableType = ((sizeof(void*)==4) && ((uptrval)src > LZ4_DISTANCE_MAX)) ? byPtr : byU32; + LZ4_prepareTable(ctx, srcSize, tableType); + return LZ4_compress_generic(ctx, src, dst, srcSize, NULL, dstCapacity, limitedOutput, tableType, noDict, noDictIssue, acceleration); + } + } +} + + +int LZ4_compress_fast(const char* source, char* dest, int inputSize, int maxOutputSize, int acceleration) +{ + int result; +#if (LZ4_HEAPMODE) + LZ4_stream_t* ctxPtr = (LZ4_stream_t*)ALLOC(sizeof(LZ4_stream_t)); /* malloc-calloc always properly aligned */ + if (ctxPtr == NULL) return 0; +#else + LZ4_stream_t ctx; + LZ4_stream_t* const ctxPtr = &ctx; +#endif + result = LZ4_compress_fast_extState(ctxPtr, source, dest, inputSize, maxOutputSize, acceleration); + +#if (LZ4_HEAPMODE) + FREEMEM(ctxPtr); +#endif + return result; +} + + +int LZ4_compress_default(const char* src, char* dst, int srcSize, int maxOutputSize) +{ + return LZ4_compress_fast(src, dst, srcSize, maxOutputSize, 1); +} + + +/* Note!: This function leaves the stream in an unclean/broken state! + * It is not safe to subsequently use the same state with a _fastReset() or + * _continue() call without resetting it. */ +static int LZ4_compress_destSize_extState (LZ4_stream_t* state, const char* src, char* dst, int* srcSizePtr, int targetDstSize) +{ + void* const s = LZ4_initStream(state, sizeof (*state)); + assert(s != NULL); (void)s; + + if (targetDstSize >= LZ4_compressBound(*srcSizePtr)) { /* compression success is guaranteed */ + return LZ4_compress_fast_extState(state, src, dst, *srcSizePtr, targetDstSize, 1); + } else { + if (*srcSizePtr < LZ4_64Klimit) { + return LZ4_compress_generic(&state->internal_donotuse, src, dst, *srcSizePtr, srcSizePtr, targetDstSize, fillOutput, byU16, noDict, noDictIssue, 1); + } else { + tableType_t const addrMode = ((sizeof(void*)==4) && ((uptrval)src > LZ4_DISTANCE_MAX)) ? byPtr : byU32; + return LZ4_compress_generic(&state->internal_donotuse, src, dst, *srcSizePtr, srcSizePtr, targetDstSize, fillOutput, addrMode, noDict, noDictIssue, 1); + } } +} + + +int LZ4_compress_destSize(const char* src, char* dst, int* srcSizePtr, int targetDstSize) +{ +#if (LZ4_HEAPMODE) + LZ4_stream_t* ctx = (LZ4_stream_t*)ALLOC(sizeof(LZ4_stream_t)); /* malloc-calloc always properly aligned */ + if (ctx == NULL) return 0; +#else + LZ4_stream_t ctxBody; + LZ4_stream_t* ctx = &ctxBody; +#endif + + int result = LZ4_compress_destSize_extState(ctx, src, dst, srcSizePtr, targetDstSize); + +#if (LZ4_HEAPMODE) + FREEMEM(ctx); +#endif + return result; +} + + + +/*-****************************** +* Streaming functions +********************************/ + +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +LZ4_stream_t* LZ4_createStream(void) +{ + LZ4_stream_t* const lz4s = (LZ4_stream_t*)ALLOC(sizeof(LZ4_stream_t)); + LZ4_STATIC_ASSERT(sizeof(LZ4_stream_t) >= sizeof(LZ4_stream_t_internal)); + DEBUGLOG(4, "LZ4_createStream %p", lz4s); + if (lz4s == NULL) return NULL; + LZ4_initStream(lz4s, sizeof(*lz4s)); + return lz4s; +} +#endif + +static size_t LZ4_stream_t_alignment(void) +{ +#if LZ4_ALIGN_TEST + typedef struct { char c; LZ4_stream_t t; } t_a; + return sizeof(t_a) - sizeof(LZ4_stream_t); +#else + return 1; /* effectively disabled */ +#endif +} + +LZ4_stream_t* LZ4_initStream (void* buffer, size_t size) +{ + DEBUGLOG(5, "LZ4_initStream"); + if (buffer == NULL) { return NULL; } + if (size < sizeof(LZ4_stream_t)) { return NULL; } + if (!LZ4_isAligned(buffer, LZ4_stream_t_alignment())) return NULL; + MEM_INIT(buffer, 0, sizeof(LZ4_stream_t_internal)); + return (LZ4_stream_t*)buffer; +} + +/* resetStream is now deprecated, + * prefer initStream() which is more general */ +void LZ4_resetStream (LZ4_stream_t* LZ4_stream) +{ + DEBUGLOG(5, "LZ4_resetStream (ctx:%p)", LZ4_stream); + MEM_INIT(LZ4_stream, 0, sizeof(LZ4_stream_t_internal)); +} + +void LZ4_resetStream_fast(LZ4_stream_t* ctx) { + LZ4_prepareTable(&(ctx->internal_donotuse), 0, byU32); +} + +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +int LZ4_freeStream (LZ4_stream_t* LZ4_stream) +{ + if (!LZ4_stream) return 0; /* support free on NULL */ + DEBUGLOG(5, "LZ4_freeStream %p", LZ4_stream); + FREEMEM(LZ4_stream); + return (0); +} +#endif + + +#define HASH_UNIT sizeof(reg_t) +int LZ4_loadDict (LZ4_stream_t* LZ4_dict, const char* dictionary, int dictSize) +{ + LZ4_stream_t_internal* dict = &LZ4_dict->internal_donotuse; + const tableType_t tableType = byU32; + const BYTE* p = (const BYTE*)dictionary; + const BYTE* const dictEnd = p + dictSize; + const BYTE* base; + + DEBUGLOG(4, "LZ4_loadDict (%i bytes from %p into %p)", dictSize, dictionary, LZ4_dict); + + /* It's necessary to reset the context, + * and not just continue it with prepareTable() + * to avoid any risk of generating overflowing matchIndex + * when compressing using this dictionary */ + LZ4_resetStream(LZ4_dict); + + /* We always increment the offset by 64 KB, since, if the dict is longer, + * we truncate it to the last 64k, and if it's shorter, we still want to + * advance by a whole window length so we can provide the guarantee that + * there are only valid offsets in the window, which allows an optimization + * in LZ4_compress_fast_continue() where it uses noDictIssue even when the + * dictionary isn't a full 64k. */ + dict->currentOffset += 64 KB; + + if (dictSize < (int)HASH_UNIT) { + return 0; + } + + if ((dictEnd - p) > 64 KB) p = dictEnd - 64 KB; + base = dictEnd - dict->currentOffset; + dict->dictionary = p; + dict->dictSize = (U32)(dictEnd - p); + dict->tableType = (U32)tableType; + + while (p <= dictEnd-HASH_UNIT) { + LZ4_putPosition(p, dict->hashTable, tableType, base); + p+=3; + } + + return (int)dict->dictSize; +} + +void LZ4_attach_dictionary(LZ4_stream_t* workingStream, const LZ4_stream_t* dictionaryStream) +{ + const LZ4_stream_t_internal* dictCtx = (dictionaryStream == NULL) ? NULL : + &(dictionaryStream->internal_donotuse); + + DEBUGLOG(4, "LZ4_attach_dictionary (%p, %p, size %u)", + workingStream, dictionaryStream, + dictCtx != NULL ? dictCtx->dictSize : 0); + + if (dictCtx != NULL) { + /* If the current offset is zero, we will never look in the + * external dictionary context, since there is no value a table + * entry can take that indicate a miss. In that case, we need + * to bump the offset to something non-zero. + */ + if (workingStream->internal_donotuse.currentOffset == 0) { + workingStream->internal_donotuse.currentOffset = 64 KB; + } + + /* Don't actually attach an empty dictionary. + */ + if (dictCtx->dictSize == 0) { + dictCtx = NULL; + } + } + workingStream->internal_donotuse.dictCtx = dictCtx; +} + + +static void LZ4_renormDictT(LZ4_stream_t_internal* LZ4_dict, int nextSize) +{ + assert(nextSize >= 0); + if (LZ4_dict->currentOffset + (unsigned)nextSize > 0x80000000) { /* potential ptrdiff_t overflow (32-bits mode) */ + /* rescale hash table */ + U32 const delta = LZ4_dict->currentOffset - 64 KB; + const BYTE* dictEnd = LZ4_dict->dictionary + LZ4_dict->dictSize; + int i; + DEBUGLOG(4, "LZ4_renormDictT"); + for (i=0; ihashTable[i] < delta) LZ4_dict->hashTable[i]=0; + else LZ4_dict->hashTable[i] -= delta; + } + LZ4_dict->currentOffset = 64 KB; + if (LZ4_dict->dictSize > 64 KB) LZ4_dict->dictSize = 64 KB; + LZ4_dict->dictionary = dictEnd - LZ4_dict->dictSize; + } +} + + +int LZ4_compress_fast_continue (LZ4_stream_t* LZ4_stream, + const char* source, char* dest, + int inputSize, int maxOutputSize, + int acceleration) +{ + const tableType_t tableType = byU32; + LZ4_stream_t_internal* const streamPtr = &LZ4_stream->internal_donotuse; + const char* dictEnd = streamPtr->dictSize ? (const char*)streamPtr->dictionary + streamPtr->dictSize : NULL; + + DEBUGLOG(5, "LZ4_compress_fast_continue (inputSize=%i, dictSize=%u)", inputSize, streamPtr->dictSize); + + LZ4_renormDictT(streamPtr, inputSize); /* fix index overflow */ + if (acceleration < 1) acceleration = LZ4_ACCELERATION_DEFAULT; + if (acceleration > LZ4_ACCELERATION_MAX) acceleration = LZ4_ACCELERATION_MAX; + + /* invalidate tiny dictionaries */ + if ( (streamPtr->dictSize < 4) /* tiny dictionary : not enough for a hash */ + && (dictEnd != source) /* prefix mode */ + && (inputSize > 0) /* tolerance : don't lose history, in case next invocation would use prefix mode */ + && (streamPtr->dictCtx == NULL) /* usingDictCtx */ + ) { + DEBUGLOG(5, "LZ4_compress_fast_continue: dictSize(%u) at addr:%p is too small", streamPtr->dictSize, streamPtr->dictionary); + /* remove dictionary existence from history, to employ faster prefix mode */ + streamPtr->dictSize = 0; + streamPtr->dictionary = (const BYTE*)source; + dictEnd = source; + } + + /* Check overlapping input/dictionary space */ + { const char* const sourceEnd = source + inputSize; + if ((sourceEnd > (const char*)streamPtr->dictionary) && (sourceEnd < dictEnd)) { + streamPtr->dictSize = (U32)(dictEnd - sourceEnd); + if (streamPtr->dictSize > 64 KB) streamPtr->dictSize = 64 KB; + if (streamPtr->dictSize < 4) streamPtr->dictSize = 0; + streamPtr->dictionary = (const BYTE*)dictEnd - streamPtr->dictSize; + } + } + + /* prefix mode : source data follows dictionary */ + if (dictEnd == source) { + if ((streamPtr->dictSize < 64 KB) && (streamPtr->dictSize < streamPtr->currentOffset)) + return LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, withPrefix64k, dictSmall, acceleration); + else + return LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, withPrefix64k, noDictIssue, acceleration); + } + + /* external dictionary mode */ + { int result; + if (streamPtr->dictCtx) { + /* We depend here on the fact that dictCtx'es (produced by + * LZ4_loadDict) guarantee that their tables contain no references + * to offsets between dictCtx->currentOffset - 64 KB and + * dictCtx->currentOffset - dictCtx->dictSize. This makes it safe + * to use noDictIssue even when the dict isn't a full 64 KB. + */ + if (inputSize > 4 KB) { + /* For compressing large blobs, it is faster to pay the setup + * cost to copy the dictionary's tables into the active context, + * so that the compression loop is only looking into one table. + */ + LZ4_memcpy(streamPtr, streamPtr->dictCtx, sizeof(*streamPtr)); + result = LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, usingExtDict, noDictIssue, acceleration); + } else { + result = LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, usingDictCtx, noDictIssue, acceleration); + } + } else { /* small data <= 4 KB */ + if ((streamPtr->dictSize < 64 KB) && (streamPtr->dictSize < streamPtr->currentOffset)) { + result = LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, usingExtDict, dictSmall, acceleration); + } else { + result = LZ4_compress_generic(streamPtr, source, dest, inputSize, NULL, maxOutputSize, limitedOutput, tableType, usingExtDict, noDictIssue, acceleration); + } + } + streamPtr->dictionary = (const BYTE*)source; + streamPtr->dictSize = (U32)inputSize; + return result; + } +} + + +/* Hidden debug function, to force-test external dictionary mode */ +int LZ4_compress_forceExtDict (LZ4_stream_t* LZ4_dict, const char* source, char* dest, int srcSize) +{ + LZ4_stream_t_internal* streamPtr = &LZ4_dict->internal_donotuse; + int result; + + LZ4_renormDictT(streamPtr, srcSize); + + if ((streamPtr->dictSize < 64 KB) && (streamPtr->dictSize < streamPtr->currentOffset)) { + result = LZ4_compress_generic(streamPtr, source, dest, srcSize, NULL, 0, notLimited, byU32, usingExtDict, dictSmall, 1); + } else { + result = LZ4_compress_generic(streamPtr, source, dest, srcSize, NULL, 0, notLimited, byU32, usingExtDict, noDictIssue, 1); + } + + streamPtr->dictionary = (const BYTE*)source; + streamPtr->dictSize = (U32)srcSize; + + return result; +} + + +/*! LZ4_saveDict() : + * If previously compressed data block is not guaranteed to remain available at its memory location, + * save it into a safer place (char* safeBuffer). + * Note : no need to call LZ4_loadDict() afterwards, dictionary is immediately usable, + * one can therefore call LZ4_compress_fast_continue() right after. + * @return : saved dictionary size in bytes (necessarily <= dictSize), or 0 if error. + */ +int LZ4_saveDict (LZ4_stream_t* LZ4_dict, char* safeBuffer, int dictSize) +{ + LZ4_stream_t_internal* const dict = &LZ4_dict->internal_donotuse; + + DEBUGLOG(5, "LZ4_saveDict : dictSize=%i, safeBuffer=%p", dictSize, safeBuffer); + + if ((U32)dictSize > 64 KB) { dictSize = 64 KB; } /* useless to define a dictionary > 64 KB */ + if ((U32)dictSize > dict->dictSize) { dictSize = (int)dict->dictSize; } + + if (safeBuffer == NULL) assert(dictSize == 0); + if (dictSize > 0) { + const BYTE* const previousDictEnd = dict->dictionary + dict->dictSize; + assert(dict->dictionary); + LZ4_memmove(safeBuffer, previousDictEnd - dictSize, (size_t)dictSize); + } + + dict->dictionary = (const BYTE*)safeBuffer; + dict->dictSize = (U32)dictSize; + + return dictSize; +} + + + +/*-******************************* + * Decompression functions + ********************************/ + +typedef enum { decode_full_block = 0, partial_decode = 1 } earlyEnd_directive; + +#undef MIN +#define MIN(a,b) ( (a) < (b) ? (a) : (b) ) + + +/* variant for decompress_unsafe() + * does not know end of input + * presumes input is well formed + * note : will consume at least one byte */ +size_t read_long_length_no_check(const BYTE** pp) +{ + size_t b, l = 0; + do { b = **pp; (*pp)++; l += b; } while (b==255); + DEBUGLOG(6, "read_long_length_no_check: +length=%zu using %zu input bytes", l, l/255 + 1) + return l; +} + +/* core decoder variant for LZ4_decompress_fast*() + * for legacy support only : these entry points are deprecated. + * - Presumes input is correctly formed (no defense vs malformed inputs) + * - Does not know input size (presume input buffer is "large enough") + * - Decompress a full block (only) + * @return : nb of bytes read from input. + * Note : this variant is not optimized for speed, just for maintenance. + * the goal is to remove support of decompress_fast*() variants by v2.0 +**/ +LZ4_FORCE_INLINE int +LZ4_decompress_unsafe_generic( + const BYTE* const istart, + BYTE* const ostart, + int decompressedSize, + + size_t prefixSize, + const BYTE* const dictStart, /* only if dict==usingExtDict */ + const size_t dictSize /* note: =0 if dictStart==NULL */ + ) +{ + const BYTE* ip = istart; + BYTE* op = (BYTE*)ostart; + BYTE* const oend = ostart + decompressedSize; + const BYTE* const prefixStart = ostart - prefixSize; + + DEBUGLOG(5, "LZ4_decompress_unsafe_generic"); + if (dictStart == NULL) assert(dictSize == 0); + + while (1) { + /* start new sequence */ + unsigned token = *ip++; + + /* literals */ + { size_t ll = token >> ML_BITS; + if (ll==15) { + /* long literal length */ + ll += read_long_length_no_check(&ip); + } + if ((size_t)(oend-op) < ll) return -1; /* output buffer overflow */ + LZ4_memmove(op, ip, ll); /* support in-place decompression */ + op += ll; + ip += ll; + if ((size_t)(oend-op) < MFLIMIT) { + if (op==oend) break; /* end of block */ + DEBUGLOG(5, "invalid: literals end at distance %zi from end of block", oend-op); + /* incorrect end of block : + * last match must start at least MFLIMIT==12 bytes before end of output block */ + return -1; + } } + + /* match */ + { size_t ml = token & 15; + size_t const offset = LZ4_readLE16(ip); + ip+=2; + + if (ml==15) { + /* long literal length */ + ml += read_long_length_no_check(&ip); + } + ml += MINMATCH; + + if ((size_t)(oend-op) < ml) return -1; /* output buffer overflow */ + + { const BYTE* match = op - offset; + + /* out of range */ + if (offset > (size_t)(op - prefixStart) + dictSize) { + DEBUGLOG(6, "offset out of range"); + return -1; + } + + /* check special case : extDict */ + if (offset > (size_t)(op - prefixStart)) { + /* extDict scenario */ + const BYTE* const dictEnd = dictStart + dictSize; + const BYTE* extMatch = dictEnd - (offset - (size_t)(op-prefixStart)); + size_t const extml = (size_t)(dictEnd - extMatch); + if (extml > ml) { + /* match entirely within extDict */ + LZ4_memmove(op, extMatch, ml); + op += ml; + ml = 0; + } else { + /* match split between extDict & prefix */ + LZ4_memmove(op, extMatch, extml); + op += extml; + ml -= extml; + } + match = prefixStart; + } + + /* match copy - slow variant, supporting overlap copy */ + { size_t u; + for (u=0; u= ipmax before start of loop. Returns initial_error if so. + * @error (output) - error code. Must be set to 0 before call. +**/ +typedef size_t Rvl_t; +static const Rvl_t rvl_error = (Rvl_t)(-1); +LZ4_FORCE_INLINE Rvl_t +read_variable_length(const BYTE** ip, const BYTE* ilimit, + int initial_check) +{ + Rvl_t s, length = 0; + assert(ip != NULL); + assert(*ip != NULL); + assert(ilimit != NULL); + if (initial_check && unlikely((*ip) >= ilimit)) { /* read limit reached */ + return rvl_error; + } + do { + s = **ip; + (*ip)++; + length += s; + if (unlikely((*ip) > ilimit)) { /* read limit reached */ + return rvl_error; + } + /* accumulator overflow detection (32-bit mode only) */ + if ((sizeof(length)<8) && unlikely(length > ((Rvl_t)(-1)/2)) ) { + return rvl_error; + } + } while (s==255); + + return length; +} + +/*! LZ4_decompress_generic() : + * This generic decompression function covers all use cases. + * It shall be instantiated several times, using different sets of directives. + * Note that it is important for performance that this function really get inlined, + * in order to remove useless branches during compilation optimization. + */ +LZ4_FORCE_INLINE int +LZ4_decompress_generic( + const char* const src, + char* const dst, + int srcSize, + int outputSize, /* If endOnInput==endOnInputSize, this value is `dstCapacity` */ + + earlyEnd_directive partialDecoding, /* full, partial */ + dict_directive dict, /* noDict, withPrefix64k, usingExtDict */ + const BYTE* const lowPrefix, /* always <= dst, == dst when no prefix */ + const BYTE* const dictStart, /* only if dict==usingExtDict */ + const size_t dictSize /* note : = 0 if noDict */ + ) +{ + if ((src == NULL) || (outputSize < 0)) { return -1; } + + { const BYTE* ip = (const BYTE*) src; + const BYTE* const iend = ip + srcSize; + + BYTE* op = (BYTE*) dst; + BYTE* const oend = op + outputSize; + BYTE* cpy; + + const BYTE* const dictEnd = (dictStart == NULL) ? NULL : dictStart + dictSize; + + const int checkOffset = (dictSize < (int)(64 KB)); + + + /* Set up the "end" pointers for the shortcut. */ + const BYTE* const shortiend = iend - 14 /*maxLL*/ - 2 /*offset*/; + const BYTE* const shortoend = oend - 14 /*maxLL*/ - 18 /*maxML*/; + + const BYTE* match; + size_t offset; + unsigned token; + size_t length; + + + DEBUGLOG(5, "LZ4_decompress_generic (srcSize:%i, dstSize:%i)", srcSize, outputSize); + + /* Special cases */ + assert(lowPrefix <= op); + if (unlikely(outputSize==0)) { + /* Empty output buffer */ + if (partialDecoding) return 0; + return ((srcSize==1) && (*ip==0)) ? 0 : -1; + } + if (unlikely(srcSize==0)) { return -1; } + + /* LZ4_FAST_DEC_LOOP: + * designed for modern OoO performance cpus, + * where copying reliably 32-bytes is preferable to an unpredictable branch. + * note : fast loop may show a regression for some client arm chips. */ +#if LZ4_FAST_DEC_LOOP + if ((oend - op) < FASTLOOP_SAFE_DISTANCE) { + DEBUGLOG(6, "skip fast decode loop"); + goto safe_decode; + } + + /* Fast loop : decode sequences as long as output < oend-FASTLOOP_SAFE_DISTANCE */ + while (1) { + /* Main fastloop assertion: We can always wildcopy FASTLOOP_SAFE_DISTANCE */ + assert(oend - op >= FASTLOOP_SAFE_DISTANCE); + assert(ip < iend); + token = *ip++; + length = token >> ML_BITS; /* literal length */ + + /* decode literal length */ + if (length == RUN_MASK) { + size_t const addl = read_variable_length(&ip, iend-RUN_MASK, 1); + if (addl == rvl_error) { goto _output_error; } + length += addl; + if (unlikely((uptrval)(op)+length<(uptrval)(op))) { goto _output_error; } /* overflow detection */ + if (unlikely((uptrval)(ip)+length<(uptrval)(ip))) { goto _output_error; } /* overflow detection */ + + /* copy literals */ + cpy = op+length; + LZ4_STATIC_ASSERT(MFLIMIT >= WILDCOPYLENGTH); + if ((cpy>oend-32) || (ip+length>iend-32)) { goto safe_literal_copy; } + LZ4_wildCopy32(op, ip, cpy); + ip += length; op = cpy; + } else { + cpy = op+length; + DEBUGLOG(7, "copy %u bytes in a 16-bytes stripe", (unsigned)length); + /* We don't need to check oend, since we check it once for each loop below */ + if (ip > iend-(16 + 1/*max lit + offset + nextToken*/)) { goto safe_literal_copy; } + /* Literals can only be <= 14, but hope compilers optimize better when copy by a register size */ + LZ4_memcpy(op, ip, 16); + ip += length; op = cpy; + } + + /* get offset */ + offset = LZ4_readLE16(ip); ip+=2; + match = op - offset; + assert(match <= op); /* overflow check */ + + /* get matchlength */ + length = token & ML_MASK; + + if (length == ML_MASK) { + size_t const addl = read_variable_length(&ip, iend - LASTLITERALS + 1, 0); + if (addl == rvl_error) { goto _output_error; } + length += addl; + length += MINMATCH; + if (unlikely((uptrval)(op)+length<(uptrval)op)) { goto _output_error; } /* overflow detection */ + if ((checkOffset) && (unlikely(match + dictSize < lowPrefix))) { goto _output_error; } /* Error : offset outside buffers */ + if (op + length >= oend - FASTLOOP_SAFE_DISTANCE) { + goto safe_match_copy; + } + } else { + length += MINMATCH; + if (op + length >= oend - FASTLOOP_SAFE_DISTANCE) { + goto safe_match_copy; + } + + /* Fastpath check: skip LZ4_wildCopy32 when true */ + if ((dict == withPrefix64k) || (match >= lowPrefix)) { + if (offset >= 8) { + assert(match >= lowPrefix); + assert(match <= op); + assert(op + 18 <= oend); + + LZ4_memcpy(op, match, 8); + LZ4_memcpy(op+8, match+8, 8); + LZ4_memcpy(op+16, match+16, 2); + op += length; + continue; + } } } + + if (checkOffset && (unlikely(match + dictSize < lowPrefix))) { goto _output_error; } /* Error : offset outside buffers */ + /* match starting within external dictionary */ + if ((dict==usingExtDict) && (match < lowPrefix)) { + assert(dictEnd != NULL); + if (unlikely(op+length > oend-LASTLITERALS)) { + if (partialDecoding) { + DEBUGLOG(7, "partialDecoding: dictionary match, close to dstEnd"); + length = MIN(length, (size_t)(oend-op)); + } else { + goto _output_error; /* end-of-block condition violated */ + } } + + if (length <= (size_t)(lowPrefix-match)) { + /* match fits entirely within external dictionary : just copy */ + LZ4_memmove(op, dictEnd - (lowPrefix-match), length); + op += length; + } else { + /* match stretches into both external dictionary and current block */ + size_t const copySize = (size_t)(lowPrefix - match); + size_t const restSize = length - copySize; + LZ4_memcpy(op, dictEnd - copySize, copySize); + op += copySize; + if (restSize > (size_t)(op - lowPrefix)) { /* overlap copy */ + BYTE* const endOfMatch = op + restSize; + const BYTE* copyFrom = lowPrefix; + while (op < endOfMatch) { *op++ = *copyFrom++; } + } else { + LZ4_memcpy(op, lowPrefix, restSize); + op += restSize; + } } + continue; + } + + /* copy match within block */ + cpy = op + length; + + assert((op <= oend) && (oend-op >= 32)); + if (unlikely(offset<16)) { + LZ4_memcpy_using_offset(op, match, cpy, offset); + } else { + LZ4_wildCopy32(op, match, cpy); + } + + op = cpy; /* wildcopy correction */ + } + safe_decode: +#endif + + /* Main Loop : decode remaining sequences where output < FASTLOOP_SAFE_DISTANCE */ + while (1) { + assert(ip < iend); + token = *ip++; + length = token >> ML_BITS; /* literal length */ + + /* A two-stage shortcut for the most common case: + * 1) If the literal length is 0..14, and there is enough space, + * enter the shortcut and copy 16 bytes on behalf of the literals + * (in the fast mode, only 8 bytes can be safely copied this way). + * 2) Further if the match length is 4..18, copy 18 bytes in a similar + * manner; but we ensure that there's enough space in the output for + * those 18 bytes earlier, upon entering the shortcut (in other words, + * there is a combined check for both stages). + */ + if ( (length != RUN_MASK) + /* strictly "less than" on input, to re-enter the loop with at least one byte */ + && likely((ip < shortiend) & (op <= shortoend)) ) { + /* Copy the literals */ + LZ4_memcpy(op, ip, 16); + op += length; ip += length; + + /* The second stage: prepare for match copying, decode full info. + * If it doesn't work out, the info won't be wasted. */ + length = token & ML_MASK; /* match length */ + offset = LZ4_readLE16(ip); ip += 2; + match = op - offset; + assert(match <= op); /* check overflow */ + + /* Do not deal with overlapping matches. */ + if ( (length != ML_MASK) + && (offset >= 8) + && (dict==withPrefix64k || match >= lowPrefix) ) { + /* Copy the match. */ + LZ4_memcpy(op + 0, match + 0, 8); + LZ4_memcpy(op + 8, match + 8, 8); + LZ4_memcpy(op +16, match +16, 2); + op += length + MINMATCH; + /* Both stages worked, load the next token. */ + continue; + } + + /* The second stage didn't work out, but the info is ready. + * Propel it right to the point of match copying. */ + goto _copy_match; + } + + /* decode literal length */ + if (length == RUN_MASK) { + size_t const addl = read_variable_length(&ip, iend-RUN_MASK, 1); + if (addl == rvl_error) { goto _output_error; } + length += addl; + if (unlikely((uptrval)(op)+length<(uptrval)(op))) { goto _output_error; } /* overflow detection */ + if (unlikely((uptrval)(ip)+length<(uptrval)(ip))) { goto _output_error; } /* overflow detection */ + } + + /* copy literals */ + cpy = op+length; +#if LZ4_FAST_DEC_LOOP + safe_literal_copy: +#endif + LZ4_STATIC_ASSERT(MFLIMIT >= WILDCOPYLENGTH); + if ((cpy>oend-MFLIMIT) || (ip+length>iend-(2+1+LASTLITERALS))) { + /* We've either hit the input parsing restriction or the output parsing restriction. + * In the normal scenario, decoding a full block, it must be the last sequence, + * otherwise it's an error (invalid input or dimensions). + * In partialDecoding scenario, it's necessary to ensure there is no buffer overflow. + */ + if (partialDecoding) { + /* Since we are partial decoding we may be in this block because of the output parsing + * restriction, which is not valid since the output buffer is allowed to be undersized. + */ + DEBUGLOG(7, "partialDecoding: copying literals, close to input or output end") + DEBUGLOG(7, "partialDecoding: literal length = %u", (unsigned)length); + DEBUGLOG(7, "partialDecoding: remaining space in dstBuffer : %i", (int)(oend - op)); + DEBUGLOG(7, "partialDecoding: remaining space in srcBuffer : %i", (int)(iend - ip)); + /* Finishing in the middle of a literals segment, + * due to lack of input. + */ + if (ip+length > iend) { + length = (size_t)(iend-ip); + cpy = op + length; + } + /* Finishing in the middle of a literals segment, + * due to lack of output space. + */ + if (cpy > oend) { + cpy = oend; + assert(op<=oend); + length = (size_t)(oend-op); + } + } else { + /* We must be on the last sequence (or invalid) because of the parsing limitations + * so check that we exactly consume the input and don't overrun the output buffer. + */ + if ((ip+length != iend) || (cpy > oend)) { + DEBUGLOG(6, "should have been last run of literals") + DEBUGLOG(6, "ip(%p) + length(%i) = %p != iend (%p)", ip, (int)length, ip+length, iend); + DEBUGLOG(6, "or cpy(%p) > oend(%p)", cpy, oend); + goto _output_error; + } + } + LZ4_memmove(op, ip, length); /* supports overlapping memory regions, for in-place decompression scenarios */ + ip += length; + op += length; + /* Necessarily EOF when !partialDecoding. + * When partialDecoding, it is EOF if we've either + * filled the output buffer or + * can't proceed with reading an offset for following match. + */ + if (!partialDecoding || (cpy == oend) || (ip >= (iend-2))) { + break; + } + } else { + LZ4_wildCopy8(op, ip, cpy); /* can overwrite up to 8 bytes beyond cpy */ + ip += length; op = cpy; + } + + /* get offset */ + offset = LZ4_readLE16(ip); ip+=2; + match = op - offset; + + /* get matchlength */ + length = token & ML_MASK; + + _copy_match: + if (length == ML_MASK) { + size_t const addl = read_variable_length(&ip, iend - LASTLITERALS + 1, 0); + if (addl == rvl_error) { goto _output_error; } + length += addl; + if (unlikely((uptrval)(op)+length<(uptrval)op)) goto _output_error; /* overflow detection */ + } + length += MINMATCH; + +#if LZ4_FAST_DEC_LOOP + safe_match_copy: +#endif + if ((checkOffset) && (unlikely(match + dictSize < lowPrefix))) goto _output_error; /* Error : offset outside buffers */ + /* match starting within external dictionary */ + if ((dict==usingExtDict) && (match < lowPrefix)) { + assert(dictEnd != NULL); + if (unlikely(op+length > oend-LASTLITERALS)) { + if (partialDecoding) length = MIN(length, (size_t)(oend-op)); + else goto _output_error; /* doesn't respect parsing restriction */ + } + + if (length <= (size_t)(lowPrefix-match)) { + /* match fits entirely within external dictionary : just copy */ + LZ4_memmove(op, dictEnd - (lowPrefix-match), length); + op += length; + } else { + /* match stretches into both external dictionary and current block */ + size_t const copySize = (size_t)(lowPrefix - match); + size_t const restSize = length - copySize; + LZ4_memcpy(op, dictEnd - copySize, copySize); + op += copySize; + if (restSize > (size_t)(op - lowPrefix)) { /* overlap copy */ + BYTE* const endOfMatch = op + restSize; + const BYTE* copyFrom = lowPrefix; + while (op < endOfMatch) *op++ = *copyFrom++; + } else { + LZ4_memcpy(op, lowPrefix, restSize); + op += restSize; + } } + continue; + } + assert(match >= lowPrefix); + + /* copy match within block */ + cpy = op + length; + + /* partialDecoding : may end anywhere within the block */ + assert(op<=oend); + if (partialDecoding && (cpy > oend-MATCH_SAFEGUARD_DISTANCE)) { + size_t const mlen = MIN(length, (size_t)(oend-op)); + const BYTE* const matchEnd = match + mlen; + BYTE* const copyEnd = op + mlen; + if (matchEnd > op) { /* overlap copy */ + while (op < copyEnd) { *op++ = *match++; } + } else { + LZ4_memcpy(op, match, mlen); + } + op = copyEnd; + if (op == oend) { break; } + continue; + } + + if (unlikely(offset<8)) { + LZ4_write32(op, 0); /* silence msan warning when offset==0 */ + op[0] = match[0]; + op[1] = match[1]; + op[2] = match[2]; + op[3] = match[3]; + match += inc32table[offset]; + LZ4_memcpy(op+4, match, 4); + match -= dec64table[offset]; + } else { + LZ4_memcpy(op, match, 8); + match += 8; + } + op += 8; + + if (unlikely(cpy > oend-MATCH_SAFEGUARD_DISTANCE)) { + BYTE* const oCopyLimit = oend - (WILDCOPYLENGTH-1); + if (cpy > oend-LASTLITERALS) { goto _output_error; } /* Error : last LASTLITERALS bytes must be literals (uncompressed) */ + if (op < oCopyLimit) { + LZ4_wildCopy8(op, match, oCopyLimit); + match += oCopyLimit - op; + op = oCopyLimit; + } + while (op < cpy) { *op++ = *match++; } + } else { + LZ4_memcpy(op, match, 8); + if (length > 16) { LZ4_wildCopy8(op+8, match+8, cpy); } + } + op = cpy; /* wildcopy correction */ + } + + /* end of decoding */ + DEBUGLOG(5, "decoded %i bytes", (int) (((char*)op)-dst)); + return (int) (((char*)op)-dst); /* Nb of output bytes decoded */ + + /* Overflow error detected */ + _output_error: + return (int) (-(((const char*)ip)-src))-1; + } +} + + +/*===== Instantiate the API decoding functions. =====*/ + +LZ4_FORCE_O2 +int LZ4_decompress_safe(const char* source, char* dest, int compressedSize, int maxDecompressedSize) +{ + return LZ4_decompress_generic(source, dest, compressedSize, maxDecompressedSize, + decode_full_block, noDict, + (BYTE*)dest, NULL, 0); +} + +LZ4_FORCE_O2 +int LZ4_decompress_safe_partial(const char* src, char* dst, int compressedSize, int targetOutputSize, int dstCapacity) +{ + dstCapacity = MIN(targetOutputSize, dstCapacity); + return LZ4_decompress_generic(src, dst, compressedSize, dstCapacity, + partial_decode, + noDict, (BYTE*)dst, NULL, 0); +} + +LZ4_FORCE_O2 +int LZ4_decompress_fast(const char* source, char* dest, int originalSize) +{ + DEBUGLOG(5, "LZ4_decompress_fast"); + return LZ4_decompress_unsafe_generic( + (const BYTE*)source, (BYTE*)dest, originalSize, + 0, NULL, 0); +} + +/*===== Instantiate a few more decoding cases, used more than once. =====*/ + +LZ4_FORCE_O2 /* Exported, an obsolete API function. */ +int LZ4_decompress_safe_withPrefix64k(const char* source, char* dest, int compressedSize, int maxOutputSize) +{ + return LZ4_decompress_generic(source, dest, compressedSize, maxOutputSize, + decode_full_block, withPrefix64k, + (BYTE*)dest - 64 KB, NULL, 0); +} + +LZ4_FORCE_O2 +static int LZ4_decompress_safe_partial_withPrefix64k(const char* source, char* dest, int compressedSize, int targetOutputSize, int dstCapacity) +{ + dstCapacity = MIN(targetOutputSize, dstCapacity); + return LZ4_decompress_generic(source, dest, compressedSize, dstCapacity, + partial_decode, withPrefix64k, + (BYTE*)dest - 64 KB, NULL, 0); +} + +/* Another obsolete API function, paired with the previous one. */ +int LZ4_decompress_fast_withPrefix64k(const char* source, char* dest, int originalSize) +{ + return LZ4_decompress_unsafe_generic( + (const BYTE*)source, (BYTE*)dest, originalSize, + 64 KB, NULL, 0); +} + +LZ4_FORCE_O2 +static int LZ4_decompress_safe_withSmallPrefix(const char* source, char* dest, int compressedSize, int maxOutputSize, + size_t prefixSize) +{ + return LZ4_decompress_generic(source, dest, compressedSize, maxOutputSize, + decode_full_block, noDict, + (BYTE*)dest-prefixSize, NULL, 0); +} + +LZ4_FORCE_O2 +static int LZ4_decompress_safe_partial_withSmallPrefix(const char* source, char* dest, int compressedSize, int targetOutputSize, int dstCapacity, + size_t prefixSize) +{ + dstCapacity = MIN(targetOutputSize, dstCapacity); + return LZ4_decompress_generic(source, dest, compressedSize, dstCapacity, + partial_decode, noDict, + (BYTE*)dest-prefixSize, NULL, 0); +} + +LZ4_FORCE_O2 +int LZ4_decompress_safe_forceExtDict(const char* source, char* dest, + int compressedSize, int maxOutputSize, + const void* dictStart, size_t dictSize) +{ + return LZ4_decompress_generic(source, dest, compressedSize, maxOutputSize, + decode_full_block, usingExtDict, + (BYTE*)dest, (const BYTE*)dictStart, dictSize); +} + +LZ4_FORCE_O2 +int LZ4_decompress_safe_partial_forceExtDict(const char* source, char* dest, + int compressedSize, int targetOutputSize, int dstCapacity, + const void* dictStart, size_t dictSize) +{ + dstCapacity = MIN(targetOutputSize, dstCapacity); + return LZ4_decompress_generic(source, dest, compressedSize, dstCapacity, + partial_decode, usingExtDict, + (BYTE*)dest, (const BYTE*)dictStart, dictSize); +} + +LZ4_FORCE_O2 +static int LZ4_decompress_fast_extDict(const char* source, char* dest, int originalSize, + const void* dictStart, size_t dictSize) +{ + return LZ4_decompress_unsafe_generic( + (const BYTE*)source, (BYTE*)dest, originalSize, + 0, (const BYTE*)dictStart, dictSize); +} + +/* The "double dictionary" mode, for use with e.g. ring buffers: the first part + * of the dictionary is passed as prefix, and the second via dictStart + dictSize. + * These routines are used only once, in LZ4_decompress_*_continue(). + */ +LZ4_FORCE_INLINE +int LZ4_decompress_safe_doubleDict(const char* source, char* dest, int compressedSize, int maxOutputSize, + size_t prefixSize, const void* dictStart, size_t dictSize) +{ + return LZ4_decompress_generic(source, dest, compressedSize, maxOutputSize, + decode_full_block, usingExtDict, + (BYTE*)dest-prefixSize, (const BYTE*)dictStart, dictSize); +} + +/*===== streaming decompression functions =====*/ + +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +LZ4_streamDecode_t* LZ4_createStreamDecode(void) +{ + LZ4_STATIC_ASSERT(sizeof(LZ4_streamDecode_t) >= sizeof(LZ4_streamDecode_t_internal)); + return (LZ4_streamDecode_t*) ALLOC_AND_ZERO(sizeof(LZ4_streamDecode_t)); +} + +int LZ4_freeStreamDecode (LZ4_streamDecode_t* LZ4_stream) +{ + if (LZ4_stream == NULL) { return 0; } /* support free on NULL */ + FREEMEM(LZ4_stream); + return 0; +} +#endif + +/*! LZ4_setStreamDecode() : + * Use this function to instruct where to find the dictionary. + * This function is not necessary if previous data is still available where it was decoded. + * Loading a size of 0 is allowed (same effect as no dictionary). + * @return : 1 if OK, 0 if error + */ +int LZ4_setStreamDecode (LZ4_streamDecode_t* LZ4_streamDecode, const char* dictionary, int dictSize) +{ + LZ4_streamDecode_t_internal* lz4sd = &LZ4_streamDecode->internal_donotuse; + lz4sd->prefixSize = (size_t)dictSize; + if (dictSize) { + assert(dictionary != NULL); + lz4sd->prefixEnd = (const BYTE*) dictionary + dictSize; + } else { + lz4sd->prefixEnd = (const BYTE*) dictionary; + } + lz4sd->externalDict = NULL; + lz4sd->extDictSize = 0; + return 1; +} + +/*! LZ4_decoderRingBufferSize() : + * when setting a ring buffer for streaming decompression (optional scenario), + * provides the minimum size of this ring buffer + * to be compatible with any source respecting maxBlockSize condition. + * Note : in a ring buffer scenario, + * blocks are presumed decompressed next to each other. + * When not enough space remains for next block (remainingSize < maxBlockSize), + * decoding resumes from beginning of ring buffer. + * @return : minimum ring buffer size, + * or 0 if there is an error (invalid maxBlockSize). + */ +int LZ4_decoderRingBufferSize(int maxBlockSize) +{ + if (maxBlockSize < 0) return 0; + if (maxBlockSize > LZ4_MAX_INPUT_SIZE) return 0; + if (maxBlockSize < 16) maxBlockSize = 16; + return LZ4_DECODER_RING_BUFFER_SIZE(maxBlockSize); +} + +/* +*_continue() : + These decoding functions allow decompression of multiple blocks in "streaming" mode. + Previously decoded blocks must still be available at the memory position where they were decoded. + If it's not possible, save the relevant part of decoded data into a safe buffer, + and indicate where it stands using LZ4_setStreamDecode() +*/ +LZ4_FORCE_O2 +int LZ4_decompress_safe_continue (LZ4_streamDecode_t* LZ4_streamDecode, const char* source, char* dest, int compressedSize, int maxOutputSize) +{ + LZ4_streamDecode_t_internal* lz4sd = &LZ4_streamDecode->internal_donotuse; + int result; + + if (lz4sd->prefixSize == 0) { + /* The first call, no dictionary yet. */ + assert(lz4sd->extDictSize == 0); + result = LZ4_decompress_safe(source, dest, compressedSize, maxOutputSize); + if (result <= 0) return result; + lz4sd->prefixSize = (size_t)result; + lz4sd->prefixEnd = (BYTE*)dest + result; + } else if (lz4sd->prefixEnd == (BYTE*)dest) { + /* They're rolling the current segment. */ + if (lz4sd->prefixSize >= 64 KB - 1) + result = LZ4_decompress_safe_withPrefix64k(source, dest, compressedSize, maxOutputSize); + else if (lz4sd->extDictSize == 0) + result = LZ4_decompress_safe_withSmallPrefix(source, dest, compressedSize, maxOutputSize, + lz4sd->prefixSize); + else + result = LZ4_decompress_safe_doubleDict(source, dest, compressedSize, maxOutputSize, + lz4sd->prefixSize, lz4sd->externalDict, lz4sd->extDictSize); + if (result <= 0) return result; + lz4sd->prefixSize += (size_t)result; + lz4sd->prefixEnd += result; + } else { + /* The buffer wraps around, or they're switching to another buffer. */ + lz4sd->extDictSize = lz4sd->prefixSize; + lz4sd->externalDict = lz4sd->prefixEnd - lz4sd->extDictSize; + result = LZ4_decompress_safe_forceExtDict(source, dest, compressedSize, maxOutputSize, + lz4sd->externalDict, lz4sd->extDictSize); + if (result <= 0) return result; + lz4sd->prefixSize = (size_t)result; + lz4sd->prefixEnd = (BYTE*)dest + result; + } + + return result; +} + +LZ4_FORCE_O2 int +LZ4_decompress_fast_continue (LZ4_streamDecode_t* LZ4_streamDecode, + const char* source, char* dest, int originalSize) +{ + LZ4_streamDecode_t_internal* const lz4sd = + (assert(LZ4_streamDecode!=NULL), &LZ4_streamDecode->internal_donotuse); + int result; + + DEBUGLOG(5, "LZ4_decompress_fast_continue (toDecodeSize=%i)", originalSize); + assert(originalSize >= 0); + + if (lz4sd->prefixSize == 0) { + DEBUGLOG(5, "first invocation : no prefix nor extDict"); + assert(lz4sd->extDictSize == 0); + result = LZ4_decompress_fast(source, dest, originalSize); + if (result <= 0) return result; + lz4sd->prefixSize = (size_t)originalSize; + lz4sd->prefixEnd = (BYTE*)dest + originalSize; + } else if (lz4sd->prefixEnd == (BYTE*)dest) { + DEBUGLOG(5, "continue using existing prefix"); + result = LZ4_decompress_unsafe_generic( + (const BYTE*)source, (BYTE*)dest, originalSize, + lz4sd->prefixSize, + lz4sd->externalDict, lz4sd->extDictSize); + if (result <= 0) return result; + lz4sd->prefixSize += (size_t)originalSize; + lz4sd->prefixEnd += originalSize; + } else { + DEBUGLOG(5, "prefix becomes extDict"); + lz4sd->extDictSize = lz4sd->prefixSize; + lz4sd->externalDict = lz4sd->prefixEnd - lz4sd->extDictSize; + result = LZ4_decompress_fast_extDict(source, dest, originalSize, + lz4sd->externalDict, lz4sd->extDictSize); + if (result <= 0) return result; + lz4sd->prefixSize = (size_t)originalSize; + lz4sd->prefixEnd = (BYTE*)dest + originalSize; + } + + return result; +} + + +/* +Advanced decoding functions : +*_usingDict() : + These decoding functions work the same as "_continue" ones, + the dictionary must be explicitly provided within parameters +*/ + +int LZ4_decompress_safe_usingDict(const char* source, char* dest, int compressedSize, int maxOutputSize, const char* dictStart, int dictSize) +{ + if (dictSize==0) + return LZ4_decompress_safe(source, dest, compressedSize, maxOutputSize); + if (dictStart+dictSize == dest) { + if (dictSize >= 64 KB - 1) { + return LZ4_decompress_safe_withPrefix64k(source, dest, compressedSize, maxOutputSize); + } + assert(dictSize >= 0); + return LZ4_decompress_safe_withSmallPrefix(source, dest, compressedSize, maxOutputSize, (size_t)dictSize); + } + assert(dictSize >= 0); + return LZ4_decompress_safe_forceExtDict(source, dest, compressedSize, maxOutputSize, dictStart, (size_t)dictSize); +} + +int LZ4_decompress_safe_partial_usingDict(const char* source, char* dest, int compressedSize, int targetOutputSize, int dstCapacity, const char* dictStart, int dictSize) +{ + if (dictSize==0) + return LZ4_decompress_safe_partial(source, dest, compressedSize, targetOutputSize, dstCapacity); + if (dictStart+dictSize == dest) { + if (dictSize >= 64 KB - 1) { + return LZ4_decompress_safe_partial_withPrefix64k(source, dest, compressedSize, targetOutputSize, dstCapacity); + } + assert(dictSize >= 0); + return LZ4_decompress_safe_partial_withSmallPrefix(source, dest, compressedSize, targetOutputSize, dstCapacity, (size_t)dictSize); + } + assert(dictSize >= 0); + return LZ4_decompress_safe_partial_forceExtDict(source, dest, compressedSize, targetOutputSize, dstCapacity, dictStart, (size_t)dictSize); +} + +int LZ4_decompress_fast_usingDict(const char* source, char* dest, int originalSize, const char* dictStart, int dictSize) +{ + if (dictSize==0 || dictStart+dictSize == dest) + return LZ4_decompress_unsafe_generic( + (const BYTE*)source, (BYTE*)dest, originalSize, + (size_t)dictSize, NULL, 0); + assert(dictSize >= 0); + return LZ4_decompress_fast_extDict(source, dest, originalSize, dictStart, (size_t)dictSize); +} + + +/*=************************************************* +* Obsolete Functions +***************************************************/ +/* obsolete compression functions */ +int LZ4_compress_limitedOutput(const char* source, char* dest, int inputSize, int maxOutputSize) +{ + return LZ4_compress_default(source, dest, inputSize, maxOutputSize); +} +int LZ4_compress(const char* src, char* dest, int srcSize) +{ + return LZ4_compress_default(src, dest, srcSize, LZ4_compressBound(srcSize)); +} +int LZ4_compress_limitedOutput_withState (void* state, const char* src, char* dst, int srcSize, int dstSize) +{ + return LZ4_compress_fast_extState(state, src, dst, srcSize, dstSize, 1); +} +int LZ4_compress_withState (void* state, const char* src, char* dst, int srcSize) +{ + return LZ4_compress_fast_extState(state, src, dst, srcSize, LZ4_compressBound(srcSize), 1); +} +int LZ4_compress_limitedOutput_continue (LZ4_stream_t* LZ4_stream, const char* src, char* dst, int srcSize, int dstCapacity) +{ + return LZ4_compress_fast_continue(LZ4_stream, src, dst, srcSize, dstCapacity, 1); +} +int LZ4_compress_continue (LZ4_stream_t* LZ4_stream, const char* source, char* dest, int inputSize) +{ + return LZ4_compress_fast_continue(LZ4_stream, source, dest, inputSize, LZ4_compressBound(inputSize), 1); +} + +/* +These decompression functions are deprecated and should no longer be used. +They are only provided here for compatibility with older user programs. +- LZ4_uncompress is totally equivalent to LZ4_decompress_fast +- LZ4_uncompress_unknownOutputSize is totally equivalent to LZ4_decompress_safe +*/ +int LZ4_uncompress (const char* source, char* dest, int outputSize) +{ + return LZ4_decompress_fast(source, dest, outputSize); +} +int LZ4_uncompress_unknownOutputSize (const char* source, char* dest, int isize, int maxOutputSize) +{ + return LZ4_decompress_safe(source, dest, isize, maxOutputSize); +} + +/* Obsolete Streaming functions */ + +int LZ4_sizeofStreamState(void) { return sizeof(LZ4_stream_t); } + +int LZ4_resetStreamState(void* state, char* inputBuffer) +{ + (void)inputBuffer; + LZ4_resetStream((LZ4_stream_t*)state); + return 0; +} + +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +void* LZ4_create (char* inputBuffer) +{ + (void)inputBuffer; + return LZ4_createStream(); +} +#endif + +char* LZ4_slideInputBuffer (void* state) +{ + /* avoid const char * -> char * conversion warning */ + return (char *)(uptrval)((LZ4_stream_t*)state)->internal_donotuse.dictionary; +} + +#endif /* LZ4_COMMONDEFS_ONLY */ diff --git a/native/lz4/lz4.h b/native/lz4/lz4.h new file mode 100644 index 0000000..491c608 --- /dev/null +++ b/native/lz4/lz4.h @@ -0,0 +1,842 @@ +/* + * LZ4 - Fast LZ compression algorithm + * Header File + * Copyright (C) 2011-2020, Yann Collet. + + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + You can contact the author at : + - LZ4 homepage : http://www.lz4.org + - LZ4 source repository : https://github.com/lz4/lz4 +*/ +#if defined (__cplusplus) +extern "C" { +#endif + +#ifndef LZ4_H_2983827168210 +#define LZ4_H_2983827168210 + +/* --- Dependency --- */ +#include /* size_t */ + + +/** + Introduction + + LZ4 is lossless compression algorithm, providing compression speed >500 MB/s per core, + scalable with multi-cores CPU. It features an extremely fast decoder, with speed in + multiple GB/s per core, typically reaching RAM speed limits on multi-core systems. + + The LZ4 compression library provides in-memory compression and decompression functions. + It gives full buffer control to user. + Compression can be done in: + - a single step (described as Simple Functions) + - a single step, reusing a context (described in Advanced Functions) + - unbounded multiple steps (described as Streaming compression) + + lz4.h generates and decodes LZ4-compressed blocks (doc/lz4_Block_format.md). + Decompressing such a compressed block requires additional metadata. + Exact metadata depends on exact decompression function. + For the typical case of LZ4_decompress_safe(), + metadata includes block's compressed size, and maximum bound of decompressed size. + Each application is free to encode and pass such metadata in whichever way it wants. + + lz4.h only handle blocks, it can not generate Frames. + + Blocks are different from Frames (doc/lz4_Frame_format.md). + Frames bundle both blocks and metadata in a specified manner. + Embedding metadata is required for compressed data to be self-contained and portable. + Frame format is delivered through a companion API, declared in lz4frame.h. + The `lz4` CLI can only manage frames. +*/ + +/*^*************************************************************** +* Export parameters +*****************************************************************/ +/* +* LZ4_DLL_EXPORT : +* Enable exporting of functions when building a Windows DLL +* LZ4LIB_VISIBILITY : +* Control library symbols visibility. +*/ +#ifndef LZ4LIB_VISIBILITY +# if defined(__GNUC__) && (__GNUC__ >= 4) +# define LZ4LIB_VISIBILITY __attribute__ ((visibility ("default"))) +# else +# define LZ4LIB_VISIBILITY +# endif +#endif +#if defined(LZ4_DLL_EXPORT) && (LZ4_DLL_EXPORT==1) +# define LZ4LIB_API __declspec(dllexport) LZ4LIB_VISIBILITY +#elif defined(LZ4_DLL_IMPORT) && (LZ4_DLL_IMPORT==1) +# define LZ4LIB_API __declspec(dllimport) LZ4LIB_VISIBILITY /* It isn't required but allows to generate better code, saving a function pointer load from the IAT and an indirect jump.*/ +#else +# define LZ4LIB_API LZ4LIB_VISIBILITY +#endif + +/*! LZ4_FREESTANDING : + * When this macro is set to 1, it enables "freestanding mode" that is + * suitable for typical freestanding environment which doesn't support + * standard C library. + * + * - LZ4_FREESTANDING is a compile-time switch. + * - It requires the following macros to be defined: + * LZ4_memcpy, LZ4_memmove, LZ4_memset. + * - It only enables LZ4/HC functions which don't use heap. + * All LZ4F_* functions are not supported. + * - See tests/freestanding.c to check its basic setup. + */ +#if defined(LZ4_FREESTANDING) && (LZ4_FREESTANDING == 1) +# define LZ4_HEAPMODE 0 +# define LZ4HC_HEAPMODE 0 +# define LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION 1 +# if !defined(LZ4_memcpy) +# error "LZ4_FREESTANDING requires macro 'LZ4_memcpy'." +# endif +# if !defined(LZ4_memset) +# error "LZ4_FREESTANDING requires macro 'LZ4_memset'." +# endif +# if !defined(LZ4_memmove) +# error "LZ4_FREESTANDING requires macro 'LZ4_memmove'." +# endif +#elif ! defined(LZ4_FREESTANDING) +# define LZ4_FREESTANDING 0 +#endif + + +/*------ Version ------*/ +#define LZ4_VERSION_MAJOR 1 /* for breaking interface changes */ +#define LZ4_VERSION_MINOR 9 /* for new (non-breaking) interface capabilities */ +#define LZ4_VERSION_RELEASE 4 /* for tweaks, bug-fixes, or development */ + +#define LZ4_VERSION_NUMBER (LZ4_VERSION_MAJOR *100*100 + LZ4_VERSION_MINOR *100 + LZ4_VERSION_RELEASE) + +#define LZ4_LIB_VERSION LZ4_VERSION_MAJOR.LZ4_VERSION_MINOR.LZ4_VERSION_RELEASE +#define LZ4_QUOTE(str) #str +#define LZ4_EXPAND_AND_QUOTE(str) LZ4_QUOTE(str) +#define LZ4_VERSION_STRING LZ4_EXPAND_AND_QUOTE(LZ4_LIB_VERSION) /* requires v1.7.3+ */ + +LZ4LIB_API int LZ4_versionNumber (void); /**< library version number; useful to check dll version; requires v1.3.0+ */ +LZ4LIB_API const char* LZ4_versionString (void); /**< library version string; useful to check dll version; requires v1.7.5+ */ + + +/*-************************************ +* Tuning parameter +**************************************/ +#define LZ4_MEMORY_USAGE_MIN 10 +#define LZ4_MEMORY_USAGE_DEFAULT 14 +#define LZ4_MEMORY_USAGE_MAX 20 + +/*! + * LZ4_MEMORY_USAGE : + * Memory usage formula : N->2^N Bytes (examples : 10 -> 1KB; 12 -> 4KB ; 16 -> 64KB; 20 -> 1MB; ) + * Increasing memory usage improves compression ratio, at the cost of speed. + * Reduced memory usage may improve speed at the cost of ratio, thanks to better cache locality. + * Default value is 14, for 16KB, which nicely fits into Intel x86 L1 cache + */ +#ifndef LZ4_MEMORY_USAGE +# define LZ4_MEMORY_USAGE LZ4_MEMORY_USAGE_DEFAULT +#endif + +#if (LZ4_MEMORY_USAGE < LZ4_MEMORY_USAGE_MIN) +# error "LZ4_MEMORY_USAGE is too small !" +#endif + +#if (LZ4_MEMORY_USAGE > LZ4_MEMORY_USAGE_MAX) +# error "LZ4_MEMORY_USAGE is too large !" +#endif + +/*-************************************ +* Simple Functions +**************************************/ +/*! LZ4_compress_default() : + * Compresses 'srcSize' bytes from buffer 'src' + * into already allocated 'dst' buffer of size 'dstCapacity'. + * Compression is guaranteed to succeed if 'dstCapacity' >= LZ4_compressBound(srcSize). + * It also runs faster, so it's a recommended setting. + * If the function cannot compress 'src' into a more limited 'dst' budget, + * compression stops *immediately*, and the function result is zero. + * In which case, 'dst' content is undefined (invalid). + * srcSize : max supported value is LZ4_MAX_INPUT_SIZE. + * dstCapacity : size of buffer 'dst' (which must be already allocated) + * @return : the number of bytes written into buffer 'dst' (necessarily <= dstCapacity) + * or 0 if compression fails + * Note : This function is protected against buffer overflow scenarios (never writes outside 'dst' buffer, nor read outside 'source' buffer). + */ +LZ4LIB_API int LZ4_compress_default(const char* src, char* dst, int srcSize, int dstCapacity); + +/*! LZ4_decompress_safe() : + * compressedSize : is the exact complete size of the compressed block. + * dstCapacity : is the size of destination buffer (which must be already allocated), presumed an upper bound of decompressed size. + * @return : the number of bytes decompressed into destination buffer (necessarily <= dstCapacity) + * If destination buffer is not large enough, decoding will stop and output an error code (negative value). + * If the source stream is detected malformed, the function will stop decoding and return a negative result. + * Note 1 : This function is protected against malicious data packets : + * it will never writes outside 'dst' buffer, nor read outside 'source' buffer, + * even if the compressed block is maliciously modified to order the decoder to do these actions. + * In such case, the decoder stops immediately, and considers the compressed block malformed. + * Note 2 : compressedSize and dstCapacity must be provided to the function, the compressed block does not contain them. + * The implementation is free to send / store / derive this information in whichever way is most beneficial. + * If there is a need for a different format which bundles together both compressed data and its metadata, consider looking at lz4frame.h instead. + */ +LZ4LIB_API int LZ4_decompress_safe (const char* src, char* dst, int compressedSize, int dstCapacity); + + +/*-************************************ +* Advanced Functions +**************************************/ +#define LZ4_MAX_INPUT_SIZE 0x7E000000 /* 2 113 929 216 bytes */ +#define LZ4_COMPRESSBOUND(isize) ((unsigned)(isize) > (unsigned)LZ4_MAX_INPUT_SIZE ? 0 : (isize) + ((isize)/255) + 16) + +/*! LZ4_compressBound() : + Provides the maximum size that LZ4 compression may output in a "worst case" scenario (input data not compressible) + This function is primarily useful for memory allocation purposes (destination buffer size). + Macro LZ4_COMPRESSBOUND() is also provided for compilation-time evaluation (stack memory allocation for example). + Note that LZ4_compress_default() compresses faster when dstCapacity is >= LZ4_compressBound(srcSize) + inputSize : max supported value is LZ4_MAX_INPUT_SIZE + return : maximum output size in a "worst case" scenario + or 0, if input size is incorrect (too large or negative) +*/ +LZ4LIB_API int LZ4_compressBound(int inputSize); + +/*! LZ4_compress_fast() : + Same as LZ4_compress_default(), but allows selection of "acceleration" factor. + The larger the acceleration value, the faster the algorithm, but also the lesser the compression. + It's a trade-off. It can be fine tuned, with each successive value providing roughly +~3% to speed. + An acceleration value of "1" is the same as regular LZ4_compress_default() + Values <= 0 will be replaced by LZ4_ACCELERATION_DEFAULT (currently == 1, see lz4.c). + Values > LZ4_ACCELERATION_MAX will be replaced by LZ4_ACCELERATION_MAX (currently == 65537, see lz4.c). +*/ +LZ4LIB_API int LZ4_compress_fast (const char* src, char* dst, int srcSize, int dstCapacity, int acceleration); + + +/*! LZ4_compress_fast_extState() : + * Same as LZ4_compress_fast(), using an externally allocated memory space for its state. + * Use LZ4_sizeofState() to know how much memory must be allocated, + * and allocate it on 8-bytes boundaries (using `malloc()` typically). + * Then, provide this buffer as `void* state` to compression function. + */ +LZ4LIB_API int LZ4_sizeofState(void); +LZ4LIB_API int LZ4_compress_fast_extState (void* state, const char* src, char* dst, int srcSize, int dstCapacity, int acceleration); + + +/*! LZ4_compress_destSize() : + * Reverse the logic : compresses as much data as possible from 'src' buffer + * into already allocated buffer 'dst', of size >= 'targetDestSize'. + * This function either compresses the entire 'src' content into 'dst' if it's large enough, + * or fill 'dst' buffer completely with as much data as possible from 'src'. + * note: acceleration parameter is fixed to "default". + * + * *srcSizePtr : will be modified to indicate how many bytes where read from 'src' to fill 'dst'. + * New value is necessarily <= input value. + * @return : Nb bytes written into 'dst' (necessarily <= targetDestSize) + * or 0 if compression fails. + * + * Note : from v1.8.2 to v1.9.1, this function had a bug (fixed un v1.9.2+): + * the produced compressed content could, in specific circumstances, + * require to be decompressed into a destination buffer larger + * by at least 1 byte than the content to decompress. + * If an application uses `LZ4_compress_destSize()`, + * it's highly recommended to update liblz4 to v1.9.2 or better. + * If this can't be done or ensured, + * the receiving decompression function should provide + * a dstCapacity which is > decompressedSize, by at least 1 byte. + * See https://github.com/lz4/lz4/issues/859 for details + */ +LZ4LIB_API int LZ4_compress_destSize (const char* src, char* dst, int* srcSizePtr, int targetDstSize); + + +/*! LZ4_decompress_safe_partial() : + * Decompress an LZ4 compressed block, of size 'srcSize' at position 'src', + * into destination buffer 'dst' of size 'dstCapacity'. + * Up to 'targetOutputSize' bytes will be decoded. + * The function stops decoding on reaching this objective. + * This can be useful to boost performance + * whenever only the beginning of a block is required. + * + * @return : the number of bytes decoded in `dst` (necessarily <= targetOutputSize) + * If source stream is detected malformed, function returns a negative result. + * + * Note 1 : @return can be < targetOutputSize, if compressed block contains less data. + * + * Note 2 : targetOutputSize must be <= dstCapacity + * + * Note 3 : this function effectively stops decoding on reaching targetOutputSize, + * so dstCapacity is kind of redundant. + * This is because in older versions of this function, + * decoding operation would still write complete sequences. + * Therefore, there was no guarantee that it would stop writing at exactly targetOutputSize, + * it could write more bytes, though only up to dstCapacity. + * Some "margin" used to be required for this operation to work properly. + * Thankfully, this is no longer necessary. + * The function nonetheless keeps the same signature, in an effort to preserve API compatibility. + * + * Note 4 : If srcSize is the exact size of the block, + * then targetOutputSize can be any value, + * including larger than the block's decompressed size. + * The function will, at most, generate block's decompressed size. + * + * Note 5 : If srcSize is _larger_ than block's compressed size, + * then targetOutputSize **MUST** be <= block's decompressed size. + * Otherwise, *silent corruption will occur*. + */ +LZ4LIB_API int LZ4_decompress_safe_partial (const char* src, char* dst, int srcSize, int targetOutputSize, int dstCapacity); + + +/*-********************************************* +* Streaming Compression Functions +***********************************************/ +typedef union LZ4_stream_u LZ4_stream_t; /* incomplete type (defined later) */ + +/** + Note about RC_INVOKED + + - RC_INVOKED is predefined symbol of rc.exe (the resource compiler which is part of MSVC/Visual Studio). + https://docs.microsoft.com/en-us/windows/win32/menurc/predefined-macros + + - Since rc.exe is a legacy compiler, it truncates long symbol (> 30 chars) + and reports warning "RC4011: identifier truncated". + + - To eliminate the warning, we surround long preprocessor symbol with + "#if !defined(RC_INVOKED) ... #endif" block that means + "skip this block when rc.exe is trying to read it". +*/ +#if !defined(RC_INVOKED) /* https://docs.microsoft.com/en-us/windows/win32/menurc/predefined-macros */ +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +LZ4LIB_API LZ4_stream_t* LZ4_createStream(void); +LZ4LIB_API int LZ4_freeStream (LZ4_stream_t* streamPtr); +#endif /* !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) */ +#endif + +/*! LZ4_resetStream_fast() : v1.9.0+ + * Use this to prepare an LZ4_stream_t for a new chain of dependent blocks + * (e.g., LZ4_compress_fast_continue()). + * + * An LZ4_stream_t must be initialized once before usage. + * This is automatically done when created by LZ4_createStream(). + * However, should the LZ4_stream_t be simply declared on stack (for example), + * it's necessary to initialize it first, using LZ4_initStream(). + * + * After init, start any new stream with LZ4_resetStream_fast(). + * A same LZ4_stream_t can be re-used multiple times consecutively + * and compress multiple streams, + * provided that it starts each new stream with LZ4_resetStream_fast(). + * + * LZ4_resetStream_fast() is much faster than LZ4_initStream(), + * but is not compatible with memory regions containing garbage data. + * + * Note: it's only useful to call LZ4_resetStream_fast() + * in the context of streaming compression. + * The *extState* functions perform their own resets. + * Invoking LZ4_resetStream_fast() before is redundant, and even counterproductive. + */ +LZ4LIB_API void LZ4_resetStream_fast (LZ4_stream_t* streamPtr); + +/*! LZ4_loadDict() : + * Use this function to reference a static dictionary into LZ4_stream_t. + * The dictionary must remain available during compression. + * LZ4_loadDict() triggers a reset, so any previous data will be forgotten. + * The same dictionary will have to be loaded on decompression side for successful decoding. + * Dictionary are useful for better compression of small data (KB range). + * While LZ4 accept any input as dictionary, + * results are generally better when using Zstandard's Dictionary Builder. + * Loading a size of 0 is allowed, and is the same as reset. + * @return : loaded dictionary size, in bytes (necessarily <= 64 KB) + */ +LZ4LIB_API int LZ4_loadDict (LZ4_stream_t* streamPtr, const char* dictionary, int dictSize); + +/*! LZ4_compress_fast_continue() : + * Compress 'src' content using data from previously compressed blocks, for better compression ratio. + * 'dst' buffer must be already allocated. + * If dstCapacity >= LZ4_compressBound(srcSize), compression is guaranteed to succeed, and runs faster. + * + * @return : size of compressed block + * or 0 if there is an error (typically, cannot fit into 'dst'). + * + * Note 1 : Each invocation to LZ4_compress_fast_continue() generates a new block. + * Each block has precise boundaries. + * Each block must be decompressed separately, calling LZ4_decompress_*() with relevant metadata. + * It's not possible to append blocks together and expect a single invocation of LZ4_decompress_*() to decompress them together. + * + * Note 2 : The previous 64KB of source data is __assumed__ to remain present, unmodified, at same address in memory ! + * + * Note 3 : When input is structured as a double-buffer, each buffer can have any size, including < 64 KB. + * Make sure that buffers are separated, by at least one byte. + * This construction ensures that each block only depends on previous block. + * + * Note 4 : If input buffer is a ring-buffer, it can have any size, including < 64 KB. + * + * Note 5 : After an error, the stream status is undefined (invalid), it can only be reset or freed. + */ +LZ4LIB_API int LZ4_compress_fast_continue (LZ4_stream_t* streamPtr, const char* src, char* dst, int srcSize, int dstCapacity, int acceleration); + +/*! LZ4_saveDict() : + * If last 64KB data cannot be guaranteed to remain available at its current memory location, + * save it into a safer place (char* safeBuffer). + * This is schematically equivalent to a memcpy() followed by LZ4_loadDict(), + * but is much faster, because LZ4_saveDict() doesn't need to rebuild tables. + * @return : saved dictionary size in bytes (necessarily <= maxDictSize), or 0 if error. + */ +LZ4LIB_API int LZ4_saveDict (LZ4_stream_t* streamPtr, char* safeBuffer, int maxDictSize); + + +/*-********************************************** +* Streaming Decompression Functions +* Bufferless synchronous API +************************************************/ +typedef union LZ4_streamDecode_u LZ4_streamDecode_t; /* tracking context */ + +/*! LZ4_createStreamDecode() and LZ4_freeStreamDecode() : + * creation / destruction of streaming decompression tracking context. + * A tracking context can be re-used multiple times. + */ +#if !defined(RC_INVOKED) /* https://docs.microsoft.com/en-us/windows/win32/menurc/predefined-macros */ +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +LZ4LIB_API LZ4_streamDecode_t* LZ4_createStreamDecode(void); +LZ4LIB_API int LZ4_freeStreamDecode (LZ4_streamDecode_t* LZ4_stream); +#endif /* !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) */ +#endif + +/*! LZ4_setStreamDecode() : + * An LZ4_streamDecode_t context can be allocated once and re-used multiple times. + * Use this function to start decompression of a new stream of blocks. + * A dictionary can optionally be set. Use NULL or size 0 for a reset order. + * Dictionary is presumed stable : it must remain accessible and unmodified during next decompression. + * @return : 1 if OK, 0 if error + */ +LZ4LIB_API int LZ4_setStreamDecode (LZ4_streamDecode_t* LZ4_streamDecode, const char* dictionary, int dictSize); + +/*! LZ4_decoderRingBufferSize() : v1.8.2+ + * Note : in a ring buffer scenario (optional), + * blocks are presumed decompressed next to each other + * up to the moment there is not enough remaining space for next block (remainingSize < maxBlockSize), + * at which stage it resumes from beginning of ring buffer. + * When setting such a ring buffer for streaming decompression, + * provides the minimum size of this ring buffer + * to be compatible with any source respecting maxBlockSize condition. + * @return : minimum ring buffer size, + * or 0 if there is an error (invalid maxBlockSize). + */ +LZ4LIB_API int LZ4_decoderRingBufferSize(int maxBlockSize); +#define LZ4_DECODER_RING_BUFFER_SIZE(maxBlockSize) (65536 + 14 + (maxBlockSize)) /* for static allocation; maxBlockSize presumed valid */ + +/*! LZ4_decompress_*_continue() : + * These decoding functions allow decompression of consecutive blocks in "streaming" mode. + * A block is an unsplittable entity, it must be presented entirely to a decompression function. + * Decompression functions only accepts one block at a time. + * The last 64KB of previously decoded data *must* remain available and unmodified at the memory position where they were decoded. + * If less than 64KB of data has been decoded, all the data must be present. + * + * Special : if decompression side sets a ring buffer, it must respect one of the following conditions : + * - Decompression buffer size is _at least_ LZ4_decoderRingBufferSize(maxBlockSize). + * maxBlockSize is the maximum size of any single block. It can have any value > 16 bytes. + * In which case, encoding and decoding buffers do not need to be synchronized. + * Actually, data can be produced by any source compliant with LZ4 format specification, and respecting maxBlockSize. + * - Synchronized mode : + * Decompression buffer size is _exactly_ the same as compression buffer size, + * and follows exactly same update rule (block boundaries at same positions), + * and decoding function is provided with exact decompressed size of each block (exception for last block of the stream), + * _then_ decoding & encoding ring buffer can have any size, including small ones ( < 64 KB). + * - Decompression buffer is larger than encoding buffer, by a minimum of maxBlockSize more bytes. + * In which case, encoding and decoding buffers do not need to be synchronized, + * and encoding ring buffer can have any size, including small ones ( < 64 KB). + * + * Whenever these conditions are not possible, + * save the last 64KB of decoded data into a safe buffer where it can't be modified during decompression, + * then indicate where this data is saved using LZ4_setStreamDecode(), before decompressing next block. +*/ +LZ4LIB_API int +LZ4_decompress_safe_continue (LZ4_streamDecode_t* LZ4_streamDecode, + const char* src, char* dst, + int srcSize, int dstCapacity); + + +/*! LZ4_decompress_*_usingDict() : + * These decoding functions work the same as + * a combination of LZ4_setStreamDecode() followed by LZ4_decompress_*_continue() + * They are stand-alone, and don't need an LZ4_streamDecode_t structure. + * Dictionary is presumed stable : it must remain accessible and unmodified during decompression. + * Performance tip : Decompression speed can be substantially increased + * when dst == dictStart + dictSize. + */ +LZ4LIB_API int +LZ4_decompress_safe_usingDict(const char* src, char* dst, + int srcSize, int dstCapacity, + const char* dictStart, int dictSize); + +LZ4LIB_API int +LZ4_decompress_safe_partial_usingDict(const char* src, char* dst, + int compressedSize, + int targetOutputSize, int maxOutputSize, + const char* dictStart, int dictSize); + +#endif /* LZ4_H_2983827168210 */ + + +/*^************************************* + * !!!!!! STATIC LINKING ONLY !!!!!! + ***************************************/ + +/*-**************************************************************************** + * Experimental section + * + * Symbols declared in this section must be considered unstable. Their + * signatures or semantics may change, or they may be removed altogether in the + * future. They are therefore only safe to depend on when the caller is + * statically linked against the library. + * + * To protect against unsafe usage, not only are the declarations guarded, + * the definitions are hidden by default + * when building LZ4 as a shared/dynamic library. + * + * In order to access these declarations, + * define LZ4_STATIC_LINKING_ONLY in your application + * before including LZ4's headers. + * + * In order to make their implementations accessible dynamically, you must + * define LZ4_PUBLISH_STATIC_FUNCTIONS when building the LZ4 library. + ******************************************************************************/ + +#ifdef LZ4_STATIC_LINKING_ONLY + +#ifndef LZ4_STATIC_3504398509 +#define LZ4_STATIC_3504398509 + +#ifdef LZ4_PUBLISH_STATIC_FUNCTIONS +#define LZ4LIB_STATIC_API LZ4LIB_API +#else +#define LZ4LIB_STATIC_API +#endif + + +/*! LZ4_compress_fast_extState_fastReset() : + * A variant of LZ4_compress_fast_extState(). + * + * Using this variant avoids an expensive initialization step. + * It is only safe to call if the state buffer is known to be correctly initialized already + * (see above comment on LZ4_resetStream_fast() for a definition of "correctly initialized"). + * From a high level, the difference is that + * this function initializes the provided state with a call to something like LZ4_resetStream_fast() + * while LZ4_compress_fast_extState() starts with a call to LZ4_resetStream(). + */ +LZ4LIB_STATIC_API int LZ4_compress_fast_extState_fastReset (void* state, const char* src, char* dst, int srcSize, int dstCapacity, int acceleration); + +/*! LZ4_attach_dictionary() : + * This is an experimental API that allows + * efficient use of a static dictionary many times. + * + * Rather than re-loading the dictionary buffer into a working context before + * each compression, or copying a pre-loaded dictionary's LZ4_stream_t into a + * working LZ4_stream_t, this function introduces a no-copy setup mechanism, + * in which the working stream references the dictionary stream in-place. + * + * Several assumptions are made about the state of the dictionary stream. + * Currently, only streams which have been prepared by LZ4_loadDict() should + * be expected to work. + * + * Alternatively, the provided dictionaryStream may be NULL, + * in which case any existing dictionary stream is unset. + * + * If a dictionary is provided, it replaces any pre-existing stream history. + * The dictionary contents are the only history that can be referenced and + * logically immediately precede the data compressed in the first subsequent + * compression call. + * + * The dictionary will only remain attached to the working stream through the + * first compression call, at the end of which it is cleared. The dictionary + * stream (and source buffer) must remain in-place / accessible / unchanged + * through the completion of the first compression call on the stream. + */ +LZ4LIB_STATIC_API void +LZ4_attach_dictionary(LZ4_stream_t* workingStream, + const LZ4_stream_t* dictionaryStream); + + +/*! In-place compression and decompression + * + * It's possible to have input and output sharing the same buffer, + * for highly constrained memory environments. + * In both cases, it requires input to lay at the end of the buffer, + * and decompression to start at beginning of the buffer. + * Buffer size must feature some margin, hence be larger than final size. + * + * |<------------------------buffer--------------------------------->| + * |<-----------compressed data--------->| + * |<-----------decompressed size------------------>| + * |<----margin---->| + * + * This technique is more useful for decompression, + * since decompressed size is typically larger, + * and margin is short. + * + * In-place decompression will work inside any buffer + * which size is >= LZ4_DECOMPRESS_INPLACE_BUFFER_SIZE(decompressedSize). + * This presumes that decompressedSize > compressedSize. + * Otherwise, it means compression actually expanded data, + * and it would be more efficient to store such data with a flag indicating it's not compressed. + * This can happen when data is not compressible (already compressed, or encrypted). + * + * For in-place compression, margin is larger, as it must be able to cope with both + * history preservation, requiring input data to remain unmodified up to LZ4_DISTANCE_MAX, + * and data expansion, which can happen when input is not compressible. + * As a consequence, buffer size requirements are much higher, + * and memory savings offered by in-place compression are more limited. + * + * There are ways to limit this cost for compression : + * - Reduce history size, by modifying LZ4_DISTANCE_MAX. + * Note that it is a compile-time constant, so all compressions will apply this limit. + * Lower values will reduce compression ratio, except when input_size < LZ4_DISTANCE_MAX, + * so it's a reasonable trick when inputs are known to be small. + * - Require the compressor to deliver a "maximum compressed size". + * This is the `dstCapacity` parameter in `LZ4_compress*()`. + * When this size is < LZ4_COMPRESSBOUND(inputSize), then compression can fail, + * in which case, the return code will be 0 (zero). + * The caller must be ready for these cases to happen, + * and typically design a backup scheme to send data uncompressed. + * The combination of both techniques can significantly reduce + * the amount of margin required for in-place compression. + * + * In-place compression can work in any buffer + * which size is >= (maxCompressedSize) + * with maxCompressedSize == LZ4_COMPRESSBOUND(srcSize) for guaranteed compression success. + * LZ4_COMPRESS_INPLACE_BUFFER_SIZE() depends on both maxCompressedSize and LZ4_DISTANCE_MAX, + * so it's possible to reduce memory requirements by playing with them. + */ + +#define LZ4_DECOMPRESS_INPLACE_MARGIN(compressedSize) (((compressedSize) >> 8) + 32) +#define LZ4_DECOMPRESS_INPLACE_BUFFER_SIZE(decompressedSize) ((decompressedSize) + LZ4_DECOMPRESS_INPLACE_MARGIN(decompressedSize)) /**< note: presumes that compressedSize < decompressedSize. note2: margin is overestimated a bit, since it could use compressedSize instead */ + +#ifndef LZ4_DISTANCE_MAX /* history window size; can be user-defined at compile time */ +# define LZ4_DISTANCE_MAX 65535 /* set to maximum value by default */ +#endif + +#define LZ4_COMPRESS_INPLACE_MARGIN (LZ4_DISTANCE_MAX + 32) /* LZ4_DISTANCE_MAX can be safely replaced by srcSize when it's smaller */ +#define LZ4_COMPRESS_INPLACE_BUFFER_SIZE(maxCompressedSize) ((maxCompressedSize) + LZ4_COMPRESS_INPLACE_MARGIN) /**< maxCompressedSize is generally LZ4_COMPRESSBOUND(inputSize), but can be set to any lower value, with the risk that compression can fail (return code 0(zero)) */ + +#endif /* LZ4_STATIC_3504398509 */ +#endif /* LZ4_STATIC_LINKING_ONLY */ + + + +#ifndef LZ4_H_98237428734687 +#define LZ4_H_98237428734687 + +/*-************************************************************ + * Private Definitions + ************************************************************** + * Do not use these definitions directly. + * They are only exposed to allow static allocation of `LZ4_stream_t` and `LZ4_streamDecode_t`. + * Accessing members will expose user code to API and/or ABI break in future versions of the library. + **************************************************************/ +#define LZ4_HASHLOG (LZ4_MEMORY_USAGE-2) +#define LZ4_HASHTABLESIZE (1 << LZ4_MEMORY_USAGE) +#define LZ4_HASH_SIZE_U32 (1 << LZ4_HASHLOG) /* required as macro for static allocation */ + +#if defined(__cplusplus) || (defined (__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L) /* C99 */) +# include + typedef int8_t LZ4_i8; + typedef uint8_t LZ4_byte; + typedef uint16_t LZ4_u16; + typedef uint32_t LZ4_u32; +#else + typedef signed char LZ4_i8; + typedef unsigned char LZ4_byte; + typedef unsigned short LZ4_u16; + typedef unsigned int LZ4_u32; +#endif + +/*! LZ4_stream_t : + * Never ever use below internal definitions directly ! + * These definitions are not API/ABI safe, and may change in future versions. + * If you need static allocation, declare or allocate an LZ4_stream_t object. +**/ + +typedef struct LZ4_stream_t_internal LZ4_stream_t_internal; +struct LZ4_stream_t_internal { + LZ4_u32 hashTable[LZ4_HASH_SIZE_U32]; + const LZ4_byte* dictionary; + const LZ4_stream_t_internal* dictCtx; + LZ4_u32 currentOffset; + LZ4_u32 tableType; + LZ4_u32 dictSize; + /* Implicit padding to ensure structure is aligned */ +}; + +#define LZ4_STREAM_MINSIZE ((1UL << LZ4_MEMORY_USAGE) + 32) /* static size, for inter-version compatibility */ +union LZ4_stream_u { + char minStateSize[LZ4_STREAM_MINSIZE]; + LZ4_stream_t_internal internal_donotuse; +}; /* previously typedef'd to LZ4_stream_t */ + + +/*! LZ4_initStream() : v1.9.0+ + * An LZ4_stream_t structure must be initialized at least once. + * This is automatically done when invoking LZ4_createStream(), + * but it's not when the structure is simply declared on stack (for example). + * + * Use LZ4_initStream() to properly initialize a newly declared LZ4_stream_t. + * It can also initialize any arbitrary buffer of sufficient size, + * and will @return a pointer of proper type upon initialization. + * + * Note : initialization fails if size and alignment conditions are not respected. + * In which case, the function will @return NULL. + * Note2: An LZ4_stream_t structure guarantees correct alignment and size. + * Note3: Before v1.9.0, use LZ4_resetStream() instead +**/ +LZ4LIB_API LZ4_stream_t* LZ4_initStream (void* buffer, size_t size); + + +/*! LZ4_streamDecode_t : + * Never ever use below internal definitions directly ! + * These definitions are not API/ABI safe, and may change in future versions. + * If you need static allocation, declare or allocate an LZ4_streamDecode_t object. +**/ +typedef struct { + const LZ4_byte* externalDict; + const LZ4_byte* prefixEnd; + size_t extDictSize; + size_t prefixSize; +} LZ4_streamDecode_t_internal; + +#define LZ4_STREAMDECODE_MINSIZE 32 +union LZ4_streamDecode_u { + char minStateSize[LZ4_STREAMDECODE_MINSIZE]; + LZ4_streamDecode_t_internal internal_donotuse; +} ; /* previously typedef'd to LZ4_streamDecode_t */ + + + +/*-************************************ +* Obsolete Functions +**************************************/ + +/*! Deprecation warnings + * + * Deprecated functions make the compiler generate a warning when invoked. + * This is meant to invite users to update their source code. + * Should deprecation warnings be a problem, it is generally possible to disable them, + * typically with -Wno-deprecated-declarations for gcc + * or _CRT_SECURE_NO_WARNINGS in Visual. + * + * Another method is to define LZ4_DISABLE_DEPRECATE_WARNINGS + * before including the header file. + */ +#ifdef LZ4_DISABLE_DEPRECATE_WARNINGS +# define LZ4_DEPRECATED(message) /* disable deprecation warnings */ +#else +# if defined (__cplusplus) && (__cplusplus >= 201402) /* C++14 or greater */ +# define LZ4_DEPRECATED(message) [[deprecated(message)]] +# elif defined(_MSC_VER) +# define LZ4_DEPRECATED(message) __declspec(deprecated(message)) +# elif defined(__clang__) || (defined(__GNUC__) && (__GNUC__ * 10 + __GNUC_MINOR__ >= 45)) +# define LZ4_DEPRECATED(message) __attribute__((deprecated(message))) +# elif defined(__GNUC__) && (__GNUC__ * 10 + __GNUC_MINOR__ >= 31) +# define LZ4_DEPRECATED(message) __attribute__((deprecated)) +# else +# pragma message("WARNING: LZ4_DEPRECATED needs custom implementation for this compiler") +# define LZ4_DEPRECATED(message) /* disabled */ +# endif +#endif /* LZ4_DISABLE_DEPRECATE_WARNINGS */ + +/*! Obsolete compression functions (since v1.7.3) */ +LZ4_DEPRECATED("use LZ4_compress_default() instead") LZ4LIB_API int LZ4_compress (const char* src, char* dest, int srcSize); +LZ4_DEPRECATED("use LZ4_compress_default() instead") LZ4LIB_API int LZ4_compress_limitedOutput (const char* src, char* dest, int srcSize, int maxOutputSize); +LZ4_DEPRECATED("use LZ4_compress_fast_extState() instead") LZ4LIB_API int LZ4_compress_withState (void* state, const char* source, char* dest, int inputSize); +LZ4_DEPRECATED("use LZ4_compress_fast_extState() instead") LZ4LIB_API int LZ4_compress_limitedOutput_withState (void* state, const char* source, char* dest, int inputSize, int maxOutputSize); +LZ4_DEPRECATED("use LZ4_compress_fast_continue() instead") LZ4LIB_API int LZ4_compress_continue (LZ4_stream_t* LZ4_streamPtr, const char* source, char* dest, int inputSize); +LZ4_DEPRECATED("use LZ4_compress_fast_continue() instead") LZ4LIB_API int LZ4_compress_limitedOutput_continue (LZ4_stream_t* LZ4_streamPtr, const char* source, char* dest, int inputSize, int maxOutputSize); + +/*! Obsolete decompression functions (since v1.8.0) */ +LZ4_DEPRECATED("use LZ4_decompress_fast() instead") LZ4LIB_API int LZ4_uncompress (const char* source, char* dest, int outputSize); +LZ4_DEPRECATED("use LZ4_decompress_safe() instead") LZ4LIB_API int LZ4_uncompress_unknownOutputSize (const char* source, char* dest, int isize, int maxOutputSize); + +/* Obsolete streaming functions (since v1.7.0) + * degraded functionality; do not use! + * + * In order to perform streaming compression, these functions depended on data + * that is no longer tracked in the state. They have been preserved as well as + * possible: using them will still produce a correct output. However, they don't + * actually retain any history between compression calls. The compression ratio + * achieved will therefore be no better than compressing each chunk + * independently. + */ +LZ4_DEPRECATED("Use LZ4_createStream() instead") LZ4LIB_API void* LZ4_create (char* inputBuffer); +LZ4_DEPRECATED("Use LZ4_createStream() instead") LZ4LIB_API int LZ4_sizeofStreamState(void); +LZ4_DEPRECATED("Use LZ4_resetStream() instead") LZ4LIB_API int LZ4_resetStreamState(void* state, char* inputBuffer); +LZ4_DEPRECATED("Use LZ4_saveDict() instead") LZ4LIB_API char* LZ4_slideInputBuffer (void* state); + +/*! Obsolete streaming decoding functions (since v1.7.0) */ +LZ4_DEPRECATED("use LZ4_decompress_safe_usingDict() instead") LZ4LIB_API int LZ4_decompress_safe_withPrefix64k (const char* src, char* dst, int compressedSize, int maxDstSize); +LZ4_DEPRECATED("use LZ4_decompress_fast_usingDict() instead") LZ4LIB_API int LZ4_decompress_fast_withPrefix64k (const char* src, char* dst, int originalSize); + +/*! Obsolete LZ4_decompress_fast variants (since v1.9.0) : + * These functions used to be faster than LZ4_decompress_safe(), + * but this is no longer the case. They are now slower. + * This is because LZ4_decompress_fast() doesn't know the input size, + * and therefore must progress more cautiously into the input buffer to not read beyond the end of block. + * On top of that `LZ4_decompress_fast()` is not protected vs malformed or malicious inputs, making it a security liability. + * As a consequence, LZ4_decompress_fast() is strongly discouraged, and deprecated. + * + * The last remaining LZ4_decompress_fast() specificity is that + * it can decompress a block without knowing its compressed size. + * Such functionality can be achieved in a more secure manner + * by employing LZ4_decompress_safe_partial(). + * + * Parameters: + * originalSize : is the uncompressed size to regenerate. + * `dst` must be already allocated, its size must be >= 'originalSize' bytes. + * @return : number of bytes read from source buffer (== compressed size). + * The function expects to finish at block's end exactly. + * If the source stream is detected malformed, the function stops decoding and returns a negative result. + * note : LZ4_decompress_fast*() requires originalSize. Thanks to this information, it never writes past the output buffer. + * However, since it doesn't know its 'src' size, it may read an unknown amount of input, past input buffer bounds. + * Also, since match offsets are not validated, match reads from 'src' may underflow too. + * These issues never happen if input (compressed) data is correct. + * But they may happen if input data is invalid (error or intentional tampering). + * As a consequence, use these functions in trusted environments with trusted data **only**. + */ +LZ4_DEPRECATED("This function is deprecated and unsafe. Consider using LZ4_decompress_safe() instead") +LZ4LIB_API int LZ4_decompress_fast (const char* src, char* dst, int originalSize); +LZ4_DEPRECATED("This function is deprecated and unsafe. Consider using LZ4_decompress_safe_continue() instead") +LZ4LIB_API int LZ4_decompress_fast_continue (LZ4_streamDecode_t* LZ4_streamDecode, const char* src, char* dst, int originalSize); +LZ4_DEPRECATED("This function is deprecated and unsafe. Consider using LZ4_decompress_safe_usingDict() instead") +LZ4LIB_API int LZ4_decompress_fast_usingDict (const char* src, char* dst, int originalSize, const char* dictStart, int dictSize); + +/*! LZ4_resetStream() : + * An LZ4_stream_t structure must be initialized at least once. + * This is done with LZ4_initStream(), or LZ4_resetStream(). + * Consider switching to LZ4_initStream(), + * invoking LZ4_resetStream() will trigger deprecation warnings in the future. + */ +LZ4LIB_API void LZ4_resetStream (LZ4_stream_t* streamPtr); + + +#endif /* LZ4_H_98237428734687 */ + + +#if defined (__cplusplus) +} +#endif diff --git a/native/lz4/lz4hc.c b/native/lz4/lz4hc.c new file mode 100644 index 0000000..b21ad6b --- /dev/null +++ b/native/lz4/lz4hc.c @@ -0,0 +1,1631 @@ +/* + LZ4 HC - High Compression Mode of LZ4 + Copyright (C) 2011-2020, Yann Collet. + + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + You can contact the author at : + - LZ4 source repository : https://github.com/lz4/lz4 + - LZ4 public forum : https://groups.google.com/forum/#!forum/lz4c +*/ +/* note : lz4hc is not an independent module, it requires lz4.h/lz4.c for proper compilation */ + + +/* ************************************* +* Tuning Parameter +***************************************/ + +/*! HEAPMODE : + * Select how default compression function will allocate workplace memory, + * in stack (0:fastest), or in heap (1:requires malloc()). + * Since workplace is rather large, heap mode is recommended. +**/ +#ifndef LZ4HC_HEAPMODE +# define LZ4HC_HEAPMODE 1 +#endif + + +/*=== Dependency ===*/ +#define LZ4_HC_STATIC_LINKING_ONLY +#include "lz4hc.h" + + +/*=== Common definitions ===*/ +#if defined(__GNUC__) +# pragma GCC diagnostic ignored "-Wunused-function" +#endif +#if defined (__clang__) +# pragma clang diagnostic ignored "-Wunused-function" +#endif + +#define LZ4_COMMONDEFS_ONLY +#ifndef LZ4_SRC_INCLUDED +#include "lz4.c" /* LZ4_count, constants, mem */ +#endif + + +/*=== Enums ===*/ +typedef enum { noDictCtx, usingDictCtxHc } dictCtx_directive; + + +/*=== Constants ===*/ +#define OPTIMAL_ML (int)((ML_MASK-1)+MINMATCH) +#define LZ4_OPT_NUM (1<<12) + + +/*=== Macros ===*/ +#define MIN(a,b) ( (a) < (b) ? (a) : (b) ) +#define MAX(a,b) ( (a) > (b) ? (a) : (b) ) +#define HASH_FUNCTION(i) (((i) * 2654435761U) >> ((MINMATCH*8)-LZ4HC_HASH_LOG)) +#define DELTANEXTMAXD(p) chainTable[(p) & LZ4HC_MAXD_MASK] /* flexible, LZ4HC_MAXD dependent */ +#define DELTANEXTU16(table, pos) table[(U16)(pos)] /* faster */ +/* Make fields passed to, and updated by LZ4HC_encodeSequence explicit */ +#define UPDATABLE(ip, op, anchor) &ip, &op, &anchor + +static U32 LZ4HC_hashPtr(const void* ptr) { return HASH_FUNCTION(LZ4_read32(ptr)); } + + +/************************************** +* HC Compression +**************************************/ +static void LZ4HC_clearTables (LZ4HC_CCtx_internal* hc4) +{ + MEM_INIT(hc4->hashTable, 0, sizeof(hc4->hashTable)); + MEM_INIT(hc4->chainTable, 0xFF, sizeof(hc4->chainTable)); +} + +static void LZ4HC_init_internal (LZ4HC_CCtx_internal* hc4, const BYTE* start) +{ + size_t const bufferSize = (size_t)(hc4->end - hc4->prefixStart); + size_t newStartingOffset = bufferSize + hc4->dictLimit; + assert(newStartingOffset >= bufferSize); /* check overflow */ + if (newStartingOffset > 1 GB) { + LZ4HC_clearTables(hc4); + newStartingOffset = 0; + } + newStartingOffset += 64 KB; + hc4->nextToUpdate = (U32)newStartingOffset; + hc4->prefixStart = start; + hc4->end = start; + hc4->dictStart = start; + hc4->dictLimit = (U32)newStartingOffset; + hc4->lowLimit = (U32)newStartingOffset; +} + + +/* Update chains up to ip (excluded) */ +LZ4_FORCE_INLINE void LZ4HC_Insert (LZ4HC_CCtx_internal* hc4, const BYTE* ip) +{ + U16* const chainTable = hc4->chainTable; + U32* const hashTable = hc4->hashTable; + const BYTE* const prefixPtr = hc4->prefixStart; + U32 const prefixIdx = hc4->dictLimit; + U32 const target = (U32)(ip - prefixPtr) + prefixIdx; + U32 idx = hc4->nextToUpdate; + assert(ip >= prefixPtr); + assert(target >= prefixIdx); + + while (idx < target) { + U32 const h = LZ4HC_hashPtr(prefixPtr+idx-prefixIdx); + size_t delta = idx - hashTable[h]; + if (delta>LZ4_DISTANCE_MAX) delta = LZ4_DISTANCE_MAX; + DELTANEXTU16(chainTable, idx) = (U16)delta; + hashTable[h] = idx; + idx++; + } + + hc4->nextToUpdate = target; +} + +/** LZ4HC_countBack() : + * @return : negative value, nb of common bytes before ip/match */ +LZ4_FORCE_INLINE +int LZ4HC_countBack(const BYTE* const ip, const BYTE* const match, + const BYTE* const iMin, const BYTE* const mMin) +{ + int back = 0; + int const min = (int)MAX(iMin - ip, mMin - match); + assert(min <= 0); + assert(ip >= iMin); assert((size_t)(ip-iMin) < (1U<<31)); + assert(match >= mMin); assert((size_t)(match - mMin) < (1U<<31)); + while ( (back > min) + && (ip[back-1] == match[back-1]) ) + back--; + return back; +} + +#if defined(_MSC_VER) +# define LZ4HC_rotl32(x,r) _rotl(x,r) +#else +# define LZ4HC_rotl32(x,r) ((x << r) | (x >> (32 - r))) +#endif + + +static U32 LZ4HC_rotatePattern(size_t const rotate, U32 const pattern) +{ + size_t const bitsToRotate = (rotate & (sizeof(pattern) - 1)) << 3; + if (bitsToRotate == 0) return pattern; + return LZ4HC_rotl32(pattern, (int)bitsToRotate); +} + +/* LZ4HC_countPattern() : + * pattern32 must be a sample of repetitive pattern of length 1, 2 or 4 (but not 3!) */ +static unsigned +LZ4HC_countPattern(const BYTE* ip, const BYTE* const iEnd, U32 const pattern32) +{ + const BYTE* const iStart = ip; + reg_t const pattern = (sizeof(pattern)==8) ? + (reg_t)pattern32 + (((reg_t)pattern32) << (sizeof(pattern)*4)) : pattern32; + + while (likely(ip < iEnd-(sizeof(pattern)-1))) { + reg_t const diff = LZ4_read_ARCH(ip) ^ pattern; + if (!diff) { ip+=sizeof(pattern); continue; } + ip += LZ4_NbCommonBytes(diff); + return (unsigned)(ip - iStart); + } + + if (LZ4_isLittleEndian()) { + reg_t patternByte = pattern; + while ((ip>= 8; + } + } else { /* big endian */ + U32 bitOffset = (sizeof(pattern)*8) - 8; + while (ip < iEnd) { + BYTE const byte = (BYTE)(pattern >> bitOffset); + if (*ip != byte) break; + ip ++; bitOffset -= 8; + } } + + return (unsigned)(ip - iStart); +} + +/* LZ4HC_reverseCountPattern() : + * pattern must be a sample of repetitive pattern of length 1, 2 or 4 (but not 3!) + * read using natural platform endianness */ +static unsigned +LZ4HC_reverseCountPattern(const BYTE* ip, const BYTE* const iLow, U32 pattern) +{ + const BYTE* const iStart = ip; + + while (likely(ip >= iLow+4)) { + if (LZ4_read32(ip-4) != pattern) break; + ip -= 4; + } + { const BYTE* bytePtr = (const BYTE*)(&pattern) + 3; /* works for any endianness */ + while (likely(ip>iLow)) { + if (ip[-1] != *bytePtr) break; + ip--; bytePtr--; + } } + return (unsigned)(iStart - ip); +} + +/* LZ4HC_protectDictEnd() : + * Checks if the match is in the last 3 bytes of the dictionary, so reading the + * 4 byte MINMATCH would overflow. + * @returns true if the match index is okay. + */ +static int LZ4HC_protectDictEnd(U32 const dictLimit, U32 const matchIndex) +{ + return ((U32)((dictLimit - 1) - matchIndex) >= 3); +} + +typedef enum { rep_untested, rep_not, rep_confirmed } repeat_state_e; +typedef enum { favorCompressionRatio=0, favorDecompressionSpeed } HCfavor_e; + +LZ4_FORCE_INLINE int +LZ4HC_InsertAndGetWiderMatch ( + LZ4HC_CCtx_internal* const hc4, + const BYTE* const ip, + const BYTE* const iLowLimit, const BYTE* const iHighLimit, + int longest, + const BYTE** matchpos, + const BYTE** startpos, + const int maxNbAttempts, + const int patternAnalysis, const int chainSwap, + const dictCtx_directive dict, + const HCfavor_e favorDecSpeed) +{ + U16* const chainTable = hc4->chainTable; + U32* const HashTable = hc4->hashTable; + const LZ4HC_CCtx_internal * const dictCtx = hc4->dictCtx; + const BYTE* const prefixPtr = hc4->prefixStart; + const U32 prefixIdx = hc4->dictLimit; + const U32 ipIndex = (U32)(ip - prefixPtr) + prefixIdx; + const int withinStartDistance = (hc4->lowLimit + (LZ4_DISTANCE_MAX + 1) > ipIndex); + const U32 lowestMatchIndex = (withinStartDistance) ? hc4->lowLimit : ipIndex - LZ4_DISTANCE_MAX; + const BYTE* const dictStart = hc4->dictStart; + const U32 dictIdx = hc4->lowLimit; + const BYTE* const dictEnd = dictStart + prefixIdx - dictIdx; + int const lookBackLength = (int)(ip-iLowLimit); + int nbAttempts = maxNbAttempts; + U32 matchChainPos = 0; + U32 const pattern = LZ4_read32(ip); + U32 matchIndex; + repeat_state_e repeat = rep_untested; + size_t srcPatternLength = 0; + + DEBUGLOG(7, "LZ4HC_InsertAndGetWiderMatch"); + /* First Match */ + LZ4HC_Insert(hc4, ip); + matchIndex = HashTable[LZ4HC_hashPtr(ip)]; + DEBUGLOG(7, "First match at index %u / %u (lowestMatchIndex)", + matchIndex, lowestMatchIndex); + + while ((matchIndex>=lowestMatchIndex) && (nbAttempts>0)) { + int matchLength=0; + nbAttempts--; + assert(matchIndex < ipIndex); + if (favorDecSpeed && (ipIndex - matchIndex < 8)) { + /* do nothing */ + } else if (matchIndex >= prefixIdx) { /* within current Prefix */ + const BYTE* const matchPtr = prefixPtr + matchIndex - prefixIdx; + assert(matchPtr < ip); + assert(longest >= 1); + if (LZ4_read16(iLowLimit + longest - 1) == LZ4_read16(matchPtr - lookBackLength + longest - 1)) { + if (LZ4_read32(matchPtr) == pattern) { + int const back = lookBackLength ? LZ4HC_countBack(ip, matchPtr, iLowLimit, prefixPtr) : 0; + matchLength = MINMATCH + (int)LZ4_count(ip+MINMATCH, matchPtr+MINMATCH, iHighLimit); + matchLength -= back; + if (matchLength > longest) { + longest = matchLength; + *matchpos = matchPtr + back; + *startpos = ip + back; + } } } + } else { /* lowestMatchIndex <= matchIndex < dictLimit */ + const BYTE* const matchPtr = dictStart + (matchIndex - dictIdx); + assert(matchIndex >= dictIdx); + if ( likely(matchIndex <= prefixIdx - 4) + && (LZ4_read32(matchPtr) == pattern) ) { + int back = 0; + const BYTE* vLimit = ip + (prefixIdx - matchIndex); + if (vLimit > iHighLimit) vLimit = iHighLimit; + matchLength = (int)LZ4_count(ip+MINMATCH, matchPtr+MINMATCH, vLimit) + MINMATCH; + if ((ip+matchLength == vLimit) && (vLimit < iHighLimit)) + matchLength += LZ4_count(ip+matchLength, prefixPtr, iHighLimit); + back = lookBackLength ? LZ4HC_countBack(ip, matchPtr, iLowLimit, dictStart) : 0; + matchLength -= back; + if (matchLength > longest) { + longest = matchLength; + *matchpos = prefixPtr - prefixIdx + matchIndex + back; /* virtual pos, relative to ip, to retrieve offset */ + *startpos = ip + back; + } } } + + if (chainSwap && matchLength==longest) { /* better match => select a better chain */ + assert(lookBackLength==0); /* search forward only */ + if (matchIndex + (U32)longest <= ipIndex) { + int const kTrigger = 4; + U32 distanceToNextMatch = 1; + int const end = longest - MINMATCH + 1; + int step = 1; + int accel = 1 << kTrigger; + int pos; + for (pos = 0; pos < end; pos += step) { + U32 const candidateDist = DELTANEXTU16(chainTable, matchIndex + (U32)pos); + step = (accel++ >> kTrigger); + if (candidateDist > distanceToNextMatch) { + distanceToNextMatch = candidateDist; + matchChainPos = (U32)pos; + accel = 1 << kTrigger; + } } + if (distanceToNextMatch > 1) { + if (distanceToNextMatch > matchIndex) break; /* avoid overflow */ + matchIndex -= distanceToNextMatch; + continue; + } } } + + { U32 const distNextMatch = DELTANEXTU16(chainTable, matchIndex); + if (patternAnalysis && distNextMatch==1 && matchChainPos==0) { + U32 const matchCandidateIdx = matchIndex-1; + /* may be a repeated pattern */ + if (repeat == rep_untested) { + if ( ((pattern & 0xFFFF) == (pattern >> 16)) + & ((pattern & 0xFF) == (pattern >> 24)) ) { + repeat = rep_confirmed; + srcPatternLength = LZ4HC_countPattern(ip+sizeof(pattern), iHighLimit, pattern) + sizeof(pattern); + } else { + repeat = rep_not; + } } + if ( (repeat == rep_confirmed) && (matchCandidateIdx >= lowestMatchIndex) + && LZ4HC_protectDictEnd(prefixIdx, matchCandidateIdx) ) { + const int extDict = matchCandidateIdx < prefixIdx; + const BYTE* const matchPtr = (extDict ? dictStart - dictIdx : prefixPtr - prefixIdx) + matchCandidateIdx; + if (LZ4_read32(matchPtr) == pattern) { /* good candidate */ + const BYTE* const iLimit = extDict ? dictEnd : iHighLimit; + size_t forwardPatternLength = LZ4HC_countPattern(matchPtr+sizeof(pattern), iLimit, pattern) + sizeof(pattern); + if (extDict && matchPtr + forwardPatternLength == iLimit) { + U32 const rotatedPattern = LZ4HC_rotatePattern(forwardPatternLength, pattern); + forwardPatternLength += LZ4HC_countPattern(prefixPtr, iHighLimit, rotatedPattern); + } + { const BYTE* const lowestMatchPtr = extDict ? dictStart : prefixPtr; + size_t backLength = LZ4HC_reverseCountPattern(matchPtr, lowestMatchPtr, pattern); + size_t currentSegmentLength; + if (!extDict + && matchPtr - backLength == prefixPtr + && dictIdx < prefixIdx) { + U32 const rotatedPattern = LZ4HC_rotatePattern((U32)(-(int)backLength), pattern); + backLength += LZ4HC_reverseCountPattern(dictEnd, dictStart, rotatedPattern); + } + /* Limit backLength not go further than lowestMatchIndex */ + backLength = matchCandidateIdx - MAX(matchCandidateIdx - (U32)backLength, lowestMatchIndex); + assert(matchCandidateIdx - backLength >= lowestMatchIndex); + currentSegmentLength = backLength + forwardPatternLength; + /* Adjust to end of pattern if the source pattern fits, otherwise the beginning of the pattern */ + if ( (currentSegmentLength >= srcPatternLength) /* current pattern segment large enough to contain full srcPatternLength */ + && (forwardPatternLength <= srcPatternLength) ) { /* haven't reached this position yet */ + U32 const newMatchIndex = matchCandidateIdx + (U32)forwardPatternLength - (U32)srcPatternLength; /* best position, full pattern, might be followed by more match */ + if (LZ4HC_protectDictEnd(prefixIdx, newMatchIndex)) + matchIndex = newMatchIndex; + else { + /* Can only happen if started in the prefix */ + assert(newMatchIndex >= prefixIdx - 3 && newMatchIndex < prefixIdx && !extDict); + matchIndex = prefixIdx; + } + } else { + U32 const newMatchIndex = matchCandidateIdx - (U32)backLength; /* farthest position in current segment, will find a match of length currentSegmentLength + maybe some back */ + if (!LZ4HC_protectDictEnd(prefixIdx, newMatchIndex)) { + assert(newMatchIndex >= prefixIdx - 3 && newMatchIndex < prefixIdx && !extDict); + matchIndex = prefixIdx; + } else { + matchIndex = newMatchIndex; + if (lookBackLength==0) { /* no back possible */ + size_t const maxML = MIN(currentSegmentLength, srcPatternLength); + if ((size_t)longest < maxML) { + assert(prefixPtr - prefixIdx + matchIndex != ip); + if ((size_t)(ip - prefixPtr) + prefixIdx - matchIndex > LZ4_DISTANCE_MAX) break; + assert(maxML < 2 GB); + longest = (int)maxML; + *matchpos = prefixPtr - prefixIdx + matchIndex; /* virtual pos, relative to ip, to retrieve offset */ + *startpos = ip; + } + { U32 const distToNextPattern = DELTANEXTU16(chainTable, matchIndex); + if (distToNextPattern > matchIndex) break; /* avoid overflow */ + matchIndex -= distToNextPattern; + } } } } } + continue; + } } + } } /* PA optimization */ + + /* follow current chain */ + matchIndex -= DELTANEXTU16(chainTable, matchIndex + matchChainPos); + + } /* while ((matchIndex>=lowestMatchIndex) && (nbAttempts)) */ + + if ( dict == usingDictCtxHc + && nbAttempts > 0 + && ipIndex - lowestMatchIndex < LZ4_DISTANCE_MAX) { + size_t const dictEndOffset = (size_t)(dictCtx->end - dictCtx->prefixStart) + dictCtx->dictLimit; + U32 dictMatchIndex = dictCtx->hashTable[LZ4HC_hashPtr(ip)]; + assert(dictEndOffset <= 1 GB); + matchIndex = dictMatchIndex + lowestMatchIndex - (U32)dictEndOffset; + while (ipIndex - matchIndex <= LZ4_DISTANCE_MAX && nbAttempts--) { + const BYTE* const matchPtr = dictCtx->prefixStart - dictCtx->dictLimit + dictMatchIndex; + + if (LZ4_read32(matchPtr) == pattern) { + int mlt; + int back = 0; + const BYTE* vLimit = ip + (dictEndOffset - dictMatchIndex); + if (vLimit > iHighLimit) vLimit = iHighLimit; + mlt = (int)LZ4_count(ip+MINMATCH, matchPtr+MINMATCH, vLimit) + MINMATCH; + back = lookBackLength ? LZ4HC_countBack(ip, matchPtr, iLowLimit, dictCtx->prefixStart) : 0; + mlt -= back; + if (mlt > longest) { + longest = mlt; + *matchpos = prefixPtr - prefixIdx + matchIndex + back; + *startpos = ip + back; + } } + + { U32 const nextOffset = DELTANEXTU16(dictCtx->chainTable, dictMatchIndex); + dictMatchIndex -= nextOffset; + matchIndex -= nextOffset; + } } } + + return longest; +} + +LZ4_FORCE_INLINE int +LZ4HC_InsertAndFindBestMatch(LZ4HC_CCtx_internal* const hc4, /* Index table will be updated */ + const BYTE* const ip, const BYTE* const iLimit, + const BYTE** matchpos, + const int maxNbAttempts, + const int patternAnalysis, + const dictCtx_directive dict) +{ + const BYTE* uselessPtr = ip; + /* note : LZ4HC_InsertAndGetWiderMatch() is able to modify the starting position of a match (*startpos), + * but this won't be the case here, as we define iLowLimit==ip, + * so LZ4HC_InsertAndGetWiderMatch() won't be allowed to search past ip */ + return LZ4HC_InsertAndGetWiderMatch(hc4, ip, ip, iLimit, MINMATCH-1, matchpos, &uselessPtr, maxNbAttempts, patternAnalysis, 0 /*chainSwap*/, dict, favorCompressionRatio); +} + +/* LZ4HC_encodeSequence() : + * @return : 0 if ok, + * 1 if buffer issue detected */ +LZ4_FORCE_INLINE int LZ4HC_encodeSequence ( + const BYTE** _ip, + BYTE** _op, + const BYTE** _anchor, + int matchLength, + const BYTE* const match, + limitedOutput_directive limit, + BYTE* oend) +{ +#define ip (*_ip) +#define op (*_op) +#define anchor (*_anchor) + + size_t length; + BYTE* const token = op++; + +#if defined(LZ4_DEBUG) && (LZ4_DEBUG >= 6) + static const BYTE* start = NULL; + static U32 totalCost = 0; + U32 const pos = (start==NULL) ? 0 : (U32)(anchor - start); + U32 const ll = (U32)(ip - anchor); + U32 const llAdd = (ll>=15) ? ((ll-15) / 255) + 1 : 0; + U32 const mlAdd = (matchLength>=19) ? ((matchLength-19) / 255) + 1 : 0; + U32 const cost = 1 + llAdd + ll + 2 + mlAdd; + if (start==NULL) start = anchor; /* only works for single segment */ + /* g_debuglog_enable = (pos >= 2228) & (pos <= 2262); */ + DEBUGLOG(6, "pos:%7u -- literals:%4u, match:%4i, offset:%5u, cost:%4u + %5u", + pos, + (U32)(ip - anchor), matchLength, (U32)(ip-match), + cost, totalCost); + totalCost += cost; +#endif + + /* Encode Literal length */ + length = (size_t)(ip - anchor); + LZ4_STATIC_ASSERT(notLimited == 0); + /* Check output limit */ + if (limit && ((op + (length / 255) + length + (2 + 1 + LASTLITERALS)) > oend)) { + DEBUGLOG(6, "Not enough room to write %i literals (%i bytes remaining)", + (int)length, (int)(oend - op)); + return 1; + } + if (length >= RUN_MASK) { + size_t len = length - RUN_MASK; + *token = (RUN_MASK << ML_BITS); + for(; len >= 255 ; len -= 255) *op++ = 255; + *op++ = (BYTE)len; + } else { + *token = (BYTE)(length << ML_BITS); + } + + /* Copy Literals */ + LZ4_wildCopy8(op, anchor, op + length); + op += length; + + /* Encode Offset */ + assert( (ip - match) <= LZ4_DISTANCE_MAX ); /* note : consider providing offset as a value, rather than as a pointer difference */ + LZ4_writeLE16(op, (U16)(ip - match)); op += 2; + + /* Encode MatchLength */ + assert(matchLength >= MINMATCH); + length = (size_t)matchLength - MINMATCH; + if (limit && (op + (length / 255) + (1 + LASTLITERALS) > oend)) { + DEBUGLOG(6, "Not enough room to write match length"); + return 1; /* Check output limit */ + } + if (length >= ML_MASK) { + *token += ML_MASK; + length -= ML_MASK; + for(; length >= 510 ; length -= 510) { *op++ = 255; *op++ = 255; } + if (length >= 255) { length -= 255; *op++ = 255; } + *op++ = (BYTE)length; + } else { + *token += (BYTE)(length); + } + + /* Prepare next loop */ + ip += matchLength; + anchor = ip; + + return 0; +} +#undef ip +#undef op +#undef anchor + +LZ4_FORCE_INLINE int LZ4HC_compress_hashChain ( + LZ4HC_CCtx_internal* const ctx, + const char* const source, + char* const dest, + int* srcSizePtr, + int const maxOutputSize, + int maxNbAttempts, + const limitedOutput_directive limit, + const dictCtx_directive dict + ) +{ + const int inputSize = *srcSizePtr; + const int patternAnalysis = (maxNbAttempts > 128); /* levels 9+ */ + + const BYTE* ip = (const BYTE*) source; + const BYTE* anchor = ip; + const BYTE* const iend = ip + inputSize; + const BYTE* const mflimit = iend - MFLIMIT; + const BYTE* const matchlimit = (iend - LASTLITERALS); + + BYTE* optr = (BYTE*) dest; + BYTE* op = (BYTE*) dest; + BYTE* oend = op + maxOutputSize; + + int ml0, ml, ml2, ml3; + const BYTE* start0; + const BYTE* ref0; + const BYTE* ref = NULL; + const BYTE* start2 = NULL; + const BYTE* ref2 = NULL; + const BYTE* start3 = NULL; + const BYTE* ref3 = NULL; + + /* init */ + *srcSizePtr = 0; + if (limit == fillOutput) oend -= LASTLITERALS; /* Hack for support LZ4 format restriction */ + if (inputSize < LZ4_minLength) goto _last_literals; /* Input too small, no compression (all literals) */ + + /* Main Loop */ + while (ip <= mflimit) { + ml = LZ4HC_InsertAndFindBestMatch(ctx, ip, matchlimit, &ref, maxNbAttempts, patternAnalysis, dict); + if (ml encode ML1 */ + optr = op; + if (LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ref, limit, oend)) goto _dest_overflow; + continue; + } + + if (start0 < ip) { /* first match was skipped at least once */ + if (start2 < ip + ml0) { /* squeezing ML1 between ML0(original ML1) and ML2 */ + ip = start0; ref = ref0; ml = ml0; /* restore initial ML1 */ + } } + + /* Here, start0==ip */ + if ((start2 - ip) < 3) { /* First Match too small : removed */ + ml = ml2; + ip = start2; + ref =ref2; + goto _Search2; + } + +_Search3: + /* At this stage, we have : + * ml2 > ml1, and + * ip1+3 <= ip2 (usually < ip1+ml1) */ + if ((start2 - ip) < OPTIMAL_ML) { + int correction; + int new_ml = ml; + if (new_ml > OPTIMAL_ML) new_ml = OPTIMAL_ML; + if (ip+new_ml > start2 + ml2 - MINMATCH) new_ml = (int)(start2 - ip) + ml2 - MINMATCH; + correction = new_ml - (int)(start2 - ip); + if (correction > 0) { + start2 += correction; + ref2 += correction; + ml2 -= correction; + } + } + /* Now, we have start2 = ip+new_ml, with new_ml = min(ml, OPTIMAL_ML=18) */ + + if (start2 + ml2 <= mflimit) { + ml3 = LZ4HC_InsertAndGetWiderMatch(ctx, + start2 + ml2 - 3, start2, matchlimit, ml2, &ref3, &start3, + maxNbAttempts, patternAnalysis, 0, dict, favorCompressionRatio); + } else { + ml3 = ml2; + } + + if (ml3 == ml2) { /* No better match => encode ML1 and ML2 */ + /* ip & ref are known; Now for ml */ + if (start2 < ip+ml) ml = (int)(start2 - ip); + /* Now, encode 2 sequences */ + optr = op; + if (LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ref, limit, oend)) goto _dest_overflow; + ip = start2; + optr = op; + if (LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml2, ref2, limit, oend)) { + ml = ml2; + ref = ref2; + goto _dest_overflow; + } + continue; + } + + if (start3 < ip+ml+3) { /* Not enough space for match 2 : remove it */ + if (start3 >= (ip+ml)) { /* can write Seq1 immediately ==> Seq2 is removed, so Seq3 becomes Seq1 */ + if (start2 < ip+ml) { + int correction = (int)(ip+ml - start2); + start2 += correction; + ref2 += correction; + ml2 -= correction; + if (ml2 < MINMATCH) { + start2 = start3; + ref2 = ref3; + ml2 = ml3; + } + } + + optr = op; + if (LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ref, limit, oend)) goto _dest_overflow; + ip = start3; + ref = ref3; + ml = ml3; + + start0 = start2; + ref0 = ref2; + ml0 = ml2; + goto _Search2; + } + + start2 = start3; + ref2 = ref3; + ml2 = ml3; + goto _Search3; + } + + /* + * OK, now we have 3 ascending matches; + * let's write the first one ML1. + * ip & ref are known; Now decide ml. + */ + if (start2 < ip+ml) { + if ((start2 - ip) < OPTIMAL_ML) { + int correction; + if (ml > OPTIMAL_ML) ml = OPTIMAL_ML; + if (ip + ml > start2 + ml2 - MINMATCH) ml = (int)(start2 - ip) + ml2 - MINMATCH; + correction = ml - (int)(start2 - ip); + if (correction > 0) { + start2 += correction; + ref2 += correction; + ml2 -= correction; + } + } else { + ml = (int)(start2 - ip); + } + } + optr = op; + if (LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ref, limit, oend)) goto _dest_overflow; + + /* ML2 becomes ML1 */ + ip = start2; ref = ref2; ml = ml2; + + /* ML3 becomes ML2 */ + start2 = start3; ref2 = ref3; ml2 = ml3; + + /* let's find a new ML3 */ + goto _Search3; + } + +_last_literals: + /* Encode Last Literals */ + { size_t lastRunSize = (size_t)(iend - anchor); /* literals */ + size_t llAdd = (lastRunSize + 255 - RUN_MASK) / 255; + size_t const totalSize = 1 + llAdd + lastRunSize; + if (limit == fillOutput) oend += LASTLITERALS; /* restore correct value */ + if (limit && (op + totalSize > oend)) { + if (limit == limitedOutput) return 0; + /* adapt lastRunSize to fill 'dest' */ + lastRunSize = (size_t)(oend - op) - 1 /*token*/; + llAdd = (lastRunSize + 256 - RUN_MASK) / 256; + lastRunSize -= llAdd; + } + DEBUGLOG(6, "Final literal run : %i literals", (int)lastRunSize); + ip = anchor + lastRunSize; /* can be != iend if limit==fillOutput */ + + if (lastRunSize >= RUN_MASK) { + size_t accumulator = lastRunSize - RUN_MASK; + *op++ = (RUN_MASK << ML_BITS); + for(; accumulator >= 255 ; accumulator -= 255) *op++ = 255; + *op++ = (BYTE) accumulator; + } else { + *op++ = (BYTE)(lastRunSize << ML_BITS); + } + LZ4_memcpy(op, anchor, lastRunSize); + op += lastRunSize; + } + + /* End */ + *srcSizePtr = (int) (((const char*)ip) - source); + return (int) (((char*)op)-dest); + +_dest_overflow: + if (limit == fillOutput) { + /* Assumption : ip, anchor, ml and ref must be set correctly */ + size_t const ll = (size_t)(ip - anchor); + size_t const ll_addbytes = (ll + 240) / 255; + size_t const ll_totalCost = 1 + ll_addbytes + ll; + BYTE* const maxLitPos = oend - 3; /* 2 for offset, 1 for token */ + DEBUGLOG(6, "Last sequence overflowing"); + op = optr; /* restore correct out pointer */ + if (op + ll_totalCost <= maxLitPos) { + /* ll validated; now adjust match length */ + size_t const bytesLeftForMl = (size_t)(maxLitPos - (op+ll_totalCost)); + size_t const maxMlSize = MINMATCH + (ML_MASK-1) + (bytesLeftForMl * 255); + assert(maxMlSize < INT_MAX); assert(ml >= 0); + if ((size_t)ml > maxMlSize) ml = (int)maxMlSize; + if ((oend + LASTLITERALS) - (op + ll_totalCost + 2) - 1 + ml >= MFLIMIT) { + LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ref, notLimited, oend); + } } + goto _last_literals; + } + /* compression failed */ + return 0; +} + + +static int LZ4HC_compress_optimal( LZ4HC_CCtx_internal* ctx, + const char* const source, char* dst, + int* srcSizePtr, int dstCapacity, + int const nbSearches, size_t sufficient_len, + const limitedOutput_directive limit, int const fullUpdate, + const dictCtx_directive dict, + const HCfavor_e favorDecSpeed); + + +LZ4_FORCE_INLINE int LZ4HC_compress_generic_internal ( + LZ4HC_CCtx_internal* const ctx, + const char* const src, + char* const dst, + int* const srcSizePtr, + int const dstCapacity, + int cLevel, + const limitedOutput_directive limit, + const dictCtx_directive dict + ) +{ + typedef enum { lz4hc, lz4opt } lz4hc_strat_e; + typedef struct { + lz4hc_strat_e strat; + int nbSearches; + U32 targetLength; + } cParams_t; + static const cParams_t clTable[LZ4HC_CLEVEL_MAX+1] = { + { lz4hc, 2, 16 }, /* 0, unused */ + { lz4hc, 2, 16 }, /* 1, unused */ + { lz4hc, 2, 16 }, /* 2, unused */ + { lz4hc, 4, 16 }, /* 3 */ + { lz4hc, 8, 16 }, /* 4 */ + { lz4hc, 16, 16 }, /* 5 */ + { lz4hc, 32, 16 }, /* 6 */ + { lz4hc, 64, 16 }, /* 7 */ + { lz4hc, 128, 16 }, /* 8 */ + { lz4hc, 256, 16 }, /* 9 */ + { lz4opt, 96, 64 }, /*10==LZ4HC_CLEVEL_OPT_MIN*/ + { lz4opt, 512,128 }, /*11 */ + { lz4opt,16384,LZ4_OPT_NUM }, /* 12==LZ4HC_CLEVEL_MAX */ + }; + + DEBUGLOG(4, "LZ4HC_compress_generic(ctx=%p, src=%p, srcSize=%d, limit=%d)", + ctx, src, *srcSizePtr, limit); + + if (limit == fillOutput && dstCapacity < 1) return 0; /* Impossible to store anything */ + if ((U32)*srcSizePtr > (U32)LZ4_MAX_INPUT_SIZE) return 0; /* Unsupported input size (too large or negative) */ + + ctx->end += *srcSizePtr; + if (cLevel < 1) cLevel = LZ4HC_CLEVEL_DEFAULT; /* note : convention is different from lz4frame, maybe something to review */ + cLevel = MIN(LZ4HC_CLEVEL_MAX, cLevel); + { cParams_t const cParam = clTable[cLevel]; + HCfavor_e const favor = ctx->favorDecSpeed ? favorDecompressionSpeed : favorCompressionRatio; + int result; + + if (cParam.strat == lz4hc) { + result = LZ4HC_compress_hashChain(ctx, + src, dst, srcSizePtr, dstCapacity, + cParam.nbSearches, limit, dict); + } else { + assert(cParam.strat == lz4opt); + result = LZ4HC_compress_optimal(ctx, + src, dst, srcSizePtr, dstCapacity, + cParam.nbSearches, cParam.targetLength, limit, + cLevel == LZ4HC_CLEVEL_MAX, /* ultra mode */ + dict, favor); + } + if (result <= 0) ctx->dirty = 1; + return result; + } +} + +static void LZ4HC_setExternalDict(LZ4HC_CCtx_internal* ctxPtr, const BYTE* newBlock); + +static int +LZ4HC_compress_generic_noDictCtx ( + LZ4HC_CCtx_internal* const ctx, + const char* const src, + char* const dst, + int* const srcSizePtr, + int const dstCapacity, + int cLevel, + limitedOutput_directive limit + ) +{ + assert(ctx->dictCtx == NULL); + return LZ4HC_compress_generic_internal(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit, noDictCtx); +} + +static int +LZ4HC_compress_generic_dictCtx ( + LZ4HC_CCtx_internal* const ctx, + const char* const src, + char* const dst, + int* const srcSizePtr, + int const dstCapacity, + int cLevel, + limitedOutput_directive limit + ) +{ + const size_t position = (size_t)(ctx->end - ctx->prefixStart) + (ctx->dictLimit - ctx->lowLimit); + assert(ctx->dictCtx != NULL); + if (position >= 64 KB) { + ctx->dictCtx = NULL; + return LZ4HC_compress_generic_noDictCtx(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit); + } else if (position == 0 && *srcSizePtr > 4 KB) { + LZ4_memcpy(ctx, ctx->dictCtx, sizeof(LZ4HC_CCtx_internal)); + LZ4HC_setExternalDict(ctx, (const BYTE *)src); + ctx->compressionLevel = (short)cLevel; + return LZ4HC_compress_generic_noDictCtx(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit); + } else { + return LZ4HC_compress_generic_internal(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit, usingDictCtxHc); + } +} + +static int +LZ4HC_compress_generic ( + LZ4HC_CCtx_internal* const ctx, + const char* const src, + char* const dst, + int* const srcSizePtr, + int const dstCapacity, + int cLevel, + limitedOutput_directive limit + ) +{ + if (ctx->dictCtx == NULL) { + return LZ4HC_compress_generic_noDictCtx(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit); + } else { + return LZ4HC_compress_generic_dictCtx(ctx, src, dst, srcSizePtr, dstCapacity, cLevel, limit); + } +} + + +int LZ4_sizeofStateHC(void) { return (int)sizeof(LZ4_streamHC_t); } + +static size_t LZ4_streamHC_t_alignment(void) +{ +#if LZ4_ALIGN_TEST + typedef struct { char c; LZ4_streamHC_t t; } t_a; + return sizeof(t_a) - sizeof(LZ4_streamHC_t); +#else + return 1; /* effectively disabled */ +#endif +} + +/* state is presumed correctly initialized, + * in which case its size and alignment have already been validate */ +int LZ4_compress_HC_extStateHC_fastReset (void* state, const char* src, char* dst, int srcSize, int dstCapacity, int compressionLevel) +{ + LZ4HC_CCtx_internal* const ctx = &((LZ4_streamHC_t*)state)->internal_donotuse; + if (!LZ4_isAligned(state, LZ4_streamHC_t_alignment())) return 0; + LZ4_resetStreamHC_fast((LZ4_streamHC_t*)state, compressionLevel); + LZ4HC_init_internal (ctx, (const BYTE*)src); + if (dstCapacity < LZ4_compressBound(srcSize)) + return LZ4HC_compress_generic (ctx, src, dst, &srcSize, dstCapacity, compressionLevel, limitedOutput); + else + return LZ4HC_compress_generic (ctx, src, dst, &srcSize, dstCapacity, compressionLevel, notLimited); +} + +int LZ4_compress_HC_extStateHC (void* state, const char* src, char* dst, int srcSize, int dstCapacity, int compressionLevel) +{ + LZ4_streamHC_t* const ctx = LZ4_initStreamHC(state, sizeof(*ctx)); + if (ctx==NULL) return 0; /* init failure */ + return LZ4_compress_HC_extStateHC_fastReset(state, src, dst, srcSize, dstCapacity, compressionLevel); +} + +int LZ4_compress_HC(const char* src, char* dst, int srcSize, int dstCapacity, int compressionLevel) +{ + int cSize; +#if defined(LZ4HC_HEAPMODE) && LZ4HC_HEAPMODE==1 + LZ4_streamHC_t* const statePtr = (LZ4_streamHC_t*)ALLOC(sizeof(LZ4_streamHC_t)); + if (statePtr==NULL) return 0; +#else + LZ4_streamHC_t state; + LZ4_streamHC_t* const statePtr = &state; +#endif + cSize = LZ4_compress_HC_extStateHC(statePtr, src, dst, srcSize, dstCapacity, compressionLevel); +#if defined(LZ4HC_HEAPMODE) && LZ4HC_HEAPMODE==1 + FREEMEM(statePtr); +#endif + return cSize; +} + +/* state is presumed sized correctly (>= sizeof(LZ4_streamHC_t)) */ +int LZ4_compress_HC_destSize(void* state, const char* source, char* dest, int* sourceSizePtr, int targetDestSize, int cLevel) +{ + LZ4_streamHC_t* const ctx = LZ4_initStreamHC(state, sizeof(*ctx)); + if (ctx==NULL) return 0; /* init failure */ + LZ4HC_init_internal(&ctx->internal_donotuse, (const BYTE*) source); + LZ4_setCompressionLevel(ctx, cLevel); + return LZ4HC_compress_generic(&ctx->internal_donotuse, source, dest, sourceSizePtr, targetDestSize, cLevel, fillOutput); +} + + + +/************************************** +* Streaming Functions +**************************************/ +/* allocation */ +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +LZ4_streamHC_t* LZ4_createStreamHC(void) +{ + LZ4_streamHC_t* const state = + (LZ4_streamHC_t*)ALLOC_AND_ZERO(sizeof(LZ4_streamHC_t)); + if (state == NULL) return NULL; + LZ4_setCompressionLevel(state, LZ4HC_CLEVEL_DEFAULT); + return state; +} + +int LZ4_freeStreamHC (LZ4_streamHC_t* LZ4_streamHCPtr) +{ + DEBUGLOG(4, "LZ4_freeStreamHC(%p)", LZ4_streamHCPtr); + if (!LZ4_streamHCPtr) return 0; /* support free on NULL */ + FREEMEM(LZ4_streamHCPtr); + return 0; +} +#endif + + +LZ4_streamHC_t* LZ4_initStreamHC (void* buffer, size_t size) +{ + LZ4_streamHC_t* const LZ4_streamHCPtr = (LZ4_streamHC_t*)buffer; + DEBUGLOG(4, "LZ4_initStreamHC(%p, %u)", buffer, (unsigned)size); + /* check conditions */ + if (buffer == NULL) return NULL; + if (size < sizeof(LZ4_streamHC_t)) return NULL; + if (!LZ4_isAligned(buffer, LZ4_streamHC_t_alignment())) return NULL; + /* init */ + { LZ4HC_CCtx_internal* const hcstate = &(LZ4_streamHCPtr->internal_donotuse); + MEM_INIT(hcstate, 0, sizeof(*hcstate)); } + LZ4_setCompressionLevel(LZ4_streamHCPtr, LZ4HC_CLEVEL_DEFAULT); + return LZ4_streamHCPtr; +} + +/* just a stub */ +void LZ4_resetStreamHC (LZ4_streamHC_t* LZ4_streamHCPtr, int compressionLevel) +{ + LZ4_initStreamHC(LZ4_streamHCPtr, sizeof(*LZ4_streamHCPtr)); + LZ4_setCompressionLevel(LZ4_streamHCPtr, compressionLevel); +} + +void LZ4_resetStreamHC_fast (LZ4_streamHC_t* LZ4_streamHCPtr, int compressionLevel) +{ + DEBUGLOG(4, "LZ4_resetStreamHC_fast(%p, %d)", LZ4_streamHCPtr, compressionLevel); + if (LZ4_streamHCPtr->internal_donotuse.dirty) { + LZ4_initStreamHC(LZ4_streamHCPtr, sizeof(*LZ4_streamHCPtr)); + } else { + /* preserve end - prefixStart : can trigger clearTable's threshold */ + if (LZ4_streamHCPtr->internal_donotuse.end != NULL) { + LZ4_streamHCPtr->internal_donotuse.end -= (uptrval)LZ4_streamHCPtr->internal_donotuse.prefixStart; + } else { + assert(LZ4_streamHCPtr->internal_donotuse.prefixStart == NULL); + } + LZ4_streamHCPtr->internal_donotuse.prefixStart = NULL; + LZ4_streamHCPtr->internal_donotuse.dictCtx = NULL; + } + LZ4_setCompressionLevel(LZ4_streamHCPtr, compressionLevel); +} + +void LZ4_setCompressionLevel(LZ4_streamHC_t* LZ4_streamHCPtr, int compressionLevel) +{ + DEBUGLOG(5, "LZ4_setCompressionLevel(%p, %d)", LZ4_streamHCPtr, compressionLevel); + if (compressionLevel < 1) compressionLevel = LZ4HC_CLEVEL_DEFAULT; + if (compressionLevel > LZ4HC_CLEVEL_MAX) compressionLevel = LZ4HC_CLEVEL_MAX; + LZ4_streamHCPtr->internal_donotuse.compressionLevel = (short)compressionLevel; +} + +void LZ4_favorDecompressionSpeed(LZ4_streamHC_t* LZ4_streamHCPtr, int favor) +{ + LZ4_streamHCPtr->internal_donotuse.favorDecSpeed = (favor!=0); +} + +/* LZ4_loadDictHC() : + * LZ4_streamHCPtr is presumed properly initialized */ +int LZ4_loadDictHC (LZ4_streamHC_t* LZ4_streamHCPtr, + const char* dictionary, int dictSize) +{ + LZ4HC_CCtx_internal* const ctxPtr = &LZ4_streamHCPtr->internal_donotuse; + DEBUGLOG(4, "LZ4_loadDictHC(ctx:%p, dict:%p, dictSize:%d)", LZ4_streamHCPtr, dictionary, dictSize); + assert(LZ4_streamHCPtr != NULL); + if (dictSize > 64 KB) { + dictionary += (size_t)dictSize - 64 KB; + dictSize = 64 KB; + } + /* need a full initialization, there are bad side-effects when using resetFast() */ + { int const cLevel = ctxPtr->compressionLevel; + LZ4_initStreamHC(LZ4_streamHCPtr, sizeof(*LZ4_streamHCPtr)); + LZ4_setCompressionLevel(LZ4_streamHCPtr, cLevel); + } + LZ4HC_init_internal (ctxPtr, (const BYTE*)dictionary); + ctxPtr->end = (const BYTE*)dictionary + dictSize; + if (dictSize >= 4) LZ4HC_Insert (ctxPtr, ctxPtr->end-3); + return dictSize; +} + +void LZ4_attach_HC_dictionary(LZ4_streamHC_t *working_stream, const LZ4_streamHC_t *dictionary_stream) { + working_stream->internal_donotuse.dictCtx = dictionary_stream != NULL ? &(dictionary_stream->internal_donotuse) : NULL; +} + +/* compression */ + +static void LZ4HC_setExternalDict(LZ4HC_CCtx_internal* ctxPtr, const BYTE* newBlock) +{ + DEBUGLOG(4, "LZ4HC_setExternalDict(%p, %p)", ctxPtr, newBlock); + if (ctxPtr->end >= ctxPtr->prefixStart + 4) + LZ4HC_Insert (ctxPtr, ctxPtr->end-3); /* Referencing remaining dictionary content */ + + /* Only one memory segment for extDict, so any previous extDict is lost at this stage */ + ctxPtr->lowLimit = ctxPtr->dictLimit; + ctxPtr->dictStart = ctxPtr->prefixStart; + ctxPtr->dictLimit += (U32)(ctxPtr->end - ctxPtr->prefixStart); + ctxPtr->prefixStart = newBlock; + ctxPtr->end = newBlock; + ctxPtr->nextToUpdate = ctxPtr->dictLimit; /* match referencing will resume from there */ + + /* cannot reference an extDict and a dictCtx at the same time */ + ctxPtr->dictCtx = NULL; +} + +static int +LZ4_compressHC_continue_generic (LZ4_streamHC_t* LZ4_streamHCPtr, + const char* src, char* dst, + int* srcSizePtr, int dstCapacity, + limitedOutput_directive limit) +{ + LZ4HC_CCtx_internal* const ctxPtr = &LZ4_streamHCPtr->internal_donotuse; + DEBUGLOG(5, "LZ4_compressHC_continue_generic(ctx=%p, src=%p, srcSize=%d, limit=%d)", + LZ4_streamHCPtr, src, *srcSizePtr, limit); + assert(ctxPtr != NULL); + /* auto-init if forgotten */ + if (ctxPtr->prefixStart == NULL) LZ4HC_init_internal (ctxPtr, (const BYTE*) src); + + /* Check overflow */ + if ((size_t)(ctxPtr->end - ctxPtr->prefixStart) + ctxPtr->dictLimit > 2 GB) { + size_t dictSize = (size_t)(ctxPtr->end - ctxPtr->prefixStart); + if (dictSize > 64 KB) dictSize = 64 KB; + LZ4_loadDictHC(LZ4_streamHCPtr, (const char*)(ctxPtr->end) - dictSize, (int)dictSize); + } + + /* Check if blocks follow each other */ + if ((const BYTE*)src != ctxPtr->end) + LZ4HC_setExternalDict(ctxPtr, (const BYTE*)src); + + /* Check overlapping input/dictionary space */ + { const BYTE* sourceEnd = (const BYTE*) src + *srcSizePtr; + const BYTE* const dictBegin = ctxPtr->dictStart; + const BYTE* const dictEnd = ctxPtr->dictStart + (ctxPtr->dictLimit - ctxPtr->lowLimit); + if ((sourceEnd > dictBegin) && ((const BYTE*)src < dictEnd)) { + if (sourceEnd > dictEnd) sourceEnd = dictEnd; + ctxPtr->lowLimit += (U32)(sourceEnd - ctxPtr->dictStart); + ctxPtr->dictStart += (U32)(sourceEnd - ctxPtr->dictStart); + if (ctxPtr->dictLimit - ctxPtr->lowLimit < 4) { + ctxPtr->lowLimit = ctxPtr->dictLimit; + ctxPtr->dictStart = ctxPtr->prefixStart; + } } } + + return LZ4HC_compress_generic (ctxPtr, src, dst, srcSizePtr, dstCapacity, ctxPtr->compressionLevel, limit); +} + +int LZ4_compress_HC_continue (LZ4_streamHC_t* LZ4_streamHCPtr, const char* src, char* dst, int srcSize, int dstCapacity) +{ + if (dstCapacity < LZ4_compressBound(srcSize)) + return LZ4_compressHC_continue_generic (LZ4_streamHCPtr, src, dst, &srcSize, dstCapacity, limitedOutput); + else + return LZ4_compressHC_continue_generic (LZ4_streamHCPtr, src, dst, &srcSize, dstCapacity, notLimited); +} + +int LZ4_compress_HC_continue_destSize (LZ4_streamHC_t* LZ4_streamHCPtr, const char* src, char* dst, int* srcSizePtr, int targetDestSize) +{ + return LZ4_compressHC_continue_generic(LZ4_streamHCPtr, src, dst, srcSizePtr, targetDestSize, fillOutput); +} + + + +/* LZ4_saveDictHC : + * save history content + * into a user-provided buffer + * which is then used to continue compression + */ +int LZ4_saveDictHC (LZ4_streamHC_t* LZ4_streamHCPtr, char* safeBuffer, int dictSize) +{ + LZ4HC_CCtx_internal* const streamPtr = &LZ4_streamHCPtr->internal_donotuse; + int const prefixSize = (int)(streamPtr->end - streamPtr->prefixStart); + DEBUGLOG(5, "LZ4_saveDictHC(%p, %p, %d)", LZ4_streamHCPtr, safeBuffer, dictSize); + assert(prefixSize >= 0); + if (dictSize > 64 KB) dictSize = 64 KB; + if (dictSize < 4) dictSize = 0; + if (dictSize > prefixSize) dictSize = prefixSize; + if (safeBuffer == NULL) assert(dictSize == 0); + if (dictSize > 0) + LZ4_memmove(safeBuffer, streamPtr->end - dictSize, dictSize); + { U32 const endIndex = (U32)(streamPtr->end - streamPtr->prefixStart) + streamPtr->dictLimit; + streamPtr->end = (const BYTE*)safeBuffer + dictSize; + streamPtr->prefixStart = streamPtr->end - dictSize; + streamPtr->dictLimit = endIndex - (U32)dictSize; + streamPtr->lowLimit = endIndex - (U32)dictSize; + streamPtr->dictStart = streamPtr->prefixStart; + if (streamPtr->nextToUpdate < streamPtr->dictLimit) + streamPtr->nextToUpdate = streamPtr->dictLimit; + } + return dictSize; +} + + +/*************************************************** +* Deprecated Functions +***************************************************/ + +/* These functions currently generate deprecation warnings */ + +/* Wrappers for deprecated compression functions */ +int LZ4_compressHC(const char* src, char* dst, int srcSize) { return LZ4_compress_HC (src, dst, srcSize, LZ4_compressBound(srcSize), 0); } +int LZ4_compressHC_limitedOutput(const char* src, char* dst, int srcSize, int maxDstSize) { return LZ4_compress_HC(src, dst, srcSize, maxDstSize, 0); } +int LZ4_compressHC2(const char* src, char* dst, int srcSize, int cLevel) { return LZ4_compress_HC (src, dst, srcSize, LZ4_compressBound(srcSize), cLevel); } +int LZ4_compressHC2_limitedOutput(const char* src, char* dst, int srcSize, int maxDstSize, int cLevel) { return LZ4_compress_HC(src, dst, srcSize, maxDstSize, cLevel); } +int LZ4_compressHC_withStateHC (void* state, const char* src, char* dst, int srcSize) { return LZ4_compress_HC_extStateHC (state, src, dst, srcSize, LZ4_compressBound(srcSize), 0); } +int LZ4_compressHC_limitedOutput_withStateHC (void* state, const char* src, char* dst, int srcSize, int maxDstSize) { return LZ4_compress_HC_extStateHC (state, src, dst, srcSize, maxDstSize, 0); } +int LZ4_compressHC2_withStateHC (void* state, const char* src, char* dst, int srcSize, int cLevel) { return LZ4_compress_HC_extStateHC(state, src, dst, srcSize, LZ4_compressBound(srcSize), cLevel); } +int LZ4_compressHC2_limitedOutput_withStateHC (void* state, const char* src, char* dst, int srcSize, int maxDstSize, int cLevel) { return LZ4_compress_HC_extStateHC(state, src, dst, srcSize, maxDstSize, cLevel); } +int LZ4_compressHC_continue (LZ4_streamHC_t* ctx, const char* src, char* dst, int srcSize) { return LZ4_compress_HC_continue (ctx, src, dst, srcSize, LZ4_compressBound(srcSize)); } +int LZ4_compressHC_limitedOutput_continue (LZ4_streamHC_t* ctx, const char* src, char* dst, int srcSize, int maxDstSize) { return LZ4_compress_HC_continue (ctx, src, dst, srcSize, maxDstSize); } + + +/* Deprecated streaming functions */ +int LZ4_sizeofStreamStateHC(void) { return sizeof(LZ4_streamHC_t); } + +/* state is presumed correctly sized, aka >= sizeof(LZ4_streamHC_t) + * @return : 0 on success, !=0 if error */ +int LZ4_resetStreamStateHC(void* state, char* inputBuffer) +{ + LZ4_streamHC_t* const hc4 = LZ4_initStreamHC(state, sizeof(*hc4)); + if (hc4 == NULL) return 1; /* init failed */ + LZ4HC_init_internal (&hc4->internal_donotuse, (const BYTE*)inputBuffer); + return 0; +} + +#if !defined(LZ4_STATIC_LINKING_ONLY_DISABLE_MEMORY_ALLOCATION) +void* LZ4_createHC (const char* inputBuffer) +{ + LZ4_streamHC_t* const hc4 = LZ4_createStreamHC(); + if (hc4 == NULL) return NULL; /* not enough memory */ + LZ4HC_init_internal (&hc4->internal_donotuse, (const BYTE*)inputBuffer); + return hc4; +} + +int LZ4_freeHC (void* LZ4HC_Data) +{ + if (!LZ4HC_Data) return 0; /* support free on NULL */ + FREEMEM(LZ4HC_Data); + return 0; +} +#endif + +int LZ4_compressHC2_continue (void* LZ4HC_Data, const char* src, char* dst, int srcSize, int cLevel) +{ + return LZ4HC_compress_generic (&((LZ4_streamHC_t*)LZ4HC_Data)->internal_donotuse, src, dst, &srcSize, 0, cLevel, notLimited); +} + +int LZ4_compressHC2_limitedOutput_continue (void* LZ4HC_Data, const char* src, char* dst, int srcSize, int dstCapacity, int cLevel) +{ + return LZ4HC_compress_generic (&((LZ4_streamHC_t*)LZ4HC_Data)->internal_donotuse, src, dst, &srcSize, dstCapacity, cLevel, limitedOutput); +} + +char* LZ4_slideInputBufferHC(void* LZ4HC_Data) +{ + LZ4_streamHC_t* const ctx = (LZ4_streamHC_t*)LZ4HC_Data; + const BYTE* bufferStart = ctx->internal_donotuse.prefixStart - ctx->internal_donotuse.dictLimit + ctx->internal_donotuse.lowLimit; + LZ4_resetStreamHC_fast(ctx, ctx->internal_donotuse.compressionLevel); + /* avoid const char * -> char * conversion warning :( */ + return (char*)(uptrval)bufferStart; +} + + +/* ================================================ + * LZ4 Optimal parser (levels [LZ4HC_CLEVEL_OPT_MIN - LZ4HC_CLEVEL_MAX]) + * ===============================================*/ +typedef struct { + int price; + int off; + int mlen; + int litlen; +} LZ4HC_optimal_t; + +/* price in bytes */ +LZ4_FORCE_INLINE int LZ4HC_literalsPrice(int const litlen) +{ + int price = litlen; + assert(litlen >= 0); + if (litlen >= (int)RUN_MASK) + price += 1 + ((litlen-(int)RUN_MASK) / 255); + return price; +} + + +/* requires mlen >= MINMATCH */ +LZ4_FORCE_INLINE int LZ4HC_sequencePrice(int litlen, int mlen) +{ + int price = 1 + 2 ; /* token + 16-bit offset */ + assert(litlen >= 0); + assert(mlen >= MINMATCH); + + price += LZ4HC_literalsPrice(litlen); + + if (mlen >= (int)(ML_MASK+MINMATCH)) + price += 1 + ((mlen-(int)(ML_MASK+MINMATCH)) / 255); + + return price; +} + + +typedef struct { + int off; + int len; +} LZ4HC_match_t; + +LZ4_FORCE_INLINE LZ4HC_match_t +LZ4HC_FindLongerMatch(LZ4HC_CCtx_internal* const ctx, + const BYTE* ip, const BYTE* const iHighLimit, + int minLen, int nbSearches, + const dictCtx_directive dict, + const HCfavor_e favorDecSpeed) +{ + LZ4HC_match_t match = { 0 , 0 }; + const BYTE* matchPtr = NULL; + /* note : LZ4HC_InsertAndGetWiderMatch() is able to modify the starting position of a match (*startpos), + * but this won't be the case here, as we define iLowLimit==ip, + * so LZ4HC_InsertAndGetWiderMatch() won't be allowed to search past ip */ + int matchLength = LZ4HC_InsertAndGetWiderMatch(ctx, ip, ip, iHighLimit, minLen, &matchPtr, &ip, nbSearches, 1 /*patternAnalysis*/, 1 /*chainSwap*/, dict, favorDecSpeed); + if (matchLength <= minLen) return match; + if (favorDecSpeed) { + if ((matchLength>18) & (matchLength<=36)) matchLength=18; /* favor shortcut */ + } + match.len = matchLength; + match.off = (int)(ip-matchPtr); + return match; +} + + +static int LZ4HC_compress_optimal ( LZ4HC_CCtx_internal* ctx, + const char* const source, + char* dst, + int* srcSizePtr, + int dstCapacity, + int const nbSearches, + size_t sufficient_len, + const limitedOutput_directive limit, + int const fullUpdate, + const dictCtx_directive dict, + const HCfavor_e favorDecSpeed) +{ + int retval = 0; +#define TRAILING_LITERALS 3 +#if defined(LZ4HC_HEAPMODE) && LZ4HC_HEAPMODE==1 + LZ4HC_optimal_t* const opt = (LZ4HC_optimal_t*)ALLOC(sizeof(LZ4HC_optimal_t) * (LZ4_OPT_NUM + TRAILING_LITERALS)); +#else + LZ4HC_optimal_t opt[LZ4_OPT_NUM + TRAILING_LITERALS]; /* ~64 KB, which is a bit large for stack... */ +#endif + + const BYTE* ip = (const BYTE*) source; + const BYTE* anchor = ip; + const BYTE* const iend = ip + *srcSizePtr; + const BYTE* const mflimit = iend - MFLIMIT; + const BYTE* const matchlimit = iend - LASTLITERALS; + BYTE* op = (BYTE*) dst; + BYTE* opSaved = (BYTE*) dst; + BYTE* oend = op + dstCapacity; + int ovml = MINMATCH; /* overflow - last sequence */ + const BYTE* ovref = NULL; + + /* init */ +#if defined(LZ4HC_HEAPMODE) && LZ4HC_HEAPMODE==1 + if (opt == NULL) goto _return_label; +#endif + DEBUGLOG(5, "LZ4HC_compress_optimal(dst=%p, dstCapa=%u)", dst, (unsigned)dstCapacity); + *srcSizePtr = 0; + if (limit == fillOutput) oend -= LASTLITERALS; /* Hack for support LZ4 format restriction */ + if (sufficient_len >= LZ4_OPT_NUM) sufficient_len = LZ4_OPT_NUM-1; + + /* Main Loop */ + while (ip <= mflimit) { + int const llen = (int)(ip - anchor); + int best_mlen, best_off; + int cur, last_match_pos = 0; + + LZ4HC_match_t const firstMatch = LZ4HC_FindLongerMatch(ctx, ip, matchlimit, MINMATCH-1, nbSearches, dict, favorDecSpeed); + if (firstMatch.len==0) { ip++; continue; } + + if ((size_t)firstMatch.len > sufficient_len) { + /* good enough solution : immediate encoding */ + int const firstML = firstMatch.len; + const BYTE* const matchPos = ip - firstMatch.off; + opSaved = op; + if ( LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), firstML, matchPos, limit, oend) ) { /* updates ip, op and anchor */ + ovml = firstML; + ovref = matchPos; + goto _dest_overflow; + } + continue; + } + + /* set prices for first positions (literals) */ + { int rPos; + for (rPos = 0 ; rPos < MINMATCH ; rPos++) { + int const cost = LZ4HC_literalsPrice(llen + rPos); + opt[rPos].mlen = 1; + opt[rPos].off = 0; + opt[rPos].litlen = llen + rPos; + opt[rPos].price = cost; + DEBUGLOG(7, "rPos:%3i => price:%3i (litlen=%i) -- initial setup", + rPos, cost, opt[rPos].litlen); + } } + /* set prices using initial match */ + { int mlen = MINMATCH; + int const matchML = firstMatch.len; /* necessarily < sufficient_len < LZ4_OPT_NUM */ + int const offset = firstMatch.off; + assert(matchML < LZ4_OPT_NUM); + for ( ; mlen <= matchML ; mlen++) { + int const cost = LZ4HC_sequencePrice(llen, mlen); + opt[mlen].mlen = mlen; + opt[mlen].off = offset; + opt[mlen].litlen = llen; + opt[mlen].price = cost; + DEBUGLOG(7, "rPos:%3i => price:%3i (matchlen=%i) -- initial setup", + mlen, cost, mlen); + } } + last_match_pos = firstMatch.len; + { int addLit; + for (addLit = 1; addLit <= TRAILING_LITERALS; addLit ++) { + opt[last_match_pos+addLit].mlen = 1; /* literal */ + opt[last_match_pos+addLit].off = 0; + opt[last_match_pos+addLit].litlen = addLit; + opt[last_match_pos+addLit].price = opt[last_match_pos].price + LZ4HC_literalsPrice(addLit); + DEBUGLOG(7, "rPos:%3i => price:%3i (litlen=%i) -- initial setup", + last_match_pos+addLit, opt[last_match_pos+addLit].price, addLit); + } } + + /* check further positions */ + for (cur = 1; cur < last_match_pos; cur++) { + const BYTE* const curPtr = ip + cur; + LZ4HC_match_t newMatch; + + if (curPtr > mflimit) break; + DEBUGLOG(7, "rPos:%u[%u] vs [%u]%u", + cur, opt[cur].price, opt[cur+1].price, cur+1); + if (fullUpdate) { + /* not useful to search here if next position has same (or lower) cost */ + if ( (opt[cur+1].price <= opt[cur].price) + /* in some cases, next position has same cost, but cost rises sharply after, so a small match would still be beneficial */ + && (opt[cur+MINMATCH].price < opt[cur].price + 3/*min seq price*/) ) + continue; + } else { + /* not useful to search here if next position has same (or lower) cost */ + if (opt[cur+1].price <= opt[cur].price) continue; + } + + DEBUGLOG(7, "search at rPos:%u", cur); + if (fullUpdate) + newMatch = LZ4HC_FindLongerMatch(ctx, curPtr, matchlimit, MINMATCH-1, nbSearches, dict, favorDecSpeed); + else + /* only test matches of minimum length; slightly faster, but misses a few bytes */ + newMatch = LZ4HC_FindLongerMatch(ctx, curPtr, matchlimit, last_match_pos - cur, nbSearches, dict, favorDecSpeed); + if (!newMatch.len) continue; + + if ( ((size_t)newMatch.len > sufficient_len) + || (newMatch.len + cur >= LZ4_OPT_NUM) ) { + /* immediate encoding */ + best_mlen = newMatch.len; + best_off = newMatch.off; + last_match_pos = cur + 1; + goto encode; + } + + /* before match : set price with literals at beginning */ + { int const baseLitlen = opt[cur].litlen; + int litlen; + for (litlen = 1; litlen < MINMATCH; litlen++) { + int const price = opt[cur].price - LZ4HC_literalsPrice(baseLitlen) + LZ4HC_literalsPrice(baseLitlen+litlen); + int const pos = cur + litlen; + if (price < opt[pos].price) { + opt[pos].mlen = 1; /* literal */ + opt[pos].off = 0; + opt[pos].litlen = baseLitlen+litlen; + opt[pos].price = price; + DEBUGLOG(7, "rPos:%3i => price:%3i (litlen=%i)", + pos, price, opt[pos].litlen); + } } } + + /* set prices using match at position = cur */ + { int const matchML = newMatch.len; + int ml = MINMATCH; + + assert(cur + newMatch.len < LZ4_OPT_NUM); + for ( ; ml <= matchML ; ml++) { + int const pos = cur + ml; + int const offset = newMatch.off; + int price; + int ll; + DEBUGLOG(7, "testing price rPos %i (last_match_pos=%i)", + pos, last_match_pos); + if (opt[cur].mlen == 1) { + ll = opt[cur].litlen; + price = ((cur > ll) ? opt[cur - ll].price : 0) + + LZ4HC_sequencePrice(ll, ml); + } else { + ll = 0; + price = opt[cur].price + LZ4HC_sequencePrice(0, ml); + } + + assert((U32)favorDecSpeed <= 1); + if (pos > last_match_pos+TRAILING_LITERALS + || price <= opt[pos].price - (int)favorDecSpeed) { + DEBUGLOG(7, "rPos:%3i => price:%3i (matchlen=%i)", + pos, price, ml); + assert(pos < LZ4_OPT_NUM); + if ( (ml == matchML) /* last pos of last match */ + && (last_match_pos < pos) ) + last_match_pos = pos; + opt[pos].mlen = ml; + opt[pos].off = offset; + opt[pos].litlen = ll; + opt[pos].price = price; + } } } + /* complete following positions with literals */ + { int addLit; + for (addLit = 1; addLit <= TRAILING_LITERALS; addLit ++) { + opt[last_match_pos+addLit].mlen = 1; /* literal */ + opt[last_match_pos+addLit].off = 0; + opt[last_match_pos+addLit].litlen = addLit; + opt[last_match_pos+addLit].price = opt[last_match_pos].price + LZ4HC_literalsPrice(addLit); + DEBUGLOG(7, "rPos:%3i => price:%3i (litlen=%i)", last_match_pos+addLit, opt[last_match_pos+addLit].price, addLit); + } } + } /* for (cur = 1; cur <= last_match_pos; cur++) */ + + assert(last_match_pos < LZ4_OPT_NUM + TRAILING_LITERALS); + best_mlen = opt[last_match_pos].mlen; + best_off = opt[last_match_pos].off; + cur = last_match_pos - best_mlen; + +encode: /* cur, last_match_pos, best_mlen, best_off must be set */ + assert(cur < LZ4_OPT_NUM); + assert(last_match_pos >= 1); /* == 1 when only one candidate */ + DEBUGLOG(6, "reverse traversal, looking for shortest path (last_match_pos=%i)", last_match_pos); + { int candidate_pos = cur; + int selected_matchLength = best_mlen; + int selected_offset = best_off; + while (1) { /* from end to beginning */ + int const next_matchLength = opt[candidate_pos].mlen; /* can be 1, means literal */ + int const next_offset = opt[candidate_pos].off; + DEBUGLOG(7, "pos %i: sequence length %i", candidate_pos, selected_matchLength); + opt[candidate_pos].mlen = selected_matchLength; + opt[candidate_pos].off = selected_offset; + selected_matchLength = next_matchLength; + selected_offset = next_offset; + if (next_matchLength > candidate_pos) break; /* last match elected, first match to encode */ + assert(next_matchLength > 0); /* can be 1, means literal */ + candidate_pos -= next_matchLength; + } } + + /* encode all recorded sequences in order */ + { int rPos = 0; /* relative position (to ip) */ + while (rPos < last_match_pos) { + int const ml = opt[rPos].mlen; + int const offset = opt[rPos].off; + if (ml == 1) { ip++; rPos++; continue; } /* literal; note: can end up with several literals, in which case, skip them */ + rPos += ml; + assert(ml >= MINMATCH); + assert((offset >= 1) && (offset <= LZ4_DISTANCE_MAX)); + opSaved = op; + if ( LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ml, ip - offset, limit, oend) ) { /* updates ip, op and anchor */ + ovml = ml; + ovref = ip - offset; + goto _dest_overflow; + } } } + } /* while (ip <= mflimit) */ + +_last_literals: + /* Encode Last Literals */ + { size_t lastRunSize = (size_t)(iend - anchor); /* literals */ + size_t llAdd = (lastRunSize + 255 - RUN_MASK) / 255; + size_t const totalSize = 1 + llAdd + lastRunSize; + if (limit == fillOutput) oend += LASTLITERALS; /* restore correct value */ + if (limit && (op + totalSize > oend)) { + if (limit == limitedOutput) { /* Check output limit */ + retval = 0; + goto _return_label; + } + /* adapt lastRunSize to fill 'dst' */ + lastRunSize = (size_t)(oend - op) - 1 /*token*/; + llAdd = (lastRunSize + 256 - RUN_MASK) / 256; + lastRunSize -= llAdd; + } + DEBUGLOG(6, "Final literal run : %i literals", (int)lastRunSize); + ip = anchor + lastRunSize; /* can be != iend if limit==fillOutput */ + + if (lastRunSize >= RUN_MASK) { + size_t accumulator = lastRunSize - RUN_MASK; + *op++ = (RUN_MASK << ML_BITS); + for(; accumulator >= 255 ; accumulator -= 255) *op++ = 255; + *op++ = (BYTE) accumulator; + } else { + *op++ = (BYTE)(lastRunSize << ML_BITS); + } + LZ4_memcpy(op, anchor, lastRunSize); + op += lastRunSize; + } + + /* End */ + *srcSizePtr = (int) (((const char*)ip) - source); + retval = (int) ((char*)op-dst); + goto _return_label; + +_dest_overflow: +if (limit == fillOutput) { + /* Assumption : ip, anchor, ovml and ovref must be set correctly */ + size_t const ll = (size_t)(ip - anchor); + size_t const ll_addbytes = (ll + 240) / 255; + size_t const ll_totalCost = 1 + ll_addbytes + ll; + BYTE* const maxLitPos = oend - 3; /* 2 for offset, 1 for token */ + DEBUGLOG(6, "Last sequence overflowing (only %i bytes remaining)", (int)(oend-1-opSaved)); + op = opSaved; /* restore correct out pointer */ + if (op + ll_totalCost <= maxLitPos) { + /* ll validated; now adjust match length */ + size_t const bytesLeftForMl = (size_t)(maxLitPos - (op+ll_totalCost)); + size_t const maxMlSize = MINMATCH + (ML_MASK-1) + (bytesLeftForMl * 255); + assert(maxMlSize < INT_MAX); assert(ovml >= 0); + if ((size_t)ovml > maxMlSize) ovml = (int)maxMlSize; + if ((oend + LASTLITERALS) - (op + ll_totalCost + 2) - 1 + ovml >= MFLIMIT) { + DEBUGLOG(6, "Space to end : %i + ml (%i)", (int)((oend + LASTLITERALS) - (op + ll_totalCost + 2) - 1), ovml); + DEBUGLOG(6, "Before : ip = %p, anchor = %p", ip, anchor); + LZ4HC_encodeSequence(UPDATABLE(ip, op, anchor), ovml, ovref, notLimited, oend); + DEBUGLOG(6, "After : ip = %p, anchor = %p", ip, anchor); + } } + goto _last_literals; +} +_return_label: +#if defined(LZ4HC_HEAPMODE) && LZ4HC_HEAPMODE==1 + FREEMEM(opt); +#endif + return retval; +} diff --git a/native/lz4/lz4hc.h b/native/lz4/lz4hc.h new file mode 100644 index 0000000..e937acf --- /dev/null +++ b/native/lz4/lz4hc.h @@ -0,0 +1,413 @@ +/* + LZ4 HC - High Compression Mode of LZ4 + Header File + Copyright (C) 2011-2020, Yann Collet. + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + You can contact the author at : + - LZ4 source repository : https://github.com/lz4/lz4 + - LZ4 public forum : https://groups.google.com/forum/#!forum/lz4c +*/ +#ifndef LZ4_HC_H_19834876238432 +#define LZ4_HC_H_19834876238432 + +#if defined (__cplusplus) +extern "C" { +#endif + +/* --- Dependency --- */ +/* note : lz4hc requires lz4.h/lz4.c for compilation */ +#include "lz4.h" /* stddef, LZ4LIB_API, LZ4_DEPRECATED */ + + +/* --- Useful constants --- */ +#define LZ4HC_CLEVEL_MIN 3 +#define LZ4HC_CLEVEL_DEFAULT 9 +#define LZ4HC_CLEVEL_OPT_MIN 10 +#define LZ4HC_CLEVEL_MAX 12 + + +/*-************************************ + * Block Compression + **************************************/ +/*! LZ4_compress_HC() : + * Compress data from `src` into `dst`, using the powerful but slower "HC" algorithm. + * `dst` must be already allocated. + * Compression is guaranteed to succeed if `dstCapacity >= LZ4_compressBound(srcSize)` (see "lz4.h") + * Max supported `srcSize` value is LZ4_MAX_INPUT_SIZE (see "lz4.h") + * `compressionLevel` : any value between 1 and LZ4HC_CLEVEL_MAX will work. + * Values > LZ4HC_CLEVEL_MAX behave the same as LZ4HC_CLEVEL_MAX. + * @return : the number of bytes written into 'dst' + * or 0 if compression fails. + */ +LZ4LIB_API int LZ4_compress_HC (const char* src, char* dst, int srcSize, int dstCapacity, int compressionLevel); + + +/* Note : + * Decompression functions are provided within "lz4.h" (BSD license) + */ + + +/*! LZ4_compress_HC_extStateHC() : + * Same as LZ4_compress_HC(), but using an externally allocated memory segment for `state`. + * `state` size is provided by LZ4_sizeofStateHC(). + * Memory segment must be aligned on 8-bytes boundaries (which a normal malloc() should do properly). + */ +LZ4LIB_API int LZ4_sizeofStateHC(void); +LZ4LIB_API int LZ4_compress_HC_extStateHC(void* stateHC, const char* src, char* dst, int srcSize, int maxDstSize, int compressionLevel); + + +/*! LZ4_compress_HC_destSize() : v1.9.0+ + * Will compress as much data as possible from `src` + * to fit into `targetDstSize` budget. + * Result is provided in 2 parts : + * @return : the number of bytes written into 'dst' (necessarily <= targetDstSize) + * or 0 if compression fails. + * `srcSizePtr` : on success, *srcSizePtr is updated to indicate how much bytes were read from `src` + */ +LZ4LIB_API int LZ4_compress_HC_destSize(void* stateHC, + const char* src, char* dst, + int* srcSizePtr, int targetDstSize, + int compressionLevel); + + +/*-************************************ + * Streaming Compression + * Bufferless synchronous API + **************************************/ + typedef union LZ4_streamHC_u LZ4_streamHC_t; /* incomplete type (defined later) */ + +/*! LZ4_createStreamHC() and LZ4_freeStreamHC() : + * These functions create and release memory for LZ4 HC streaming state. + * Newly created states are automatically initialized. + * A same state can be used multiple times consecutively, + * starting with LZ4_resetStreamHC_fast() to start a new stream of blocks. + */ +LZ4LIB_API LZ4_streamHC_t* LZ4_createStreamHC(void); +LZ4LIB_API int LZ4_freeStreamHC (LZ4_streamHC_t* streamHCPtr); + +/* + These functions compress data in successive blocks of any size, + using previous blocks as dictionary, to improve compression ratio. + One key assumption is that previous blocks (up to 64 KB) remain read-accessible while compressing next blocks. + There is an exception for ring buffers, which can be smaller than 64 KB. + Ring-buffer scenario is automatically detected and handled within LZ4_compress_HC_continue(). + + Before starting compression, state must be allocated and properly initialized. + LZ4_createStreamHC() does both, though compression level is set to LZ4HC_CLEVEL_DEFAULT. + + Selecting the compression level can be done with LZ4_resetStreamHC_fast() (starts a new stream) + or LZ4_setCompressionLevel() (anytime, between blocks in the same stream) (experimental). + LZ4_resetStreamHC_fast() only works on states which have been properly initialized at least once, + which is automatically the case when state is created using LZ4_createStreamHC(). + + After reset, a first "fictional block" can be designated as initial dictionary, + using LZ4_loadDictHC() (Optional). + + Invoke LZ4_compress_HC_continue() to compress each successive block. + The number of blocks is unlimited. + Previous input blocks, including initial dictionary when present, + must remain accessible and unmodified during compression. + + It's allowed to update compression level anytime between blocks, + using LZ4_setCompressionLevel() (experimental). + + 'dst' buffer should be sized to handle worst case scenarios + (see LZ4_compressBound(), it ensures compression success). + In case of failure, the API does not guarantee recovery, + so the state _must_ be reset. + To ensure compression success + whenever `dst` buffer size cannot be made >= LZ4_compressBound(), + consider using LZ4_compress_HC_continue_destSize(). + + Whenever previous input blocks can't be preserved unmodified in-place during compression of next blocks, + it's possible to copy the last blocks into a more stable memory space, using LZ4_saveDictHC(). + Return value of LZ4_saveDictHC() is the size of dictionary effectively saved into 'safeBuffer' (<= 64 KB) + + After completing a streaming compression, + it's possible to start a new stream of blocks, using the same LZ4_streamHC_t state, + just by resetting it, using LZ4_resetStreamHC_fast(). +*/ + +LZ4LIB_API void LZ4_resetStreamHC_fast(LZ4_streamHC_t* streamHCPtr, int compressionLevel); /* v1.9.0+ */ +LZ4LIB_API int LZ4_loadDictHC (LZ4_streamHC_t* streamHCPtr, const char* dictionary, int dictSize); + +LZ4LIB_API int LZ4_compress_HC_continue (LZ4_streamHC_t* streamHCPtr, + const char* src, char* dst, + int srcSize, int maxDstSize); + +/*! LZ4_compress_HC_continue_destSize() : v1.9.0+ + * Similar to LZ4_compress_HC_continue(), + * but will read as much data as possible from `src` + * to fit into `targetDstSize` budget. + * Result is provided into 2 parts : + * @return : the number of bytes written into 'dst' (necessarily <= targetDstSize) + * or 0 if compression fails. + * `srcSizePtr` : on success, *srcSizePtr will be updated to indicate how much bytes were read from `src`. + * Note that this function may not consume the entire input. + */ +LZ4LIB_API int LZ4_compress_HC_continue_destSize(LZ4_streamHC_t* LZ4_streamHCPtr, + const char* src, char* dst, + int* srcSizePtr, int targetDstSize); + +LZ4LIB_API int LZ4_saveDictHC (LZ4_streamHC_t* streamHCPtr, char* safeBuffer, int maxDictSize); + + + +/*^********************************************** + * !!!!!! STATIC LINKING ONLY !!!!!! + ***********************************************/ + +/*-****************************************************************** + * PRIVATE DEFINITIONS : + * Do not use these definitions directly. + * They are merely exposed to allow static allocation of `LZ4_streamHC_t`. + * Declare an `LZ4_streamHC_t` directly, rather than any type below. + * Even then, only do so in the context of static linking, as definitions may change between versions. + ********************************************************************/ + +#define LZ4HC_DICTIONARY_LOGSIZE 16 +#define LZ4HC_MAXD (1<= LZ4HC_CLEVEL_OPT_MIN. + */ +LZ4LIB_STATIC_API void LZ4_favorDecompressionSpeed( + LZ4_streamHC_t* LZ4_streamHCPtr, int favor); + +/*! LZ4_resetStreamHC_fast() : v1.9.0+ + * When an LZ4_streamHC_t is known to be in a internally coherent state, + * it can often be prepared for a new compression with almost no work, only + * sometimes falling back to the full, expensive reset that is always required + * when the stream is in an indeterminate state (i.e., the reset performed by + * LZ4_resetStreamHC()). + * + * LZ4_streamHCs are guaranteed to be in a valid state when: + * - returned from LZ4_createStreamHC() + * - reset by LZ4_resetStreamHC() + * - memset(stream, 0, sizeof(LZ4_streamHC_t)) + * - the stream was in a valid state and was reset by LZ4_resetStreamHC_fast() + * - the stream was in a valid state and was then used in any compression call + * that returned success + * - the stream was in an indeterminate state and was used in a compression + * call that fully reset the state (LZ4_compress_HC_extStateHC()) and that + * returned success + * + * Note: + * A stream that was last used in a compression call that returned an error + * may be passed to this function. However, it will be fully reset, which will + * clear any existing history and settings from the context. + */ +LZ4LIB_STATIC_API void LZ4_resetStreamHC_fast( + LZ4_streamHC_t* LZ4_streamHCPtr, int compressionLevel); + +/*! LZ4_compress_HC_extStateHC_fastReset() : + * A variant of LZ4_compress_HC_extStateHC(). + * + * Using this variant avoids an expensive initialization step. It is only safe + * to call if the state buffer is known to be correctly initialized already + * (see above comment on LZ4_resetStreamHC_fast() for a definition of + * "correctly initialized"). From a high level, the difference is that this + * function initializes the provided state with a call to + * LZ4_resetStreamHC_fast() while LZ4_compress_HC_extStateHC() starts with a + * call to LZ4_resetStreamHC(). + */ +LZ4LIB_STATIC_API int LZ4_compress_HC_extStateHC_fastReset ( + void* state, + const char* src, char* dst, + int srcSize, int dstCapacity, + int compressionLevel); + +/*! LZ4_attach_HC_dictionary() : + * This is an experimental API that allows for the efficient use of a + * static dictionary many times. + * + * Rather than re-loading the dictionary buffer into a working context before + * each compression, or copying a pre-loaded dictionary's LZ4_streamHC_t into a + * working LZ4_streamHC_t, this function introduces a no-copy setup mechanism, + * in which the working stream references the dictionary stream in-place. + * + * Several assumptions are made about the state of the dictionary stream. + * Currently, only streams which have been prepared by LZ4_loadDictHC() should + * be expected to work. + * + * Alternatively, the provided dictionary stream pointer may be NULL, in which + * case any existing dictionary stream is unset. + * + * A dictionary should only be attached to a stream without any history (i.e., + * a stream that has just been reset). + * + * The dictionary will remain attached to the working stream only for the + * current stream session. Calls to LZ4_resetStreamHC(_fast) will remove the + * dictionary context association from the working stream. The dictionary + * stream (and source buffer) must remain in-place / accessible / unchanged + * through the lifetime of the stream session. + */ +LZ4LIB_STATIC_API void LZ4_attach_HC_dictionary( + LZ4_streamHC_t *working_stream, + const LZ4_streamHC_t *dictionary_stream); + +#if defined (__cplusplus) +} +#endif + +#endif /* LZ4_HC_SLO_098092834 */ +#endif /* LZ4_HC_STATIC_LINKING_ONLY */ diff --git a/native/mkpfs_lz4.c b/native/mkpfs_lz4.c new file mode 100644 index 0000000..97a7c6c --- /dev/null +++ b/native/mkpfs_lz4.c @@ -0,0 +1,71 @@ +/* + * mkpfs_lz4: raw LZ4 block API over lz4 1.9.4 for AMPR asset packs. + * + * The encoder calls lz4 exactly like python-lz4 4.4.5 lz4/block/_block.c (store_size=False), which + * ampr_emu's ampr_pack.py uses: fast mode resets a stream and calls LZ4_compress_fast_continue, and + * HC mode resets an HC stream and calls LZ4_compress_HC_continue. LZ4_compress_fast emits different + * (larger) blocks, so it must not be used for parity. + * All lz4 symbols stay hidden; only the mkpfs_lz4_* entry points are exported. + */ + +#include +#include + +#include "lz4.h" +#include "lz4hc.h" + +#if defined(_WIN32) +#define MKPFS_API __declspec(dllexport) +#else +#define MKPFS_API __attribute__((visibility("default"))) +#endif + +typedef struct mkpfs_lz4_encoder { + LZ4_stream_t fast; + LZ4_streamHC_t hc; +} mkpfs_lz4_encoder; + +MKPFS_API const char *mkpfs_lz4_version(void) { + return LZ4_versionString(); +} + +/* Returns 0 when source_len exceeds LZ4_MAX_INPUT_SIZE. */ +MKPFS_API int32_t mkpfs_lz4_bound(int32_t source_len) { + return LZ4_compressBound(source_len); +} + +/* Holds both stream states (about 280 KB), so one encoder per worker avoids large stack frames. */ +MKPFS_API void *mkpfs_lz4_encoder_create(void) { + return calloc(1, sizeof(mkpfs_lz4_encoder)); +} + +MKPFS_API void mkpfs_lz4_encoder_free(void *handle) { + free(handle); +} + +/* Compress one independent raw block. Returns the block size, or 0 when dst is too small. */ +MKPFS_API int32_t mkpfs_lz4_compress_fast(void *handle, const uint8_t *src, int32_t src_len, + uint8_t *dst, int32_t dst_cap, int32_t acceleration) { + mkpfs_lz4_encoder *encoder = (mkpfs_lz4_encoder *)handle; + if (encoder == NULL) return 0; + LZ4_resetStream(&encoder->fast); + return LZ4_compress_fast_continue(&encoder->fast, (const char *)src, (char *)dst, src_len, dst_cap, + acceleration); +} + +/* Compress one independent raw block at HC level 1..12. Returns the block size, or 0 when dst is too small. */ +MKPFS_API int32_t mkpfs_lz4_compress_hc(void *handle, const uint8_t *src, int32_t src_len, + uint8_t *dst, int32_t dst_cap, int32_t level) { + mkpfs_lz4_encoder *encoder = (mkpfs_lz4_encoder *)handle; + if (encoder == NULL) return 0; + LZ4_resetStreamHC(&encoder->hc, level); + return LZ4_compress_HC_continue(&encoder->hc, (const char *)src, (char *)dst, src_len, dst_cap); +} + +/* + * Decode one raw block (LZ4_decompress_safe, as the ampr_emu runtime does). + * Returns the decoded size, or a negative value for malformed input or a too small dst. + */ +MKPFS_API int32_t mkpfs_lz4_decompress_safe(const uint8_t *src, int32_t src_len, uint8_t *dst, int32_t dst_cap) { + return LZ4_decompress_safe((const char *)src, (char *)dst, src_len, dst_cap); +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRBlockCompressor.cs b/src/MkPFS.Build/AMPRPack/AMPRBlockCompressor.cs new file mode 100644 index 0000000..5875ad2 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRBlockCompressor.cs @@ -0,0 +1,66 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using MkPFS.Core.AMPR; +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// One encoded block (Python CompressedBlock). +/// Decoded bytes. +/// Bytes written to the volume. +/// ChunkCodecRaw or ChunkCodecLZ4. +/// CRC-32 of . +/// Deduplication key of the decoded bytes. +public sealed record AMPRCompressedBlock(byte[] Raw, byte[] Stored, int Codec, uint RawCrc, byte[] Fingerprint); + +/// Per-block codec decision (Python _compress_block). +public static class AMPRBlockCompressor +{ + /// + /// Compress one block with the rule's LZ4 mode and keep it only if it saves enough. Random/mixed blocks whose + /// I/O page count does not drop must also meet the stricter I/O-neutral thresholds. + /// + /// Encoder owned by the calling worker. + /// Block bytes (not empty). + /// Effective rule. + /// I/O page size of the target group. + /// The block as it will be stored. + public static AMPRCompressedBlock Compress(LZ4Encoder encoder, byte[] raw, AMPRRule rule, long ioPageSize) + { + uint rawCrc = Crc32.Update(0, raw); + // Python uses crc32 + length + BLAKE2b-128; any collision-resistant digest gives the same dedup decisions. + byte[] fingerprint = new byte[12 + 32]; + BinaryPrimitives.WriteUInt32LittleEndian(fingerprint, rawCrc); + BinaryPrimitives.WriteInt64LittleEndian(fingerprint.AsSpan(4), raw.Length); + SHA256.HashData(raw, fingerprint.AsSpan(12)); + if (rule.Action == "store") + { + return new AMPRCompressedBlock(raw, raw, AMPRPackFormat.ChunkCodecRaw, rawCrc, fingerprint); + } + + byte[] compressed = rule.Mode == "hc" + ? encoder.CompressHC(raw, (int)Math.Clamp(rule.Level, 1, 12)) + : encoder.CompressFast(raw, (int)Math.Clamp(rule.Acceleration, 1, int.MaxValue)); + long saved = raw.Length - compressed.Length; + double ratio = raw.Length > 0 ? (double)saved / raw.Length : 0.0; + long requiredBytes = rule.MinSavingsBytes; + double requiredRatio = rule.MinSavingsRatio; + // An isolated cold read of a random/mixed chunk costs whole I/O pages; require a stronger gain when + // compression does not reduce the page count. + if (rule.ResolvedLayout() != "streaming") + { + long rawPages = (raw.Length + ioPageSize - 1) / ioPageSize; + long compressedPages = (compressed.Length + ioPageSize - 1) / ioPageSize; + if (compressedPages >= rawPages) + { + requiredBytes = Math.Max(requiredBytes, rule.IONeutralMinSavingsBytes); + requiredRatio = Math.Max(requiredRatio, rule.IONeutralMinSavingsRatio); + } + } + + return saved < requiredBytes || ratio < requiredRatio + ? new AMPRCompressedBlock(raw, raw, AMPRPackFormat.ChunkCodecRaw, rawCrc, fingerprint) + : new AMPRCompressedBlock(raw, compressed, AMPRPackFormat.ChunkCodecLZ4, rawCrc, fingerprint); + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs b/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs new file mode 100644 index 0000000..5df53cc --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs @@ -0,0 +1,86 @@ +using MkPFS.Core.AMPR; + +namespace MkPFS.Build.AMPRPack; + +/// Counters reported by a pack build (Python BuildStats; the CLI prints them as JSON). +public sealed class AMPRBuildStats +{ + /// AMPRIDX3 files. + public long FilesTotal { get; set; } + + /// Files stored in pack volumes. + public long FilesPacked { get; set; } + + /// Files left on the filesystem. + public long FilesLoose { get; set; } + + /// Relative paths that must stay in /app0. + public List LoosePaths { get; } = []; + + /// Files left loose by sampling. + public long FilesAutoLoose { get; set; } + + /// Bytes of auto-loose files. + public long AutoLooseLogicalBytes { get; set; } + + /// Bytes read while sampling. + public long AutoLooseSampledBytes { get; set; } + + /// Chunk records. + public long Chunks { get; set; } + + /// LZ4 chunk records. + public long ChunksLZ4 { get; set; } + + /// RAW chunk records. + public long ChunksRaw { get; set; } + + /// Chunk records that reuse an earlier block. + public long ChunksShared { get; set; } + + /// Decoded bytes of packed files. + public long LogicalBytes { get; set; } + + /// Bytes written to volumes (excluding padding). + public long StoredBytes { get; set; } + + /// Alignment padding in volumes. + public long PaddingBytes { get; set; } + + /// I/O pages covered by volume payloads. + public long IOPagesTouched { get; set; } + + /// Chunks placed page-contained or page-aligned. + public long IOPageSafeChunks { get; set; } + + /// Chunks in dense streaming extents. + public long DenseStreamingChunks { get; set; } +} + +/// Progress snapshot (Python BuildProgress). +/// reading-index, planning, packing, finalizing, publishing or complete. +/// Files done in this phase. +/// Files in this phase. +/// Bytes packed. +/// Bytes to pack. +/// File being processed, if any. +public sealed record AMPRBuildProgress( + string Phase, long FilesDone, long FilesTotal, long LogicalBytesDone, long LogicalBytesTotal, string CurrentPath = ""); + +/// A planned file (Python SelectedFile). +/// AMPRIDX3 row + 1. +/// AMPRIDX3 row. +/// Source path under the root. +/// Path relative to /app0. +/// Effective rule (loose for files that stay on the filesystem). +/// Size on disk (index size for loose files). +/// Modification time on disk (index time for loose files). +/// Pack lane within the rule's group. +public sealed record AMPRSelectedFile( + int FileId, AMPRIndexEntry IndexEntry, string Source, string Relative, AMPRRule Rule, long Size, long MTime, int Lane); + +/// Result of a pack build. +/// Published manifest. +/// Counters. +/// Auto-loose and missing-source notes. +public sealed record AMPRBuildResult(string IndexPath, AMPRBuildStats Stats, IReadOnlyList Warnings); diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs b/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs new file mode 100644 index 0000000..519a494 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs @@ -0,0 +1,471 @@ +using MkPFS.Core.AMPR; +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// +/// Builds an AMPR asset-pack set (manifest, volumes, CRC sidecar, optional runtime settings) from an /app0 +/// tree and its AMPRIDX3 index. Port of ampr_pack build_packs and _publish_transaction; output is +/// byte-identical to the oracle for the same inputs. +/// +public static class AMPRPackBuilder +{ + /// Build and publish a pack set. + /// /app0 source directory. + /// AMPRIDX3 index of that tree. + /// Output directory. + /// Configuration. + /// CLI include globs. + /// CLI exclude globs. + /// Leave missing sources loose. + /// Progress callback. + /// Manifest path, counters and warnings. + /// The build failed; temporary files are removed. + public static AMPRBuildResult Build( + string root, + string amprIndex, + string outputDir, + AMPRPackConfig config, + IReadOnlyList? includePatterns = null, + IReadOnlyList? excludePatterns = null, + bool allowMissing = false, + Action? progress = null) + { + root = Path.GetFullPath(root); + outputDir = Path.GetFullPath(outputDir); + Directory.CreateDirectory(outputDir); + progress?.Invoke(new AMPRBuildProgress("reading-index", 0, 0, 0, 0, amprIndex)); + List entries = AMPRIndexReader.Read(amprIndex); + AMPRBuildStats stats = new() { FilesTotal = entries.Count }; + (List selected, List warnings) = AMPRPackPlanner.Plan( + entries, root, config, stats, includePatterns ?? [], excludePatterns ?? [], allowMissing, progress); + + List packedItems = [.. selected.Where(item => item.Rule.Action != "loose")]; + PackingProgress packing = new(progress, packedItems.Count, packedItems.Sum(item => item.Size)); + packing.Report("packing"); + AMPRPackLayout layout = new(config, outputDir); + string? tempIndex = null; + string? tempCrc = null; + string? tempRuntime = null; + try + { + PackedContent content = PackFiles(selected, config, layout, stats, packing); + + // Names go into the string table before the build id; sizes and placement are final now. + packing.Report("finalizing"); + List provisional = []; + foreach (AMPRPackVolumeWriter writer in layout.Volumes) + { + (uint nameOffset, uint nameLength) = content.Strings.Add(writer.Name); + provisional.Add(new AMPRPackRecord( + (ulong)(writer.ProjectedFinalSize() - writer.PayloadOffset), + (ulong)writer.ProjectedFinalSize(), + nameOffset, + nameLength, + writer.Flags, + (uint)writer.IOPageSize)); + } + + byte[] crcPayload = AMPRChunkCrcs.EncodePayload(content.ChunkCrcs); + byte[] strings = content.Strings.ToArray(); + byte[] buildId = AMPRBuildId.Compute([ + config.CanonicalBytes(), + Records(content.Files, AMPRPackFormat.FileRecordSize, (r, s) => r.Write(s)), + Records(content.Chunks, AMPRPackFormat.ChunkRecordSize, (r, s) => r.Write(s)), + crcPayload, + Records(provisional, AMPRPackFormat.PackRecordSize, (r, s) => r.Write(s)), + strings, + ]); + + List finalRecords = []; + for (int i = 0; i < layout.Volumes.Count; i++) + { + AMPRPackVolumeWriter writer = layout.Volumes[i]; + AMPRPackRecord actual = writer.Finalize(buildId) with + { + NameOffset = provisional[i].NameOffset, + NameLength = provisional[i].NameLength, + }; + finalRecords.Add(actual); + stats.PaddingBytes += writer.PaddingBytes; + stats.IOPagesTouched += ((long)actual.PayloadBytes + writer.IOPageSize - 1) / writer.IOPageSize; + } + + byte[] indexData = new AMPRPackManifest(buildId, 0, content.Files, content.Chunks, finalRecords, strings).Serialize(); + string indexPath = AMPRAssetPath.SafeOutputPath(outputDir, config.IndexName); + Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!); + tempIndex = Path.Combine(Path.GetDirectoryName(indexPath)!, $".{Path.GetFileName(indexPath)}.tmp-{Environment.ProcessId}"); + WriteDurable(tempIndex, indexData); + tempCrc = tempIndex + ".crc"; + WriteDurable(tempCrc, AMPRChunkCrcs.Build(buildId, content.ChunkCrcs)); + if (config.Runtime is not null) + { + tempRuntime = tempIndex + ".runtime"; + WriteDurable(tempRuntime, config.Runtime.Encode(buildId)); + } + + packing.Report("publishing"); + PublishTransaction(layout, tempIndex, indexPath, tempCrc, tempRuntime); + tempIndex = tempCrc = tempRuntime = null; + packing.Report("complete"); + return new AMPRBuildResult(indexPath, stats, warnings); + } + catch + { + layout.Abort(); + foreach (string? temp in (string?[])[tempRuntime, tempCrc, tempIndex]) + { + if (temp is not null) + { + File.Delete(temp); + } + } + + throw; + } + } + + private static PackedContent PackFiles( + List selected, AMPRPackConfig config, AMPRPackLayout layout, AMPRBuildStats stats, PackingProgress packing) + { + PackedContent content = new(); + Dictionary dedupe = new(StringComparer.Ordinal); + List sortedGroups = config.SortedGroupNames(); + using EncoderPool encoders = new(); + TaskScheduler scheduler = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, (int)config.Workers).ConcurrentScheduler; + foreach (AMPRSelectedFile item in selected) + { + string path = AMPRAssetPath.Canonical(item.IndexEntry.Path); + (uint pathOffset, uint pathLength) = content.Strings.Add(path); + ulong pathHash = AMPRAssetPath.Hash(path); + if (item.Rule.Action == "loose") + { + stats.LoosePaths.Add(item.Relative); + content.Files.Add(new AMPRFileRecord(pathHash, item.IndexEntry.Size, item.IndexEntry.MTime, 0, 0, pathOffset, pathLength, 0, 0)); + stats.FilesLoose++; + continue; + } + + packing.Report("packing", item.Relative); + AMPRGroupConfig group = config.Groups[item.Rule.Group]; + long ioPageSize = group.IOPageSize != 0 ? group.IOPageSize : config.IOPageSize; + int firstChunk = content.Chunks.Count; + uint fileFlags = AMPRPackFormat.FileFlagPacked; + if (item.Rule.Action == "store") + { + fileFlags |= AMPRPackFormat.FileFlagStoreOnly; + } + + string layoutMode = item.Rule.ResolvedLayout(); + fileFlags |= layoutMode switch + { + "streaming" => AMPRPackFormat.FileFlagStreaming, + "random" => AMPRPackFormat.FileFlagRandomAccess, + _ => 0U, + }; + if (item.Rule.Hot) + { + fileFlags |= AMPRPackFormat.FileFlagHot; + } + + bool striped = group.StripeLargeFiles && group.PackCount > 1 && item.Size >= group.StripeThreshold; + bool allowDeduplicate = config.Deduplicate && (layoutMode != "streaming" || config.DeduplicateStreaming); + string physicalLayout = layoutMode == "streaming" ? "dense" : "isolated"; + int chunkIndex = 0; + using (FileStream source = File.OpenRead(item.Source)) + { + (long Length, DateTime Written) before = AMPRPackPlanner.FileStamp(item.Source); + foreach (AMPRCompressedBlock block in CompressBlocks(source, item, config, ioPageSize, encoders, scheduler)) + { + int lane = item.Lane; + if (striped) + { + lane = (int)((item.Lane + (chunkIndex / group.StripeGroupBlocks)) % group.PackCount); + } + + string domain = config.DeduplicateScope == "group" ? item.Rule.Group : $"{item.Rule.Group}:{lane}"; + string dedupeKey = $"{domain}\0{physicalLayout}\0{block.Codec}\0{Convert.ToHexString(block.Fingerprint)}"; + AMPRChunkRecord record; + if (allowDeduplicate && dedupe.TryGetValue(dedupeKey, out AMPRChunkRecord prior)) + { + int flags = AMPRPackFormat.ChunkFlagShared + | (prior.Flags & (AMPRPackFormat.ChunkFlagPageContained | AMPRPackFormat.ChunkFlagPageAligned)) + | (layoutMode == "streaming" ? AMPRPackFormat.ChunkFlagStreaming : 0); + record = prior with { Flags = flags }; + stats.ChunksShared++; + } + else + { + // Only dense streaming extents need a page-aligned start; random/mixed chunks share pages + // but never straddle one. + bool extentStart = layoutMode == "streaming" + && (chunkIndex == 0 || (striped && chunkIndex % group.StripeGroupBlocks == 0)); + AMPRPackVolumeWriter writer = layout.WriterFor(group, lane, block.Stored.Length, striped, layoutMode, extentStart); + (long offset, int placement) = writer.Write(block.Stored, layoutMode, extentStart); + int flags = placement | (layoutMode == "streaming" ? AMPRPackFormat.ChunkFlagStreaming : 0); + if (layoutMode != "streaming" && !IsPageSafe(block.Stored.Length, placement, writer.IOPageSize)) + { + throw new AMPRPackException("non-streaming chunk was not placed page-safely"); + } + + record = new AMPRChunkRecord((ulong)offset, block.Stored.Length, block.Raw.Length, writer.PackId, block.Codec, flags); + if (allowDeduplicate) + { + dedupe[dedupeKey] = record; + } + + stats.StoredBytes += block.Stored.Length; + if (layoutMode == "streaming") + { + stats.DenseStreamingChunks++; + } + } + + if (IsPageSafe(record.StoredSize, record.Flags, layout.Volumes[record.PackId].IOPageSize)) + { + stats.IOPageSafeChunks++; + } + + content.Chunks.Add(record); + content.ChunkCrcs.Add(block.RawCrc); + chunkIndex++; + stats.Chunks++; + stats.LogicalBytes += block.Raw.Length; + packing.AddBytes(block.Raw.Length, item.Relative); + if (record.Codec == AMPRPackFormat.ChunkCodecLZ4) + { + stats.ChunksLZ4++; + } + else + { + stats.ChunksRaw++; + } + } + + if (AMPRPackPlanner.FileStamp(item.Source) != before) + { + throw new AMPRPackException($"source changed while packing: {item.Relative}"); + } + } + + content.Files.Add(new AMPRFileRecord( + pathHash, + (ulong)item.Size, + config.PreserveMTime ? item.MTime : item.IndexEntry.MTime, + (uint)firstChunk, + (uint)chunkIndex, + pathOffset, + pathLength, + fileFlags, + (byte)item.Rule.BlockShift, + (byte)sortedGroups.IndexOf(item.Rule.Group))); + stats.FilesPacked++; + packing.FileDone(item.Relative); + } + + return content; + } + + // Reads blocks in order and compresses up to max(2, 2 * workers) of them ahead on at most `workers` threads, + // yielding results in file order (Python _iter_compressed_blocks). + private static IEnumerable CompressBlocks( + FileStream source, AMPRSelectedFile item, AMPRPackConfig config, long ioPageSize, EncoderPool encoders, TaskScheduler scheduler) + { + long blockSize = 1L << item.Rule.BlockShift; + long remaining = item.Size; + int window = (int)Math.Max(2, config.Workers * 2); + Queue> pending = new(); + while (remaining > 0 || pending.Count > 0) + { + while (remaining > 0 && pending.Count < window) + { + byte[] raw = new byte[Math.Min(blockSize, remaining)]; + int read = source.ReadAtLeast(raw, raw.Length, throwOnEndOfStream: false); + if (read == 0) + { + throw new AMPRPackException("source file was truncated while packing"); + } + + if (read < raw.Length) + { + Array.Resize(ref raw, read); + } + + remaining -= read; + AMPRRule rule = item.Rule; + pending.Enqueue(Task.Factory.StartNew( + () => encoders.Run(encoder => AMPRBlockCompressor.Compress(encoder, raw, rule, ioPageSize)), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + scheduler)); + } + + yield return pending.Dequeue().GetAwaiter().GetResult(); + } + + if (source.ReadByte() != -1) + { + throw new AMPRPackException("source file grew while packing"); + } + } + + private static bool IsPageSafe(long storedSize, int flags, long ioPageSize) => + storedSize <= ioPageSize + ? (flags & AMPRPackFormat.ChunkFlagPageContained) != 0 + : (flags & AMPRPackFormat.ChunkFlagPageAligned) != 0; + + private static byte[] Records(IReadOnlyList records, int size, Action> write) + { + byte[] data = new byte[records.Count * size]; + for (int i = 0; i < records.Count; i++) + { + write(records[i], data.AsSpan(i * size, size)); + } + + return data; + } + + private static void WriteDurable(string path, byte[] data) + { + using FileStream stream = new(path, FileMode.Create, FileAccess.Write, FileShare.None); + stream.Write(data); + stream.Flush(flushToDisk: true); + } + + /// + /// Publish volumes first, then the CRC sidecar and runtime settings (a stale .runtime is removed when the + /// build has none), and the manifest last; on failure restore the previous files (Python _publish_transaction). + /// The build id in every header makes partial combinations fail closed even after a crash. + /// + private static void PublishTransaction(AMPRPackLayout layout, string tempIndex, string indexPath, string tempCrc, string? tempRuntime) + { + List<(string Final, string? Backup)> replacements = []; + int pid = Environment.ProcessId; + try + { + foreach (AMPRPackVolumeWriter writer in layout.Volumes) + { + string? backup = MoveAside(writer.FinalPath, $".{Path.GetFileName(writer.FinalPath)}.backup-{pid}-{writer.PackId}"); + replacements.Add((writer.FinalPath, backup)); + writer.Publish(); + } + + string crcPath = AMPRPackFormat.ChunkCrcPath(indexPath); + replacements.Add((crcPath, MoveAside(crcPath, $".{Path.GetFileName(crcPath)}.backup-{pid}"))); + File.Move(tempCrc, crcPath, overwrite: true); + string runtimePath = AMPRPackFormat.RuntimePath(indexPath); + replacements.Add((runtimePath, MoveAside(runtimePath, $".{Path.GetFileName(runtimePath)}.backup-{pid}"))); + if (tempRuntime is not null) + { + File.Move(tempRuntime, runtimePath, overwrite: true); + } + + File.Move(tempIndex, indexPath, overwrite: true); + } + catch + { + for (int i = replacements.Count - 1; i >= 0; i--) + { + (string final, string? backup) = replacements[i]; + File.Delete(final); + if (backup is not null && File.Exists(backup)) + { + File.Move(backup, final, overwrite: true); + } + } + + throw; + } + + foreach ((_, string? backup) in replacements) + { + if (backup is not null) + { + File.Delete(backup); + } + } + } + + private static string? MoveAside(string path, string backupName) + { + if (!File.Exists(path)) + { + return null; + } + + string backup = Path.Combine(Path.GetDirectoryName(path)!, backupName); + File.Delete(backup); + File.Move(path, backup); + return backup; + } + + private sealed class PackedContent + { + public AMPRStringTable Strings { get; } = new(); + + public List Files { get; } = []; + + public List Chunks { get; } = []; + + public List ChunkCrcs { get; } = []; + } + + // One LZ4 encoder per concurrently running task, reused across blocks and files. + private sealed class EncoderPool : IDisposable + { + private readonly System.Collections.Concurrent.ConcurrentBag _idle = []; + private readonly List _all = []; + + public AMPRCompressedBlock Run(Func work) + { + if (!_idle.TryTake(out LZ4Encoder? encoder)) + { + encoder = new LZ4Encoder(); + lock (_all) + { + _all.Add(encoder); + } + } + + try + { + return work(encoder); + } + finally + { + _idle.Add(encoder); + } + } + + public void Dispose() + { + foreach (LZ4Encoder encoder in _all) + { + encoder.Dispose(); + } + } + } + + // Progress bookkeeping for the packing, finalizing, publishing and complete phases. + private sealed class PackingProgress(Action? callback, long filesTotal, long bytesTotal) + { + private long _filesDone; + private long _bytesDone; + + public void Report(string phase, string currentPath = "") => + callback?.Invoke(new AMPRBuildProgress(phase, _filesDone, filesTotal, _bytesDone, bytesTotal, currentPath)); + + public void AddBytes(long bytes, string currentPath) + { + _bytesDone += bytes; + Report("packing", currentPath); + } + + public void FileDone(string currentPath) + { + _filesDone++; + Report("packing", currentPath); + } + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs new file mode 100644 index 0000000..164ebe3 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -0,0 +1,746 @@ +using System.Text; +using MkPFS.Core.AMPR; +using MkPFS.Core.Util; +using Tomlyn.Model; + +namespace MkPFS.Build.AMPRPack; + +/// A packing group ([groups.<name>], Python GroupConfig). +/// Group name. +/// Pack lanes (1..65535). +/// balanced, hash or round_robin. +/// Volume size limit; 0 means unlimited. +/// Stripe large files across lanes. +/// Smallest file that is striped. +/// Blocks per stripe. +/// Per-group I/O page size; 0 uses the global one. +public sealed record AMPRGroupConfig( + string Name, + long PackCount = 1, + string Assignment = "balanced", + long MaxPackSize = 0, + bool StripeLargeFiles = false, + long StripeThreshold = 256L * 1024 * 1024, + long StripeGroupBlocks = 8, + long IOPageSize = 0); + +/// An include/exclude rule ([[rule]], Python Rule). +/// compress, store or loose. +/// Include globs. +/// Exclude globs. +/// log2 of the block size. +/// Packing group. +/// LZ4 mode, fast or hc. +/// HC level. +/// Fast-mode acceleration. +/// Smallest saving that keeps a compressed block. +/// Smallest saving ratio that keeps a compressed block. +/// Smallest saving when RAW would use the same I/O pages. +/// Smallest saving ratio when RAW would use the same I/O pages. +/// auto, random, mixed or streaming. +/// Hint for the auto layout. +/// Hint for the auto layout and immediate cache admission. +/// Never auto-loose matched files. +public sealed record AMPRRule( + string Action, + IReadOnlyList Include, + IReadOnlyList Exclude, + int BlockShift = 16, + string Group = "default", + string Mode = "hc", + long Level = 12, + long Acceleration = 1, + long MinSavingsBytes = 64, + double MinSavingsRatio = 0.01, + long IONeutralMinSavingsBytes = 8192, + double IONeutralMinSavingsRatio = 0.125, + string Layout = "auto", + bool Streaming = false, + bool Hot = false, + bool ForcePack = false) +{ + /// A loose rule (Python Rule(action="loose", block_shift=...)). + /// Block shift to keep. + /// The rule. + public static AMPRRule Loose(int blockShift) => new("loose", ["**"], [], blockShift); + + /// Whether a relative path is included and not excluded. + /// Path relative to /app0. + /// when the rule applies. + public bool Matches(string relativePath) => + AMPRGlob.Matches(relativePath, Include) && !AMPRGlob.Matches(relativePath, Exclude); + + /// The layout with auto resolved: streaming, then hot (random), else mixed. + /// Layout. + public string ResolvedLayout() => Layout != "auto" ? Layout : Streaming ? "streaming" : Hot ? "random" : "mixed"; +} + +/// +/// Asset-pack build configuration (Python BuildConfig and load_config). Keys are read leniently like +/// the oracle (unknown keys are ignored) and converted with Python semantics; validation order and messages match. +/// +public sealed class AMPRPackConfig +{ + /// ampr_pack tool version; part of the build id. + public const string ToolVersion = "4.0"; + + /// Default manifest name. + public const string DefaultIndexName = "ampr_assets.index"; + + /// Default volume name pattern. + public const string DefaultPackPattern = "ampr_assets-{id:03d}.pak"; + + private static readonly string[] RuntimeKeys = ["decoded_cache_bytes", "physical_cache_bytes", "workers", "latency_reserve_workers"]; + + /// Runtime settings written as <index>.runtime, or . + public AMPRRuntimeSettings? Runtime { get; set; } + + /// Manifest file name. + public string IndexName { get; set; } = DefaultIndexName; + + /// Volume name pattern. + public string PackPattern { get; set; } = DefaultPackPattern; + + /// Action for files no rule matches. + public string DefaultAction { get; set; } = "loose"; + + /// Default block shift. + public int DefaultBlockShift { get; set; } = 16; + + /// Filesystem I/O page size. + public long IOPageSize { get; set; } = 64 * 1024; + + /// Volume payload alignment. + public long PayloadAlignment { get; set; } = 64 * 1024; + + /// Chunk alignment inside a page. + public long ChunkAlignment { get; set; } = 64; + + /// Compression worker threads (not part of the build id). + public long Workers { get; set; } = Math.Max(1, Math.Min(8, Environment.ProcessorCount)); + + /// Default LZ4 mode. + public string CompressionMode { get; set; } = "hc"; + + /// Default HC level. + public long CompressionLevel { get; set; } = 12; + + /// Default fast-mode acceleration. + public long Acceleration { get; set; } = 1; + + /// Default minimum saving in bytes. + public long MinSavingsBytes { get; set; } = 64; + + /// Default minimum saving ratio. + public double MinSavingsRatio { get; set; } = 0.01; + + /// Default minimum saving in bytes when the page count does not change. + public long IONeutralMinSavingsBytes { get; set; } = 8 * 1024; + + /// Default minimum saving ratio when the page count does not change. + public double IONeutralMinSavingsRatio { get; set; } = 0.125; + + /// Deduplicate identical blocks. + public bool Deduplicate { get; set; } = true; + + /// lane or group. + public string DeduplicateScope { get; set; } = "lane"; + + /// Also deduplicate inside streaming files. + public bool DeduplicateStreaming { get; set; } + + /// Store the source mtime instead of the AMPRIDX3 one. + public bool PreserveMTime { get; set; } = true; + + /// Check source sizes against AMPRIDX3. + public bool ValidateIndexMetadata { get; set; } = true; + + /// Never auto-loose explicitly packed files. + public bool SelfContained { get; set; } + + /// Globs that must end up packed. + public IReadOnlyList RequiredPacked { get; set; } = []; + + /// Leave large incompressible files loose. + public bool AutoLooseLargeFiles { get; set; } = true; + + /// Also auto-loose files from hot rules. + public bool AutoLooseHotFiles { get; set; } + + /// Smallest file considered for auto-loose. + public long AutoLooseMinFileSize { get; set; } = 64L * 1024 * 1024; + + /// Blocks sampled per candidate. + public long AutoLooseSampleBlocks { get; set; } = 32; + + /// Byte budget of the sample. + public long AutoLooseSampleBytes { get; set; } = 16L * 1024 * 1024; + + /// Saving ratio below which a file goes loose. + public double AutoLooseMinSavingsRatio { get; set; } = 0.05; + + /// RAW sample ratio above which a file goes loose. + public double AutoLooseMaxRawRatio { get; set; } = 0.90; + + /// Groups in declaration order; default is added last when missing. + public IReadOnlyDictionary Groups { get; set; } = new Dictionary(); + + /// Rules in order (last match wins). + public IReadOnlyList Rules { get; set; } = []; + + /// The rule for files no rule matches (Python _default_rule). + /// Rule. + public AMPRRule DefaultRule() => new( + DefaultAction, + ["**"], + [], + DefaultBlockShift, + Mode: CompressionMode, + Level: CompressionLevel, + Acceleration: Acceleration, + MinSavingsBytes: MinSavingsBytes, + MinSavingsRatio: MinSavingsRatio, + IONeutralMinSavingsBytes: IONeutralMinSavingsBytes, + IONeutralMinSavingsRatio: IONeutralMinSavingsRatio); + + /// The last matching rule, else the default rule (Python select_rule). + /// Path relative to /app0. + /// Rule. + public AMPRRule SelectRule(string relative) + { + AMPRRule selected = DefaultRule(); + foreach (AMPRRule rule in Rules) + { + if (rule.Matches(relative)) + { + selected = rule; + } + } + + return selected; + } + + /// + /// Build-id input: json.dumps(payload, sort_keys=True, separators=(",", ":")) of the content-relevant + /// settings, groups (sorted by name) and rules (Python _canonical_config_bytes). + /// + /// UTF-8 bytes. + public byte[] CanonicalBytes() + { + Dictionary payload = new() + { + ["tool_version"] = ToolVersion, + ["index_name"] = IndexName, + ["pack_pattern"] = PackPattern, + ["default_action"] = DefaultAction, + ["default_block_shift"] = (long)DefaultBlockShift, + ["io_page_size"] = IOPageSize, + ["payload_alignment"] = PayloadAlignment, + ["chunk_alignment"] = ChunkAlignment, + ["compression_mode"] = CompressionMode, + ["compression_level"] = CompressionLevel, + ["acceleration"] = Acceleration, + ["min_savings_bytes"] = MinSavingsBytes, + ["min_savings_ratio"] = MinSavingsRatio, + ["io_neutral_min_savings_bytes"] = IONeutralMinSavingsBytes, + ["io_neutral_min_savings_ratio"] = IONeutralMinSavingsRatio, + ["deduplicate"] = Deduplicate, + ["deduplicate_scope"] = DeduplicateScope, + ["deduplicate_streaming"] = DeduplicateStreaming, + ["auto_loose_large_files"] = AutoLooseLargeFiles, + ["auto_loose_hot_files"] = AutoLooseHotFiles, + ["auto_loose_min_file_size"] = AutoLooseMinFileSize, + ["auto_loose_sample_blocks"] = AutoLooseSampleBlocks, + ["auto_loose_sample_bytes"] = AutoLooseSampleBytes, + ["auto_loose_min_savings_ratio"] = AutoLooseMinSavingsRatio, + ["auto_loose_max_raw_ratio"] = AutoLooseMaxRawRatio, + ["groups"] = SortedGroupNames().Select(name => (object?)GroupPayload(Groups[name])).ToList(), + ["rules"] = Rules.Select(rule => (object?)RulePayload(rule)).ToList(), + }; + return Encoding.UTF8.GetBytes(PythonSortedJson.Compact(payload)); + } + + /// Group names sorted by code point (Python sorted(config.groups)); the index is the packing class. + /// Names. + public List SortedGroupNames() => [.. Groups.Keys.OrderBy(name => name, PythonCodePointComparer.Instance)]; + + /// + /// Load a configuration, or the defaults when is (Python + /// load_config). + /// + /// TOML file. + /// Configuration. + /// The configuration is invalid. + /// A value cannot be converted (Python ValueError). + public static AMPRPackConfig Load(string? path) + { + TomlTable raw = []; + string configDir = Directory.GetCurrentDirectory(); + if (path is not null) + { + string resolved = Path.GetFullPath(path); + configDir = Path.GetDirectoryName(resolved)!; + raw = AMPRToml.Load(resolved); + } + + return FromTable(raw, configDir); + } + + /// Built-in rule sets for (an extension; ampr_pack has none). + public static IReadOnlyList PresetNames { get; } = ["default"]; + + /// + /// The default preset: compress every file, but keep loose everything remove-sources protects + /// (executables, modules, system and save folders, indexes) and Unity IL2CPP global-metadata.dat, which + /// IL2CPP memory-maps (ampr_emu does not intercept mmap). * also matches /, so *.prx covers + /// every depth. + /// + public const string DefaultPresetToml = """ + [pack] + default_action = "compress" + + [[rule]] + action = "loose" + include = [ + "eboot.bin", "*/eboot.bin", "param.sfo", "*/param.sfo", "nptitle.dat", "*/nptitle.dat", + "ampr_emu.index", "*/ampr_emu.index", "*.prx", "*.sprx", "*.elf", "*.self", + "sce_sys/*", "sce_module/*", "fakelib/*", "mods/*", "save/*", "system/*", + "*/global-metadata.dat", + ] + """; + + /// Load a built-in preset. + /// Preset name (see ). + /// Configuration. + /// The preset does not exist. + public static AMPRPackConfig LoadPreset(string name) => name switch + { + "default" => FromTable(AMPRToml.Parse(DefaultPresetToml, "preset default"), Directory.GetCurrentDirectory()), + _ => throw new AMPRPackException($"unknown preset: {name}"), + }; + + /// + /// Read pattern lines: stripped, without blank lines and # comments (Python _load_pattern_file). + /// The file is decoded as UTF-8 without BOM detection, like Python read_text(encoding="utf-8"). + /// + /// File, or for none. + /// Patterns. + public static List LoadPatternFile(string? path) + { + if (path is null) + { + return []; + } + + string text; + using (StreamReader reader = new(path, new UTF8Encoding(false, throwOnInvalidBytes: true), detectEncodingFromByteOrderMarks: false)) + { + text = reader.ReadToEnd(); + } + + List patterns = []; + foreach (string line in PythonText.SplitLines(text)) + { + string stripped = PythonText.Strip(line); + if (stripped.Length > 0 && !stripped.StartsWith('#')) + { + patterns.Add(stripped); + } + } + + return patterns; + } + + /// Build a configuration from a parsed TOML document. + /// Root table. + /// Directory that include_from/exclude_from are relative to. + /// Configuration. + internal static AMPRPackConfig FromTable(TomlTable raw, string configDir) + { + object? packValue = raw.TryGetValue("pack", out object? p) ? p : new TomlTable(); + if (packValue is not TomlTable pack) + { + throw new AMPRPackException("[pack] must be a table"); + } + + AMPRRuntimeSettings? runtime = raw.TryGetValue("runtime", out object? runtimeValue) ? LoadRuntime(runtimeValue) : null; + + // Python evaluates BuildConfig(...) arguments in this order; the first failing conversion wins. + int defaultBlock = PythonValue.BlockShift(Get(pack, "default_block_size", "64KiB")); + long ioPageSize = PythonValue.Size(Get(pack, "io_page_size", "64KiB")); + AMPRPackConfig config = new() + { + Runtime = runtime, + IndexName = PythonValue.Str(Get(pack, "index_name", DefaultIndexName)), + PackPattern = PythonValue.Str(Get(pack, "pack_pattern", DefaultPackPattern)), + DefaultAction = Action(Get(pack, "default_action", "loose")), + DefaultBlockShift = defaultBlock, + IOPageSize = ioPageSize, + PayloadAlignment = PythonValue.Size(Get(pack, "payload_alignment", ioPageSize)), + ChunkAlignment = PythonValue.Size(Get(pack, "chunk_alignment", "64B")), + Workers = PythonValue.Int(Get(pack, "workers", (long)Math.Max(1, Math.Min(8, Environment.ProcessorCount)))), + CompressionMode = PythonValue.Str(Get(pack, "compression_mode", "hc")).ToLowerInvariant(), + CompressionLevel = PythonValue.Int(Get(pack, "compression_level", 12L)), + Acceleration = PythonValue.Int(Get(pack, "acceleration", 1L)), + MinSavingsBytes = PythonValue.Size(Get(pack, "min_savings_bytes", 64L)), + MinSavingsRatio = PythonValue.Float(Get(pack, "min_savings_ratio", 0.01)), + IONeutralMinSavingsBytes = PythonValue.Size(Get(pack, "io_neutral_min_savings_bytes", "8KiB")), + IONeutralMinSavingsRatio = PythonValue.Float(Get(pack, "io_neutral_min_savings_ratio", 0.125)), + Deduplicate = PythonValue.Bool(Get(pack, "deduplicate", true)), + DeduplicateScope = PythonValue.Str(Get(pack, "deduplicate_scope", "lane")).ToLowerInvariant(), + DeduplicateStreaming = PythonValue.Bool(Get(pack, "deduplicate_streaming", false)), + PreserveMTime = PythonValue.Bool(Get(pack, "preserve_mtime", true)), + ValidateIndexMetadata = PythonValue.Bool(Get(pack, "validate_index_metadata", true)), + SelfContained = PythonValue.Bool(Get(pack, "self_contained", false)), + RequiredPacked = ListOfStrings(Get(pack, "required_packed", new TomlArray()), "pack.required_packed"), + AutoLooseLargeFiles = PythonValue.Bool(Get(pack, "auto_loose_large_files", true)), + AutoLooseHotFiles = PythonValue.Bool(Get(pack, "auto_loose_hot_files", false)), + AutoLooseMinFileSize = PythonValue.Size(Get(pack, "auto_loose_min_file_size", "64MiB")), + AutoLooseSampleBlocks = PythonValue.Int(Get(pack, "auto_loose_sample_blocks", 32L)), + AutoLooseSampleBytes = PythonValue.Size(Get(pack, "auto_loose_sample_bytes", "16MiB")), + AutoLooseMinSavingsRatio = PythonValue.Float(Get(pack, "auto_loose_min_savings_ratio", 0.05)), + AutoLooseMaxRawRatio = PythonValue.Float(Get(pack, "auto_loose_max_raw_ratio", 0.90)), + }; + config.ValidatePackSection(); + config.Groups = LoadGroups(raw, config); + config.Rules = LoadRules(raw, config, configDir); + return config; + } + + private void ValidatePackSection() + { + if (Workers is < 1 or > 256) + { + throw new AMPRPackException("workers must be between 1 and 256"); + } + + if (IOPageSize < 1L << AMPRPackFormat.MinIOPageShift || (IOPageSize & (IOPageSize - 1)) != 0 || IOPageSize > 1L << AMPRPackFormat.MaxIOPageShift) + { + throw new AMPRPackException("io_page_size must be a power of two between 4 KiB and 1 MiB"); + } + + if (PayloadAlignment < AMPRPackFormat.DataHeaderSize + || (PayloadAlignment & (PayloadAlignment - 1)) != 0 + || PayloadAlignment > 1L << AMPRPackFormat.MaxBlockShift) + { + throw new AMPRPackException( + "payload_alignment must be a power of two between 64 B and 1 MiB; the writer automatically raises it to the selected io_page_size"); + } + + if (ChunkAlignment < AMPRPackFormat.PhysicalChunkAlignment + || (ChunkAlignment & (ChunkAlignment - 1)) != 0 + || ChunkAlignment > IOPageSize + || IOPageSize % ChunkAlignment != 0) + { + throw new AMPRPackException("chunk_alignment must be a power of two between 64 B and io_page_size"); + } + + if (CompressionMode is not ("fast" or "hc")) + { + throw new AMPRPackException("compression_mode must be fast or hc"); + } + + if (DeduplicateScope is not ("lane" or "group")) + { + throw new AMPRPackException("deduplicate_scope must be lane or group"); + } + + if (CompressionLevel is < 1 or > 12) + { + throw new AMPRPackException("compression_level must be between 1 and 12"); + } + + if (Acceleration < 1) + { + throw new AMPRPackException("acceleration must be at least 1"); + } + + CheckRatio(MinSavingsRatio, "min_savings_ratio must be in [0, 1)"); + CheckRatio(IONeutralMinSavingsRatio, "io_neutral_min_savings_ratio must be in [0, 1)"); + if (AutoLooseSampleBlocks is < 1 or > 4096) + { + throw new AMPRPackException("auto_loose_sample_blocks must be between 1 and 4096"); + } + + if (AutoLooseSampleBytes < 1) + { + throw new AMPRPackException("auto_loose_sample_bytes must be at least 1 byte"); + } + + CheckRatio(AutoLooseMinSavingsRatio, "auto_loose_min_savings_ratio must be in [0, 1)"); + if (!(AutoLooseMaxRawRatio >= 0.0 && AutoLooseMaxRawRatio <= 1.0)) + { + throw new AMPRPackException("auto_loose_max_raw_ratio must be in [0, 1]"); + } + + try + { + AMPRAssetPath.SafeOutputPath(".", IndexName.Replace('\\', '/')); + } + catch (ArgumentException exc) + { + throw new AMPRPackException($"unsafe index_name: {PythonText.Repr(IndexName)}", exc); + } + + AMPRPackPattern.Parse(PackPattern); + } + + private static Dictionary LoadGroups(TomlTable raw, AMPRPackConfig config) + { + Dictionary groups = new(StringComparer.Ordinal); + object? groupsRaw = raw.TryGetValue("groups", out object? g) ? g : null; + if (PythonValue.Bool(groupsRaw) && groupsRaw is not TomlTable) + { + throw new AMPRPackException("[groups] must be a table"); + } + + if (groupsRaw is TomlTable groupTable) + { + foreach (KeyValuePair entry in groupTable) + { + string name = entry.Key; + if (entry.Value is not TomlTable value) + { + throw new AMPRPackException($"[groups.{name}] must be a table"); + } + + AMPRGroupConfig group = new( + name, + PythonValue.Int(Get(value, "pack_count", 1L)), + Assignment(Get(value, "assignment", "balanced")), + PythonValue.Size(Get(value, "max_pack_size", 0L)), + PythonValue.Bool(Get(value, "stripe_large_files", false)), + PythonValue.Size(Get(value, "stripe_threshold", "256MiB")), + PythonValue.Int(Get(value, "stripe_group_blocks", 8L)), + PythonValue.Size(Get(value, "io_page_size", 0L))); + if (group.PackCount is < 1 or > 0xFFFF) + { + throw new AMPRPackException($"groups.{name}.pack_count must be between 1 and 65535"); + } + + long groupPage = group.IOPageSize != 0 ? group.IOPageSize : config.IOPageSize; + if (groupPage < 1L << AMPRPackFormat.MinIOPageShift + || groupPage > 1L << AMPRPackFormat.MaxIOPageShift + || (groupPage & (groupPage - 1)) != 0 + || groupPage % config.ChunkAlignment != 0) + { + throw new AMPRPackException( + $"groups.{name}.io_page_size must be a power of two between 4 KiB and 1 MiB and a multiple of chunk_alignment"); + } + + long minimumPayloadOffset = (long)AMPRPackFormat.AlignUp(AMPRPackFormat.DataHeaderSize, (ulong)Math.Max(config.PayloadAlignment, groupPage)); + long minimumVolumeSize = minimumPayloadOffset + groupPage; + if (group.MaxPackSize != 0 && group.MaxPackSize < minimumVolumeSize) + { + throw new AMPRPackException( + $"groups.{name}.max_pack_size must be at least one complete I/O page beyond the payload offset ({minimumVolumeSize} bytes)"); + } + + if (group.StripeGroupBlocks < 1) + { + throw new AMPRPackException($"groups.{name}.stripe_group_blocks must be at least 1"); + } + + groups[name] = group; + } + } + + groups.TryAdd("default", new AMPRGroupConfig("default")); + return groups.Count > 256 ? throw new AMPRPackException("the binary format supports at most 256 packing groups") : groups; + } + + private static List LoadRules(TomlTable raw, AMPRPackConfig config, string configDir) + { + object? rulesRaw = raw.TryGetValue("rule", out object? r) ? r : new TomlArray(); + List items = rulesRaw switch + { + TomlTableArray tables => [.. tables], + TomlArray array => [.. array], + _ => throw new AMPRPackException("[[rule]] entries must form an array"), + }; + + List rules = []; + for (int index = 0; index < items.Count; index++) + { + if (items[index] is not TomlTable value) + { + throw new AMPRPackException($"rule {index} must be a table"); + } + + string group = PythonValue.Str(Get(value, "group", "default")); + if (!config.Groups.ContainsKey(group)) + { + throw new AMPRPackException($"rule {index} references unknown group {PythonText.Repr(group)}"); + } + + int shift = PythonValue.BlockShift(Get(value, "block_size", 1L << config.DefaultBlockShift)); + List includeFrom = LoadRulePatternFiles(Get(value, "include_from", new TomlArray()), "include_from", configDir); + List excludeFrom = LoadRulePatternFiles(Get(value, "exclude_from", new TomlArray()), "exclude_from", configDir); + object includeDefault = includeFrom.Count > 0 ? new TomlArray() : new TomlArray { "**" }; + List include = [.. ListOfStrings(Get(value, "include", includeDefault), "include"), .. includeFrom]; + List exclude = [.. ListOfStrings(Get(value, "exclude", new TomlArray()), "exclude"), .. excludeFrom]; + AMPRRule rule = new( + Action(Get(value, "action", "compress")), + include, + exclude, + shift, + group, + PythonValue.Str(Get(value, "compression_mode", config.CompressionMode)).ToLowerInvariant(), + PythonValue.Int(Get(value, "compression_level", config.CompressionLevel)), + PythonValue.Int(Get(value, "acceleration", config.Acceleration)), + PythonValue.Size(Get(value, "min_savings_bytes", config.MinSavingsBytes)), + PythonValue.Float(Get(value, "min_savings_ratio", config.MinSavingsRatio)), + PythonValue.Size(Get(value, "io_neutral_min_savings_bytes", config.IONeutralMinSavingsBytes)), + PythonValue.Float(Get(value, "io_neutral_min_savings_ratio", config.IONeutralMinSavingsRatio)), + Layout(Get(value, "layout", "auto")), + PythonValue.Bool(Get(value, "streaming", false)), + PythonValue.Bool(Get(value, "hot", false)), + PythonValue.Bool(Get(value, "force_pack", false))); + if (rule.Include.Count == 0) + { + throw new AMPRPackException($"rule {index} include list cannot be empty"); + } + + if (rule.Mode is not ("fast" or "hc")) + { + throw new AMPRPackException($"rule {index} compression_mode must be fast or hc"); + } + + if (rule.Level is < 1 or > 12) + { + throw new AMPRPackException($"rule {index} compression_level must be between 1 and 12"); + } + + if (rule.Acceleration < 1) + { + throw new AMPRPackException($"rule {index} acceleration must be at least 1"); + } + + CheckRatio(rule.MinSavingsRatio, $"rule {index} min_savings_ratio must be in [0, 1)"); + CheckRatio(rule.IONeutralMinSavingsRatio, $"rule {index} io_neutral_min_savings_ratio must be in [0, 1)"); + rules.Add(rule); + } + + return rules; + } + + private static List LoadRulePatternFiles(object? value, string field, string configDir) + { + List patterns = []; + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(configDir)); + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (string name in ListOfStrings(value, field)) + { + if (Path.IsPathFullyQualified(name)) + { + throw new AMPRPackException($"{field} entries must be relative to the TOML file"); + } + + string candidate = Path.GetFullPath(Path.Combine(root, name)); + string prefix = Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar; + if (!candidate.Equals(root, comparison) && !candidate.StartsWith(prefix, comparison)) + { + throw new AMPRPackException($"{field} entry escapes the TOML directory: {name}"); + } + + try + { + patterns.AddRange(LoadPatternFile(candidate)); + } + catch (Exception exc) when (exc is IOException or UnauthorizedAccessException) + { + throw new AMPRPackException($"cannot read {field} file {name}: {exc.Message}", exc); + } + } + + return patterns; + } + + private static AMPRRuntimeSettings LoadRuntime(object? value) + { + if (value is not TomlTable runtime + || runtime.Count != RuntimeKeys.Length + || !RuntimeKeys.All(runtime.ContainsKey)) + { + throw new AMPRPackException( + "[runtime] requires exactly decoded_cache_bytes, physical_cache_bytes, workers, latency_reserve_workers"); + } + + // RuntimeSettings(parse_size(...), parse_size(...), workers, reserve).validate(): only real ints pass. + object? decodedRaw = runtime["decoded_cache_bytes"]; + object? physicalRaw = runtime["physical_cache_bytes"]; + long decoded = PythonValue.Size(decodedRaw); + long physical = PythonValue.Size(physicalRaw); + if (decodedRaw is bool || physicalRaw is bool || runtime["workers"] is not long workers || runtime["latency_reserve_workers"] is not long reserve) + { + throw new ArgumentException("runtime settings must be integers"); + } + + AMPRRuntimeSettings settings = new(decoded, physical, workers, reserve); + settings.Validate(); + return settings; + } + + private static object? Get(TomlTable table, string key, object? fallback) => + table.TryGetValue(key, out object? value) ? value : fallback; + + private static List ListOfStrings(object? value, string key) => value switch + { + null => [], + string s => [s], + TomlArray array when array.All(item => item is string) => [.. array.Cast()], + _ => throw new AMPRPackException($"{key} must be a string or list of strings"), + }; + + private static string Action(object? value) => Choice(value, ["compress", "store", "loose"], "action must be compress, store or loose"); + + private static string Layout(object? value) => Choice(value, ["auto", "random", "mixed", "streaming"], "layout must be auto, random, mixed or streaming"); + + private static string Assignment(object? value) => Choice(value, ["balanced", "hash", "round_robin"], "assignment must be balanced, hash or round_robin"); + + private static string Choice(object? value, string[] allowed, string message) + { + string result = PythonValue.Str(value).ToLowerInvariant(); + return allowed.Contains(result, StringComparer.Ordinal) ? result : throw new AMPRPackException($"{message}; got {PythonText.Repr(result)}"); + } + + private static void CheckRatio(double value, string message) + { + if (!(value >= 0.0 && value < 1.0)) + { + throw new AMPRPackException(message); + } + } + + private static Dictionary GroupPayload(AMPRGroupConfig group) => new() + { + ["name"] = group.Name, + ["pack_count"] = group.PackCount, + ["assignment"] = group.Assignment, + ["max_pack_size"] = group.MaxPackSize, + ["stripe_large_files"] = group.StripeLargeFiles, + ["stripe_threshold"] = group.StripeThreshold, + ["stripe_group_blocks"] = group.StripeGroupBlocks, + ["io_page_size"] = group.IOPageSize, + }; + + private static Dictionary RulePayload(AMPRRule rule) => new() + { + ["action"] = rule.Action, + ["include"] = rule.Include, + ["exclude"] = rule.Exclude, + ["block_shift"] = (long)rule.BlockShift, + ["group"] = rule.Group, + ["mode"] = rule.Mode, + ["level"] = rule.Level, + ["acceleration"] = rule.Acceleration, + ["min_savings_bytes"] = rule.MinSavingsBytes, + ["min_savings_ratio"] = rule.MinSavingsRatio, + ["io_neutral_min_savings_bytes"] = rule.IONeutralMinSavingsBytes, + ["io_neutral_min_savings_ratio"] = rule.IONeutralMinSavingsRatio, + ["layout"] = rule.Layout, + ["streaming"] = rule.Streaming, + ["hot"] = rule.Hot, + ["force_pack"] = rule.ForcePack, + }; +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackLayout.cs b/src/MkPFS.Build/AMPRPack/AMPRPackLayout.cs new file mode 100644 index 0000000..f8b8d7f --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackLayout.cs @@ -0,0 +1,350 @@ +using System.Text; +using MkPFS.Core.AMPR; + +namespace MkPFS.Build.AMPRPack; + +/// +/// One AMPRDAT3 data volume being written to a temporary file (Python PackVolumeWriter). The header is +/// written last, once the build id is known. +/// +public sealed class AMPRPackVolumeWriter : IDisposable +{ + private readonly FileStream _stream; + private bool _closed; + + internal AMPRPackVolumeWriter( + int packId, + string name, + string outputDir, + long payloadAlignment, + long chunkAlignment, + long ioPageSize, + long maxPackSize, + uint flags) + { + PackId = packId; + Name = name; + FinalPath = AMPRAssetPath.SafeOutputPath(outputDir, name); + Directory.CreateDirectory(Path.GetDirectoryName(FinalPath)!); + TempPath = Path.Combine(Path.GetDirectoryName(FinalPath)!, $".{Path.GetFileName(FinalPath)}.tmp-{Environment.ProcessId}-{packId}"); + ChunkAlignment = chunkAlignment; + IOPageSize = ioPageSize; + PayloadOffset = Align(AMPRPackFormat.DataHeaderSize, payloadAlignment); + MaxPackSize = maxPackSize; + Flags = flags | AMPRPackFormat.PackFlagIOPageLayout; + _stream = new FileStream(TempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); + WriteZeros(PayloadOffset); + Position = PayloadOffset; + PaddingBytes = PayloadOffset - AMPRPackFormat.DataHeaderSize; + } + + /// Pack id. + public int PackId { get; } + + /// Volume name relative to the output directory. + public string Name { get; } + + /// Published path. + public string FinalPath { get; } + + /// Temporary path until publication. + public string TempPath { get; } + + /// Chunk alignment. + public long ChunkAlignment { get; } + + /// I/O page size of this volume. + public long IOPageSize { get; } + + /// Payload start. + public long PayloadOffset { get; } + + /// Size limit; 0 or less means unlimited. + public long MaxPackSize { get; } + + /// Pack flags. + public uint Flags { get; internal set; } + + /// Current end of written data. + public long Position { get; private set; } + + /// Alignment padding written so far. + public long PaddingBytes { get; private set; } + + /// Where a chunk would be placed and its placement flags (Python placement_for). + /// Stored size (positive). + /// random, mixed or streaming. + /// Start of a streaming extent (page-aligned). + /// Offset and ChunkFlagPage* bits. + public (long Offset, int Flags) PlacementFor(long storedSize, string layout, bool extentStart = false) + { + if (storedSize <= 0) + { + throw new AMPRPackException("cannot place an empty physical chunk"); + } + + long position = Position; + if (extentStart) + { + position = Align(position, IOPageSize); + } + + long offset; + if (layout == "streaming") + { + // Streaming extents stay dense; the runtime merges adjacent ranges. + offset = Align(position, ChunkAlignment); + } + else if (storedSize >= IOPageSize) + { + // Large random/mixed chunks start on a page, so a cold read touches ceil(size / page) pages. + offset = Align(position, IOPageSize); + } + else + { + // Small random/mixed chunks share pages but never straddle one. + offset = Align(position, ChunkAlignment); + long pageEnd = Align(offset + 1, IOPageSize); + if (offset + storedSize > pageEnd) + { + offset = Align(offset, IOPageSize); + } + } + + int placement = 0; + if (storedSize <= IOPageSize && offset / IOPageSize == (offset + storedSize - 1) / IOPageSize) + { + placement |= AMPRPackFormat.ChunkFlagPageContained; + } + + if (offset % IOPageSize == 0) + { + placement |= AMPRPackFormat.ChunkFlagPageAligned; + } + + return (offset, placement); + } + + /// Whether a chunk still fits under (Python can_fit). + /// Stored size. + /// Layout. + /// Start of a streaming extent. + /// when the page-rounded end stays within the limit. + public bool CanFit(long storedSize, string layout, bool extentStart = false) + { + (long offset, _) = PlacementFor(storedSize, layout, extentStart); + return MaxPackSize <= 0 || Align(offset + storedSize, IOPageSize) <= MaxPackSize; + } + + /// Append a chunk with its padding (Python write). + /// Stored bytes. + /// Layout. + /// Start of a streaming extent. + /// Offset and placement flags. + public (long Offset, int Flags) Write(byte[] payload, string layout, bool extentStart = false) + { + (long offset, int placement) = PlacementFor(payload.Length, layout, extentStart); + long padding = offset - Position; + if (padding > 0) + { + WriteZeros(padding); + PaddingBytes += padding; + } + + _stream.Write(payload); + Position = offset + payload.Length; + return (offset, placement); + } + + /// File size after tail padding to a whole page. + /// Size. + public long ProjectedFinalSize() => Align(Position, IOPageSize); + + /// Pad the tail, write the header and close the temporary file (Python finalize). + /// Build id. + /// Pack record without name. + public AMPRPackRecord Finalize(byte[] buildId) + { + if (_closed) + { + throw new AMPRPackException("pack was already finalized"); + } + + long finalSize = ProjectedFinalSize(); + long tail = finalSize - Position; + if (tail > 0) + { + WriteZeros(tail); + PaddingBytes += tail; + Position = finalSize; + } + + long payloadBytes = Position - PayloadOffset; + byte[] header = new AMPRDataHeader((uint)PackId, buildId, (ulong)PayloadOffset, (ulong)payloadBytes, Flags).ToBytes(); + _stream.Seek(0, SeekOrigin.Begin); + _stream.Write(header); + _stream.Flush(flushToDisk: true); + _stream.Dispose(); + _closed = true; + long fileSize = new FileInfo(TempPath).Length; + return fileSize == Position + ? new AMPRPackRecord((ulong)payloadBytes, (ulong)fileSize, 0, 0, Flags, (uint)IOPageSize) + : throw new AMPRPackException($"unexpected size for {Name}"); + } + + /// Move the finished volume into place. + public void Publish() + { + if (!_closed) + { + throw new AMPRPackException("cannot publish an open pack"); + } + + File.Move(TempPath, FinalPath, overwrite: true); + } + + /// Close and delete the temporary file. + public void Abort() + { + Dispose(); + File.Delete(TempPath); + } + + /// + public void Dispose() + { + if (!_closed) + { + _stream.Dispose(); + _closed = true; + } + } + + internal static long Align(long value, long alignment) => + checked((long)AMPRPackFormat.AlignUp((ulong)value, (ulong)alignment)); + + private void WriteZeros(long count) + { + Span zeros = stackalloc byte[4096]; + zeros.Clear(); + while (count > 0) + { + int n = (int)Math.Min(count, zeros.Length); + _stream.Write(zeros[..n]); + count -= n; + } + } +} + +/// Volume allocation per (group, lane) with rollover (Python PackLayout). +public sealed class AMPRPackLayout +{ + private readonly AMPRPackConfig _config; + private readonly AMPRPackPattern _pattern; + private readonly string _outputDir; + private readonly Dictionary<(string Group, int Lane), AMPRPackVolumeWriter> _current = []; + private readonly Dictionary<(string Group, int Lane), int> _volumeNumbers = []; + private readonly HashSet _names = new(StringComparer.Ordinal); + + /// Create a layout. + /// Configuration. + /// Output directory. + public AMPRPackLayout(AMPRPackConfig config, string outputDir) + { + _config = config; + _pattern = AMPRPackPattern.Parse(config.PackPattern); + _outputDir = outputDir; + } + + /// Volumes in pack-id order. + public List Volumes { get; } = []; + + /// The volume to append a chunk to, opening a new one when the current one is full (Python writer_for). + /// Group. + /// Lane. + /// Stored size. + /// Whether the file is striped. + /// Layout. + /// Start of a streaming extent. + /// Writer. + public AMPRPackVolumeWriter WriterFor(AMPRGroupConfig group, int lane, long storedSize, bool striped, string layout, bool extentStart) + { + uint flags = AMPRPackFormat.PackFlagIOPageLayout | (striped ? AMPRPackFormat.PackFlagStriped : 0); + if (!_current.TryGetValue((group.Name, lane), out AMPRPackVolumeWriter? writer)) + { + writer = NewVolume(group, lane, flags); + } + + if (!writer.CanFit(storedSize, layout, extentStart)) + { + writer = NewVolume(group, lane, flags); + if (!writer.CanFit(storedSize, layout, extentStart: true)) + { + throw new AMPRPackException($"one block ({storedSize} bytes) exceeds max pack size for group {group.Name}"); + } + } + + if (striped) + { + writer.Flags |= AMPRPackFormat.PackFlagStriped; + } + + return writer; + } + + /// Delete every temporary volume. + public void Abort() + { + foreach (AMPRPackVolumeWriter writer in Volumes) + { + writer.Abort(); + } + } + + private AMPRPackVolumeWriter NewVolume(AMPRGroupConfig group, int lane, uint flags) + { + int packId = Volumes.Count; + if (packId > 0xFFFF) + { + throw new AMPRPackException("pack count exceeds uint16 format limit"); + } + + int volumeNumber = _volumeNumbers.GetValueOrDefault((group.Name, lane)); + _volumeNumbers[(group.Name, lane)] = volumeNumber + 1; + string name = RenderName(group.Name, lane, volumeNumber, packId); + long pageSize = group.IOPageSize != 0 ? group.IOPageSize : _config.IOPageSize; + AMPRPackVolumeWriter writer = new( + packId, name, _outputDir, Math.Max(_config.PayloadAlignment, pageSize), _config.ChunkAlignment, pageSize, group.MaxPackSize, flags); + Volumes.Add(writer); + _current[(group.Name, lane)] = writer; + return writer; + } + + private string RenderName(string group, int lane, int volume, int packId) + { + string name; + try + { + name = _pattern.Render(group, lane, volume, packId); + } + catch (FormatException exc) + { + throw new AMPRPackException($"invalid pack_pattern: {Core.Util.PythonText.Repr(_config.PackPattern)}", exc); + } + + name = name.Replace('\\', '/'); + // The runtime root is /app0; keep room for it, a slash and NUL within SCE_KERNEL_PATH_MAX (1024). + if (Encoding.UTF8.GetByteCount(name) > 1017) + { + throw new AMPRPackException("pack_pattern produced a name longer than the runtime path limit"); + } + + if (!_names.Add(name)) + { + throw new AMPRPackException($"pack_pattern produced duplicate name: {name}"); + } + + AMPRAssetPath.SafeOutputPath(_outputDir, name); + return name; + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackMaintenance.cs b/src/MkPFS.Build/AMPRPack/AMPRPackMaintenance.cs new file mode 100644 index 0000000..99beb0a --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackMaintenance.cs @@ -0,0 +1,289 @@ +using MkPFS.Core.AMPR; + +namespace MkPFS.Build.AMPRPack; + +/// Read-only plan of . +/// Packed files. +/// Packed files present under the root. +/// Packed files already absent. +/// Bytes of present files. +/// Relative paths in file-id order. +public sealed record AMPRRemovalPlan(long Files, long PresentFiles, long MissingFiles, long Bytes, IReadOnlyList Paths); + +/// Result of . +/// Files removed. +/// Bytes removed. +/// Empty directories removed. +/// Always : the set was verified before removal. +public sealed record AMPRRemovalResult(long Files, long Bytes, long Directories, bool Verified); + +/// Result of . +/// Written .runtime file. +/// Manifest build id, lower-case hex. +/// Written settings. +public sealed record AMPRRuntimeConfigResult(string Path, string BuildId, AMPRRuntimeSettings Runtime); + +/// +/// Pack-set maintenance that changes files: removing packed sources from /app0 and replacing runtime +/// settings (ampr_pack remove-packed-sources, runtime-config). +/// +public static class AMPRPackMaintenance +{ + private static readonly HashSet ProtectedDirectories = new(StringComparer.Ordinal) { "mods", "save", "sce_module", "sce_sys", "system" }; + + private static readonly HashSet ProtectedNames = new(StringComparer.Ordinal) + { + "ampr_assets.index", "ampr_assets.index.crc", "ampr_assets.index.runtime", "ampr_emu.index", "eboot.bin", "nptitle.dat", "param.sfo", + }; + + private static readonly HashSet ProtectedSuffixes = new(StringComparer.Ordinal) { ".elf", ".prx", ".self", ".sprx" }; + + /// + /// Whether a relative path must never be removed: system and save directories, executables and modules, + /// indexes and pack volumes (Python _removal_path_is_protected). + /// + /// Path relative to /app0. + /// when protected. + public static bool IsProtected(string relative) + { + string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return false; + } + + string name = parts[^1].ToLowerInvariant(); + int dot = name.LastIndexOf('.'); + // PurePosixPath.suffix: empty for names that only start with a dot. + string suffix = dot > 0 && dot < name.Length - 1 ? name[dot..] : string.Empty; + return ProtectedDirectories.Contains(parts[0].ToLowerInvariant()) + || ProtectedNames.Contains(name) + || ProtectedSuffixes.Contains(suffix) + || (name.StartsWith("ampr_assets-", StringComparison.Ordinal) && name.EndsWith(".pak", StringComparison.Ordinal)); + } + + /// The exact safe source set the manifest packs; nothing is changed (Python packed_source_removal_plan). + /// Manifest path. + /// /app0 source tree. + /// Plan. + /// The manifest packs a protected file or a source is unsafe. + public static AMPRRemovalPlan RemovalPlan(string indexPath, string root) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + root = RequireRoot(root); + List paths = []; + long present = 0; + long missing = 0; + for (int fileId = 1; fileId <= manifest.Files.Count; fileId++) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + string relative = AMPRAssetPath.Relative(manifest.FilePath(fileId)); + if (IsProtected(relative)) + { + throw new AMPRPackException($"manifest marks a protected game/runtime file as packed: {relative}"); + } + + paths.Add(relative); + if (ValidatedSource(root, relative, record.LogicalSize, allowMissing: true) is null) + { + missing++; + } + else + { + present += (long)record.LogicalSize; + } + } + + return new AMPRRemovalPlan(paths.Count, paths.Count - missing, missing, present, paths); + } + + /// + /// Verify the pack set and every source byte, then permanently delete the sources of packed files + /// (Python remove_packed_sources). Offline verification cannot prove the title never reads a file + /// through an unhooked path; the caller decides that. + /// + /// Manifest path. + /// /app0 source tree. + /// Remove directories left empty, never the root. + /// Counters. + /// A source is missing, protected, unsafe or differs from the packed data. + public static AMPRRemovalResult RemovePackedSources(string indexPath, string root, bool removeEmptyDirs = false) + { + AMPRRemovalPlan initial = RemovalPlan(indexPath, root); + if (initial.MissingFiles > 0) + { + throw new AMPRPackException($"cannot verify before removal: {initial.MissingFiles} packed source files are missing"); + } + + // Check the pack payload and every source byte immediately before the irreversible phase. + AMPRPackTools.Verify(indexPath); + AMPRPackTools.VerifyAgainstRoot(indexPath, root); + + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + root = RequireRoot(root); + List<(string Path, ulong Size)> candidates = []; + for (int fileId = 1; fileId <= manifest.Files.Count; fileId++) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + string relative = AMPRAssetPath.Relative(manifest.FilePath(fileId)); + if (IsProtected(relative)) + { + throw new AMPRPackException($"refusing to remove protected path: {relative}"); + } + + candidates.Add((ValidatedSource(root, relative, record.LogicalSize, allowMissing: false)!, record.LogicalSize)); + } + + long bytes = 0; + HashSet parents = new(StringComparer.Ordinal); + foreach ((string path, ulong size) in candidates) + { + File.Delete(path); + bytes += (long)size; + parents.Add(Path.GetDirectoryName(path)!); + } + + long removedDirs = 0; + if (removeEmptyDirs) + { + HashSet expanded = new(StringComparer.Ordinal); + foreach (string directory in parents) + { + for (string current = directory; !SamePath(current, root); current = Path.GetDirectoryName(current)!) + { + expanded.Add(current); + } + } + + foreach (string directory in expanded.OrderByDescending(d => d.Split(Path.DirectorySeparatorChar).Length)) + { + try + { + Directory.Delete(directory); + removedDirs++; + } + catch (IOException) + { + // Non-empty directories and directories reused concurrently are kept. + } + catch (UnauthorizedAccessException) + { + } + } + } + + return new AMPRRemovalResult(candidates.Count, bytes, removedDirs, true); + } + + /// Atomically replace <index>.runtime from a TOML [runtime] section without repacking. + /// Manifest path. + /// TOML file with [runtime]. + /// What was written. + /// The configuration has no [runtime]. + public static AMPRRuntimeConfigResult WriteRuntimeConfig(string indexPath, string configPath) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + AMPRRuntimeSettings settings = AMPRPackConfig.Load(configPath).Runtime + ?? throw new AMPRPackException("configuration must contain [runtime]"); + string destination = AMPRPackFormat.RuntimePath(indexPath); + string temporary = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(destination))!, $".runtime-{Guid.NewGuid():N}"); + try + { + using (FileStream handle = new(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + handle.Write(settings.Encode(manifest.BuildId)); + handle.Flush(flushToDisk: true); + } + + File.Move(temporary, destination, overwrite: true); + } + finally + { + File.Delete(temporary); + } + + return new AMPRRuntimeConfigResult(destination, Convert.ToHexStringLower(manifest.BuildId), settings); + } + + private static string RequireRoot(string root) + { + string full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + if (!Directory.Exists(full)) + { + throw File.Exists(full) + ? new AMPRPackException($"source root is not a directory: {full}") + : new DirectoryNotFoundException($"No such file or directory: '{root}'"); + } + + return full; + } + + // Python _validated_removal_source: a regular, non-link file inside the root with the manifest size. + private static string? ValidatedSource(string root, string relative, ulong expectedSize, bool allowMissing) + { + string candidate = AMPRAssetPath.SafeOutputPath(root, relative); + FileInfo info = new(candidate); + bool isDirectory = Directory.Exists(candidate); + if (!info.Exists && !isDirectory && info.LinkTarget is null) + { + return allowMissing ? null : throw new AMPRPackException($"packed source is missing: {relative}"); + } + + FileSystemInfo entry = isDirectory ? new DirectoryInfo(candidate) : info; + if (entry.LinkTarget is not null || (entry.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new AMPRPackException($"refusing to remove a symlink/reparse point: {relative}"); + } + + if (isDirectory) + { + throw new AMPRPackException($"packed source is not a regular file: {relative}"); + } + + string parent = Path.GetDirectoryName(Path.GetFullPath(candidate))!; + if (!SamePath(parent, root) && !IsUnder(ResolveDirectory(parent), ResolveDirectory(root))) + { + throw new AMPRPackException($"packed source resolves outside /app0: {relative}"); + } + + return (ulong)info.Length == expectedSize + ? candidate + : throw new AMPRPackException($"packed source size changed for {relative}: manifest={expectedSize}, disk={info.Length}"); + } + + // Directory path with linked components resolved. + private static string ResolveDirectory(string path) + { + string full = Path.GetFullPath(path); + string current = Path.GetPathRoot(full)!; + foreach (string part in full[current.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(current, part); + DirectoryInfo info = new(next); + current = info.Exists && info.LinkTarget is not null && info.ResolveLinkTarget(returnFinalTarget: true) is { } target + ? ResolveDirectory(target.FullName) + : next; + } + + return Path.TrimEndingDirectorySeparator(current); + } + + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private static bool SamePath(string a, string b) => + Path.TrimEndingDirectorySeparator(a).Equals(Path.TrimEndingDirectorySeparator(b), PathComparison); + + private static bool IsUnder(string path, string root) => + SamePath(path, root) || path.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, PathComparison); +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackPattern.cs b/src/MkPFS.Build/AMPRPack/AMPRPackPattern.cs new file mode 100644 index 0000000..1da3aee --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackPattern.cs @@ -0,0 +1,521 @@ +using System.Globalization; +using System.Text; +using MkPFS.Core.AMPR; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// +/// Volume name pattern such as ampr_assets-{id:03d}.pak: a Python str.format string over the fields +/// group (text) and lane, volume, id (integers). Port of ampr_pack +/// _pack_output_glob plus the parts of string.Formatter and the format-spec mini-language it relies on. +/// Float presentation types g/G on integers are not supported (rejected as invalid). +/// +public sealed class AMPRPackPattern +{ + private static readonly string[] AllowedFields = ["group", "lane", "volume", "id"]; + + private readonly List _segments; + + private AMPRPackPattern(string pattern, List segments, string outputGlob) + { + Pattern = pattern; + _segments = segments; + OutputGlob = outputGlob; + } + + /// The pattern text. + public string Pattern { get; } + + /// Glob matching every volume this pattern can produce (fields become *). + public string OutputGlob { get; } + + /// Parse and check a pattern like ampr_pack _pack_output_glob. + /// Pattern. + /// The pattern. + /// The pattern is malformed, uses another field, is empty, or renders an unsafe path. + public static AMPRPackPattern Parse(string pattern) + { + List segments; + try + { + segments = ParseFormat(pattern); + } + catch (FormatException exc) + { + throw new AMPRPackException($"invalid pack_pattern: {PythonText.Repr(pattern)}", exc); + } + + StringBuilder glob = new(); + foreach (Segment segment in segments) + { + if (segment.Field is null) + { + glob.Append(segment.Literal); + continue; + } + + if (!AllowedFields.Contains(segment.Field, StringComparer.Ordinal)) + { + throw new AMPRPackException( + $"pack_pattern uses unsupported field {PythonText.Repr(segment.Field)}; allowed fields are group, lane, volume and id"); + } + + glob.Append('*'); + } + + string outputGlob = glob.ToString().Replace('\\', '/').TrimStart('/'); + if (outputGlob.Length == 0) + { + throw new AMPRPackException("pack_pattern cannot be empty"); + } + + AMPRPackPattern result = new(pattern, segments, outputGlob); + // A concrete rendering catches absolute paths and traversal before any data is written. + try + { + AMPRAssetPath.SafeOutputPath(".", result.Render("g", 0, 0, 0).Replace('\\', '/')); + } + catch (Exception exc) when (exc is ArgumentException or FormatException) + { + throw new AMPRPackException($"unsafe or invalid pack_pattern: {PythonText.Repr(pattern)}", exc); + } + + return result; + } + + /// Python pattern.format(group=..., lane=..., volume=..., id=...). + /// Group name. + /// Lane. + /// Volume within the lane. + /// Pack id. + /// Volume file name. + /// A conversion or format spec is invalid for its value. + public string Render(string group, long lane, long volume, long id) + { + StringBuilder output = new(); + foreach (Segment segment in _segments) + { + if (segment.Field is null) + { + output.Append(segment.Literal); + continue; + } + + object value = segment.Field switch + { + "group" => group, + "lane" => lane, + "volume" => volume, + _ => id, + }; + output.Append(FormatField(value, segment.Conversion, segment.Spec)); + } + + return output.ToString(); + } + + private static string FormatField(object value, char? conversion, string spec) + { + if (conversion is { } c) + { + value = c switch + { + 's' => value is string s ? s : ((long)value).ToString(CultureInfo.InvariantCulture), + 'r' or 'a' => value is string s ? PythonText.Repr(s) : ((long)value).ToString(CultureInfo.InvariantCulture), + _ => throw new FormatException($"Unknown conversion specifier {c}"), + }; + } + + if (spec.Contains('{', StringComparison.Ordinal) || spec.Contains('}', StringComparison.Ordinal)) + { + // Nested replacement fields would need arguments this pattern does not have (Python KeyError). + throw new FormatException("nested replacement field in format spec"); + } + + FormatSpec parsed = FormatSpec.Parse(spec); + return value is string text ? parsed.FormatString(text) : parsed.FormatInteger((long)value); + } + + // string.Formatter().parse: literal text with {{ and }} escapes, then fields "{name[!conv][:spec]}". + private static List ParseFormat(string pattern) + { + List segments = []; + StringBuilder literal = new(); + int i = 0; + while (i < pattern.Length) + { + char c = pattern[i]; + if (c == '}') + { + if (i + 1 < pattern.Length && pattern[i + 1] == '}') + { + literal.Append('}'); + i += 2; + continue; + } + + throw new FormatException("Single '}' encountered in format string"); + } + + if (c != '{') + { + literal.Append(c); + i++; + continue; + } + + if (i + 1 < pattern.Length && pattern[i + 1] == '{') + { + literal.Append('{'); + i += 2; + continue; + } + + if (i + 1 >= pattern.Length) + { + throw new FormatException("Single '{' encountered in format string"); + } + + // Find the matching '}' (format specs may nest braces). + int depth = 1; + int j = i + 1; + while (j < pattern.Length) + { + if (pattern[j] == '{') + { + depth++; + } + else if (pattern[j] == '}' && --depth == 0) + { + break; + } + + j++; + } + + if (j >= pattern.Length) + { + throw new FormatException("expected '}' before end of string"); + } + + segments.Add(new Segment(literal.ToString(), null, null, string.Empty)); + literal.Clear(); + segments.Add(ParseField(pattern[(i + 1)..j])); + i = j + 1; + } + + segments.Add(new Segment(literal.ToString(), null, null, string.Empty)); + return segments; + } + + private static Segment ParseField(string field) + { + int k = 0; + while (k < field.Length) + { + char c = field[k]; + if (c == '{') + { + throw new FormatException("unexpected '{' in field name"); + } + + if (c == '[') + { + while (k < field.Length && field[k] != ']') + { + k++; + } + + continue; + } + + if (c is ':' or '!') + { + break; + } + + k++; + } + + string name = field[..k]; + char? conversion = null; + if (k < field.Length && field[k] == '!') + { + if (k + 1 >= field.Length) + { + throw new FormatException("end of string while looking for conversion specifier"); + } + + conversion = field[k + 1]; + k += 2; + if (k < field.Length && field[k] != ':') + { + throw new FormatException("expected ':' after conversion specifier"); + } + } + + string spec = k < field.Length ? field[(k + 1)..] : string.Empty; + return new Segment(string.Empty, name, conversion, spec); + } + + private sealed record Segment(string Literal, string? Field, char? Conversion, string Spec); + + // Python format-spec mini-language: [[fill]align][sign][z][#][0][width][grouping][.precision][type]. + private sealed record FormatSpec( + char? Fill, char? Align, char Sign, bool Alternate, bool ZeroPad, int Width, char? Grouping, int? Precision, char? Type) + { + public static FormatSpec Parse(string spec) + { + int i = 0; + char? fill = null; + char? align = null; + if (spec.Length >= 2 && spec[1] is '<' or '>' or '=' or '^') + { + fill = spec[0]; + align = spec[1]; + i = 2; + } + else if (spec.Length >= 1 && spec[0] is '<' or '>' or '=' or '^') + { + align = spec[0]; + i = 1; + } + + char sign = '-'; + if (i < spec.Length && spec[i] is '+' or '-' or ' ') + { + sign = spec[i++]; + } + + if (i < spec.Length && spec[i] == 'z') + { + throw new FormatException("Negative zero coercion (z) not allowed in format specifier"); + } + + bool alternate = false; + if (i < spec.Length && spec[i] == '#') + { + alternate = true; + i++; + } + + bool zero = false; + if (i < spec.Length && spec[i] == '0') + { + zero = true; + i++; + } + + int width = ReadNumber(spec, ref i) ?? 0; + char? grouping = null; + if (i < spec.Length && spec[i] is ',' or '_') + { + grouping = spec[i++]; + } + + int? precision = null; + if (i < spec.Length && spec[i] == '.') + { + i++; + precision = ReadNumber(spec, ref i) ?? throw new FormatException("Format specifier missing precision"); + } + + char? type = null; + if (i < spec.Length) + { + type = spec[i++]; + } + + return i == spec.Length + ? new FormatSpec(fill, align, sign, alternate, zero, width, grouping, precision, type) + : throw new FormatException("Invalid format specifier"); + } + + public string FormatString(string value) + { + if (Type is not (null or 's')) + { + throw new FormatException($"Unknown format code '{Type}' for object of type 'str'"); + } + + if (Sign != '-' || Alternate || Align == '=' || Grouping is not null) + { + throw new FormatException("invalid format specifier for a string"); + } + + if (Precision is { } p && p < value.Length) + { + value = value[..p]; + } + + // Since Python 3.10 a leading '0' only sets the fill for strings; the default alignment stays left. + char fill = Fill ?? (ZeroPad ? '0' : ' '); + return Pad(string.Empty, value, fill, Align ?? '<'); + } + + public string FormatInteger(long value) + { + string digits; + string prefix = string.Empty; + ulong magnitude = value < 0 ? (ulong)(-(value + 1)) + 1 : (ulong)value; + switch (Type) + { + case null or 'd' or 'n': + if (Type == 'n' && Grouping is not null) + { + throw new FormatException("Cannot specify ',' with 'n'."); + } + + digits = magnitude.ToString(CultureInfo.InvariantCulture); + break; + case 'b' or 'o' or 'x' or 'X': + if (Grouping == ',') + { + throw new FormatException($"Cannot specify ',' with '{Type}'."); + } + + digits = Type switch + { + 'b' => Convert.ToString((long)magnitude, 2), + 'o' => Convert.ToString((long)magnitude, 8), + 'x' => magnitude.ToString("x", CultureInfo.InvariantCulture), + _ => magnitude.ToString("X", CultureInfo.InvariantCulture), + }; + if (Alternate) + { + prefix = Type switch { 'b' => "0b", 'o' => "0o", 'x' => "0x", _ => "0X" }; + } + + break; + case 'c': + if (Sign != '-' || Alternate || Grouping is not null || value is < 0 or > 0x10FFFF) + { + throw new FormatException("invalid format specifier for 'c'"); + } + + return FormatChar((int)value); + case 'f' or 'F' or 'e' or 'E' or '%': + return FormatFloat(value); + default: + throw new FormatException($"Unknown format code '{Type}' for object of type 'int'"); + } + + if (Precision is not null) + { + throw new FormatException("Precision not allowed in integer format specifier"); + } + + string signText = value < 0 ? "-" : Sign == '+' ? "+" : Sign == ' ' ? " " : string.Empty; + return PadNumber(signText + prefix, digits, Grouping is null ? 0 : Type is 'b' or 'o' or 'x' or 'X' ? 4 : 3); + } + + private string FormatChar(int value) + { + string text = char.ConvertFromUtf32(value); + return Pad(string.Empty, text, Fill ?? (ZeroPad ? '0' : ' '), Align ?? (ZeroPad ? '=' : '>')); + } + + private string FormatFloat(long value) + { + int precision = Precision ?? 6; + double number = Type == '%' ? value * 100.0 : value; + string digits = Type switch + { + 'e' or 'E' => FormatExponent(Math.Abs(number), precision, Type == 'E'), + _ => Math.Abs(number).ToString("F" + precision.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture), + }; + if (Alternate && precision == 0) + { + digits = Type is 'e' or 'E' ? digits.Insert(1, ".") : digits + "."; + } + + string suffix = Type == '%' ? "%" : string.Empty; + string signText = number < 0 ? "-" : Sign == '+' ? "+" : Sign == ' ' ? " " : string.Empty; + if (Grouping is not null && Type is 'e' or 'E') + { + return PadNumber(signText, digits + suffix, 0); + } + + int dot = digits.IndexOf('.', StringComparison.Ordinal); + string whole = dot < 0 ? digits : digits[..dot]; + string fraction = dot < 0 ? string.Empty : digits[dot..]; + return PadNumber(signText, whole, Grouping is null ? 0 : 3, fraction + suffix); + } + + private static string FormatExponent(double value, int precision, bool upper) + { + string text = value.ToString((upper ? "E" : "e") + precision.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + // .NET writes e+006; Python writes e+06. + int e = text.IndexOfAny(['e', 'E']); + int exponent = int.Parse(text.AsSpan(e + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + return text[..(e + 1)] + (exponent < 0 ? "-" : "+") + Math.Abs(exponent).ToString("00", CultureInfo.InvariantCulture); + } + + // Numbers align right by default; '=' (or a leading '0' without an explicit alignment) pads between the + // sign/prefix and the digits, and zero padding is grouped like the digits. + private string PadNumber(string head, string digits, int groupSize, string tail = "") + { + char align = Align ?? (ZeroPad ? '=' : '>'); + char fill = Fill ?? (ZeroPad ? '0' : ' '); + if (align == '=' && fill == '0' && Fill is null && groupSize > 0) + { + string grouped = Group(digits, groupSize); + while (head.Length + grouped.Length + tail.Length < Width) + { + digits = "0" + digits; + grouped = Group(digits, groupSize); + } + + return head + grouped + tail; + } + + string body = (groupSize > 0 ? Group(digits, groupSize) : digits) + tail; + return Pad(head, body, fill, align); + } + + private string Group(string digits, int size) + { + StringBuilder builder = new(); + for (int k = 0; k < digits.Length; k++) + { + if (k > 0 && (digits.Length - k) % size == 0) + { + builder.Append(Grouping); + } + + builder.Append(digits[k]); + } + + return builder.ToString(); + } + + private string Pad(string head, string body, char fill, char align) + { + int padding = Width - head.Length - body.Length; + if (padding <= 0) + { + return head + body; + } + + return align switch + { + '<' => head + body + new string(fill, padding), + '^' => new string(fill, padding / 2) + head + body + new string(fill, padding - (padding / 2)), + '=' => head + new string(fill, padding) + body, + _ => new string(fill, padding) + head + body, + }; + } + + private static int? ReadNumber(string spec, ref int i) + { + int start = i; + while (i < spec.Length && char.IsAsciiDigit(spec[i])) + { + i++; + } + + return i == start ? null : int.Parse(spec.AsSpan(start, i - start), CultureInfo.InvariantCulture); + } + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackPlanner.cs b/src/MkPFS.Build/AMPRPack/AMPRPackPlanner.cs new file mode 100644 index 0000000..6d9a50b --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRPackPlanner.cs @@ -0,0 +1,348 @@ +using MkPFS.Core.AMPR; +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// +/// Chooses a rule, source and lane for every AMPRIDX3 file (Python plan_files, _source_path, +/// _assign_lanes, _auto_loose_decision). +/// +public static class AMPRPackPlanner +{ + /// Plan every file. + /// AMPRIDX3 rows (file id = row + 1). + /// Resolved /app0 source directory. + /// Configuration. + /// Counters to update. + /// CLI include globs; when given, other files stay loose. + /// CLI exclude globs. + /// Leave missing sources loose instead of failing. + /// Progress callback. + /// One planned file per row, and warnings. + public static (List Files, List Warnings) Plan( + IReadOnlyList entries, + string root, + AMPRPackConfig config, + AMPRBuildStats stats, + IReadOnlyList includePatterns, + IReadOnlyList excludePatterns, + bool allowMissing, + Action? progress = null) + { + List candidates = []; + List warnings = []; + string indexName = config.IndexName.Replace('\\', '/').TrimStart('/'); + string packOutputGlob = AMPRPackPattern.Parse(config.PackPattern).OutputGlob; + using LZ4Encoder encoder = new(); + for (int fileId = 1; fileId <= entries.Count; fileId++) + { + AMPRIndexEntry entry = entries[fileId - 1]; + string relative = AMPRAssetPath.Relative(entry.Path); + progress?.Invoke(new AMPRBuildProgress("planning", fileId - 1, entries.Count, 0, 0, relative)); + AMPRRule rule = config.SelectRule(relative); + if (includePatterns.Count > 0 && !AMPRGlob.Matches(relative, includePatterns)) + { + rule = AMPRRule.Loose(rule.BlockShift); + } + + if (excludePatterns.Count > 0 && AMPRGlob.Matches(relative, excludePatterns)) + { + rule = AMPRRule.Loose(rule.BlockShift); + } + + // Never consume an index or pack left by an earlier build, including custom nested pack names. + string normalized = relative.Replace('\\', '/').TrimStart('/'); + if (normalized == indexName || normalized == indexName + ".crc" || normalized == indexName + ".runtime" + || AMPRGlob.Matches(normalized, [packOutputGlob])) + { + rule = AMPRRule.Loose(rule.BlockShift); + } + + string source = Path.Combine([root, .. relative.Split('/')]); + if (rule.Action == "loose") + { + candidates.Add(new AMPRSelectedFile(fileId, entry, source, relative, rule, (long)entry.Size, entry.MTime, 0)); + continue; + } + + string resolved; + try + { + (source, resolved) = SourcePath(root, relative); + } + catch (Exception exc) when (exc is FileNotFoundException or DirectoryNotFoundException or ArgumentException) + { + if (!allowMissing) + { + throw new AMPRPackException($"source is missing or unsafe for file id {fileId}: {relative}", exc); + } + + warnings.Add($"file id {fileId} left loose because source is missing: {relative}"); + candidates.Add(new AMPRSelectedFile(fileId, entry, source, relative, AMPRRule.Loose(rule.BlockShift), (long)entry.Size, entry.MTime, 0)); + continue; + } + + FileInfo info = new(resolved); + if (!info.Exists) + { + throw new AMPRPackException($"source is not a regular file: {relative}"); + } + + if (config.ValidateIndexMetadata && (ulong)info.Length != entry.Size) + { + throw new AMPRPackException($"AMPRIDX3 size mismatch for {relative}: index={entry.Size}, disk={info.Length}"); + } + + AMPRSelectedFile candidate = new(fileId, entry, source, relative, rule, info.Length, UnixSeconds(info.LastWriteTimeUtc), 0); + AMPRGroupConfig group = config.Groups[rule.Group]; + AutoLooseDecision autoLoose = DecideAutoLoose(candidate, config, encoder, group.IOPageSize != 0 ? group.IOPageSize : config.IOPageSize); + stats.AutoLooseSampledBytes += autoLoose.SampledBytes; + if (autoLoose.UseLoose) + { + string details = $"{relative} size={candidate.Size} sampled={autoLoose.SampledBytes} " + + $"saving={Percent(autoLoose.SavingsRatio)} rawBlocks={Percent(autoLoose.RawRatio)}; "; + if (config.SelfContained) + { + warnings.Add("self-contained: auto-loose suppressed for " + details + "incompressible blocks will be stored RAW"); + } + else + { + stats.FilesAutoLoose++; + stats.AutoLooseLogicalBytes += candidate.Size; + warnings.Add("auto-loose: " + details + "use force_pack=true or self_contained=true to override"); + rule = rule with { Action = "loose", Hot = false, Streaming = false, Layout = "mixed" }; + } + } + + candidates.Add(candidate with { Rule = rule }); + } + + Dictionary lanes = AssignLanes(candidates, config); + List result = [.. candidates.Select(c => c with { Lane = lanes.GetValueOrDefault(c.FileId) })]; + if (config.RequiredPacked.Count > 0) + { + List missing = [.. result + .Where(item => AMPRGlob.Matches(item.Relative, config.RequiredPacked) && item.Rule.Action == "loose") + .Select(item => item.Relative)]; + if (missing.Count > 0) + { + string preview = string.Join(", ", missing.Take(8)); + string suffix = missing.Count <= 8 ? string.Empty : $" (+{missing.Count - 8} more)"; + throw new AMPRPackException($"required_packed matched files that would remain loose: {preview}{suffix}"); + } + } + + progress?.Invoke(new AMPRBuildProgress("planning", entries.Count, entries.Count, 0, 0)); + return (result, warnings); + } + + /// + /// Sample positions for auto-loose: all blocks when few, the middle one for one sample, else an even + /// integer interpolation that includes the first and last block (Python _sample_block_indices). + /// + /// Blocks in the file. + /// Samples wanted. + /// Sorted distinct block indices. + public static List SampleBlockIndices(long totalBlocks, long sampleBlocks) + { + if (totalBlocks <= 0 || sampleBlocks <= 0) + { + return []; + } + + if (totalBlocks <= sampleBlocks) + { + return [.. Enumerable.Range(0, (int)totalBlocks).Select(i => (long)i)]; + } + + if (sampleBlocks == 1) + { + return [totalBlocks / 2]; + } + + SortedSet indices = []; + for (long sample = 0; sample < sampleBlocks; sample++) + { + indices.Add(checked(sample * (totalBlocks - 1)) / (sampleBlocks - 1)); + } + + return [.. indices]; + } + + /// Unix seconds truncated toward zero, like Python int(st_mtime). + /// UTC time. + /// Seconds. + internal static long UnixSeconds(DateTime utc) => (utc.Ticks - DateTime.UnixEpoch.Ticks) / TimeSpan.TicksPerSecond; + + /// Python f"{value:.2%}". + private static string Percent(double value) => PythonText.FormatFixed(value * 100.0, 2) + "%"; + + // Python _source_path: a safe relative path whose resolved target stays under the resolved root. + private static (string Source, string Resolved) SourcePath(string root, string relative) + { + string candidate = AMPRAssetPath.SafeOutputPath(root, relative); + string rootReal = RealPath(root); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) + { + throw new FileNotFoundException("source is missing", candidate); + } + + string resolved = RealPath(candidate); + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + string prefix = Path.EndsInDirectorySeparator(rootReal) ? rootReal : rootReal + Path.DirectorySeparatorChar; + return resolved.Equals(rootReal, comparison) || resolved.StartsWith(prefix, comparison) + ? (candidate, resolved) + : throw new AMPRPackException($"source symlink escapes root: {relative}"); + } + + // Absolute path with every symlink or junction component resolved (Path.resolve()). + private static string RealPath(string path, int depth = 0) + { + if (depth > 40) + { + throw new AMPRPackException($"too many levels of symbolic links: {path}"); + } + + string full = Path.GetFullPath(path); + string current = Path.GetPathRoot(full)!; + foreach (string part in full[current.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(current, part); + FileSystemInfo info = Directory.Exists(next) ? new DirectoryInfo(next) : new FileInfo(next); + if (info.Exists && info.LinkTarget is not null && info.ResolveLinkTarget(returnFinalTarget: true) is { } target) + { + next = RealPath(target.FullName, depth + 1); + } + + current = next; + } + + return Path.TrimEndingDirectorySeparator(current); + } + + private static Dictionary AssignLanes(List candidates, AMPRPackConfig config) + { + Dictionary> byGroup = new(StringComparer.Ordinal); + foreach (AMPRSelectedFile item in candidates.Where(c => c.Rule.Action != "loose")) + { + if (!byGroup.TryGetValue(item.Rule.Group, out List? files)) + { + byGroup[item.Rule.Group] = files = []; + } + + files.Add(item); + } + + Dictionary result = []; + foreach ((string groupName, List files) in byGroup) + { + AMPRGroupConfig group = config.Groups[groupName]; + int packCount = (int)group.PackCount; + if (group.Assignment == "balanced") + { + // Largest first, then lower-cased path by code point, then file id; each goes to the lightest lane. + long[] loads = new long[packCount]; + IEnumerable ordered = files + .OrderBy(f => -f.Size) + .ThenBy(f => f.Relative.ToLowerInvariant(), PythonCodePointComparer.Instance) + .ThenBy(f => f.FileId); + foreach (AMPRSelectedFile file in ordered) + { + int lane = 0; + for (int candidate = 1; candidate < packCount; candidate++) + { + if (loads[candidate] < loads[lane]) + { + lane = candidate; + } + } + + result[file.FileId] = lane; + loads[lane] += file.Size; + } + } + else if (group.Assignment == "hash") + { + foreach (AMPRSelectedFile file in files) + { + result[file.FileId] = (int)(AMPRAssetPath.Fnv1a64(AMPRAssetPath.AsciiFoldPathBytes(file.Relative)) % (ulong)packCount); + } + } + else + { + foreach (AMPRSelectedFile file in files) + { + result[file.FileId] = (file.FileId - 1) % packCount; + } + } + } + + return result; + } + + private static AutoLooseDecision DecideAutoLoose(AMPRSelectedFile item, AMPRPackConfig config, LZ4Encoder encoder, long ioPageSize) + { + AMPRRule rule = item.Rule; + if (!config.AutoLooseLargeFiles + || rule.Action != "compress" + || rule.ForcePack + || (rule.Hot && !config.AutoLooseHotFiles) + || item.Size < config.AutoLooseMinFileSize + || item.Size == 0) + { + return new AutoLooseDecision(false, 0, 0, 0, 0); + } + + long blockSize = 1L << rule.BlockShift; + long totalBlocks = (item.Size + blockSize - 1) / blockSize; + long byteLimitedBlocks = Math.Max(1, config.AutoLooseSampleBytes / blockSize); + List indices = SampleBlockIndices(totalBlocks, Math.Min(config.AutoLooseSampleBlocks, byteLimitedBlocks)); + long sampledBytes = 0; + long storedBytes = 0; + long rawBlocks = 0; + using (FileStream handle = File.OpenRead(item.Source)) + { + (long Length, DateTime Written) before = FileStamp(item.Source); + foreach (long blockIndex in indices) + { + long offset = blockIndex * blockSize; + int expected = (int)Math.Min(blockSize, item.Size - offset); + byte[] raw = new byte[expected]; + handle.Seek(offset, SeekOrigin.Begin); + if (handle.ReadAtLeast(raw, expected, throwOnEndOfStream: false) != expected) + { + throw new AMPRPackException($"source was truncated while sampling compression: {item.Relative}"); + } + + AMPRCompressedBlock block = AMPRBlockCompressor.Compress(encoder, raw, rule, ioPageSize); + sampledBytes += raw.Length; + storedBytes += block.Stored.Length; + rawBlocks += block.Codec == AMPRPackFormat.ChunkCodecRaw ? 1 : 0; + } + + if (FileStamp(item.Source) != before) + { + throw new AMPRPackException($"source changed while sampling compression: {item.Relative}"); + } + } + + AutoLooseDecision decision = new(false, sampledBytes, storedBytes, rawBlocks, indices.Count); + bool useLoose = decision.SavingsRatio < config.AutoLooseMinSavingsRatio || decision.RawRatio >= config.AutoLooseMaxRawRatio; + return decision with { UseLoose = useLoose }; + } + + /// Size and write time; Python also compares inode and device, which .NET does not expose portably. + internal static (long Length, DateTime Written) FileStamp(string path) + { + FileInfo info = new(path); + return (info.Length, info.LastWriteTimeUtc); + } + + private sealed record AutoLooseDecision(bool UseLoose, long SampledBytes, long StoredBytes, long RawBlocks, long SampledBlocks) + { + public double SavingsRatio => SampledBytes == 0 ? 0.0 : 1.0 - ((double)StoredBytes / SampledBytes); + + public double RawRatio => SampledBlocks == 0 ? 0.0 : (double)RawBlocks / SampledBlocks; + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRToml.cs b/src/MkPFS.Build/AMPRPack/AMPRToml.cs new file mode 100644 index 0000000..307ab53 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRToml.cs @@ -0,0 +1,70 @@ +using System.Text; +using MkPFS.Core.AMPR; +using Tomlyn; +using Tomlyn.Model; +using Tomlyn.Parsing; +using Tomlyn.Serialization; +using Tomlyn.Syntax; + +namespace MkPFS.Build.AMPRPack; + +/// +/// TOML 1.0 reader for pack configurations. Syntax and semantic validation (duplicate keys and tables, dotted +/// keys redefined by headers) runs first because the table converter alone is lenient; Python tomllib +/// rejects the same documents, with different message text. +/// +internal static class AMPRToml +{ + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// Read a TOML file (UTF-8, like tomllib.load). + /// File path. + /// Root table. + /// The file is not valid UTF-8 or not valid TOML. + public static TomlTable Load(string path) + { + string text; + try + { + text = StrictUtf8.GetString(File.ReadAllBytes(path)); + } + catch (DecoderFallbackException exc) + { + throw new AMPRPackException($"{path} is not valid UTF-8", exc); + } + + return Parse(text, path); + } + + /// Parse TOML text. + /// Document. + /// Name used in diagnostics. + /// Root table. + /// The text is not valid TOML. + public static TomlTable Parse(string text, string sourceName) + { + DocumentSyntax document = SyntaxParser.Parse(text, sourceName, true); + if (document.HasErrors) + { + DiagnosticMessage first = document.Diagnostics.First(d => d.Kind == DiagnosticMessageKind.Error); + throw new AMPRPackException( + $"{first.Message} (at line {first.Span.Start.Line + 1}, column {first.Span.Start.Column + 1})"); + } + + try + { + return TomlSerializer.Deserialize(text, AMPRTomlContext.Default) + ?? throw new AMPRPackException("configuration root must be a TOML table"); + } + catch (TomlException exc) + { + throw new AMPRPackException(exc.Message, exc); + } + } +} + +/// Source-generated Tomlyn metadata (Native AOT). +[TomlSerializable(typeof(TomlTable))] +internal sealed partial class AMPRTomlContext : TomlSerializerContext +{ +} diff --git a/src/MkPFS.Build/AMPRPack/PythonValue.cs b/src/MkPFS.Build/AMPRPack/PythonValue.cs new file mode 100644 index 0000000..7c6442f --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/PythonValue.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using MkPFS.Core.AMPR; +using MkPFS.Core.Util; +using Tomlyn; +using Tomlyn.Model; + +namespace MkPFS.Build.AMPRPack; + +/// +/// Python built-in conversions (str, int, float, bool, parse_size) applied to TOML +/// values, as ampr_pack's load_config does. TOML values are , , +/// , , , , +/// and . Where Python raises TypeError or +/// AttributeError (a traceback, exit 1) this raises with the same text. +/// +internal static class PythonValue +{ + /// Python type name for messages. + public static string TypeName(object? value) => value switch + { + null => "NoneType", + string => "str", + bool => "bool", + long => "int", + double => "float", + TomlDateTime => "datetime", + TomlTable => "dict", + TomlArray or TomlTableArray => "list", + _ => value.GetType().Name, + }; + + /// Python str(value). + public static string Str(object? value) => value switch + { + string s => s, + _ => Repr(value), + }; + + /// Python repr(value). + public static string Repr(object? value) => value switch + { + null => "None", + string s => PythonText.Repr(s), + bool b => b ? "True" : "False", + long l => l.ToString(CultureInfo.InvariantCulture), + double d => PythonText.FloatRepr(d), + TomlTable t => "{" + string.Join(", ", t.Select(kv => PythonText.Repr(kv.Key) + ": " + Repr(kv.Value))) + "}", + TomlTableArray a => "[" + string.Join(", ", a.Select(Repr)) + "]", + TomlArray a => "[" + string.Join(", ", a.Select(Repr)) + "]", + _ => value.ToString() ?? string.Empty, + }; + + /// Python int(value). + public static long Int(object? value) + { + switch (value) + { + case bool b: + return b ? 1 : 0; + case long l: + return l; + case double d when double.IsNaN(d): + throw new ArgumentException("cannot convert float NaN to integer"); + case double d when double.IsInfinity(d): + throw new ArgumentException("cannot convert float infinity to integer"); + case double d: + return (long)Math.Truncate(d); + case string s: + string text = PythonText.Strip(s); + if (IsPythonNumber(text, allowFraction: false) + && long.TryParse(text.Replace("_", string.Empty, StringComparison.Ordinal), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long parsed)) + { + return parsed; + } + + throw new ArgumentException($"invalid literal for int() with base 10: {PythonText.Repr(s)}"); + default: + throw new ArgumentException($"int() argument must be a string, a bytes-like object or a real number, not '{TypeName(value)}'"); + } + } + + /// Python float(value). + public static double Float(object? value) + { + switch (value) + { + case bool b: + return b ? 1.0 : 0.0; + case long l: + return l; + case double d: + return d; + case string s: + string text = PythonText.Strip(s); + string lower = text.TrimStart('+', '-').ToLowerInvariant(); + if (lower is "inf" or "infinity" or "nan") + { + double special = lower == "nan" ? double.NaN : double.PositiveInfinity; + return text.StartsWith('-') ? -special : special; + } + + if (IsPythonNumber(text, allowFraction: true) + && double.TryParse(text.Replace("_", string.Empty, StringComparison.Ordinal), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)) + { + return parsed; + } + + throw new ArgumentException($"could not convert string to float: {PythonText.Repr(s)}"); + default: + throw new ArgumentException($"float() argument must be a string or a real number, not '{TypeName(value)}'"); + } + } + + /// Python bool(value) (truthiness). + public static bool Bool(object? value) => value switch + { + null => false, + bool b => b, + long l => l != 0, + double d => d != 0.0, + string s => s.Length > 0, + TomlTable t => t.Count > 0, + TomlArray a => a.Count > 0, + TomlTableArray a => a.Count > 0, + _ => true, + }; + + /// + /// ampr_pack parse_size(value): integers pass through, strings are parsed. A TOML boolean is treated as + /// 0 or 1 (Python keeps the bool; only the canonical JSON would differ). + /// + public static long Size(object? value) => value switch + { + bool b => b ? 1 : 0, + long l => AMPRSize.Parse(l), + string s => AMPRSize.Parse(s), + _ => throw new ArgumentException($"'{TypeName(value)}' object has no attribute 'strip'"), + }; + + /// ampr_pack block_shift(value). + public static int BlockShift(object? value) => AMPRSize.BlockShift(Size(value)); + + // Python int()/float() literals: digits with single underscores between digits, optional sign, + // and for floats an optional fraction and exponent. + private static bool IsPythonNumber(string text, bool allowFraction) + { + int i = 0; + if (i < text.Length && text[i] is '+' or '-') + { + i++; + } + + bool digits = false; + bool lastDigit = false; + bool seenDot = false; + bool seenExp = false; + for (; i < text.Length; i++) + { + char c = text[i]; + if (char.IsAsciiDigit(c)) + { + digits = true; + lastDigit = true; + } + else if (c == '_' && lastDigit && i + 1 < text.Length && char.IsAsciiDigit(text[i + 1])) + { + lastDigit = false; + } + else if (allowFraction && c == '.' && !seenDot && !seenExp) + { + seenDot = true; + lastDigit = false; + } + else if (allowFraction && c is 'e' or 'E' && digits && !seenExp) + { + seenExp = true; + lastDigit = false; + digits = false; + if (i + 1 < text.Length && text[i + 1] is '+' or '-') + { + i++; + } + } + else + { + return false; + } + } + + return digits; + } +} diff --git a/src/MkPFS.Build/AmprIndex.cs b/src/MkPFS.Build/AmprIndex.cs index 80fab43..66e4cf5 100644 --- a/src/MkPFS.Build/AmprIndex.cs +++ b/src/MkPFS.Build/AmprIndex.cs @@ -100,6 +100,20 @@ public static int HashSlotCount(int entryCount) return null; } + // An AMPR pack set addresses files by their row in this index; rebuilding renumbers the rows (the new + // ampr_assets-*.pak files sort first) and the emulator then fails every packed read (not in Python MkPFS). + bool packSet = File.Exists(indexPath) && File.Exists(Path.Combine(sourceRoot, AMPRPack.AMPRPackConfig.DefaultIndexName)); + if (packSet && !forceRegen) + { + log.Warning($"{IndexName} kept: the AMPR packs in this folder (ampr_assets.index) are bound to it; --ampr-force-regen rebuilds it and breaks them"); + return null; + } + + if (packSet) + { + log.Warning($"Rebuilding {IndexName} although ampr_assets.index is present; the AMPR packs will stop working until they are rebuilt"); + } + if (createIfMissing && !forceRegen && (File.Exists(indexPath) || Directory.Exists(indexPath))) { if (Validate(indexPath, sourceRoot)) diff --git a/src/MkPFS.Build/MkPFS.Build.csproj b/src/MkPFS.Build/MkPFS.Build.csproj index df54737..913dbf8 100644 --- a/src/MkPFS.Build/MkPFS.Build.csproj +++ b/src/MkPFS.Build/MkPFS.Build.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs new file mode 100644 index 0000000..5462bc0 --- /dev/null +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -0,0 +1,442 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Globalization; +using MkPFS.Build.AMPRPack; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; +using MkPFS.Core.Diagnostics; +using MkPFS.Core.Util; + +namespace MkPFS.Cli.Commands; + +/// +/// ampr: AMPR Emu seekable LZ4 asset packs (port of ampr_emu tools/ampr_pack.py). Subcommands, options, +/// JSON (json.dumps(indent=2, ensure_ascii=False, sort_keys=True)) and error: ... lines with exit code 2 +/// match the oracle. +/// +internal static class AmprCommand +{ + /// Printed to stderr when a pack run packs nothing (an addition to the oracle output). + internal const string NothingPackedWarning = + "warning: no files were packed; without --config or --preset default every file stays loose"; + + public static Command Create(CliContext ctx) + { + Command command = new("ampr", "Build and manage AMPR Emu seekable LZ4 asset packs for /app0"); + command.Subcommands.Add(Pack(ctx)); + command.Subcommands.Add(Unpack(ctx)); + command.Subcommands.Add(Verify(ctx)); + command.Subcommands.Add(RemoveSources(ctx)); + command.Subcommands.Add(List(ctx)); + command.Subcommands.Add(Inspect(ctx)); + command.Subcommands.Add(RuntimeConfig(ctx)); + return command; + } + + private static Option Required(string name, string description) => new(name) { Description = description, Required = true }; + + private static Option Repeated(string name, string description, string? helpName = null) => + new(name) { Description = description, HelpName = helpName, AllowMultipleArgumentsPerToken = false, DefaultValueFactory = _ => [] }; + + private static Command Pack(CliContext ctx) + { + Option root = Required("--root", "deployed /app0 directory"); + Option amprIndex = Required("--ampr-index", "AMPRIDX3 file"); + Option output = Required("--output", "output directory"); + Option config = new("--config") { Description = "TOML pack configuration" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose" }; + preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); + Option include = Repeated("--include", "additional include glob"); + Option exclude = Repeated("--exclude", "force-loose glob"); + Option includeFrom = new("--include-from") { Description = "newline-delimited include globs" }; + Option excludeFrom = new("--exclude-from") { Description = "newline-delimited exclude globs" }; + Option workers = new("--workers") { Description = "compression workers" }; + Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; + Option requirePacked = Repeated("--require-packed", "fail if a matching logical file would remain loose (repeatable)", "GLOB"); + Option allowMissing = new("--allow-missing") { Description = "leave missing selected files loose" }; + Option noProgress = new("--no-progress") { Description = "suppress build progress on stderr; final JSON still goes to stdout" }; + Command command = new("pack", "build pack index and data volumes") + { + root, amprIndex, output, config, preset, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, + }; + command.SetAction(parse => Run(ctx, () => + { + string? configPath = parse.GetValue(config); + string? presetName = parse.GetValue(preset); + AMPRPackConfig loaded = (configPath, presetName) switch + { + (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), + (null, not null) => AMPRPackConfig.LoadPreset(presetName), + _ => AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)), + }; + if (parse.GetValue(workers) is { } count) + { + loaded.Workers = count is >= 1 and <= 256 ? count : throw new AMPRPackException("--workers must be between 1 and 256"); + } + + if (parse.GetValue(selfContained)) + { + loaded.SelfContained = true; + } + + if (parse.GetValue(requirePacked) is { Length: > 0 } required) + { + loaded.RequiredPacked = [.. loaded.RequiredPacked, .. required]; + } + + List includes = [.. parse.GetValue(include)!, .. PatternFile(ctx, parse.GetValue(includeFrom))]; + List excludes = [.. parse.GetValue(exclude)!, .. PatternFile(ctx, parse.GetValue(excludeFrom))]; + Action? progress = parse.GetValue(noProgress) || !ctx.ProgressEnabled ? null : ProgressReporter(ctx); + AMPRBuildResult result = AMPRPackBuilder.Build( + Resolve(ctx, parse.GetValue(root)!), + Resolve(ctx, parse.GetValue(amprIndex)!), + Resolve(ctx, parse.GetValue(output)!), + loaded, + includes, + excludes, + parse.GetValue(allowMissing), + progress); + AMPRBuildStats stats = result.Stats; + PrintJson(ctx, new Dictionary + { + ["files_total"] = stats.FilesTotal, + ["files_packed"] = stats.FilesPacked, + ["files_loose"] = stats.FilesLoose, + ["loose_paths"] = stats.LoosePaths, + ["files_auto_loose"] = stats.FilesAutoLoose, + ["auto_loose_logical_bytes"] = stats.AutoLooseLogicalBytes, + ["auto_loose_sampled_bytes"] = stats.AutoLooseSampledBytes, + ["chunks"] = stats.Chunks, + ["chunks_lz4"] = stats.ChunksLZ4, + ["chunks_raw"] = stats.ChunksRaw, + ["chunks_shared"] = stats.ChunksShared, + ["logical_bytes"] = stats.LogicalBytes, + ["stored_bytes"] = stats.StoredBytes, + ["padding_bytes"] = stats.PaddingBytes, + ["io_pages_touched"] = stats.IOPagesTouched, + ["io_page_safe_chunks"] = stats.IOPageSafeChunks, + ["dense_streaming_chunks"] = stats.DenseStreamingChunks, + ["index"] = result.IndexPath, + ["crc"] = AMPRPackFormat.ChunkCrcPath(result.IndexPath), + ["compression_ratio"] = stats.LogicalBytes != 0 ? (double)stats.StoredBytes / stats.LogicalBytes : 1.0, + ["warnings"] = result.Warnings, + }); + if (stats.FilesPacked == 0) + { + // Not in ampr_pack.py: a run without rules leaves every file loose and is easy to start by mistake. + ctx.Err.WriteLine(NothingPackedWarning); + } + })); + return command; + } + + private static Command Unpack(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Option output = Required("--output", "output directory"); + Option file = Repeated("--file", "file glob"); + Option overwrite = new("--overwrite") { Description = "replace existing files" }; + Option noMTime = new("--no-preserve-mtime") { Description = "do not restore file modification times" }; + Command command = new("unpack", "extract packed files") { index, output, file, overwrite, noMTime }; + command.SetAction(parse => Run(ctx, () => + { + AMPRExtractResult result = AMPRPackTools.Extract( + Resolve(ctx, parse.GetValue(index)!), Resolve(ctx, parse.GetValue(output)!), parse.GetValue(file), parse.GetValue(overwrite), !parse.GetValue(noMTime)); + PrintJson(ctx, new Dictionary { ["files"] = result.Files, ["bytes"] = result.Bytes }); + })); + return command; + } + + private static Command Verify(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Option file = Repeated("--file", "file glob"); + Option root = new("--root") { Description = "also reconstruct packed files and compare byte-for-byte with this /app0 root" }; + Command command = new("verify", "validate every selected packed block") { index, file, root }; + command.SetAction(parse => Run(ctx, () => + { + string indexPath = Resolve(ctx, parse.GetValue(index)!); + AMPRVerifyResult verify = AMPRPackTools.Verify(indexPath, parse.GetValue(file)); + Dictionary result = new() + { + ["files"] = verify.Files, + ["physical_chunks"] = verify.PhysicalChunks, + ["stored_bytes"] = verify.StoredBytes, + ["raw_bytes"] = verify.RawBytes, + }; + if (parse.GetValue(root) is { } sourceRoot) + { + AMPRSourceCompareResult compare = AMPRPackTools.VerifyAgainstRoot(indexPath, Resolve(ctx, sourceRoot), parse.GetValue(file)); + result["source_compare"] = new Dictionary { ["files"] = compare.Files, ["chunks"] = compare.Chunks, ["bytes"] = compare.Bytes }; + } + + PrintJson(ctx, result); + })); + return command; + } + + private static Command RemoveSources(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Option root = Required("--root", "/app0 source directory"); + Option confirm = new("--confirm") { Description = "perform permanent removal; without it, print a read-only plan" }; + Option removeEmptyDirs = new("--remove-empty-dirs") { Description = "remove directories left empty by source removal, never the /app0 root" }; + Command command = new("remove-sources", "verify, then remove local /app0 sources represented by PACK records") { index, root, confirm, removeEmptyDirs }; + command.Aliases.Add("remove-packed-sources"); + command.SetAction(parse => Run(ctx, () => + { + string indexPath = Resolve(ctx, parse.GetValue(index)!); + string rootPath = Resolve(ctx, parse.GetValue(root)!); + if (parse.GetValue(confirm)) + { + AMPRRemovalResult removed = AMPRPackMaintenance.RemovePackedSources(indexPath, rootPath, parse.GetValue(removeEmptyDirs)); + PrintJson(ctx, new Dictionary + { + ["files"] = removed.Files, + ["bytes"] = removed.Bytes, + ["directories"] = removed.Directories, + ["verified"] = removed.Verified, + }); + return; + } + + AMPRRemovalPlan plan = AMPRPackMaintenance.RemovalPlan(indexPath, rootPath); + PrintJson(ctx, new Dictionary + { + ["files"] = plan.Files, + ["present_files"] = plan.PresentFiles, + ["missing_files"] = plan.MissingFiles, + ["bytes"] = plan.Bytes, + ["paths"] = plan.Paths, + ["dry_run"] = true, + }); + })); + return command; + } + + private static Command List(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Option file = Repeated("--file", "file glob"); + Option json = new("--json") { Description = "print JSON" }; + Command command = new("list", "list logical files and placement") { index, file, json }; + command.SetAction(parse => Run(ctx, () => + { + List rows = AMPRPackTools.List(Resolve(ctx, parse.GetValue(index)!), parse.GetValue(file)); + if (parse.GetValue(json)) + { + PrintJson(ctx, rows.Select(row => new Dictionary + { + ["file_id"] = row.FileId, + ["path"] = row.Path, + ["packed"] = row.Packed, + ["logical_size"] = row.LogicalSize, + ["stored_size"] = row.StoredSize, + ["block_size"] = row.BlockSize, + ["chunks"] = row.Chunks, + ["codecs"] = row.Codecs, + ["packs"] = row.Packs, + ["io_page_sizes"] = row.IOPageSizes, + ["layout"] = row.Layout, + ["streaming"] = row.Streaming, + ["random_access"] = row.RandomAccess, + ["hot"] = row.Hot, + }).ToList()); + return; + } + + foreach (AMPRListEntry row in rows) + { + ctx.Out.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"{row.FileId,8} {(row.Packed ? "PACK" : "LOOSE"),-5} {row.LogicalSize,12} {row.StoredSize,12} {row.BlockSize,8} {row.Path}")); + } + })); + return command; + } + + private static Command Inspect(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Command command = new("inspect", "show manifest-level summary") { index }; + command.SetAction(parse => Run(ctx, () => + { + AMPRInspectResult result = AMPRPackTools.Inspect(Resolve(ctx, parse.GetValue(index)!)); + PrintJson(ctx, new Dictionary + { + ["build_id"] = result.BuildId, + ["runtime"] = RuntimeJson(result.Runtime), + ["files"] = result.Files, + ["packed_files"] = result.PackedFiles, + ["loose_files"] = result.LooseFiles, + ["chunks"] = result.Chunks, + ["packs"] = result.Packs.Select(pack => new Dictionary + { + ["id"] = pack.Id, + ["name"] = pack.Name, + ["file_size"] = pack.FileSize, + ["payload_bytes"] = pack.PayloadBytes, + ["io_page_size"] = pack.IOPageSize, + ["io_pages"] = pack.IOPages, + ["flags"] = pack.Flags, + }).ToList(), + }); + })); + return command; + } + + private static Command RuntimeConfig(CliContext ctx) + { + Option index = Required("--index", "pack manifest"); + Option config = Required("--config", "TOML file with [runtime]"); + Command command = new("runtime-config", "atomically update per-pack runtime settings without repacking") { index, config }; + command.SetAction(parse => Run(ctx, () => + { + string typed = parse.GetValue(index)!; + AMPRRuntimeConfigResult result = AMPRPackMaintenance.WriteRuntimeConfig(Resolve(ctx, typed), Resolve(ctx, parse.GetValue(config)!)); + // Python prints Path(str(args.index) + ".runtime"): the path as typed. + PrintJson(ctx, new Dictionary + { + ["path"] = PythonPathText(typed + ".runtime"), + ["build_id"] = result.BuildId, + ["runtime"] = RuntimeJson(result.Runtime), + }); + })); + return command; + } + + // Python main(): PackError, ValueError, OSError and RuntimeError print "error: " and exit 2. + private static int Run(CliContext ctx, Action action) + { + try + { + action(); + return 0; + } + catch (Exception exc) when (exc is AMPRPackException or ArgumentException or InvalidDataException or IOException + or UnauthorizedAccessException or InvalidOperationException) + { + ctx.Err.WriteLine($"error: {exc.Message}"); + return 2; + } + } + + private static void PrintJson(CliContext ctx, object value) => ctx.Out.WriteLine(PythonSortedJson.Indented(value)); + + private static Dictionary? RuntimeJson(AMPRRuntimeSettings? settings) => settings is null ? null : new() + { + ["decoded_cache_bytes"] = settings.DecodedCacheBytes, + ["physical_cache_bytes"] = settings.PhysicalCacheBytes, + ["workers"] = settings.Workers, + ["latency_reserve_workers"] = settings.LatencyReserveWorkers, + }; + + private static string Resolve(CliContext ctx, string path) => Path.GetFullPath(PathRules.ExpandUser(path), ctx.WorkingDirectory); + + private static List PatternFile(CliContext ctx, string? path) => + path is null ? [] : AMPRPackConfig.LoadPatternFile(Resolve(ctx, path)); + + // str(pathlib.Path(text)): platform separators, no empty or "." components. + private static string PythonPathText(string text) + { + char separator = Path.DirectorySeparatorChar; + string normalized = OperatingSystem.IsWindows() ? text.Replace('/', '\\') : text; + string root = Path.GetPathRoot(normalized) ?? string.Empty; + string[] parts = normalized[root.Length..].Split(separator, StringSplitOptions.RemoveEmptyEntries).Where(p => p != ".").ToArray(); + string joined = root + string.Join(separator, parts); + return joined.Length == 0 ? "." : joined; + } + + // Python ConsoleBuildProgress: throttled "[pack NN%] phase: details, elapsed HH:MM:SS" lines on stderr, which the + // GUI shows in its log. The GUI bar gets one overall "pack" phase with the same percentage on every event (the + // sink also cancels the job), so it does not log a misleading "done" line per build phase. + private static Action ProgressReporter(CliContext ctx) + { + IProgressSink? sink = ctx.ExternalProgressSink; + Stopwatch clock = Stopwatch.StartNew(); + TimeSpan lastOutput = TimeSpan.Zero; + int lastPercent = -1; + string lastPhase = string.Empty; + TimeSpan? packingStarted = null; + return progress => + { + TimeSpan now = clock.Elapsed; + int percent = Percent(progress); + sink?.Step("pack", percent, 100, progress.LogicalBytesDone); + bool phaseChanged = progress.Phase != lastPhase; + if (progress.Phase == "packing" && packingStarted is null) + { + packingStarted = now; + } + + if (!phaseChanged && progress.Phase != "complete") + { + TimeSpan since = now - lastOutput; + if (since.TotalSeconds < 0.5 || (percent <= lastPercent && since.TotalSeconds < 5.0)) + { + return; + } + } + + string details = $"files {progress.FilesDone}/{progress.FilesTotal}"; + if (progress.Phase == "packing") + { + details += $", {Size(progress.LogicalBytesDone)}/{Size(progress.LogicalBytesTotal)}"; + double seconds = packingStarted is { } started ? (now - started).TotalSeconds : 0.0; + if (seconds > 0 && progress.LogicalBytesDone > 0) + { + double rate = progress.LogicalBytesDone / seconds; + long eta = rate > 0 ? (long)(Math.Max(0, progress.LogicalBytesTotal - progress.LogicalBytesDone) / rate) : 0; + details += $", {Size((long)rate)}/s, ETA {Clock(eta)}"; + } + } + + if (progress.CurrentPath.Length > 0) + { + details += $", current={progress.CurrentPath}"; + } + + ctx.Err.WriteLine($"[pack {percent,3}%] {progress.Phase}: {details}, elapsed {Clock((long)now.TotalSeconds)}"); + ctx.Err.Flush(); + lastOutput = now; + lastPercent = percent; + lastPhase = progress.Phase; + }; + } + + private static int Percent(AMPRBuildProgress progress) + { + switch (progress.Phase) + { + case "planning": + double planned = progress.FilesTotal != 0 ? (double)progress.FilesDone / progress.FilesTotal : 1.0; + return Math.Min(10, (int)(planned * 10)); + case "packing": + double packed = progress.LogicalBytesTotal != 0 + ? (double)progress.LogicalBytesDone / progress.LogicalBytesTotal + : progress.FilesTotal != 0 ? (double)progress.FilesDone / progress.FilesTotal : 1.0; + return Math.Min(95, 10 + (int)(packed * 85)); + default: + return progress.Phase switch { "finalizing" => 96, "publishing" => 99, "complete" => 100, _ => 0 }; + } + } + + private static string Clock(long seconds) => string.Create(CultureInfo.InvariantCulture, $"{seconds / 3600:00}:{seconds / 60 % 60:00}:{seconds % 60:00}"); + + // Python _progress_size. + private static string Size(long value) + { + double amount = value; + foreach (string suffix in (string[])["B", "KiB", "MiB", "GiB", "TiB"]) + { + if (amount < 1024.0 || suffix == "TiB") + { + return suffix == "B" ? $"{PythonText.FormatFixed(amount, 0)} B" : $"{PythonText.FormatFixed(amount, 1)} {suffix}"; + } + + amount /= 1024.0; + } + + throw new UnreachableException(); + } +} diff --git a/src/MkPFS.Cli/MkPFSCli.cs b/src/MkPFS.Cli/MkPFSCli.cs index dab5fe3..035b24b 100644 --- a/src/MkPFS.Cli/MkPFSCli.cs +++ b/src/MkPFS.Cli/MkPFSCli.cs @@ -52,6 +52,7 @@ public static RootCommand BuildRootCommand(CliContext ctx) root.Subcommands.Add(Commands.ReadCommands.Unpack(ctx)); root.Subcommands.Add(Commands.BatchCommand.Create(ctx)); root.Subcommands.Add(Commands.RepairCommand.Create(ctx)); + root.Subcommands.Add(Commands.AmprCommand.Create(ctx)); root.Subcommands.Add(BuildSelfTestCommand()); root.Subcommands.Add(BuildPFSCBenchCommand()); return root; diff --git a/src/MkPFS.Cli/Output/CliContext.cs b/src/MkPFS.Cli/Output/CliContext.cs index b108e20..4949eb4 100644 --- a/src/MkPFS.Cli/Output/CliContext.cs +++ b/src/MkPFS.Cli/Output/CliContext.cs @@ -41,6 +41,15 @@ public CliContext(TextWriter stdout, TextWriter stderr, bool useColor, bool utf8 /// Progress bars enabled. public bool ProgressEnabled { get; } + /// Progress sink supplied by the host (the GUI), or on a terminal. + public IProgressSink? ExternalProgressSink => _progressSink; + + /// + /// Directory that relative path arguments resolve against (the process directory by default). Commands that + /// echo a path as typed, like Python does, resolve it here for file access. + /// + public string WorkingDirectory { get; init; } = Directory.GetCurrentDirectory(); + /// Context over the process console. /// Default context. public static CliContext CreateDefault() diff --git a/src/MkPFS.Core/AMPR/AMPRAssetPath.cs b/src/MkPFS.Core/AMPR/AMPRAssetPath.cs new file mode 100644 index 0000000..421c2be --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRAssetPath.cs @@ -0,0 +1,139 @@ +using System.Text; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// /app0 path rules shared by the pack manifest and AMPRIDX3 (ampr_pack_format.py:170-240). +/// Unlike MkPFS.Build.AmprIndex.PathHash (lower-cased code points), the pack manifest hashes the +/// UTF-8 bytes and folds only ASCII A..Z. +/// +public static class AMPRAssetPath +{ + /// FNV-1a 64 over bytes; 0 becomes 1 (Python fnv1a64). + /// Bytes. + /// Hash. + public static ulong Fnv1a64(ReadOnlySpan data) + { + ulong value = 0xCBF29CE484222325UL; + foreach (byte b in data) + { + value ^= b; + value = unchecked(value * 0x100000001B3UL); + } + + return value == 0 ? 1 : value; + } + + /// UTF-8 bytes with \ -> / and ASCII A..Z folded (Python ascii_fold_path_bytes). + /// Path. + /// Folded bytes. + public static byte[] AsciiFoldPathBytes(string path) + { + byte[] raw = Encoding.UTF8.GetBytes(path.Replace('\\', '/')); + for (int i = 0; i < raw.Length; i++) + { + if (raw[i] is >= 0x41 and <= 0x5A) + { + raw[i] += 0x20; + } + } + + return raw; + } + + /// Manifest path hash of the canonical form of (Python asset_path_hash). + /// Path. + /// Hash. + public static ulong Hash(string path) => Fnv1a64(AsciiFoldPathBytes(Canonical(path))); + + /// + /// Canonical absolute /app0 path: / separators, no empty or . components, .. + /// resolved. The spelling is kept; only the /app0 prefix check ignores case (Python canonical_asset_path). + /// + /// Path, absolute or relative to /. + /// Canonical path. + /// The path escapes the root, contains NUL, or is outside /app0. + public static string Canonical(string path) + { + path = path.Replace('\\', '/'); + if (!path.StartsWith('/')) + { + path = "/" + path; + } + + List parts = []; + foreach (string component in path.Split('/')) + { + if (component.Length == 0 || component == ".") + { + continue; + } + + if (component == "..") + { + if (parts.Count == 0) + { + throw new ArgumentException($"path escapes root: {path}"); + } + + parts.RemoveAt(parts.Count - 1); + continue; + } + + if (component.Contains('\0', StringComparison.Ordinal)) + { + throw new ArgumentException("NUL in path"); + } + + parts.Add(component); + } + + string normalized = "/" + string.Join('/', parts); + if (normalized.Equals("/app0", StringComparison.OrdinalIgnoreCase)) + { + return "/app0"; + } + + return normalized.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase) + ? normalized + : throw new ArgumentException($"asset is outside /app0: {path}"); + } + + /// Path relative to /app0, empty for /app0 itself (Python asset_relative_path). + /// Path. + /// Relative path with / separators. + public static string Relative(string path) + { + string canonical = Canonical(path); + return canonical == "/app0" ? string.Empty : canonical[6..]; + } + + /// + /// Join a relative / path to after rejecting empty, ., .., + /// absolute and backslash paths, and paths whose parent leaves the root (Python safe_output_path). + /// + /// Output root. + /// Relative path. + /// The joined path. + /// The path is unsafe. + public static string SafeOutputPath(string root, string relative) + { + if (relative.Length == 0 + || relative.Contains('\\', StringComparison.Ordinal) + || relative.StartsWith('/') + || relative.EndsWith('/') + || relative.Split('/').Any(component => component is "" or "." or "..")) + { + throw new ArgumentException($"unsafe output path: {PythonText.Repr(relative)}"); + } + + string candidate = Path.Combine([root, .. relative.Split('/')]); + string rootFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string parentFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetDirectoryName(Path.GetFullPath(candidate))!)); + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + string rootPrefix = Path.EndsInDirectorySeparator(rootFull) ? rootFull : rootFull + Path.DirectorySeparatorChar; + bool inside = parentFull.Equals(rootFull, comparison) || parentFull.StartsWith(rootPrefix, comparison); + return inside ? candidate : throw new ArgumentException($"output path escapes root: {PythonText.Repr(relative)}"); + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRBuildId.cs b/src/MkPFS.Core/AMPR/AMPRBuildId.cs new file mode 100644 index 0000000..9d78d08 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRBuildId.cs @@ -0,0 +1,26 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace MkPFS.Core.AMPR; + +/// Deterministic 16-byte build id (Python deterministic_build_id). +public static class AMPRBuildId +{ + /// SHA-256 over "AMPRPACK4\0" and each part prefixed by its u64 length; first 16 bytes. + /// Parts in order. + /// 16 bytes. + public static byte[] Compute(IEnumerable parts) + { + using IncrementalHash digest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + digest.AppendData("AMPRPACK4\0"u8); + Span length = stackalloc byte[8]; + foreach (byte[] part in parts) + { + BinaryPrimitives.WriteUInt64LittleEndian(length, (ulong)part.LongLength); + digest.AppendData(length); + digest.AppendData(part); + } + + return digest.GetHashAndReset()[..AMPRPackFormat.BuildIdSize]; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRChunkCrcs.cs b/src/MkPFS.Core/AMPR/AMPRChunkCrcs.cs new file mode 100644 index 0000000..4f2b310 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRChunkCrcs.cs @@ -0,0 +1,109 @@ +using System.Buffers.Binary; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// AMPRCRC1 sidecar (<manifest>.crc): a 48-byte header (<8sII16sQII) and one CRC-32 of the +/// decoded bytes per chunk, in chunk order. Offline only; the PRX never loads it +/// (Python build_chunk_crc_bytes, load_chunk_crcs). +/// +public static class AMPRChunkCrcs +{ + /// The sidecar payload: one little-endian CRC per chunk (Python encode_chunk_crcs; a build-id input). + /// Decoded CRC-32 per chunk. + /// Payload bytes. + public static byte[] EncodePayload(IReadOnlyList checksums) + { + byte[] payload = new byte[checked(checksums.Count * 4)]; + for (int i = 0; i < checksums.Count; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 4), checksums[i]); + } + + return payload; + } + + /// Serialize a sidecar. + /// Manifest build id. + /// Decoded CRC-32 per chunk. + /// Sidecar bytes. + /// The build id is not 16 bytes. + public static byte[] Build(ReadOnlySpan buildId, IReadOnlyList checksums) + { + if (buildId.Length != AMPRPackFormat.BuildIdSize) + { + throw new ArgumentException("CRC sidecar build ID must contain 16 bytes"); + } + + byte[] data = new byte[checked(AMPRPackFormat.CrcHeaderSize + (checksums.Count * 4))]; + Span span = data; + EncodePayload(checksums).CopyTo(span[AMPRPackFormat.CrcHeaderSize..]); + + AMPRPackFormat.CrcMagic.CopyTo(span); + BinaryPrimitives.WriteUInt32LittleEndian(span[8..], AMPRPackFormat.CrcVersion); + BinaryPrimitives.WriteUInt32LittleEndian(span[12..], AMPRPackFormat.CrcHeaderSize); + buildId.CopyTo(span[16..]); + BinaryPrimitives.WriteUInt64LittleEndian(span[32..], (ulong)checksums.Count); + BinaryPrimitives.WriteUInt32LittleEndian(span[40..], Crc32.Update(0, span[AMPRPackFormat.CrcHeaderSize..])); + BinaryPrimitives.WriteUInt32LittleEndian(span[44..], Crc32.Update(0, span[..AMPRPackFormat.CrcHeaderSize])); + return data; + } + + /// Load and check a sidecar file. + /// Sidecar path. + /// Manifest build id. + /// Manifest chunk count. + /// One CRC per chunk. + /// The sidecar is malformed or belongs to another build. + public static uint[] Load(string path, ReadOnlySpan expectedBuildId, long expectedChunkCount) => + Parse(File.ReadAllBytes(path), expectedBuildId, expectedChunkCount); + + /// Parse and check sidecar bytes. + /// Sidecar bytes. + /// Manifest build id. + /// Manifest chunk count. + /// One CRC per chunk. + /// The sidecar is malformed or belongs to another build. + public static uint[] Parse(ReadOnlySpan data, ReadOnlySpan expectedBuildId, long expectedChunkCount) + { + if (data.Length < AMPRPackFormat.CrcHeaderSize) + { + throw new InvalidDataException("chunk CRC sidecar is truncated"); + } + + if (!data[..8].SequenceEqual(AMPRPackFormat.CrcMagic) + || BinaryPrimitives.ReadUInt32LittleEndian(data[8..]) != AMPRPackFormat.CrcVersion + || BinaryPrimitives.ReadUInt32LittleEndian(data[12..]) != AMPRPackFormat.CrcHeaderSize) + { + throw new InvalidDataException("unsupported chunk CRC sidecar"); + } + + ulong chunkCount = BinaryPrimitives.ReadUInt64LittleEndian(data[32..]); + if (!data.Slice(16, AMPRPackFormat.BuildIdSize).SequenceEqual(expectedBuildId) || chunkCount != (ulong)expectedChunkCount) + { + throw new InvalidDataException("chunk CRC sidecar build ID or chunk count mismatch"); + } + + byte[] header = data[..AMPRPackFormat.CrcHeaderSize].ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(44), 0); + if (Crc32.Update(0, header) != BinaryPrimitives.ReadUInt32LittleEndian(data[44..])) + { + throw new InvalidDataException("chunk CRC sidecar header CRC mismatch"); + } + + ReadOnlySpan payload = data[AMPRPackFormat.CrcHeaderSize..]; + if ((ulong)payload.Length != chunkCount * 4 || Crc32.Update(0, payload) != BinaryPrimitives.ReadUInt32LittleEndian(data[40..])) + { + throw new InvalidDataException("chunk CRC sidecar payload CRC mismatch"); + } + + uint[] values = new uint[chunkCount]; + for (int i = 0; i < values.Length; i++) + { + values[i] = BinaryPrimitives.ReadUInt32LittleEndian(payload[(i * 4)..]); + } + + return values; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRDataHeader.cs b/src/MkPFS.Core/AMPR/AMPRDataHeader.cs new file mode 100644 index 0000000..8bb9d79 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRDataHeader.cs @@ -0,0 +1,104 @@ +using System.Buffers.Binary; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// AMPRDAT3 data volume header (64 bytes, <8sIIII16sQQII) at offset 0 of every .pak volume +/// (Python build_data_header, validate_data_header). +/// +/// Pack id. +/// 16-byte build id. +/// Payload start in the volume. +/// Payload length. +/// PackFlag* bits. +public readonly record struct AMPRDataHeader(uint PackId, byte[] BuildId, ulong PayloadOffset, ulong PayloadBytes, uint Flags = 0) +{ + /// Serialize the header with its CRC. + /// 64 bytes. + /// The build id is not 16 bytes. + public byte[] ToBytes() + { + if (BuildId.Length != AMPRPackFormat.BuildIdSize) + { + throw new ArgumentException("build id must contain 16 bytes"); + } + + byte[] data = new byte[AMPRPackFormat.DataHeaderSize]; + Span span = data; + AMPRPackFormat.DataMagic.CopyTo(span); + BinaryPrimitives.WriteUInt32LittleEndian(span[8..], AMPRPackFormat.DataVersion); + BinaryPrimitives.WriteUInt32LittleEndian(span[12..], AMPRPackFormat.DataHeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(span[16..], PackId); + BinaryPrimitives.WriteUInt32LittleEndian(span[20..], Flags); + BuildId.CopyTo(span[24..]); + BinaryPrimitives.WriteUInt64LittleEndian(span[40..], PayloadOffset); + BinaryPrimitives.WriteUInt64LittleEndian(span[48..], PayloadBytes); + BinaryPrimitives.WriteUInt32LittleEndian(span[56..], Crc32.Update(0, span)); + return data; + } + + /// Parse and check a header; optional expectations are compared after the CRC. + /// Exactly 64 bytes. + /// Pack id the manifest assigns, or . + /// Manifest build id, or . + /// Manifest pack flags, or . + /// The header. + /// The header is malformed or does not match. + public static AMPRDataHeader Parse( + ReadOnlySpan header, + uint? expectedPackId = null, + ReadOnlySpan expectedBuildId = default, + uint? expectedFlags = null) + { + if (header.Length != AMPRPackFormat.DataHeaderSize) + { + throw new InvalidDataException("pack data header is truncated"); + } + + uint version = BinaryPrimitives.ReadUInt32LittleEndian(header[8..]); + uint headerSize = BinaryPrimitives.ReadUInt32LittleEndian(header[12..]); + if (!header[..8].SequenceEqual(AMPRPackFormat.DataMagic) || version != AMPRPackFormat.DataVersion || headerSize != AMPRPackFormat.DataHeaderSize) + { + throw new InvalidDataException("unsupported pack data header"); + } + + if (BinaryPrimitives.ReadUInt32LittleEndian(header[60..]) != 0) + { + throw new InvalidDataException("invalid pack data header"); + } + + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + if ((flags & ~AMPRPackFormat.PackKnownFlags) != 0) + { + throw new InvalidDataException("unknown pack data flags"); + } + + byte[] copy = header.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(copy.AsSpan(56), 0); + if (Crc32.Update(0, copy) != BinaryPrimitives.ReadUInt32LittleEndian(header[56..])) + { + throw new InvalidDataException("pack data header CRC mismatch"); + } + + AMPRDataHeader result = new( + BinaryPrimitives.ReadUInt32LittleEndian(header[16..]), + header.Slice(24, AMPRPackFormat.BuildIdSize).ToArray(), + BinaryPrimitives.ReadUInt64LittleEndian(header[40..]), + BinaryPrimitives.ReadUInt64LittleEndian(header[48..]), + flags); + if (expectedPackId is { } packId && result.PackId != packId) + { + throw new InvalidDataException("pack id mismatch"); + } + + if (!expectedBuildId.IsEmpty && !expectedBuildId.SequenceEqual(result.BuildId)) + { + throw new InvalidDataException("pack build id mismatch"); + } + + return expectedFlags is { } expected && flags != expected + ? throw new InvalidDataException("pack flags mismatch") + : result; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRGlob.cs b/src/MkPFS.Core/AMPR/AMPRGlob.cs new file mode 100644 index 0000000..ab70b40 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRGlob.cs @@ -0,0 +1,179 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.RegularExpressions; + +namespace MkPFS.Core.AMPR; + +/// +/// Python fnmatch.fnmatchcase as used by ampr_pack (glob_matches): case-sensitive, * and +/// ? also match /, ** is the same as *, [...]/[!...] sets, no escaping. +/// Patterns are translated like CPython 3.11 fnmatch._translate and compiled to .NET regexes. +/// +public static class AMPRGlob +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + /// Python glob_matches: normalize separators, strip leading /, match any pattern. + /// Relative path. + /// Patterns. + /// when any pattern matches. + public static bool Matches(string path, IEnumerable patterns) + { + string normalized = path.Replace('\\', '/').TrimStart('/'); + return patterns.Any(pattern => FnMatchCase(normalized, pattern.Replace('\\', '/').TrimStart('/'))); + } + + /// Python fnmatch.fnmatchcase(name, pattern). + /// Name. + /// Pattern. + /// when the whole name matches. + public static bool FnMatchCase(string name, string pattern) => + Cache.GetOrAdd(pattern, static p => new Regex(Translate(p), RegexOptions.Singleline | RegexOptions.CultureInvariant)).IsMatch(name); + + /// Translate a pattern to an anchored .NET regex (CPython 3.11 fnmatch._translate). + /// Pattern. + /// Regex source. + public static string Translate(string pattern) + { + StringBuilder res = new("^"); + bool lastWasStar = false; + int i = 0; + int n = pattern.Length; + while (i < n) + { + char c = pattern[i++]; + if (c == '*') + { + // Consecutive stars compress into one. + if (!lastWasStar) + { + res.Append(".*"); + lastWasStar = true; + } + + continue; + } + + lastWasStar = false; + if (c == '?') + { + res.Append('.'); + } + else if (c == '[') + { + i = TranslateSet(pattern, i, res); + } + else + { + res.Append(Regex.Escape(c.ToString())); + } + } + + return res.Append(@"\z").ToString(); + } + + // i is just past '['. Appends the set (or a literal '[') and returns the index to continue at. + private static int TranslateSet(string pat, int i, StringBuilder res) + { + int n = pat.Length; + int j = i; + if (j < n && pat[j] == '!') + { + j++; + } + + if (j < n && pat[j] == ']') + { + j++; + } + + while (j < n && pat[j] != ']') + { + j++; + } + + if (j >= n) + { + res.Append(@"\["); + return i; + } + + string stuff; + if (!pat.AsSpan(i, j - i).Contains('-')) + { + stuff = pat[i..j].Replace(@"\", @"\\", StringComparison.Ordinal); + } + else + { + List chunks = []; + int k = pat[i] == '!' ? i + 2 : i + 1; + while (true) + { + k = k < j ? pat.IndexOf('-', k, j - k) : -1; + if (k < 0) + { + break; + } + + chunks.Add(pat[i..k]); + i = k + 1; + k += 3; + } + + string chunk = pat[i..j]; + if (chunk.Length > 0) + { + chunks.Add(chunk); + } + else + { + chunks[^1] += "-"; + } + + // Remove empty ranges (invalid in a regex). + for (int m = chunks.Count - 1; m > 0; m--) + { + if (chunks[m - 1][^1] > chunks[m][0]) + { + chunks[m - 1] = chunks[m - 1][..^1] + chunks[m][1..]; + chunks.RemoveAt(m); + } + } + + // Escape backslashes and hyphens that do not form ranges. + stuff = string.Join('-', chunks.Select(s => s.Replace(@"\", @"\\", StringComparison.Ordinal).Replace("-", @"\-", StringComparison.Ordinal))); + } + + // Python escapes set operations (&&, ~~, ||); .NET treats "-[" as class subtraction, so '[' is escaped too. + stuff = Regex.Replace(stuff, @"([&~|\[])", @"\$1"); + if (stuff.Length == 0) + { + res.Append("(?!)"); + } + else if (stuff == "!") + { + res.Append('.'); + } + else + { + if (stuff[0] == '!') + { + stuff = "^" + stuff[1..]; + } + else if (stuff[0] is '^') + { + stuff = @"\" + stuff; + } + + // A leading ']' is literal in Python; escape it for .NET. + if (stuff[0] == ']' || stuff.StartsWith("^]", StringComparison.Ordinal)) + { + stuff = stuff[0] == ']' ? @"\" + stuff : @"^\" + stuff[1..]; + } + + res.Append('[').Append(stuff).Append(']'); + } + + return j + 1; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRIndexReader.cs b/src/MkPFS.Core/AMPR/AMPRIndexReader.cs new file mode 100644 index 0000000..5c739f2 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRIndexReader.cs @@ -0,0 +1,114 @@ +using System.Buffers.Binary; +using System.Text; + +namespace MkPFS.Core.AMPR; + +/// One AMPRIDX3 row as the pack tools see it. +/// Canonical /app0 path. +/// File size. +/// Modification time, Unix seconds. +public readonly record struct AMPRIndexEntry(string Path, ulong Size, long MTime); + +/// +/// Strict AMPRIDX3 (ampr_emu.index) reader used by the pack tools (Python read_ampridx3 in +/// ampr_pack_format.py). Row n is file id n + 1. The index writer lives in +/// MkPFS.Build.AmprIndex. +/// +public static class AMPRIndexReader +{ + private const int HeaderSize = 48; + private const int EntrySize = 24; + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// Read an index file. + /// Index path. + /// Rows in record order. + /// The index is malformed. + public static List Read(string path) => Parse(File.ReadAllBytes(path)); + + /// Parse index bytes. + /// Index bytes. + /// Rows in record order. + /// The index is malformed. + public static List Parse(ReadOnlySpan data) + { + if (data.Length < HeaderSize) + { + throw new InvalidDataException("AMPRIDX3 is truncated"); + } + + if (!data[..8].SequenceEqual("AMPRIDX3"u8) || BinaryPrimitives.ReadUInt32LittleEndian(data[8..]) != 3) + { + throw new InvalidDataException("unsupported AMPR index"); + } + + uint entrySize = BinaryPrimitives.ReadUInt32LittleEndian(data[12..]); + ulong entryCount = BinaryPrimitives.ReadUInt64LittleEndian(data[16..]); + ulong pathBytes = BinaryPrimitives.ReadUInt64LittleEndian(data[24..]); + ulong hashOffset = BinaryPrimitives.ReadUInt64LittleEndian(data[32..]); + uint hashSlotSize = BinaryPrimitives.ReadUInt32LittleEndian(data[40..]); + uint hashSlotCount = BinaryPrimitives.ReadUInt32LittleEndian(data[44..]); + if (entrySize != EntrySize) + { + throw new InvalidDataException("unexpected AMPRIDX3 entry size"); + } + + UInt128 pathOffset = HeaderSize + ((UInt128)entryCount * entrySize); + if (pathOffset + pathBytes > (UInt128)data.Length) + { + throw new InvalidDataException("AMPRIDX3 records/path blob are truncated"); + } + + if (hashOffset < pathOffset + pathBytes || hashOffset > (ulong)data.Length) + { + throw new InvalidDataException("invalid AMPRIDX3 hash offset"); + } + + if (hashSlotCount != 0 && hashSlotSize == 0) + { + throw new InvalidDataException("invalid AMPRIDX3 hash table"); + } + + List result = new((int)entryCount); + for (int index = 0; index < (int)entryCount; index++) + { + ReadOnlySpan record = data.Slice(HeaderSize + (index * EntrySize), EntrySize); + ulong pathAt = BinaryPrimitives.ReadUInt32LittleEndian(record); + ulong pathLength = BinaryPrimitives.ReadUInt32LittleEndian(record[4..]); + ulong size = BinaryPrimitives.ReadUInt64LittleEndian(record[8..]); + long mtime = BinaryPrimitives.ReadInt64LittleEndian(record[16..]); + if (pathAt + pathLength >= pathBytes) + { + throw new InvalidDataException($"AMPRIDX3 path {index} is out of range"); + } + + int start = (int)pathOffset + (int)pathAt; + int end = start + (int)pathLength; + if (data[end] != 0) + { + throw new InvalidDataException($"AMPRIDX3 path {index} is not NUL terminated"); + } + + string decoded; + try + { + decoded = StrictUtf8.GetString(data[start..end]); + } + catch (DecoderFallbackException exc) + { + throw new InvalidDataException($"AMPRIDX3 path {index} is not UTF-8", exc); + } + + try + { + result.Add(new AMPRIndexEntry(AMPRAssetPath.Canonical(decoded), size, mtime)); + } + catch (ArgumentException exc) + { + throw new InvalidDataException(exc.Message, exc); + } + } + + return result; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackException.cs b/src/MkPFS.Core/AMPR/AMPRPackException.cs new file mode 100644 index 0000000..9b5cd45 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackException.cs @@ -0,0 +1,23 @@ +namespace MkPFS.Core.AMPR; + +/// +/// Pack build or configuration error (Python PackError). The CLI prints error: <message> and exits 2, +/// as it does for and (Python ValueError). +/// +public sealed class AMPRPackException : Exception +{ + /// Create an error. + /// Message. + public AMPRPackException(string message) + : base(message) + { + } + + /// Create an error with a cause. + /// Message. + /// Cause. + public AMPRPackException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackFormat.cs b/src/MkPFS.Core/AMPR/AMPRPackFormat.cs new file mode 100644 index 0000000..af29771 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackFormat.cs @@ -0,0 +1,191 @@ +namespace MkPFS.Core.AMPR; + +/// +/// Constants of the AMPR seekable asset-pack format (ampr_emu tools/ampr_pack_format.py): +/// the AMPRPAK4 manifest, AMPRDAT3 data volumes, AMPRCRC1 chunk CRC sidecar and +/// AMPRCFG1 runtime settings. All integers are little endian. +/// +public static class AMPRPackFormat +{ + /// Manifest magic. + public static ReadOnlySpan IndexMagic => "AMPRPAK4"u8; + + /// Data volume magic. + public static ReadOnlySpan DataMagic => "AMPRDAT3"u8; + + /// Chunk CRC sidecar magic. + public static ReadOnlySpan CrcMagic => "AMPRCRC1"u8; + + /// Runtime settings magic. + public static ReadOnlySpan RuntimeMagic => "AMPRCFG1"u8; + + /// Manifest version. + public const uint IndexVersion = 4; + + /// Data volume version. + public const uint DataVersion = 3; + + /// Chunk CRC sidecar version. + public const uint CrcVersion = 1; + + /// Runtime settings version. + public const uint RuntimeVersion = 1; + + /// Manifest byte-order marker. + public const uint EndianMarker = 0x01020304; + + /// Manifest header size (<8sIIII16sQQIIIIQQQQQIIQ). + public const int IndexHeaderSize = 128; + + /// Data volume header size (<8sIIII16sQQII). + public const int DataHeaderSize = 64; + + /// Chunk CRC sidecar header size (<8sII16sQII). + public const int CrcHeaderSize = 48; + + /// Runtime settings size (<8sII16sQQIIII). + public const int RuntimeSize = 64; + + /// File record size (<QQqIIIIIBBH). + public const int FileRecordSize = 48; + + /// Chunk record size (<QI). + public const int ChunkRecordSize = 12; + + /// Pack record size (<QQIIII). + public const int PackRecordSize = 32; + + /// Build id length. + public const int BuildIdSize = 16; + + /// File is stored in pack volumes. + public const uint FileFlagPacked = 1 << 0; + + /// File chunks are never compressed. + public const uint FileFlagStoreOnly = 1 << 1; + + /// File uses the dense streaming layout. + public const uint FileFlagStreaming = 1 << 2; + + /// File is admitted to the decoded cache immediately. + public const uint FileFlagHot = 1 << 3; + + /// File uses the random-access layout. + public const uint FileFlagRandomAccess = 1 << 4; + + /// Every defined file flag. + public const uint FileKnownFlags = + FileFlagPacked | FileFlagStoreOnly | FileFlagStreaming | FileFlagHot | FileFlagRandomAccess; + + /// Chunk stored uncompressed. + public const int ChunkCodecRaw = 0; + + /// Chunk stored as a raw LZ4 block. + public const int ChunkCodecLZ4 = 1; + + /// Chunk bytes are shared with an earlier chunk (deduplication). + public const int ChunkFlagShared = 1 << 0; + + /// Chunk belongs to a streaming extent. + public const int ChunkFlagStreaming = 1 << 1; + + /// Chunk lies inside one I/O page. + public const int ChunkFlagPageContained = 1 << 2; + + /// Chunk starts on an I/O page boundary. + public const int ChunkFlagPageAligned = 1 << 3; + + /// Every defined chunk flag. + public const int ChunkKnownFlags = + ChunkFlagShared | ChunkFlagStreaming | ChunkFlagPageContained | ChunkFlagPageAligned; + + /// Pack holds stripes of large files. + public const uint PackFlagStriped = 1 << 0; + + /// Pack uses the page-aware layout (required). + public const uint PackFlagIOPageLayout = 1 << 1; + + /// Every defined pack flag. + public const uint PackKnownFlags = PackFlagStriped | PackFlagIOPageLayout; + + /// Every defined manifest flag (none). + public const uint IndexKnownFlags = 0; + + /// Smallest block shift (16 KiB). + public const int MinBlockShift = 14; + + /// Largest block shift (1 MiB). + public const int MaxBlockShift = 20; + + /// Smallest I/O page shift (4 KiB). + public const int MinIOPageShift = 12; + + /// Largest I/O page shift (1 MiB). + public const int MaxIOPageShift = 20; + + /// Alignment of every chunk offset. + public const int PhysicalChunkAlignment = 64; + + /// Chunk offsets use 48 bits. + public const ulong ChunkOffsetMask = (1UL << 48) - 1; + + /// Bits of stored_size - 1 in a chunk descriptor. + public const int ChunkStoredBits = 20; + + /// Mask of stored_size - 1. + public const uint ChunkStoredMask = (1U << ChunkStoredBits) - 1; + + /// Codec position in a chunk descriptor. + public const int ChunkCodecShift = 20; + + /// Codec mask (after shifting). + public const uint ChunkCodecMask = 0x3; + + /// Flags position in a chunk descriptor. + public const int ChunkFlagsShift = 22; + + /// Flags mask (after shifting). + public const uint ChunkFlagsMask = 0xFF; + + /// Every defined descriptor bit. + public const uint ChunkDescriptorKnownMask = + ChunkStoredMask | (ChunkCodecMask << ChunkCodecShift) | (ChunkFlagsMask << ChunkFlagsShift); + + /// Return path + ".crc" (Python chunk_crc_path). + /// Manifest path. + /// Sidecar path. + public static string ChunkCrcPath(string indexPath) => indexPath + ".crc"; + + /// Return path + ".runtime". + /// Manifest path. + /// Runtime settings path. + public static string RuntimePath(string indexPath) => indexPath + ".runtime"; + + /// Round up to a power-of-two . + /// Value. + /// Power of two. + /// Aligned value. + public static ulong AlignUp(ulong value, ulong alignment) + { + CheckAlignment(alignment); + return checked(value + alignment - 1) & ~(alignment - 1); + } + + /// Round down to a power-of-two . + /// Value. + /// Power of two. + /// Aligned value. + public static ulong AlignDown(ulong value, ulong alignment) + { + CheckAlignment(alignment); + return value & ~(alignment - 1); + } + + private static void CheckAlignment(ulong alignment) + { + if (alignment == 0 || (alignment & (alignment - 1)) != 0) + { + throw new ArgumentException($"alignment must be a power of two, got {alignment}"); + } + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackManifest.cs b/src/MkPFS.Core/AMPR/AMPRPackManifest.cs new file mode 100644 index 0000000..940f932 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackManifest.cs @@ -0,0 +1,542 @@ +using System.Buffers.Binary; +using System.Text; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// AMPRPAK4 runtime storage manifest (ampr_assets.index): a 128-byte header, then file, chunk and pack +/// records and a NUL-terminated UTF-8 string table. Port of ampr_pack_format.py PackManifest, +/// build_manifest_bytes, load_manifest and validate_manifest; error messages match. +/// +public sealed class AMPRPackManifest +{ + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// Create a manifest from records. + /// 16-byte build id. + /// Header flags (must be 0). + /// File records in file-id order. + /// Chunk records. + /// Pack records. + /// String table. + /// Manifest path, when loaded from disk. + public AMPRPackManifest( + byte[] buildId, + uint flags, + IReadOnlyList files, + IReadOnlyList chunks, + IReadOnlyList packs, + byte[] strings, + string? path = null) + { + BuildId = buildId; + Flags = flags; + Files = files; + Chunks = chunks; + Packs = packs; + Strings = strings; + Path = path; + } + + /// Manifest path, when loaded from disk. + public string? Path { get; } + + /// 16-byte build id shared by the manifest, volumes, CRC sidecar and runtime settings. + public byte[] BuildId { get; } + + /// Header flags. + public uint Flags { get; } + + /// File records; file id n is Files[n - 1]. + public IReadOnlyList Files { get; } + + /// Chunk records; loaded manifests carry the derived . + public IReadOnlyList Chunks { get; } + + /// Pack volume records; pack id n is Packs[n]. + public IReadOnlyList Packs { get; } + + /// String table (paths and volume names, each followed by NUL). + public byte[] Strings { get; } + + /// Decode a string-table entry. + /// Offset. + /// Length without NUL. + /// The string. + /// The entry is out of bounds, not NUL-terminated, or not UTF-8. + public string StringAt(uint offset, uint length) + { + if ((ulong)offset + length >= (ulong)Strings.Length) + { + throw new InvalidDataException("string is out of bounds"); + } + + if (Strings[offset + length] != 0) + { + throw new InvalidDataException("string is not NUL terminated"); + } + + try + { + return StrictUtf8.GetString(Strings, (int)offset, (int)length); + } + catch (DecoderFallbackException exc) + { + throw new InvalidDataException("string is not valid UTF-8", exc); + } + } + + /// Path of a file id (1-based). + /// File id. + /// Canonical /app0 path. + public string FilePath(int fileId) + { + if (fileId <= 0 || fileId > Files.Count) + { + throw new ArgumentOutOfRangeException(nameof(fileId), "invalid file id"); + } + + AMPRFileRecord record = Files[fileId - 1]; + return StringAt(record.PathOffset, record.PathLength); + } + + /// Volume file name of a pack id. + /// Pack id. + /// Name relative to the manifest directory. + public string PackName(int packId) + { + if (packId < 0 || packId >= Packs.Count) + { + throw new ArgumentOutOfRangeException(nameof(packId), "invalid pack id"); + } + + AMPRPackRecord record = Packs[packId]; + return StringAt(record.NameOffset, record.NameLength); + } + + /// Serialize the manifest (Python build_manifest_bytes). + /// Manifest bytes. + /// The build id or a count is outside the format limits. + public byte[] Serialize() + { + if (BuildId.Length != AMPRPackFormat.BuildIdSize) + { + throw new ArgumentException("build id must contain 16 bytes"); + } + + if (Packs.Count > 0xFFFF) + { + throw new ArgumentException("pack count exceeds uint16 chunk-record limits"); + } + + long filesOffset = AMPRPackFormat.IndexHeaderSize; + long chunksOffset = checked(filesOffset + ((long)Files.Count * AMPRPackFormat.FileRecordSize)); + long packsOffset = checked(chunksOffset + ((long)Chunks.Count * AMPRPackFormat.ChunkRecordSize)); + long stringsOffset = checked(packsOffset + ((long)Packs.Count * AMPRPackFormat.PackRecordSize)); + byte[] data = new byte[checked((int)(stringsOffset + Strings.Length))]; + Span span = data; + for (int i = 0; i < Files.Count; i++) + { + Files[i].Write(span[(int)(filesOffset + ((long)i * AMPRPackFormat.FileRecordSize))..]); + } + + for (int i = 0; i < Chunks.Count; i++) + { + Chunks[i].Write(span[(int)(chunksOffset + ((long)i * AMPRPackFormat.ChunkRecordSize))..]); + } + + for (int i = 0; i < Packs.Count; i++) + { + Packs[i].Write(span[(int)(packsOffset + ((long)i * AMPRPackFormat.PackRecordSize))..]); + } + + Strings.CopyTo(span[(int)stringsOffset..]); + uint payloadCrc = Crc32.Update(0, span[AMPRPackFormat.IndexHeaderSize..]); + + Span header = span[..AMPRPackFormat.IndexHeaderSize]; + AMPRPackFormat.IndexMagic.CopyTo(header); + BinaryPrimitives.WriteUInt32LittleEndian(header[8..], AMPRPackFormat.IndexVersion); + BinaryPrimitives.WriteUInt32LittleEndian(header[12..], AMPRPackFormat.IndexHeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(header[16..], Flags); + BinaryPrimitives.WriteUInt32LittleEndian(header[20..], AMPRPackFormat.EndianMarker); + BuildId.CopyTo(header[24..]); + BinaryPrimitives.WriteUInt64LittleEndian(header[40..], (ulong)Files.Count); + BinaryPrimitives.WriteUInt64LittleEndian(header[48..], (ulong)Chunks.Count); + BinaryPrimitives.WriteUInt32LittleEndian(header[56..], (uint)Packs.Count); + BinaryPrimitives.WriteUInt32LittleEndian(header[60..], AMPRPackFormat.FileRecordSize); + BinaryPrimitives.WriteUInt32LittleEndian(header[64..], AMPRPackFormat.ChunkRecordSize); + BinaryPrimitives.WriteUInt32LittleEndian(header[68..], AMPRPackFormat.PackRecordSize); + BinaryPrimitives.WriteUInt64LittleEndian(header[72..], (ulong)filesOffset); + BinaryPrimitives.WriteUInt64LittleEndian(header[80..], (ulong)chunksOffset); + BinaryPrimitives.WriteUInt64LittleEndian(header[88..], (ulong)packsOffset); + BinaryPrimitives.WriteUInt64LittleEndian(header[96..], (ulong)stringsOffset); + BinaryPrimitives.WriteUInt64LittleEndian(header[104..], (ulong)Strings.Length); + BinaryPrimitives.WriteUInt32LittleEndian(header[112..], payloadCrc); + // header_crc (116) covers the header with itself zeroed; reserved (120) stays 0. + BinaryPrimitives.WriteUInt32LittleEndian(header[116..], Crc32.Update(0, header)); + return data; + } + + /// Load and validate a manifest file (Python load_manifest). + /// Manifest path. + /// The manifest. + /// The manifest is malformed. + public static AMPRPackManifest Load(string path) => Parse(File.ReadAllBytes(path), path); + + /// Parse and validate manifest bytes (Python load_manifest). + /// Manifest bytes. + /// Manifest path to record. + /// The manifest. + /// The manifest is malformed. + public static AMPRPackManifest Parse(ReadOnlySpan data, string? path = null) + { + if (data.Length < AMPRPackFormat.IndexHeaderSize) + { + throw new InvalidDataException("pack index is truncated"); + } + + ReadOnlySpan header = data[..AMPRPackFormat.IndexHeaderSize]; + uint version = BinaryPrimitives.ReadUInt32LittleEndian(header[8..]); + if (!header[..8].SequenceEqual(AMPRPackFormat.IndexMagic) || version != AMPRPackFormat.IndexVersion) + { + throw new InvalidDataException("unsupported AMPR pack index"); + } + + uint headerSize = BinaryPrimitives.ReadUInt32LittleEndian(header[12..]); + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(header[16..]); + uint endian = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + byte[] buildId = header.Slice(24, AMPRPackFormat.BuildIdSize).ToArray(); + ulong fileCount = BinaryPrimitives.ReadUInt64LittleEndian(header[40..]); + ulong chunkCount = BinaryPrimitives.ReadUInt64LittleEndian(header[48..]); + uint packCount = BinaryPrimitives.ReadUInt32LittleEndian(header[56..]); + uint fileRecordSize = BinaryPrimitives.ReadUInt32LittleEndian(header[60..]); + uint chunkRecordSize = BinaryPrimitives.ReadUInt32LittleEndian(header[64..]); + uint packRecordSize = BinaryPrimitives.ReadUInt32LittleEndian(header[68..]); + ulong filesOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[72..]); + ulong chunksOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[80..]); + ulong packsOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[88..]); + ulong stringsOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[96..]); + ulong stringsSize = BinaryPrimitives.ReadUInt64LittleEndian(header[104..]); + uint payloadCrc = BinaryPrimitives.ReadUInt32LittleEndian(header[112..]); + uint headerCrc = BinaryPrimitives.ReadUInt32LittleEndian(header[116..]); + ulong reserved = BinaryPrimitives.ReadUInt64LittleEndian(header[120..]); + if (headerSize != AMPRPackFormat.IndexHeaderSize + || endian != AMPRPackFormat.EndianMarker + || (flags & ~AMPRPackFormat.IndexKnownFlags) != 0 + || reserved != 0) + { + throw new InvalidDataException("invalid AMPR pack header"); + } + + if (fileRecordSize != AMPRPackFormat.FileRecordSize + || chunkRecordSize != AMPRPackFormat.ChunkRecordSize + || packRecordSize != AMPRPackFormat.PackRecordSize) + { + throw new InvalidDataException("unsupported AMPR pack record size"); + } + + byte[] headerCopy = header.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(headerCopy.AsSpan(116), 0); + if (Crc32.Update(0, headerCopy) != headerCrc) + { + throw new InvalidDataException("pack index header CRC mismatch"); + } + + if (Crc32.Update(0, data[AMPRPackFormat.IndexHeaderSize..]) != payloadCrc) + { + throw new InvalidDataException("pack index payload CRC mismatch"); + } + + // Python ints do not overflow; UInt128 keeps the layout check exact for any header values. + UInt128 expectedFiles = AMPRPackFormat.IndexHeaderSize; + UInt128 expectedChunks = expectedFiles + ((UInt128)fileCount * AMPRPackFormat.FileRecordSize); + UInt128 expectedPacks = expectedChunks + ((UInt128)chunkCount * AMPRPackFormat.ChunkRecordSize); + UInt128 expectedStrings = expectedPacks + ((UInt128)packCount * AMPRPackFormat.PackRecordSize); + if (filesOffset != expectedFiles + || chunksOffset != expectedChunks + || packsOffset != expectedPacks + || stringsOffset != expectedStrings + || (UInt128)stringsOffset + stringsSize != (UInt128)data.Length) + { + throw new InvalidDataException("invalid pack index section layout"); + } + + AMPRFileRecord[] files = new AMPRFileRecord[fileCount]; + for (int i = 0; i < files.Length; i++) + { + files[i] = AMPRFileRecord.Read(data[(int)(filesOffset + ((ulong)i * AMPRPackFormat.FileRecordSize))..]); + } + + AMPRChunkRecord[] chunks = new AMPRChunkRecord[chunkCount]; + for (int i = 0; i < chunks.Length; i++) + { + chunks[i] = AMPRChunkRecord.Read(data[(int)(chunksOffset + ((ulong)i * AMPRPackFormat.ChunkRecordSize))..]); + } + + // AMPRPAK4 derives the raw chunk size from the owning file's block geometry. + foreach (AMPRFileRecord file in files) + { + if (!file.IsPacked) + { + continue; + } + + // An invalid shift is reported by Validate; here it only has to stay larger than any file. + ulong blockSize = file.BlockShift < 63 ? 1UL << file.BlockShift : ulong.MaxValue; + for (ulong local = 0; local < file.ChunkCount; local++) + { + ulong chunkIndex = file.FirstChunk + local; + if (chunkIndex >= (ulong)chunks.Length) + { + throw new InvalidDataException("chunk range is invalid while decoding AMPRPAK4"); + } + + UInt128 blockBegin = (UInt128)local * blockSize; + if (blockBegin >= file.LogicalSize) + { + throw new InvalidDataException("AMPRPAK4 file has too many chunks"); + } + + ulong remaining = file.LogicalSize - (ulong)blockBegin; + chunks[chunkIndex] = chunks[chunkIndex] with { RawSize = (int)Math.Min(blockSize, Math.Min(remaining, int.MaxValue)) }; + } + } + + AMPRPackRecord[] packs = new AMPRPackRecord[packCount]; + for (int i = 0; i < packs.Length; i++) + { + packs[i] = AMPRPackRecord.Read(data[(int)(packsOffset + ((ulong)i * AMPRPackFormat.PackRecordSize))..]); + } + + AMPRPackManifest manifest = new(buildId, flags, files, chunks, packs, data[(int)stringsOffset..].ToArray(), path); + manifest.Validate(); + return manifest; + } + + /// Check every structural invariant the runtime relies on (Python validate_manifest). + /// An invariant is violated. + public void Validate() + { + if ((Flags & ~AMPRPackFormat.IndexKnownFlags) != 0) + { + throw new InvalidDataException("unknown pack index flags"); + } + + for (int packId = 0; packId < Packs.Count; packId++) + { + ValidatePack(packId, Packs[packId]); + } + + for (int fileId = 1; fileId <= Files.Count; fileId++) + { + ValidateFile(fileId, Files[fileId - 1]); + } + } + + private void ValidatePack(int packId, AMPRPackRecord record) + { + string name = StringAt(record.NameOffset, record.NameLength); + try + { + AMPRAssetPath.SafeOutputPath(".", name); + } + catch (ArgumentException exc) + { + throw new InvalidDataException($"unsafe pack name {packId}: {PythonText.Repr(name)}", exc); + } + + if ((record.Flags & ~AMPRPackFormat.PackKnownFlags) != 0) + { + throw new InvalidDataException($"unknown flags for pack {packId}"); + } + + uint page = record.IOPageSize; + if (page < 1U << AMPRPackFormat.MinIOPageShift + || page > 1U << AMPRPackFormat.MaxIOPageShift + || (page & (page - 1)) != 0 + || page % AMPRPackFormat.PhysicalChunkAlignment != 0) + { + throw new InvalidDataException($"invalid I/O page size for pack {packId}"); + } + + if ((record.Flags & AMPRPackFormat.PackFlagIOPageLayout) == 0) + { + throw new InvalidDataException($"pack {packId} does not declare page-aware layout"); + } + + if (record.FileSize < AMPRPackFormat.DataHeaderSize || record.PayloadBytes > record.FileSize) + { + throw new InvalidDataException($"invalid size for pack {packId}"); + } + + ulong payloadOffset = record.FileSize - record.PayloadBytes; + if (payloadOffset < AMPRPackFormat.DataHeaderSize || payloadOffset % page != 0 || record.FileSize % page != 0) + { + throw new InvalidDataException($"invalid payload offset for pack {packId}"); + } + } + + private void ValidateFile(int fileId, AMPRFileRecord record) + { + string path = StringAt(record.PathOffset, record.PathLength); + string canonical; + try + { + canonical = AMPRAssetPath.Canonical(path); + } + catch (ArgumentException exc) + { + throw new InvalidDataException(exc.Message, exc); + } + + if (canonical != path || canonical == "/app0") + { + throw new InvalidDataException($"non-canonical path for file id {fileId}"); + } + + if (record.Reserved != 0 || (record.Flags & ~AMPRPackFormat.FileKnownFlags) != 0) + { + throw new InvalidDataException($"unknown flags for file id {fileId}"); + } + + if (AMPRAssetPath.Hash(canonical) != record.PathHash) + { + throw new InvalidDataException($"path hash mismatch for file id {fileId}"); + } + + if (!record.IsPacked) + { + if (record.FirstChunk != 0 || record.ChunkCount != 0 || record.BlockShift != 0 || record.PackingClass != 0 || record.Flags != 0) + { + throw new InvalidDataException($"loose file id {fileId} references chunks"); + } + + return; + } + + if (record.BlockShift is < AMPRPackFormat.MinBlockShift or > AMPRPackFormat.MaxBlockShift) + { + throw new InvalidDataException($"invalid block shift for file id {fileId}"); + } + + bool streaming = (record.Flags & AMPRPackFormat.FileFlagStreaming) != 0; + if (streaming && (record.Flags & AMPRPackFormat.FileFlagRandomAccess) != 0) + { + throw new InvalidDataException($"file id {fileId} cannot be both streaming and random-access"); + } + + if ((ulong)record.FirstChunk + record.ChunkCount > (ulong)Chunks.Count) + { + throw new InvalidDataException($"chunk range is invalid for file id {fileId}"); + } + + ulong total = 0; + int blockSize = 1 << record.BlockShift; + for (uint local = 0; local < record.ChunkCount; local++) + { + AMPRChunkRecord chunk = Chunks[(int)(record.FirstChunk + local)]; + ValidateChunk(fileId, record, chunk, local, blockSize, streaming); + total += (ulong)chunk.RawSize; + } + + if (total != record.LogicalSize) + { + throw new InvalidDataException($"logical size mismatch for file id {fileId}"); + } + } + + private void ValidateChunk(int fileId, AMPRFileRecord record, AMPRChunkRecord chunk, uint local, int blockSize, bool streaming) + { + if (chunk.RawSize == 0 || chunk.RawSize > blockSize) + { + throw new InvalidDataException($"invalid raw chunk size for file id {fileId}"); + } + + if (chunk.StoredSize == 0 || chunk.StoredSize > blockSize) + { + throw new InvalidDataException($"invalid stored chunk size for file id {fileId}"); + } + + if (local + 1 != record.ChunkCount && chunk.RawSize != blockSize) + { + throw new InvalidDataException($"short non-final chunk for file id {fileId}"); + } + + if (chunk.Codec is not (AMPRPackFormat.ChunkCodecRaw or AMPRPackFormat.ChunkCodecLZ4)) + { + throw new InvalidDataException($"unknown chunk codec for file id {fileId}"); + } + + if (chunk.Codec == AMPRPackFormat.ChunkCodecRaw && chunk.StoredSize != chunk.RawSize) + { + throw new InvalidDataException($"raw chunk size mismatch for file id {fileId}"); + } + + if (chunk.PackId >= Packs.Count) + { + throw new InvalidDataException($"invalid pack id for file id {fileId}"); + } + + if ((chunk.Flags & ~AMPRPackFormat.ChunkKnownFlags) != 0) + { + throw new InvalidDataException($"invalid chunk flags/reserved for file id {fileId}"); + } + + if (streaming != ((chunk.Flags & AMPRPackFormat.ChunkFlagStreaming) != 0)) + { + throw new InvalidDataException($"streaming flag mismatch for file id {fileId}"); + } + + if ((record.Flags & AMPRPackFormat.FileFlagStoreOnly) != 0 && chunk.Codec != AMPRPackFormat.ChunkCodecRaw) + { + throw new InvalidDataException($"store-only file id {fileId} contains a compressed chunk"); + } + + AMPRPackRecord pack = Packs[chunk.PackId]; + ulong payloadOffset = pack.FileSize - pack.PayloadBytes; + ulong end = chunk.Offset + (ulong)chunk.StoredSize; + if (chunk.Offset < payloadOffset || end > pack.FileSize || chunk.Offset % AMPRPackFormat.PhysicalChunkAlignment != 0) + { + throw new InvalidDataException($"chunk range/alignment is invalid for file id {fileId}"); + } + + ulong page = pack.IOPageSize; + ulong pageBegin = AMPRPackFormat.AlignDown(chunk.Offset, page); + ulong pageEnd = AMPRPackFormat.AlignUp(end, page); + bool pageContained = (chunk.Flags & AMPRPackFormat.ChunkFlagPageContained) != 0; + bool pageAligned = (chunk.Flags & AMPRPackFormat.ChunkFlagPageAligned) != 0; + if (pageContained && ((ulong)chunk.StoredSize > page || pageBegin != AMPRPackFormat.AlignDown(end - 1, page))) + { + throw new InvalidDataException($"page-contained chunk crosses an I/O page for file id {fileId}"); + } + + if (pageAligned && chunk.Offset % page != 0) + { + throw new InvalidDataException($"page-aligned chunk is not page aligned for file id {fileId}"); + } + + bool pageSafe = pageContained || pageAligned; + if ((ulong)chunk.StoredSize <= page && pageAligned && !pageContained) + { + // Small page-aligned extents are also page-contained; requiring both bits catches packers + // that omit the stronger invariant used by the random-access planner. + throw new InvalidDataException($"small page-aligned chunk lacks containment flag for file id {fileId}"); + } + + if ((record.Flags & AMPRPackFormat.FileFlagRandomAccess) != 0 && !pageSafe) + { + throw new InvalidDataException($"random-access file uses a non-page-safe chunk for file id {fileId}"); + } + + if (!streaming && !pageSafe) + { + throw new InvalidDataException($"non-streaming file uses a non-page-safe chunk for file id {fileId}"); + } + + if (pageBegin < payloadOffset || pageEnd > pack.FileSize) + { + throw new InvalidDataException($"aligned I/O range is outside the pack for file id {fileId}"); + } + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackReader.cs b/src/MkPFS.Core/AMPR/AMPRPackReader.cs new file mode 100644 index 0000000..621c1da --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackReader.cs @@ -0,0 +1,188 @@ +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// Reads and checks decoded chunks of a pack set: opens every volume, validates its header against the manifest, +/// and verifies each decoded chunk against the CRC sidecar (Python PackReader). +/// +public sealed class AMPRPackReader : IDisposable +{ + private readonly FileStream[] _volumes; + private readonly (long Begin, long End)[] _payloads; + + /// Open the volumes and CRC sidecar of a loaded manifest. + /// Manifest loaded from disk ( set). + /// The sidecar or a volume is missing or does not match. + /// A volume header is malformed. + public AMPRPackReader(AMPRPackManifest manifest) + { + Manifest = manifest; + string indexPath = manifest.Path ?? throw new ArgumentException("manifest has no path", nameof(manifest)); + try + { + ChunkCrcs = AMPRChunkCrcs.Load(AMPRPackFormat.ChunkCrcPath(indexPath), manifest.BuildId, manifest.Chunks.Count); + } + catch (Exception exc) when (exc is IOException or UnauthorizedAccessException or InvalidDataException) + { + throw new AMPRPackException($"invalid or missing chunk CRC sidecar: {exc.Message}", exc); + } + + string directory = Path.GetDirectoryName(Path.GetFullPath(indexPath))!; + List opened = []; + _payloads = new (long, long)[manifest.Packs.Count]; + try + { + for (int packId = 0; packId < manifest.Packs.Count; packId++) + { + AMPRPackRecord record = manifest.Packs[packId]; + string name = manifest.PackName(packId); + FileStream stream = File.OpenRead(AMPRAssetPath.SafeOutputPath(directory, name)); + opened.Add(stream); + byte[] header = new byte[AMPRPackFormat.DataHeaderSize]; + int read = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false); + AMPRDataHeader parsed = AMPRDataHeader.Parse(header.AsSpan(0, read), (uint)packId, manifest.BuildId, record.Flags); + if ((ulong)stream.Length != record.FileSize) + { + throw new AMPRPackException($"pack size mismatch: {name}"); + } + + if (parsed.PayloadBytes != record.PayloadBytes) + { + throw new AMPRPackException($"pack payload size mismatch: {name}"); + } + + if (parsed.PayloadOffset + parsed.PayloadBytes != (ulong)stream.Length) + { + throw new AMPRPackException($"pack payload range mismatch: {name}"); + } + + _payloads[packId] = ((long)parsed.PayloadOffset, (long)(parsed.PayloadOffset + parsed.PayloadBytes)); + } + } + catch + { + foreach (FileStream stream in opened) + { + stream.Dispose(); + } + + throw; + } + + _volumes = [.. opened]; + } + + /// The manifest. + public AMPRPackManifest Manifest { get; } + + /// Decoded CRC-32 per chunk. + public uint[] ChunkCrcs { get; } + + /// Read, decode and CRC-check one chunk (Python read_chunk). + /// Chunk index. + /// Decoded bytes. + /// The chunk is out of range, truncated, undecodable or fails its CRC. + public byte[] ReadChunk(int chunkIndex) + { + if (chunkIndex < 0 || chunkIndex >= ChunkCrcs.Length) + { + throw new AMPRPackException("chunk CRC index is out of range"); + } + + AMPRChunkRecord chunk = Manifest.Chunks[chunkIndex]; + if (chunk.PackId >= _volumes.Length) + { + throw new AMPRPackException("chunk references an invalid pack"); + } + + (long begin, long end) = _payloads[chunk.PackId]; + if ((long)chunk.Offset < begin || (long)chunk.Offset + chunk.StoredSize > end) + { + throw new AMPRPackException("chunk is outside pack payload"); + } + + FileStream volume = _volumes[chunk.PackId]; + volume.Seek((long)chunk.Offset, SeekOrigin.Begin); + byte[] stored = new byte[chunk.StoredSize]; + if (volume.ReadAtLeast(stored, stored.Length, throwOnEndOfStream: false) != stored.Length) + { + throw new AMPRPackException("chunk is truncated"); + } + + byte[] raw; + if (chunk.Codec == AMPRPackFormat.ChunkCodecRaw) + { + raw = stored; + } + else if (chunk.Codec == AMPRPackFormat.ChunkCodecLZ4) + { + raw = new byte[chunk.RawSize]; + try + { + if (LZ4Codec.Decompress(stored, raw) != raw.Length) + { + throw new InvalidDataException("LZ4 output size mismatch"); + } + } + catch (InvalidDataException exc) + { + throw new AMPRPackException("LZ4 chunk decompression failed", exc); + } + } + else + { + throw new AMPRPackException($"unsupported chunk codec: {chunk.Codec}"); + } + + return raw.Length == chunk.RawSize && Crc32.Update(0, raw) == ChunkCrcs[chunkIndex] + ? raw + : throw new AMPRPackException("raw chunk CRC/size mismatch"); + } + + /// Decoded chunks of a packed file in order (Python iter_file_data). + /// File id. + /// Decoded chunks. + /// The file id is invalid or loose, or the data does not add up. + public IEnumerable ReadFile(int fileId) + { + if (fileId <= 0 || fileId > Manifest.Files.Count) + { + throw new AMPRPackException($"invalid file id: {fileId}"); + } + + AMPRFileRecord record = Manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + throw new AMPRPackException($"file id {fileId} is loose and has no packed data"); + } + + return Iterate(record, fileId); + } + + /// + public void Dispose() + { + foreach (FileStream volume in _volumes) + { + volume.Dispose(); + } + } + + private IEnumerable Iterate(AMPRFileRecord record, int fileId) + { + ulong total = 0; + for (uint local = 0; local < record.ChunkCount; local++) + { + byte[] raw = ReadChunk((int)(record.FirstChunk + local)); + total += (ulong)raw.Length; + yield return raw; + } + + if (total != record.LogicalSize) + { + throw new AMPRPackException($"file id {fileId} logical size mismatch"); + } + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackRecords.cs b/src/MkPFS.Core/AMPR/AMPRPackRecords.cs new file mode 100644 index 0000000..35c0515 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackRecords.cs @@ -0,0 +1,181 @@ +using System.Buffers.Binary; + +namespace MkPFS.Core.AMPR; + +/// +/// AMPRPAK4 file record (48 bytes, <QQqIIIIIBBH). One per AMPRIDX3 file, so +/// fileId == recordIndex + 1. Loose files have no chunks and zero flags. +/// +/// FNV-1a 64 of the ASCII-folded canonical path. +/// Decoded file size. +/// Modification time, Unix seconds. +/// Index of the first chunk record. +/// Number of chunk records. +/// Path offset in the string table. +/// Path length in bytes (without NUL). +/// FileFlag* bits. +/// log2 of the block size (0 for loose files). +/// Index of the packing group in sorted group order. +/// Must be 0. +public readonly record struct AMPRFileRecord( + ulong PathHash, + ulong LogicalSize, + long MTime, + uint FirstChunk, + uint ChunkCount, + uint PathOffset, + uint PathLength, + uint Flags, + byte BlockShift, + byte PackingClass = 0, + ushort Reserved = 0) +{ + /// Whether the file is stored in pack volumes. + public bool IsPacked => (Flags & AMPRPackFormat.FileFlagPacked) != 0; + + /// Write the record. + /// At least 48 bytes. + public void Write(Span destination) + { + BinaryPrimitives.WriteUInt64LittleEndian(destination, PathHash); + BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], LogicalSize); + BinaryPrimitives.WriteInt64LittleEndian(destination[16..], MTime); + BinaryPrimitives.WriteUInt32LittleEndian(destination[24..], FirstChunk); + BinaryPrimitives.WriteUInt32LittleEndian(destination[28..], ChunkCount); + BinaryPrimitives.WriteUInt32LittleEndian(destination[32..], PathOffset); + BinaryPrimitives.WriteUInt32LittleEndian(destination[36..], PathLength); + BinaryPrimitives.WriteUInt32LittleEndian(destination[40..], Flags); + destination[44] = BlockShift; + destination[45] = PackingClass; + BinaryPrimitives.WriteUInt16LittleEndian(destination[46..], Reserved); + } + + /// Read a record. + /// At least 48 bytes. + /// The record. + public static AMPRFileRecord Read(ReadOnlySpan source) => new( + BinaryPrimitives.ReadUInt64LittleEndian(source), + BinaryPrimitives.ReadUInt64LittleEndian(source[8..]), + BinaryPrimitives.ReadInt64LittleEndian(source[16..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[24..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[28..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[32..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[36..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[40..]), + source[44], + source[45], + BinaryPrimitives.ReadUInt16LittleEndian(source[46..])); +} + +/// +/// AMPRPAK4 chunk record (12 bytes, <QI): a 64-bit location (48-bit offset, 16-bit pack id) and a +/// 32-bit descriptor (20-bit stored_size - 1, 2-bit codec, 8-bit flags). The decoded size is not stored; +/// derives from the owning file's block geometry. +/// +/// Absolute offset in the pack volume. +/// Stored bytes (1 .. 1 MiB). +/// Decoded bytes (0 until derived). +/// Pack volume index. +/// ChunkCodecRaw or ChunkCodecLZ4. +/// ChunkFlag* bits. +public readonly record struct AMPRChunkRecord(ulong Offset, int StoredSize, int RawSize, int PackId, int Codec, int Flags = 0) +{ + /// Write the record (Python ChunkRecord.pack). + /// At least 12 bytes. + /// A field is outside its bit domain. + public void Write(Span destination) + { + if (Offset > AMPRPackFormat.ChunkOffsetMask) + { + throw new ArgumentException("chunk offset exceeds the 48-bit AMPRPAK4 domain"); + } + + if (PackId is < 0 or > 0xFFFF) + { + throw new ArgumentException("chunk pack id exceeds uint16"); + } + + if (StoredSize < 1 || StoredSize > 1 << AMPRPackFormat.MaxBlockShift) + { + throw new ArgumentException("chunk stored size is outside the AMPRPAK4 domain"); + } + + if (Codec < 0 || Codec > AMPRPackFormat.ChunkCodecMask) + { + throw new ArgumentException("chunk codec is outside the compact descriptor domain"); + } + + if ((Flags & ~AMPRPackFormat.ChunkKnownFlags) != 0) + { + throw new ArgumentException("chunk contains unknown flags"); + } + + ulong location = Offset | ((ulong)PackId << 48); + uint descriptor = (uint)(StoredSize - 1) + | ((uint)Codec << AMPRPackFormat.ChunkCodecShift) + | ((uint)Flags << AMPRPackFormat.ChunkFlagsShift); + BinaryPrimitives.WriteUInt64LittleEndian(destination, location); + BinaryPrimitives.WriteUInt32LittleEndian(destination[8..], descriptor); + } + + /// Read a record; is 0 (Python ChunkRecord.unpack_from). + /// At least 12 bytes. + /// The record. + /// Reserved descriptor bits are set. + public static AMPRChunkRecord Read(ReadOnlySpan source) + { + ulong location = BinaryPrimitives.ReadUInt64LittleEndian(source); + uint descriptor = BinaryPrimitives.ReadUInt32LittleEndian(source[8..]); + if ((descriptor & ~AMPRPackFormat.ChunkDescriptorKnownMask) != 0) + { + throw new InvalidDataException("chunk descriptor contains reserved bits"); + } + + return new AMPRChunkRecord( + location & AMPRPackFormat.ChunkOffsetMask, + (int)(descriptor & AMPRPackFormat.ChunkStoredMask) + 1, + 0, + (int)(location >> 48), + (int)((descriptor >> AMPRPackFormat.ChunkCodecShift) & AMPRPackFormat.ChunkCodecMask), + (int)((descriptor >> AMPRPackFormat.ChunkFlagsShift) & AMPRPackFormat.ChunkFlagsMask)); + } +} + +/// AMPRPAK4 pack (data volume) record (32 bytes, <QQIIII). +/// Payload bytes at the end of the volume. +/// Volume file size. +/// Volume file name offset in the string table. +/// Volume file name length in bytes. +/// PackFlag* bits. +/// Filesystem I/O page size the layout was planned for. +public readonly record struct AMPRPackRecord( + ulong PayloadBytes, + ulong FileSize, + uint NameOffset, + uint NameLength, + uint Flags, + uint IOPageSize) +{ + /// Write the record. + /// At least 32 bytes. + public void Write(Span destination) + { + BinaryPrimitives.WriteUInt64LittleEndian(destination, PayloadBytes); + BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], FileSize); + BinaryPrimitives.WriteUInt32LittleEndian(destination[16..], NameOffset); + BinaryPrimitives.WriteUInt32LittleEndian(destination[20..], NameLength); + BinaryPrimitives.WriteUInt32LittleEndian(destination[24..], Flags); + BinaryPrimitives.WriteUInt32LittleEndian(destination[28..], IOPageSize); + } + + /// Read a record. + /// At least 32 bytes. + /// The record. + public static AMPRPackRecord Read(ReadOnlySpan source) => new( + BinaryPrimitives.ReadUInt64LittleEndian(source), + BinaryPrimitives.ReadUInt64LittleEndian(source[8..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[16..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[20..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[24..]), + BinaryPrimitives.ReadUInt32LittleEndian(source[28..])); +} diff --git a/src/MkPFS.Core/AMPR/AMPRPackTools.cs b/src/MkPFS.Core/AMPR/AMPRPackTools.cs new file mode 100644 index 0000000..a19a2df --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRPackTools.cs @@ -0,0 +1,360 @@ +namespace MkPFS.Core.AMPR; + +/// Result of . +/// Packed files checked. +/// Distinct physical chunks decoded. +/// Stored bytes of those chunks. +/// Decoded bytes of those chunks. +public sealed record AMPRVerifyResult(long Files, long PhysicalChunks, long StoredBytes, long RawBytes); + +/// Result of . +/// Files compared. +/// Chunks compared. +/// Bytes compared. +public sealed record AMPRSourceCompareResult(long Files, long Chunks, long Bytes); + +/// Result of . +/// Files written. +/// Bytes written. +public sealed record AMPRExtractResult(long Files, long Bytes); + +/// One row of . +/// File id. +/// Canonical /app0 path. +/// Stored in volumes. +/// Decoded size. +/// Sum of stored chunk sizes (shared chunks counted per reference). +/// Block size, 0 for loose files. +/// Chunk records. +/// lz4/raw, sorted. +/// Pack ids, sorted. +/// I/O page sizes of those packs, sorted. +/// loose, streaming, random or mixed. +/// Streaming flag. +/// Random-access flag. +/// Hot flag. +public sealed record AMPRListEntry( + int FileId, + string Path, + bool Packed, + ulong LogicalSize, + long StoredSize, + long BlockSize, + uint Chunks, + IReadOnlyList Codecs, + IReadOnlyList Packs, + IReadOnlyList IOPageSizes, + string Layout, + bool Streaming, + bool RandomAccess, + bool Hot); + +/// One volume in . +/// Pack id. +/// Volume name. +/// Volume size. +/// Payload bytes. +/// I/O page size. +/// Volume size in pages. +/// Pack flags. +public sealed record AMPRInspectPack(int Id, string Name, ulong FileSize, ulong PayloadBytes, uint IOPageSize, ulong IOPages, uint Flags); + +/// Result of . +/// Build id, lower-case hex. +/// Runtime settings, if present. +/// Files. +/// Packed files. +/// Loose files. +/// Chunk records. +/// Volumes. +public sealed record AMPRInspectResult( + string BuildId, AMPRRuntimeSettings? Runtime, long Files, long PackedFiles, long LooseFiles, long Chunks, IReadOnlyList Packs); + +/// Offline pack-set tools (ampr_pack verify, unpack, list, inspect). +public static class AMPRPackTools +{ + /// File ids whose relative path matches any pattern; all ids without patterns (Python _selected_file_ids). + /// Manifest. + /// Globs over paths relative to /app0. + /// File ids in order. + public static List SelectedFileIds(AMPRPackManifest manifest, IReadOnlyList patterns) + { + List selected = []; + for (int fileId = 1; fileId <= manifest.Files.Count; fileId++) + { + if (patterns.Count == 0 || AMPRGlob.Matches(AMPRAssetPath.Relative(manifest.FilePath(fileId)), patterns)) + { + selected.Add(fileId); + } + } + + return selected; + } + + /// + /// Decode every selected packed file once per physical chunk and check sizes and CRCs; also validates the + /// runtime settings (Python verify_packs). + /// + /// Manifest path. + /// File globs. + /// Counters. + public static AMPRVerifyResult Verify(string indexPath, IReadOnlyList? patterns = null) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + AMPRRuntimeSettings.Read(AMPRPackFormat.RuntimePath(indexPath), manifest.BuildId); + Dictionary<(int Pack, ulong Offset, int Stored), (int Codec, int Raw, uint Crc)> seen = []; + long files = 0; + long chunks = 0; + long stored = 0; + long raw = 0; + using AMPRPackReader reader = new(manifest); + foreach (int fileId in SelectedFileIds(manifest, patterns ?? [])) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + ulong total = 0; + for (uint local = 0; local < record.ChunkCount; local++) + { + int chunkIndex = (int)(record.FirstChunk + local); + AMPRChunkRecord chunk = manifest.Chunks[chunkIndex]; + (int, ulong, int) physical = (chunk.PackId, chunk.Offset, chunk.StoredSize); + (int, int, uint) descriptor = (chunk.Codec, chunk.RawSize, reader.ChunkCrcs[chunkIndex]); + if (seen.TryGetValue(physical, out (int, int, uint) prior)) + { + if (prior != descriptor) + { + throw new AMPRPackException("one physical chunk has conflicting metadata"); + } + + total += (ulong)chunk.RawSize; + continue; + } + + total += (ulong)reader.ReadChunk(chunkIndex).Length; + seen[physical] = descriptor; + chunks++; + stored += chunk.StoredSize; + raw += chunk.RawSize; + } + + if (total != record.LogicalSize) + { + throw new AMPRPackException($"logical size mismatch for file id {fileId}"); + } + + files++; + } + + return new AMPRVerifyResult(files, chunks, stored, raw); + } + + /// Rebuild every selected packed file and compare it byte for byte with the source tree (Python verify_packs_against_root). + /// Manifest path. + /// /app0 source tree. + /// File globs. + /// Counters. + public static AMPRSourceCompareResult VerifyAgainstRoot(string indexPath, string root, IReadOnlyList? patterns = null) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + root = Path.GetFullPath(root); + long files = 0; + long chunks = 0; + long bytes = 0; + using AMPRPackReader reader = new(manifest); + foreach (int fileId in SelectedFileIds(manifest, patterns ?? [])) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + string relative = AMPRAssetPath.Relative(manifest.FilePath(fileId)); + string sourcePath = AMPRAssetPath.SafeOutputPath(root, relative); + FileInfo info = new(sourcePath); + if (!info.Exists) + { + throw new AMPRPackException($"source file missing while comparing packed data: {relative}"); + } + + if ((ulong)info.Length != record.LogicalSize) + { + throw new AMPRPackException($"source size mismatch for {relative}: manifest={record.LogicalSize}, disk={info.Length}"); + } + + long logicalOffset = 0; + using (FileStream source = File.OpenRead(sourcePath)) + { + int local = 0; + foreach (byte[] packed in reader.ReadFile(fileId)) + { + byte[] sourceRaw = new byte[packed.Length]; + if (source.ReadAtLeast(sourceRaw, sourceRaw.Length, throwOnEndOfStream: false) != sourceRaw.Length) + { + throw new AMPRPackException($"source truncated for {relative} at 0x{logicalOffset:x}"); + } + + int mismatch = sourceRaw.AsSpan().CommonPrefixLength(packed); + if (mismatch < packed.Length) + { + AMPRChunkRecord chunk = manifest.Chunks[(int)record.FirstChunk + local]; + throw new AMPRPackException( + $"packed/source mismatch for {relative}: logical=0x{logicalOffset + mismatch:x}, chunk={local}, " + + $"pack={chunk.PackId}, physical=0x{chunk.Offset:x}, source=0x{sourceRaw[mismatch]:x2}, packed=0x{packed[mismatch]:x2}"); + } + + logicalOffset += packed.Length; + bytes += packed.Length; + chunks++; + local++; + } + + if (source.ReadByte() != -1) + { + throw new AMPRPackException($"source grew while comparing: {relative}"); + } + } + + if ((ulong)logicalOffset != record.LogicalSize) + { + throw new AMPRPackException($"reconstructed size mismatch for {relative}: expected={record.LogicalSize}, got={logicalOffset}"); + } + + files++; + } + + return new AMPRSourceCompareResult(files, chunks, bytes); + } + + /// Write selected packed files under (Python extract_packs). + /// Manifest path. + /// Output directory. + /// File globs. + /// Replace existing files. + /// Set each file's modification time from the manifest. + /// Counters. + public static AMPRExtractResult Extract( + string indexPath, string outputDir, IReadOnlyList? patterns = null, bool overwrite = false, bool preserveMTime = true) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + Directory.CreateDirectory(outputDir); + long files = 0; + long bytes = 0; + using AMPRPackReader reader = new(manifest); + foreach (int fileId in SelectedFileIds(manifest, patterns ?? [])) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + string destination = AMPRAssetPath.SafeOutputPath(outputDir, AMPRAssetPath.Relative(manifest.FilePath(fileId))); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + if ((File.Exists(destination) || Directory.Exists(destination)) && !overwrite) + { + throw new AMPRPackException($"destination exists: {destination}"); + } + + string temp = Path.Combine(Path.GetDirectoryName(destination)!, $".{Path.GetFileName(destination)}.tmp-{Environment.ProcessId}"); + try + { + using (FileStream handle = new(temp, FileMode.Create, FileAccess.Write, FileShare.None)) + { + foreach (byte[] raw in reader.ReadFile(fileId)) + { + handle.Write(raw); + bytes += raw.Length; + } + + handle.Flush(flushToDisk: true); + } + + File.Move(temp, destination, overwrite: true); + if (preserveMTime) + { + DateTime time = DateTime.UnixEpoch.AddSeconds(record.MTime); + File.SetLastWriteTimeUtc(destination, time); + File.SetLastAccessTimeUtc(destination, time); + } + + files++; + } + finally + { + File.Delete(temp); + } + } + + return new AMPRExtractResult(files, bytes); + } + + /// Per-file placement summary (Python list_manifest). + /// Manifest path. + /// File globs. + /// Rows. + public static List List(string indexPath, IReadOnlyList? patterns = null) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + List rows = []; + foreach (int fileId in SelectedFileIds(manifest, patterns ?? [])) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + long stored = 0; + SortedSet codecs = new(StringComparer.Ordinal); + SortedSet packs = []; + for (uint local = 0; local < record.ChunkCount; local++) + { + AMPRChunkRecord chunk = manifest.Chunks[(int)(record.FirstChunk + local)]; + stored += chunk.StoredSize; + codecs.Add(chunk.Codec == AMPRPackFormat.ChunkCodecLZ4 ? "lz4" : "raw"); + packs.Add(chunk.PackId); + } + + bool streaming = (record.Flags & AMPRPackFormat.FileFlagStreaming) != 0; + bool randomAccess = (record.Flags & AMPRPackFormat.FileFlagRandomAccess) != 0; + string layout = !record.IsPacked ? "loose" : streaming ? "streaming" : randomAccess ? "random" : "mixed"; + rows.Add(new AMPRListEntry( + fileId, + manifest.FilePath(fileId), + record.IsPacked, + record.LogicalSize, + stored, + record.BlockShift != 0 ? 1L << record.BlockShift : 0, + record.ChunkCount, + [.. codecs], + [.. packs], + [.. packs.Select(id => manifest.Packs[id].IOPageSize).Distinct().Order()], + layout, + streaming, + randomAccess, + (record.Flags & AMPRPackFormat.FileFlagHot) != 0)); + } + + return rows; + } + + /// Manifest-level summary, validating the runtime settings (CLI inspect). + /// Manifest path. + /// Summary. + public static AMPRInspectResult Inspect(string indexPath) + { + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + AMPRRuntimeSettings? runtime = AMPRRuntimeSettings.Read(AMPRPackFormat.RuntimePath(indexPath), manifest.BuildId); + long packed = manifest.Files.Count(record => record.IsPacked); + List packs = []; + for (int packId = 0; packId < manifest.Packs.Count; packId++) + { + AMPRPackRecord record = manifest.Packs[packId]; + packs.Add(new AMPRInspectPack( + packId, manifest.PackName(packId), record.FileSize, record.PayloadBytes, record.IOPageSize, record.FileSize / record.IOPageSize, record.Flags)); + } + + return new AMPRInspectResult( + Convert.ToHexStringLower(manifest.BuildId), runtime, manifest.Files.Count, packed, manifest.Files.Count - packed, manifest.Chunks.Count, packs); + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRRuntimeSettings.cs b/src/MkPFS.Core/AMPR/AMPRRuntimeSettings.cs new file mode 100644 index 0000000..edc5c81 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRRuntimeSettings.cs @@ -0,0 +1,128 @@ +using System.Buffers.Binary; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// +/// Per-title runtime settings (<manifest>.runtime, AMPRCFG1, 64 bytes, <8sII16sQQIIII) bound to +/// a manifest build id (Python RuntimeSettings, read_runtime_settings). +/// +/// Decoded-block cache size. +/// Physical-page cache size. +/// Decode workers, 1..16. +/// Workers reserved for latency-class requests, below . +public sealed record AMPRRuntimeSettings(long DecodedCacheBytes, long PhysicalCacheBytes, long Workers, long LatencyReserveWorkers) +{ + /// Check the value ranges. + /// A value is out of range. + public void Validate() + { + if (Workers < 1 || Workers > 16 || LatencyReserveWorkers < 0 || LatencyReserveWorkers >= Workers) + { + throw new ArgumentException("runtime workers must be 1..16 and reserve must be smaller"); + } + + foreach (long value in (ReadOnlySpan)[DecodedCacheBytes, PhysicalCacheBytes]) + { + if (value < 0 || value % 16384 != 0) + { + throw new ArgumentException("runtime cache sizes must be nonnegative 16 KiB multiples"); + } + } + } + + /// Serialize for a manifest build id. + /// Manifest build id. + /// 64 bytes. + /// A value is out of range or the build id is not 16 bytes. + public byte[] Encode(ReadOnlySpan buildId) + { + Validate(); + if (buildId.Length != AMPRPackFormat.BuildIdSize) + { + throw new ArgumentException("runtime profile needs a 16-byte build ID"); + } + + byte[] data = new byte[AMPRPackFormat.RuntimeSize]; + Span span = data; + AMPRPackFormat.RuntimeMagic.CopyTo(span); + BinaryPrimitives.WriteUInt32LittleEndian(span[8..], AMPRPackFormat.RuntimeVersion); + BinaryPrimitives.WriteUInt32LittleEndian(span[12..], AMPRPackFormat.RuntimeSize); + buildId.CopyTo(span[16..]); + BinaryPrimitives.WriteUInt64LittleEndian(span[32..], (ulong)DecodedCacheBytes); + BinaryPrimitives.WriteUInt64LittleEndian(span[40..], (ulong)PhysicalCacheBytes); + BinaryPrimitives.WriteUInt32LittleEndian(span[48..], (uint)Workers); + BinaryPrimitives.WriteUInt32LittleEndian(span[52..], (uint)LatencyReserveWorkers); + BinaryPrimitives.WriteUInt32LittleEndian(span[56..], Crc32.Update(0, span)); + return data; + } + + /// Read settings bound to ; when the file does not exist. + /// Settings path. + /// Manifest build id. + /// Settings, or . + /// The file is malformed or belongs to another build. + public static AMPRRuntimeSettings? Read(string path, ReadOnlySpan buildId) + { + byte[] data; + try + { + data = File.ReadAllBytes(path); + } + catch (FileNotFoundException) + { + return null; + } + + return Parse(data, buildId); + } + + /// Parse settings bytes bound to . + /// Settings bytes. + /// Manifest build id. + /// Settings. + /// The bytes are malformed or belong to another build. + public static AMPRRuntimeSettings Parse(ReadOnlySpan data, ReadOnlySpan buildId) + { + if (data.Length != AMPRPackFormat.RuntimeSize) + { + throw new InvalidDataException("invalid runtime profile size"); + } + + byte[] copy = data.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(copy.AsSpan(56), 0); + if (!data[..8].SequenceEqual(AMPRPackFormat.RuntimeMagic) + || BinaryPrimitives.ReadUInt32LittleEndian(data[8..]) != AMPRPackFormat.RuntimeVersion + || BinaryPrimitives.ReadUInt32LittleEndian(data[12..]) != AMPRPackFormat.RuntimeSize + || !data.Slice(16, AMPRPackFormat.BuildIdSize).SequenceEqual(buildId) + || BinaryPrimitives.ReadUInt32LittleEndian(data[60..]) != 0 + || Crc32.Update(0, copy) != BinaryPrimitives.ReadUInt32LittleEndian(data[56..])) + { + throw new InvalidDataException("invalid runtime profile header, build ID or CRC"); + } + + ulong decoded = BinaryPrimitives.ReadUInt64LittleEndian(data[32..]); + ulong physical = BinaryPrimitives.ReadUInt64LittleEndian(data[40..]); + if (decoded > long.MaxValue || physical > long.MaxValue) + { + // Python accepts values below 2**64; no 16 KiB-aligned cache that large is meaningful. + throw new InvalidDataException("runtime cache sizes must be nonnegative 16 KiB multiples"); + } + + AMPRRuntimeSettings result = new( + (long)decoded, + (long)physical, + BinaryPrimitives.ReadUInt32LittleEndian(data[48..]), + BinaryPrimitives.ReadUInt32LittleEndian(data[52..])); + try + { + result.Validate(); + } + catch (ArgumentException exc) + { + throw new InvalidDataException(exc.Message, exc); + } + + return result; + } +} diff --git a/src/MkPFS.Core/AMPR/AMPRSize.cs b/src/MkPFS.Core/AMPR/AMPRSize.cs new file mode 100644 index 0000000..f7f8f29 --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRSize.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using MkPFS.Core.Util; + +namespace MkPFS.Core.AMPR; + +/// Size strings such as 64KiB or 1.5MB (Python parse_size, block_shift). +public static class AMPRSize +{ + private static readonly Dictionary Units = new(StringComparer.Ordinal) + { + ["b"] = 1, + ["k"] = 1000, + ["kb"] = 1000, + ["kib"] = 1024, + ["m"] = 1000L * 1000, + ["mb"] = 1000L * 1000, + ["mib"] = 1024L * 1024, + ["g"] = 1000L * 1000 * 1000, + ["gb"] = 1000L * 1000 * 1000, + ["gib"] = 1024L * 1024 * 1024, + ["t"] = 1000L * 1000 * 1000 * 1000, + ["tb"] = 1000L * 1000 * 1000 * 1000, + ["tib"] = 1024L * 1024 * 1024 * 1024, + }; + + /// Return a byte count; integers pass through. + /// Non-negative byte count. + /// . + /// The value is negative. + public static long Parse(long value) => + value >= 0 ? value : throw new ArgumentException("size must be non-negative"); + + /// + /// Parse a number with an optional unit (b, k/kb/kib ... tib, case-insensitive; + /// underscores ignored). Decimal units are powers of 1000; the product is truncated like Python int(). + /// + /// Size text. + /// Byte count. + /// The text is empty, malformed, negative or has an unknown unit. + public static long Parse(string value) + { + string text = PythonText.Strip(value).Replace("_", string.Empty, StringComparison.Ordinal); + if (text.Length == 0) + { + throw new ArgumentException("empty size"); + } + + int split = text.Length; + while (split > 0 && char.IsLetter(text[split - 1])) + { + split--; + } + + string number = text[..split]; + string suffix = split == text.Length ? "b" : text[split..].ToLowerInvariant(); + if (!Units.TryGetValue(suffix, out long unit)) + { + throw new ArgumentException($"unknown size suffix: {suffix}"); + } + + if (!double.TryParse(PythonText.Strip(number), NumberStyles.Float, CultureInfo.InvariantCulture, out double numeric) + || !double.IsFinite(numeric)) + { + throw new ArgumentException($"invalid size: {value}"); + } + + double product = Math.Truncate(numeric * unit); + if (product < 0) + { + throw new ArgumentException("size must be non-negative"); + } + + return product < 9.2233720368547758E18 ? (long)product : throw new ArgumentException($"invalid size: {value}"); + } + + /// Return log2 of a power-of-two block size between 16 KiB and 1 MiB. + /// Block size in bytes. + /// Shift 14..20. + /// The size is not a power of two or is out of range. + public static int BlockShift(long size) + { + size = Parse(size); + if (size == 0 || (size & (size - 1)) != 0) + { + throw new ArgumentException($"block size must be a power of two, got {size}"); + } + + int shift = 63 - (int)long.LeadingZeroCount(size); + if (shift < AMPRPackFormat.MinBlockShift || shift > AMPRPackFormat.MaxBlockShift) + { + throw new ArgumentException( + $"block size must be between {1 << AMPRPackFormat.MinBlockShift} and {1 << AMPRPackFormat.MaxBlockShift} bytes"); + } + + return shift; + } + + /// Parse a size string and return its block shift. + /// Block size text. + /// Shift 14..20. + public static int BlockShift(string size) => BlockShift(Parse(size)); +} diff --git a/src/MkPFS.Core/AMPR/AMPRStringTable.cs b/src/MkPFS.Core/AMPR/AMPRStringTable.cs new file mode 100644 index 0000000..d12486a --- /dev/null +++ b/src/MkPFS.Core/AMPR/AMPRStringTable.cs @@ -0,0 +1,44 @@ +using System.Text; + +namespace MkPFS.Core.AMPR; + +/// Deduplicating UTF-8 string table; each entry ends with NUL (Python StringTable). +public sealed class AMPRStringTable +{ + private readonly List _data = []; + private readonly Dictionary _offsets = new(StringComparer.Ordinal); + + /// Add a string, or return the location of an identical earlier one. + /// String without NUL. + /// Offset and byte length (without NUL). + /// The value contains NUL or the table exceeds 4 GiB. + public (uint Offset, uint Length) Add(string value) + { + if (_offsets.TryGetValue(value, out (uint Offset, uint Length) prior)) + { + return prior; + } + + byte[] encoded = Encoding.UTF8.GetBytes(value); + if (encoded.Contains((byte)0)) + { + throw new ArgumentException("NUL in string table value"); + } + + long offset = _data.Count; + if (offset + encoded.Length + 1 > 0x100000000L) + { + throw new ArgumentException("string table exceeds uint32 format limits"); + } + + _data.AddRange(encoded); + _data.Add(0); + (uint Offset, uint Length) result = ((uint)offset, (uint)encoded.Length); + _offsets[value] = result; + return result; + } + + /// Return the table bytes. + /// Table bytes. + public byte[] ToArray() => [.. _data]; +} diff --git a/src/MkPFS.Core/Compression/LZ4Codec.cs b/src/MkPFS.Core/Compression/LZ4Codec.cs new file mode 100644 index 0000000..ffa910c --- /dev/null +++ b/src/MkPFS.Core/Compression/LZ4Codec.cs @@ -0,0 +1,108 @@ +using System.Runtime.InteropServices; + +namespace MkPFS.Core.Compression; + +/// +/// Raw LZ4 block helpers (no frame, no size prefix) backed by the bundled lz4 1.9.4. Output is +/// byte-identical to ampr_emu ampr_pack_format.Lz4Codec with python-lz4 4.4.5. +/// +public static class LZ4Codec +{ + /// Highest HC level; ampr_pack's default. + public const int MaxHCLevel = 12; + + /// lz4 version string reported by the native library (expected 1.9.4). + public static unsafe string NativeVersion => + Marshal.PtrToStringAnsi((nint)LZ4Native.Version()) ?? string.Empty; + + /// Return the worst-case block size for bytes. + /// Uncompressed length. + /// Upper bound of the raw block length. + public static int CompressBound(int sourceLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(sourceLength); + int bound = LZ4Native.Bound(sourceLength); + return bound > 0 ? bound : throw new ArgumentOutOfRangeException(nameof(sourceLength), "input exceeds LZ4_MAX_INPUT_SIZE"); + } + + /// Decode one raw block, like the ampr_emu runtime (LZ4_decompress_safe). + /// Raw LZ4 block. + /// Output buffer. + /// Number of bytes written to . + /// The block is malformed or does not fit. + public static unsafe int Decompress(ReadOnlySpan source, Span destination) + { + int written; + fixed (byte* src = source) + fixed (byte* dst = destination) + { + written = LZ4Native.DecompressSafe(src, source.Length, dst, destination.Length); + } + + return written >= 0 + ? written + : throw new InvalidDataException($"LZ4 block is malformed or does not fit in {destination.Length} bytes"); + } +} + +/// +/// Reusable LZ4 encoder; every call produces an independent raw block. Calls lz4 the way python-lz4 +/// does (stream reset + *_continue), which differs from LZ4_compress_fast. Not +/// thread-safe; use one instance per worker. +/// +public sealed class LZ4Encoder : IDisposable +{ + private readonly LZ4EncoderHandle _handle = new(); + + /// Create an encoder (about 280 KB of native state). + public LZ4Encoder() + { + nint raw = LZ4Native.EncoderCreate(); + if (raw == 0) + { + throw new OutOfMemoryException("LZ4 encoder allocation failed"); + } + + Marshal.InitHandle(_handle, raw); + } + + /// Compress with the fast encoder. + /// Bytes to compress. + /// Acceleration; values below 1 become 1, like ampr_pack. + /// The raw block; empty for empty input, like ampr_pack. + public byte[] CompressFast(ReadOnlySpan source, int acceleration = 1) => + Compress(source, highCompression: false, Math.Max(1, acceleration)); + + /// Compress with the HC encoder. + /// Bytes to compress. + /// HC level; clamped to 1..12, like ampr_pack. + /// The raw block; empty for empty input, like ampr_pack. + public byte[] CompressHC(ReadOnlySpan source, int level = LZ4Codec.MaxHCLevel) => + Compress(source, highCompression: true, Math.Clamp(level, 1, LZ4Codec.MaxHCLevel)); + + private unsafe byte[] Compress(ReadOnlySpan source, bool highCompression, int parameter) + { + ObjectDisposedException.ThrowIf(_handle.IsClosed, this); + if (source.IsEmpty) + { + return []; + } + + byte[] buffer = new byte[LZ4Codec.CompressBound(source.Length)]; + int written; + fixed (byte* src = source) + fixed (byte* dst = buffer) + { + written = highCompression + ? LZ4Native.CompressHC(_handle, src, source.Length, dst, buffer.Length, parameter) + : LZ4Native.CompressFast(_handle, src, source.Length, dst, buffer.Length, parameter); + } + + return written > 0 + ? buffer.AsSpan(0, written).ToArray() + : throw new InvalidOperationException("LZ4 output exceeded LZ4_compressBound"); + } + + /// + public void Dispose() => _handle.Dispose(); +} diff --git a/src/MkPFS.Core/Compression/LZ4Native.cs b/src/MkPFS.Core/Compression/LZ4Native.cs new file mode 100644 index 0000000..db6b7bc --- /dev/null +++ b/src/MkPFS.Core/Compression/LZ4Native.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; + +namespace MkPFS.Core.Compression; + +/// P/Invoke surface of the native mkpfs_lz4_* shim (see native/mkpfs_lz4.c). +internal static unsafe partial class LZ4Native +{ + private const string Library = "mkpfs_zlib"; + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_version")] + internal static partial byte* Version(); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_bound")] + internal static partial int Bound(int sourceLength); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_encoder_create")] + internal static partial nint EncoderCreate(); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_encoder_free")] + internal static partial void EncoderFree(nint handle); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_compress_fast")] + internal static partial int CompressFast( + LZ4EncoderHandle handle, + byte* source, + int sourceLength, + byte* destination, + int destinationCapacity, + int acceleration); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_compress_hc")] + internal static partial int CompressHC( + LZ4EncoderHandle handle, + byte* source, + int sourceLength, + byte* destination, + int destinationCapacity, + int level); + + [LibraryImport(Library, EntryPoint = "mkpfs_lz4_decompress_safe")] + internal static partial int DecompressSafe(byte* source, int sourceLength, byte* destination, int destinationCapacity); +} + +/// Owns a native encoder created by mkpfs_lz4_encoder_create. +internal sealed class LZ4EncoderHandle : SafeHandle +{ + public LZ4EncoderHandle() + : base(invalidHandleValue: 0, ownsHandle: true) + { + } + + public override bool IsInvalid => handle == 0; + + protected override bool ReleaseHandle() + { + LZ4Native.EncoderFree(handle); + return true; + } +} diff --git a/src/MkPFS.Core/Util/PythonCodePointComparer.cs b/src/MkPFS.Core/Util/PythonCodePointComparer.cs new file mode 100644 index 0000000..5054996 --- /dev/null +++ b/src/MkPFS.Core/Util/PythonCodePointComparer.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace MkPFS.Core.Util; + +/// Orders strings by Unicode code point, like Python string comparison. +public sealed class PythonCodePointComparer : IComparer +{ + /// Shared instance. + public static readonly PythonCodePointComparer Instance = new(); + + /// + public int Compare(string? x, string? y) + { + if (x is null || y is null) + { + return x is null ? (y is null ? 0 : -1) : 1; + } + + StringRuneEnumerator a = x.EnumerateRunes(); + StringRuneEnumerator b = y.EnumerateRunes(); + while (true) + { + bool hasA = a.MoveNext(); + bool hasB = b.MoveNext(); + if (!hasA || !hasB) + { + return hasA ? 1 : hasB ? -1 : 0; + } + + int diff = a.Current.Value - b.Current.Value; + if (diff != 0) + { + return diff; + } + } + } +} diff --git a/src/MkPFS.Core/Util/PythonSortedJson.cs b/src/MkPFS.Core/Util/PythonSortedJson.cs new file mode 100644 index 0000000..96ac878 --- /dev/null +++ b/src/MkPFS.Core/Util/PythonSortedJson.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using System.Text; + +namespace MkPFS.Core.Util; + +/// +/// Python json.dumps(value, sort_keys=True, ...) for byte-exact output: keys sorted by code point, floats as +/// Python repr. Values: , , integers, , +/// , with string keys, and other +/// as arrays. +/// +public static class PythonSortedJson +{ + /// json.dumps(value, sort_keys=True, separators=(",", ":")) (ASCII escapes). + /// Value tree. + /// JSON text. + public static string Compact(object? value) + { + StringBuilder builder = new(); + Write(builder, value, indent: null, level: 0, ensureAscii: true); + return builder.ToString(); + } + + /// json.dumps(value, indent=2, sort_keys=True, ensure_ascii=...). + /// Value tree. + /// Escape non-ASCII characters as \uXXXX. + /// JSON text without a trailing newline. + public static string Indented(object? value, bool ensureAscii = false) + { + StringBuilder builder = new(); + Write(builder, value, indent: 2, level: 0, ensureAscii); + return builder.ToString(); + } + + private static void Write(StringBuilder builder, object? value, int? indent, int level, bool ensureAscii) + { + switch (value) + { + case null: + builder.Append("null"); + break; + case bool b: + builder.Append(b ? "true" : "false"); + break; + case int or long or uint or ulong or byte or ushort: + builder.Append(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + break; + case double d: + builder.Append(double.IsNaN(d) ? "NaN" : double.IsInfinity(d) ? (d > 0 ? "Infinity" : "-Infinity") : PythonText.FloatRepr(d)); + break; + case string s: + WriteString(builder, s, ensureAscii); + break; + case IReadOnlyDictionary map: + List keys = [.. map.Keys.OrderBy(k => k, PythonCodePointComparer.Instance)]; + WriteContainer(builder, '{', '}', keys.Count, indent, level, i => + { + WriteString(builder, keys[i], ensureAscii); + builder.Append(indent is null ? ":" : ": "); + Write(builder, map[keys[i]], indent, level + 1, ensureAscii); + }); + break; + case System.Collections.IEnumerable items: + List list = [.. items.Cast()]; + WriteContainer(builder, '[', ']', list.Count, indent, level, i => Write(builder, list[i], indent, level + 1, ensureAscii)); + break; + default: + throw new ArgumentException($"unsupported JSON value type {value.GetType().Name}"); + } + } + + // Python: empty containers stay "[]"/"{}"; with indent each item goes on its own line. + private static void WriteContainer(StringBuilder builder, char open, char close, int count, int? indent, int level, Action item) + { + builder.Append(open); + if (count == 0) + { + builder.Append(close); + return; + } + + for (int i = 0; i < count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + if (indent is { } width) + { + builder.Append('\n').Append(' ', width * (level + 1)); + } + + item(i); + } + + if (indent is { } closing) + { + builder.Append('\n').Append(' ', closing * level); + } + + builder.Append(close); + } + + private static void WriteString(StringBuilder builder, string value, bool ensureAscii) + { + builder.Append('"'); + foreach (char c in value) + { + switch (c) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append(@"\\"); + break; + case '\n': + builder.Append(@"\n"); + break; + case '\r': + builder.Append(@"\r"); + break; + case '\t': + builder.Append(@"\t"); + break; + case '\b': + builder.Append(@"\b"); + break; + case '\f': + builder.Append(@"\f"); + break; + default: + // ensure_ascii escapes everything outside ' '..'~'; otherwise only control characters. + if (c < 0x20 || (ensureAscii && c > 0x7E)) + { + builder.Append(@"\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(c); + } + + break; + } + } + + builder.Append('"'); + } +} diff --git a/src/MkPFS.Core/Util/PythonText.cs b/src/MkPFS.Core/Util/PythonText.cs index c2ff081..f53fd42 100644 --- a/src/MkPFS.Core/Util/PythonText.cs +++ b/src/MkPFS.Core/Util/PythonText.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using System.Text; namespace MkPFS.Core.Util; @@ -81,4 +82,204 @@ or UnicodeCategory.DecimalDigitNumber or UnicodeCategory.LetterNumber or UnicodeCategory.OtherNumber; } + + /// + /// Python repr(str): single quotes unless the text has ' and no "; escapes + /// backslash, the quote, \t \n \r, and non-printable code points as \xNN, \uNNNN + /// or \UNNNNNNNN. + /// + /// Text. + /// The repr, including quotes. + public static string Repr(string value) + { + char quote = value.Contains('\'', StringComparison.Ordinal) && !value.Contains('"', StringComparison.Ordinal) ? '"' : '\''; + StringBuilder builder = new(value.Length + 2); + builder.Append(quote); + foreach (Rune rune in value.EnumerateRunes()) + { + int code = rune.Value; + if (code == '\\' || code == quote) + { + builder.Append('\\').Append((char)code); + } + else if (code == '\t') + { + builder.Append("\\t"); + } + else if (code == '\n') + { + builder.Append("\\n"); + } + else if (code == '\r') + { + builder.Append("\\r"); + } + else if (IsPrintable(rune)) + { + builder.Append(rune.ToString()); + } + else + { + builder.Append(code switch + { + <= 0xFF => $"\\x{code:x2}", + <= 0xFFFF => $"\\u{code:x4}", + _ => $"\\U{code:x8}", + }); + } + } + + return builder.Append(quote).ToString(); + } + + /// Python str.splitlines(): splits on LF, CR, CRLF, VT, FF, U+001C..U+001E, U+0085, U+2028 and U+2029. + /// Text. + /// Lines without terminators; no trailing empty line. + public static List SplitLines(string value) + { + List lines = []; + int start = 0; + int i = 0; + while (i < value.Length) + { + char c = value[i]; + if (c is '\n' or '\r' or '\v' or '\f' or '\u001C' or '\u001D' or '\u001E' or '\u0085' or '\u2028' or '\u2029') + { + lines.Add(value[start..i]); + i += c == '\r' && i + 1 < value.Length && value[i + 1] == '\n' ? 2 : 1; + start = i; + } + else + { + i++; + } + } + + if (start < value.Length) + { + lines.Add(value[start..]); + } + + return lines; + } + + /// + /// Python repr(float) (also json.dumps): shortest round-trip digits, fixed notation when the + /// decimal exponent is in (-4, 16], otherwise d.ddde+/-XX; inf, -inf, nan. + /// + /// Value. + /// Text. + public static string FloatRepr(double value) + { + if (double.IsNaN(value)) + { + return "nan"; + } + + if (double.IsInfinity(value)) + { + return value > 0 ? "inf" : "-inf"; + } + + // "R" gives the shortest round-trip digits, as mantissa[E+/-exp]. + string text = value.ToString("R", CultureInfo.InvariantCulture); + bool negative = text.StartsWith('-'); + if (negative) + { + text = text[1..]; + } + + int exponent = 0; + int e = text.IndexOf('E', StringComparison.Ordinal); + if (e >= 0) + { + exponent = int.Parse(text.AsSpan(e + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + text = text[..e]; + } + + int dot = text.IndexOf('.', StringComparison.Ordinal); + string digits = dot < 0 ? text : text[..dot] + text[(dot + 1)..]; + int pointPosition = (dot < 0 ? text.Length : dot) + exponent; + int leadingZeros = digits.Length - digits.TrimStart('0').Length; + digits = digits.Trim('0'); + if (digits.Length == 0) + { + return negative ? "-0.0" : "0.0"; + } + + // Value is 0.DIGITS x 10^decpt. + int decpt = pointPosition - leadingZeros; + string body; + if (decpt > -4 && decpt <= 16) + { + body = decpt <= 0 + ? "0." + new string('0', -decpt) + digits + : decpt >= digits.Length + ? digits + new string('0', decpt - digits.Length) + ".0" + : digits[..decpt] + "." + digits[decpt..]; + } + else + { + int exp = decpt - 1; + string mantissa = digits.Length == 1 ? digits : digits[0] + "." + digits[1..]; + body = mantissa + "e" + (exp < 0 ? "-" : "+") + Math.Abs(exp).ToString("00", CultureInfo.InvariantCulture); + } + + return negative ? "-" + body : body; + } + + /// + /// Python format(value, f".{decimals}f"): the exact binary value rounded half to even, unlike .NET + /// fixed-point formatting, which rounds ties away from zero. + /// + /// Finite value. + /// Digits after the point. + /// Text. + public static string FormatFixed(double value, int decimals) + { + if (!double.IsFinite(value)) + { + return FloatRepr(value); + } + + long bits = BitConverter.DoubleToInt64Bits(value); + bool negative = bits < 0; + int exponentBits = (int)((bits >> 52) & 0x7FF); + long fraction = bits & 0xFFFFFFFFFFFFFL; + BigInteger mantissa = exponentBits == 0 ? fraction : fraction | (1L << 52); + int exponent = (exponentBits == 0 ? 1 : exponentBits) - 1075; + + // value * 10^decimals = mantissa * 2^exponent * 10^decimals, rounded half to even. + BigInteger numerator = mantissa * BigInteger.Pow(10, decimals); + BigInteger scaled; + if (exponent >= 0) + { + scaled = numerator << exponent; + } + else + { + BigInteger denominator = BigInteger.One << -exponent; + scaled = BigInteger.DivRem(numerator, denominator, out BigInteger remainder); + int compare = (remainder * 2).CompareTo(denominator); + if (compare > 0 || (compare == 0 && !scaled.IsEven)) + { + scaled += 1; + } + } + + string digits = scaled.ToString(CultureInfo.InvariantCulture).PadLeft(decimals + 1, '0'); + string text = decimals == 0 ? digits : digits[..^decimals] + "." + digits[^decimals..]; + return negative ? "-" + text : text; + } + + // Python str.isprintable(): everything except categories Cc, Cf, Cs, Co, Cn, Zl, Zp and Zs other than space. + private static bool IsPrintable(Rune rune) => + rune.Value == ' ' || Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.Control + or UnicodeCategory.Format + or UnicodeCategory.Surrogate + or UnicodeCategory.PrivateUse + or UnicodeCategory.OtherNotAssigned + or UnicodeCategory.LineSeparator + or UnicodeCategory.ParagraphSeparator + or UnicodeCategory.SpaceSeparator); } diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 581316e..12d5ba7 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -32,6 +32,39 @@ Folder with libSceAmpr.sprx… APR Emu Treat as APR title + Inspect manifest + List files + Pack game files + Remove packed sources + Update runtime settings + Unpack packed files + Verify packs + Action + Leave missing source files loose + TOML Configuration + Empty: default rules (compress all, keep executables and system files loose)… + Delete the packed source files (cannot be undone; unchecked shows the plan only) + ✗ Pack manifest is required. + ✗ Game folder and output folder are both required. + ✗ Pack manifest and game folder are both required. + ✗ Pack manifest and TOML configuration are both required. + ✗ Pack manifest and output folder are both required. + ✗ Workers must be a whole number from 1 to 256. + AMPR Index (optional) + Default: ampr_emu.index in the game folder + JSON output + Pack Manifest + Select ampr_assets.index… + Packs only serve files the game reads through AMPR. Files read another way (for example mmap) must stay loose, so keep the source files until the packed game is tested. + Output Folder + Folder for the manifest and .pak volumes… + Overwrite existing files + Game Folder (/app0) + Folder with eboot.bin and sce_sys… + Never leave selected files loose (store incompressible blocks uncompressed) + Build and check AMPR Emu LZ4 asset packs for a game folder + AMPR Packs + Workers (empty = from the configuration) PlayStation File System Browse File @@ -105,6 +138,7 @@ Partly written output is removed; an in-place repair finishes its rewrite first Inspect Language Import Preview + AMPR Packs Batch Inspect Pack exFAT diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 780ca0c..83a8dc4 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -32,6 +32,39 @@ Ordner mit libSceAmpr.sprx… APR Emu Als APR-Titel behandeln + Manifest untersuchen + Dateien auflisten + Spieldateien packen + Gepackte Quelldateien entfernen + Laufzeiteinstellungen aktualisieren + Gepackte Dateien entpacken + Pakete prüfen + Aktion + Fehlende Quelldateien lose lassen + TOML-Konfiguration + Leer: Standardregeln (alles komprimieren, ausführbare und Systemdateien lose lassen)… + Gepackte Quelldateien löschen (nicht rückgängig zu machen; sonst nur den Plan anzeigen) + ✗ Paket-Manifest ist erforderlich. + ✗ Spielordner und Ausgabeordner sind beide erforderlich. + ✗ Paket-Manifest und Spielordner sind beide erforderlich. + ✗ Paket-Manifest und TOML-Konfiguration sind beide erforderlich. + ✗ Paket-Manifest und Ausgabeordner sind beide erforderlich. + ✗ Worker muss eine ganze Zahl von 1 bis 256 sein. + AMPR-Index (optional) + Standard: ampr_emu.index im Spielordner + JSON-Ausgabe + Paket-Manifest + ampr_assets.index auswählen… + Pakete liefern nur Dateien, die das Spiel über AMPR liest. Auf andere Weise gelesene Dateien (zum Beispiel per mmap) müssen lose bleiben; behalte die Quelldateien, bis das gepackte Spiel getestet ist. + Ausgabeordner + Ordner für Manifest und .pak-Volumes… + Vorhandene Dateien überschreiben + Spielordner (/app0) + Ordner mit eboot.bin und sce_sys… + Ausgewählte Dateien nie lose lassen (nicht komprimierbare Blöcke unkomprimiert speichern) + AMPR-Emu-LZ4-Asset-Pakete für einen Spielordner erstellen und prüfen + AMPR-Pakete + Worker (leer = aus der Konfiguration) PlayStation File System Durchsuchen Datei @@ -105,6 +138,7 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Untersuchen Sprache Import-Vorschau + AMPR-Pakete Stapel Untersuchen exFAT packen diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 5661024..ebc806f 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -32,6 +32,39 @@ Carpeta con libSceAmpr.sprx… APR Emu Tratar como título APR + Inspeccionar manifiesto + Listar archivos + Empaquetar archivos del juego + Eliminar archivos fuente empaquetados + Actualizar ajustes de ejecución + Desempaquetar archivos + Verificar paquetes + Acción + Dejar sueltos los archivos fuente que falten + Configuración TOML + Vacío: reglas predeterminadas (comprimir todo, dejar sueltos ejecutables y archivos del sistema)… + Eliminar los archivos fuente empaquetados (no se puede deshacer; sin marcar solo muestra el plan) + ✗ Se requiere el manifiesto del paquete. + ✗ Se requieren la carpeta del juego y la carpeta de salida. + ✗ Se requieren el manifiesto del paquete y la carpeta del juego. + ✗ Se requieren el manifiesto del paquete y la configuración TOML. + ✗ Se requieren el manifiesto del paquete y la carpeta de salida. + ✗ Los hilos deben ser un número entero de 1 a 256. + Índice AMPR (opcional) + Por defecto: ampr_emu.index en la carpeta del juego + Salida JSON + Manifiesto del paquete + Selecciona ampr_assets.index… + Los paquetes solo sirven archivos que el juego lee mediante AMPR. Los archivos leídos de otra forma (por ejemplo, mmap) deben quedar sueltos; conserva los archivos fuente hasta probar el juego empaquetado. + Carpeta de salida + Carpeta para el manifiesto y los volúmenes .pak… + Sobrescribir archivos existentes + Carpeta del juego (/app0) + Carpeta con eboot.bin y sce_sys… + No dejar nunca sueltos los archivos seleccionados (guardar sin comprimir los bloques incompresibles) + Crea y comprueba paquetes de recursos LZ4 de AMPR Emu para una carpeta de juego + Paquetes AMPR + Hilos (vacío = de la configuración) Sistema de Archivos PlayStation Buscar Archivo @@ -105,6 +138,7 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Inspeccionar Idioma Vista Previa de Importación + Paquetes AMPR Lote Inspeccionar Empaquetar EXFAT diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 2d98c1d..d6dc651 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -32,6 +32,39 @@ Dossier contenant libSceAmpr.sprx… APR Emu Traiter comme un titre APR + Inspecter le manifeste + Lister les fichiers + Empaqueter les fichiers du jeu + Supprimer les sources empaquetées + Mettre à jour les réglages d’exécution + Extraire les fichiers empaquetés + Vérifier les paquets + Action + Laisser libres les fichiers sources manquants + Configuration TOML + Vide : règles par défaut (tout compresser, laisser libres exécutables et fichiers système)… + Supprimer les fichiers sources empaquetés (irréversible ; décoché, affiche seulement le plan) + ✗ Le manifeste du paquet est requis. + ✗ Le dossier du jeu et le dossier de sortie sont tous deux requis. + ✗ Le manifeste du paquet et le dossier du jeu sont tous deux requis. + ✗ Le manifeste du paquet et la configuration TOML sont tous deux requis. + ✗ Le manifeste du paquet et le dossier de sortie sont tous deux requis. + ✗ Les threads doivent être un entier de 1 à 256. + Index AMPR (facultatif) + Par défaut : ampr_emu.index dans le dossier du jeu + Sortie JSON + Manifeste du paquet + Sélectionner ampr_assets.index… + Les paquets ne servent que les fichiers que le jeu lit via AMPR. Les fichiers lus autrement (par exemple via mmap) doivent rester libres ; conservez les fichiers sources jusqu’à ce que le jeu empaqueté soit testé. + Dossier de sortie + Dossier du manifeste et des volumes .pak… + Écraser les fichiers existants + Dossier du jeu (/app0) + Dossier contenant eboot.bin et sce_sys… + Ne jamais laisser libres les fichiers sélectionnés (stocker non compressés les blocs incompressibles) + Créer et vérifier les paquets de ressources LZ4 d’AMPR Emu pour un dossier de jeu + Paquets AMPR + Threads (vide = selon la configuration) PlayStation File System Parcourir Fichier @@ -105,6 +138,7 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Inspecter Langue Aperçu de l'import + Paquets AMPR Lot Inspecter Empaqueter exFAT diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 9f150cb..84e8535 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -32,6 +32,39 @@ Pasta com libSceAmpr.sprx… APR Emu Tratar como título APR + Inspecionar manifesto + Listar arquivos + Empacotar arquivos do jogo + Remover arquivos-fonte empacotados + Atualizar configurações de execução + Desempacotar arquivos + Verificar pacotes + Ação + Deixar soltos os arquivos-fonte ausentes + Configuração TOML + Vazio: regras padrão (comprimir tudo, deixar soltos executáveis e arquivos do sistema)… + Excluir os arquivos-fonte empacotados (não pode ser desfeito; desmarcado mostra apenas o plano) + ✗ O manifesto do pacote é obrigatório. + ✗ A pasta do jogo e a pasta de saída são obrigatórias. + ✗ O manifesto do pacote e a pasta do jogo são obrigatórios. + ✗ O manifesto do pacote e a configuração TOML são obrigatórios. + ✗ O manifesto do pacote e a pasta de saída são obrigatórios. + ✗ Threads devem ser um número inteiro de 1 a 256. + Índice AMPR (opcional) + Padrão: ampr_emu.index na pasta do jogo + Saída JSON + Manifesto do pacote + Selecione ampr_assets.index… + Os pacotes só atendem arquivos que o jogo lê pelo AMPR. Arquivos lidos de outra forma (por exemplo, mmap) devem ficar soltos; mantenha os arquivos-fonte até testar o jogo empacotado. + Pasta de saída + Pasta para o manifesto e os volumes .pak… + Sobrescrever arquivos existentes + Pasta do jogo (/app0) + Pasta com eboot.bin e sce_sys… + Nunca deixar soltos os arquivos selecionados (guardar sem compressão os blocos incompressíveis) + Crie e verifique pacotes de recursos LZ4 do AMPR Emu para uma pasta de jogo + Pacotes AMPR + Threads (vazio = da configuração) Sistema de Arquivos PlayStation Procurar Arquivo @@ -105,6 +138,7 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Inspecionar Idioma Prévia da Importação + Pacotes AMPR Lote Inspecionar Empacotar EXFAT diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 73a5cc7..ccc5084 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -32,6 +32,39 @@ Folder cu libSceAmpr.sprx… APR Emu Tratează ca titlu APR + Inspectează manifestul + Listează fișierele + Împachetează fișierele jocului + Șterge sursele împachetate + Actualizează setările de rulare + Despachetează fișierele împachetate + Verifică pachetele + Acțiune + Lasă libere fișierele sursă lipsă + Configurație TOML + Gol: reguli implicite (comprimă tot, lasă libere executabilele și fișierele de sistem)… + Șterge fișierele sursă împachetate (ireversibil; nebifat arată doar planul) + ✗ Manifestul pachetului este necesar. + ✗ Folderul jocului și folderul de ieșire sunt ambele necesare. + ✗ Manifestul pachetului și folderul jocului sunt ambele necesare. + ✗ Manifestul pachetului și configurația TOML sunt ambele necesare. + ✗ Manifestul pachetului și folderul de ieșire sunt ambele necesare. + ✗ Firele trebuie să fie un număr întreg de la 1 la 256. + Index AMPR (opțional) + Implicit: ampr_emu.index din folderul jocului + Ieșire JSON + Manifestul pachetului + Selectează ampr_assets.index… + Pachetele servesc doar fișierele pe care jocul le citește prin AMPR. Fișierele citite altfel (de exemplu prin mmap) trebuie să rămână libere; păstrează fișierele sursă până testezi jocul împachetat. + Folder de ieșire + Folder pentru manifest și volumele .pak… + Suprascrie fișierele existente + Folderul jocului (/app0) + Folder cu eboot.bin și sce_sys… + Nu lăsa niciodată libere fișierele selectate (stochează necomprimate blocurile necompresibile) + Creează și verifică pachete de resurse LZ4 AMPR Emu pentru un folder de joc + Pachete AMPR + Fire (gol = din configurație) PlayStation File System Răsfoiește Fișier @@ -105,6 +138,7 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Inspectare Limbă Previzualizare import + Pachete AMPR Lot Inspectare Împachetare exFAT diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs new file mode 100644 index 0000000..c0aa2b8 --- /dev/null +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -0,0 +1,180 @@ +using System.Globalization; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using MkPFS.Gui.Jobs; +using MkPFS.Gui.Localization; + +namespace MkPFS.Gui.ViewModels; + +/// +/// AMPR Packs page: runs mkpfs ampr (pack, verify, unpack, list, inspect, runtime-config, remove-sources). +/// Only the fields the selected action uses are shown. +/// +/// Accent color. +/// Job runner, or for the UI-thread runner. +public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job = null) : PanelViewModel("ap_title", "ap_subtitle", accent, job) +{ + private static readonly Choice[] ActionChoices = + [ + new("pack", "ap_act_pack"), + new("verify", "ap_act_verify"), + new("unpack", "ap_act_unpack"), + new("list", "ap_act_list"), + new("inspect", "ap_act_inspect"), + new("runtime-config", "ap_act_runtime"), + new("remove-sources", "ap_act_remove"), + ]; + + /// Actions, in menu order; values are the CLI subcommands. + public IReadOnlyList Actions => ActionChoices; + + /// Selected action. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] + [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] + public partial Choice Action { get; set; } = ActionChoices[0]; + + /// --root: the game's /app0 folder. + [ObservableProperty] + public partial string Root { get; set; } = string.Empty; + + /// --ampr-index; defaults to <root>/ampr_emu.index. + [ObservableProperty] + public partial string AmprIndex { get; set; } = string.Empty; + + /// --output. + [ObservableProperty] + public partial string Output { get; set; } = string.Empty; + + /// --index: the pack manifest. + [ObservableProperty] + public partial string Manifest { get; set; } = string.Empty; + + /// --config; empty packs with --preset default, and runtime-config requires it. + [ObservableProperty] + public partial string Config { get; set; } = string.Empty; + + /// --workers; empty for the configuration's value. + [ObservableProperty] + public partial string Workers { get; set; } = string.Empty; + + /// --self-contained. + [ObservableProperty] + public partial bool SelfContained { get; set; } + + /// --allow-missing. + [ObservableProperty] + public partial bool AllowMissing { get; set; } + + /// --overwrite (unpack). + [ObservableProperty] + public partial bool Overwrite { get; set; } + + /// --json (list). + [ObservableProperty] + public partial bool Json { get; set; } + + /// --confirm (remove-sources): delete instead of printing the plan. + [ObservableProperty] + public partial bool Confirm { get; set; } + + /// Game folder field (pack, verify, remove-sources). + public bool ShowRoot => Action.Value is "pack" or "verify" or "remove-sources"; + + /// AMPR index field (pack). + public bool ShowPackPaths => Action.Value == "pack"; + + /// Output folder field (pack, unpack). + public bool ShowOutput => Action.Value is "pack" or "unpack"; + + /// Pack manifest field (every action but pack). + public bool ShowManifest => Action.Value != "pack"; + + /// Configuration field (pack, runtime-config). + public bool ShowConfig => Action.Value is "pack" or "runtime-config"; + + /// Workers, self-contained and allow-missing (pack). + public bool ShowPackOptions => Action.Value == "pack"; + + /// Overwrite (unpack). + public bool ShowOverwrite => Action.Value == "unpack"; + + /// JSON output (list). + public bool ShowJson => Action.Value == "list"; + + /// Delete sources (remove-sources). + public bool ShowConfirm => Action.Value == "remove-sources"; + + /// + public override object Form => this; + + /// + protected internal override IReadOnlyList? BuildArguments(out string? error) + { + string root = Root.Trim(); + string manifest = Manifest.Trim(); + string output = Output.Trim(); + string config = Config.Trim(); + error = Action.Value switch + { + "pack" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], + "pack" when Workers.Trim().Length > 0 && !(int.TryParse(Workers.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n is >= 1 and <= 256) + => Localizer.Instance["ap_err_workers"], + "verify" or "list" or "inspect" when manifest.Length == 0 => Localizer.Instance["ap_err_manifest"], + "unpack" when manifest.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_unpack"], + "runtime-config" when manifest.Length == 0 || config.Length == 0 => Localizer.Instance["ap_err_runtime"], + "remove-sources" when manifest.Length == 0 || root.Length == 0 => Localizer.Instance["ap_err_remove"], + _ => null, + }; + if (error is not null) + { + return null; + } + + List args = ["ampr", Action.Value]; + switch (Action.Value) + { + case "pack": + args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); + // Without a TOML every file would stay loose; the page uses the built-in rules instead. + args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", "default"]); + AddOption(args, "--workers", Workers); + AddFlag(args, SelfContained, "--self-contained"); + AddFlag(args, AllowMissing, "--allow-missing"); + break; + case "verify": + args.AddRange(["--index", manifest]); + AddOption(args, "--root", root); + break; + case "unpack": + args.AddRange(["--index", manifest, "--output", output]); + AddFlag(args, Overwrite, "--overwrite"); + break; + case "list": + args.AddRange(["--index", manifest]); + AddFlag(args, Json, "--json"); + break; + case "inspect": + args.AddRange(["--index", manifest]); + break; + case "runtime-config": + args.AddRange(["--index", manifest, "--config", config]); + break; + default: + args.AddRange(["--index", manifest, "--root", root]); + AddFlag(args, Confirm, "--confirm"); + break; + } + + return args; + } + + /// The manifest a pack run writes, so the other actions can use it right away. + partial void OnOutputChanged(string value) + { + if (Manifest.Length == 0 && value.Trim().Length > 0 && Action.Value == "pack") + { + Manifest = Path.Combine(value.Trim(), Build.AMPRPack.AMPRPackConfig.DefaultIndexName); + } + } +} diff --git a/src/MkPFS.Gui/ViewModels/Icons.cs b/src/MkPFS.Gui/ViewModels/Icons.cs index ae5fba0..b3402a8 100644 --- a/src/MkPFS.Gui/ViewModels/Icons.cs +++ b/src/MkPFS.Gui/ViewModels/Icons.cs @@ -12,4 +12,5 @@ internal static class Icons public const string Inspect = "M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"; public const string Tree = "M12 13H7V18H12V20H5V10H7V11H12V13M8 4V6H4V4H8M10 2H2V8H10V2M20 11V13H16V11H20M22 9H14V15H22V9M20 18V20H16V18H20M22 16H14V22H22V16Z"; public const string Unpack = "M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.85,13.36L20.13,9.73L19.55,8.72L13.27,12.35L13.85,13.36Z"; + public const string AmprPack = "M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L10.11,5.22L16,8.61L17.96,7.5L12,4.15M6.04,7.5L12,10.85L13.96,9.75L8.08,6.35L6.04,7.5M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z"; } diff --git a/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs b/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs index 3bcd353..b94407e 100644 --- a/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs @@ -56,7 +56,7 @@ public sealed record NavSection(string TitleKey, IReadOnlyList Items) /// Main window state: sidebar sections, the current page, and the language picker. public sealed partial class MainWindowViewModel : ObservableObject { - // Python theme.py neon palette; Repair is new and takes the error red. + // Python theme.py neon palette; Repair is new and takes the error red, AMPR Packs a new lime. private static readonly Color Teal = Color.Parse("#00E5A0"); private static readonly Color Blue = Color.Parse("#00C8FF"); private static readonly Color Orange = Color.Parse("#FF6B3D"); @@ -66,6 +66,7 @@ public sealed partial class MainWindowViewModel : ObservableObject private static readonly Color Purple = Color.Parse("#B560FF"); private static readonly Color Amber = Color.Parse("#FFB800"); private static readonly Color Pink = Color.Parse("#FF5CAA"); + private static readonly Color Lime = Color.Parse("#C6FF3D"); /// Create the main window state with every page. public MainWindowViewModel() @@ -78,6 +79,7 @@ public MainWindowViewModel() Item("pack_folder", "nav_pack_folder", Icons.PackFolder, new PackFolderPanelViewModel(Blue)), Item("pack_exfat", "nav_pack_exfat", Icons.PackExfat, new PackExfatPanelViewModel(Orange)), Item("pack_file", "nav_pack_file", Icons.PackFile, new PackFilePanelViewModel(Cyan)), + Item("ampr", "nav_ampr", Icons.AmprPack, new AmprPackPanelViewModel(Lime)), ]), new("section_check", [ diff --git a/src/MkPFS.Gui/Views/PanelView.axaml b/src/MkPFS.Gui/Views/PanelView.axaml index 20d0739..5226e92 100644 --- a/src/MkPFS.Gui/Views/PanelView.axaml +++ b/src/MkPFS.Gui/Views/PanelView.axaml @@ -30,6 +30,7 @@ + diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml new file mode 100644 index 0000000..0e1b39a --- /dev/null +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml.cs b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml.cs new file mode 100644 index 0000000..a4dfdf7 --- /dev/null +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; + +namespace MkPFS.Gui.Views.Panels; + +/// Options form of the page with the same name. +public sealed partial class AmprPackForm : UserControl +{ + /// Create the form. + public AmprPackForm() + { + InitializeComponent(); + } +} diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs new file mode 100644 index 0000000..813aa3a --- /dev/null +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -0,0 +1,160 @@ +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using MkPFS.Build; +using MkPFS.Gui.Jobs; +using MkPFS.Gui.ViewModels; + +namespace MkPFS.Gui.Tests; + +[Collection(GuiCollection.Name)] +public sealed class AmprPackPanelTests +{ + private static JobRunner Sync() => new(action => action()); + + private static AmprPackPanelViewModel Panel(string action) + { + AmprPackPanelViewModel panel = new(Colors.YellowGreen, Sync()); + panel.Action = panel.Actions.Single(a => a.Value == action); + return panel; + } + + [Fact] + public void Pack_builds_arguments_and_validates() + { + AmprPackPanelViewModel panel = Panel("pack"); + Assert.Null(panel.BuildArguments(out string? error)); + Assert.Equal("✗ Game folder and output folder are both required.", error); + + panel.Root = "D:/game"; + panel.Output = "D:/out"; + Assert.Equal(Path.Combine("D:/out", "ampr_assets.index"), panel.Manifest); + Assert.Equal( + ["ampr", "pack", "--root", "D:/game", "--ampr-index", Path.Combine("D:/game", "ampr_emu.index"), "--output", "D:/out", "--preset", "default"], + panel.BuildArguments(out _)); + + panel.AmprIndex = "D:/idx/ampr_emu.index"; + panel.Config = "D:/rules.toml"; + panel.Workers = "4"; + panel.SelfContained = true; + panel.AllowMissing = true; + Assert.Equal( + ["ampr", "pack", "--root", "D:/game", "--ampr-index", "D:/idx/ampr_emu.index", "--output", "D:/out", "--config", "D:/rules.toml", + "--workers", "4", "--self-contained", "--allow-missing"], + panel.BuildArguments(out _)); + + panel.Workers = "0"; + Assert.Null(panel.BuildArguments(out error)); + Assert.Equal("✗ Workers must be a whole number from 1 to 256.", error); + } + + [Fact] + public void Other_actions_build_arguments_and_validate() + { + AmprPackPanelViewModel verify = Panel("verify"); + Assert.Null(verify.BuildArguments(out string? error)); + Assert.Equal("✗ Pack manifest is required.", error); + verify.Manifest = "D:/out/ampr_assets.index"; + Assert.Equal(["ampr", "verify", "--index", "D:/out/ampr_assets.index"], verify.BuildArguments(out _)); + verify.Root = "D:/game"; + Assert.Equal(["ampr", "verify", "--index", "D:/out/ampr_assets.index", "--root", "D:/game"], verify.BuildArguments(out _)); + + AmprPackPanelViewModel unpack = Panel("unpack"); + unpack.Manifest = "m.index"; + Assert.Null(unpack.BuildArguments(out error)); + Assert.Equal("✗ Pack manifest and output folder are both required.", error); + unpack.Output = "D:/x"; + unpack.Overwrite = true; + Assert.Equal(["ampr", "unpack", "--index", "m.index", "--output", "D:/x", "--overwrite"], unpack.BuildArguments(out _)); + + AmprPackPanelViewModel list = Panel("list"); + list.Manifest = "m.index"; + list.Json = true; + Assert.Equal(["ampr", "list", "--index", "m.index", "--json"], list.BuildArguments(out _)); + Assert.Equal(["ampr", "inspect", "--index", "m.index"], Panel("inspect") is var inspect && (inspect.Manifest = "m.index") is not null ? inspect.BuildArguments(out _) : null); + + AmprPackPanelViewModel runtime = Panel("runtime-config"); + runtime.Manifest = "m.index"; + Assert.Null(runtime.BuildArguments(out error)); + Assert.Equal("✗ Pack manifest and TOML configuration are both required.", error); + runtime.Config = "r.toml"; + Assert.Equal(["ampr", "runtime-config", "--index", "m.index", "--config", "r.toml"], runtime.BuildArguments(out _)); + + AmprPackPanelViewModel remove = Panel("remove-sources"); + remove.Manifest = "m.index"; + Assert.Null(remove.BuildArguments(out error)); + Assert.Equal("✗ Pack manifest and game folder are both required.", error); + remove.Root = "D:/game"; + Assert.Equal(["ampr", "remove-sources", "--index", "m.index", "--root", "D:/game"], remove.BuildArguments(out _)); + remove.Confirm = true; + Assert.Equal(["ampr", "remove-sources", "--index", "m.index", "--root", "D:/game", "--confirm"], remove.BuildArguments(out _)); + } + + [Theory] + [InlineData("pack", true, true, true, false, true, true, false, false, false)] + [InlineData("verify", true, false, false, true, false, false, false, false, false)] + [InlineData("unpack", false, false, true, true, false, false, true, false, false)] + [InlineData("list", false, false, false, true, false, false, false, true, false)] + [InlineData("inspect", false, false, false, true, false, false, false, false, false)] + [InlineData("runtime-config", false, false, false, true, true, false, false, false, false)] + [InlineData("remove-sources", true, false, false, true, false, false, false, false, true)] + public void Each_action_shows_only_its_fields( + string action, bool root, bool packPaths, bool output, bool manifest, bool config, bool packOptions, bool overwrite, bool json, bool confirm) + { + AmprPackPanelViewModel panel = Panel(action); + Assert.Equal( + (root, packPaths, output, manifest, config, packOptions, overwrite, json, confirm), + (panel.ShowRoot, panel.ShowPackPaths, panel.ShowOutput, panel.ShowManifest, panel.ShowConfig, panel.ShowPackOptions, + panel.ShowOverwrite, panel.ShowJson, panel.ShowConfirm)); + } + + [AvaloniaFact] + public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_progress() + { + using TempDir dir = new(); + string game = BuildPanelTests.Game(dir); + dir.File("PPSA01234-app/data/level.dat", string.Concat(Enumerable.Repeat("terrain mesh texture ", 20_000))); + dir.File("PPSA01234-app/sce_module/libc.prx", "module"); + AmprIndex.Build(game, Path.Combine(game, AmprIndex.IndexName)); + + AmprPackPanelViewModel panel = Panel("pack"); + panel.Root = game; + panel.Output = Path.Combine(dir.Path, "packs"); + await panel.RunCommand.ExecuteAsync(null); + + Assert.Equal("✓ Completed successfully.", panel.Job.Lines[^1].Text); + Assert.Contains(panel.Job.Lines, l => l.Text.Contains("\"files_packed\": 1", StringComparison.Ordinal)); + Assert.Contains(panel.Job.Lines, l => l.Text.StartsWith("[pack 100%] complete: ", StringComparison.Ordinal)); + // One overall phase: no per-phase "✓ planning: N%" lines that look like finished steps. + Assert.DoesNotContain(panel.Job.Lines, l => l.Text.StartsWith("✓ planning", StringComparison.Ordinal) || l.Text.StartsWith("✓ packing", StringComparison.Ordinal)); + } + + [AvaloniaFact] + public async Task Page_packs_verifies_and_plans_removal_for_a_real_folder() + { + using TempDir dir = new(); + string game = BuildPanelTests.Game(dir); + dir.File("PPSA01234-app/data/level.dat", string.Concat(Enumerable.Repeat("terrain mesh texture ", 20_000))); + AmprIndex.Build(game, Path.Combine(game, AmprIndex.IndexName)); + string config = dir.File("rules.toml", "[pack]\ndefault_action = \"loose\"\n[[rule]]\ninclude = \"data/**\"\n"); + + AmprPackPanelViewModel panel = Panel("pack"); + panel.Root = game; + panel.Output = Path.Combine(dir.Path, "packs"); + panel.Config = config; + await panel.RunCommand.ExecuteAsync(null); + Assert.Equal("✓ Completed successfully.", panel.Job.Lines[^1].Text); + Assert.Contains(panel.Job.Lines, l => l.Text.Contains("\"files_packed\": 1", StringComparison.Ordinal)); + Assert.True(File.Exists(panel.Manifest)); + + panel.Action = panel.Actions.Single(a => a.Value == "verify"); + await panel.RunCommand.ExecuteAsync(null); + Assert.Equal("✓ Completed successfully.", panel.Job.Lines[^1].Text); + Assert.Contains(panel.Job.Lines, l => l.Text.Contains("\"source_compare\"", StringComparison.Ordinal)); + + panel.Action = panel.Actions.Single(a => a.Value == "remove-sources"); + await panel.RunCommand.ExecuteAsync(null); + Assert.Equal("✓ Completed successfully.", panel.Job.Lines[^1].Text); + Assert.Contains(panel.Job.Lines, l => l.Text.Contains("\"dry_run\": true", StringComparison.Ordinal)); + Assert.True(File.Exists(Path.Combine(game, "data", "level.dat"))); + } +} diff --git a/tests/MkPFS.Gui.Tests/MainWindowTests.cs b/tests/MkPFS.Gui.Tests/MainWindowTests.cs index 56adaea..c3dc616 100644 --- a/tests/MkPFS.Gui.Tests/MainWindowTests.cs +++ b/tests/MkPFS.Gui.Tests/MainWindowTests.cs @@ -41,7 +41,7 @@ public void Sidebar_lists_every_operation_grouped_by_section() Assert.Equal(["section_build", "section_check", "section_read"], model.Sections.Select(s => s.TitleKey)); Assert.Equal( - ["batch", "pack_folder", "pack_exfat", "pack_file", "verify", "repair", "inspect", "tree", "unpack"], + ["batch", "pack_folder", "pack_exfat", "pack_file", "ampr", "verify", "repair", "inspect", "tree", "unpack"], NavButtons(window).Select(AutomationProperties.GetName)); Assert.True(Nav(window, "batch").IsChecked); Assert.Equal("Batch Convert", PageTitle(window)); diff --git a/tests/MkPFS.Parity/AMPRCliParityTests.cs b/tests/MkPFS.Parity/AMPRCliParityTests.cs new file mode 100644 index 0000000..47055bc --- /dev/null +++ b/tests/MkPFS.Parity/AMPRCliParityTests.cs @@ -0,0 +1,111 @@ +using System.Text; +using System.Text.Json; +using MkPFS.Cli; +using MkPFS.Cli.Output; + +namespace MkPFS.Parity; + +/// +/// Replays every ampr_pack command recorded by tools/oracle/build_ampr_goldens.py through mkpfs ampr, in +/// the oracle's order and folder layout, and compares stdout, stderr and the exit code with each log. +/// +public sealed class AMPRCliParityTests +{ + private const long FixedMTime = 1_600_000_000; + + // Execution order in build_ampr_goldens.py (the manifest stores steps with sorted keys). + private static readonly string[] StepOrder = ["pack", "verify", "list", "list_text", "inspect", "unpack", "remove_plan", "runtime_config"]; + + public static TheoryData Cases() + { + TheoryData data = []; + string? manifest = Fixtures.GeneratedRoot is { } root ? Path.Combine(root, "ampr", "goldens", "manifest.json") : null; + if (manifest is null || !File.Exists(manifest)) + { + data.Add("(corpus missing)"); + return data; + } + + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(manifest)); + foreach (JsonProperty entry in document.RootElement.GetProperty("cases").EnumerateObject()) + { + data.Add(entry.Name); + } + + return data; + } + + [Theory] + [MemberData(nameof(Cases))] + public void Every_step_matches_the_oracle_log(string caseName) + { + string caseDir = Fixtures.PathOrSkip("ampr", "goldens", caseName); + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + JsonElement entry = manifest.RootElement.GetProperty("cases").GetProperty(caseName); + string work = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-cli-" + Guid.NewGuid().ToString("N")); + try + { + Prepare(caseDir, entry, work); + JsonElement steps = entry.GetProperty("steps"); + Assert.All(steps.EnumerateObject(), step => Assert.Contains(step.Name, StepOrder)); + foreach (string stepName in StepOrder.Where(name => steps.TryGetProperty(name, out _))) + { + string[] argv = [.. steps.GetProperty(stepName).EnumerateArray().Select(a => a.GetString()!)]; + string expected = File.ReadAllText(Path.Combine(caseDir, stepName + ".log"), Encoding.UTF8).Replace("\r\n", "\n", StringComparison.Ordinal); + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: true) { WorkingDirectory = work }; + int exit = MkPFSCli.Run(["ampr", .. argv], ctx); + + // The port warns when a pack run packs nothing (ampr_pack.py stays silent): exactly where the oracle + // reported files_packed 0. + string warning = Cli.Commands.AmprCommand.NothingPackedWarning + "\n"; + string errors = stderr.ToString(); + Assert.Equal(expected.Contains("\"files_packed\": 0,", StringComparison.Ordinal), errors.Contains(warning, StringComparison.Ordinal)); + errors = errors.Replace(warning, string.Empty, StringComparison.Ordinal); + string actual = CliParityTests.Normalize( + $"$ ampr_pack.py {string.Join(' ', argv)}\nexit={exit}\n--- stdout\n{stdout}\n--- stderr\n{errors}", work); + + Assert.True(expected == actual, $"{caseName}/{stepName}:\n--- expected\n{expected}\n--- actual\n{actual}"); + } + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + // The oracle's case folder: app0 (fixture copy, pinned mtimes, its index, then removals and touches) plus the + // configuration files. + private static void Prepare(string caseDir, JsonElement entry, string work) + { + string tree = Fixtures.PathOrSkip("ampr", "trees", "ampr_assets"); + string app0 = Path.Combine(work, "app0"); + foreach (string file in Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(app0, Path.GetRelativePath(tree, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + File.SetLastWriteTimeUtc(target, DateTime.UnixEpoch.AddSeconds(FixedMTime)); + } + + File.Copy(Path.Combine(caseDir, "ampr_emu.index"), Path.Combine(app0, "ampr_emu.index")); + foreach (JsonElement removed in entry.GetProperty("remove_before_pack").EnumerateArray()) + { + File.Delete(Path.Combine(app0, removed.GetString()!)); + } + + foreach (JsonProperty touched in entry.GetProperty("touch_after_index").EnumerateObject()) + { + File.SetLastWriteTimeUtc(Path.Combine(app0, touched.Name), DateTime.UnixEpoch.AddSeconds(touched.Value.GetInt64())); + } + + foreach (string name in (string[])["config.toml", "list.txt", "runtime_alt.toml"]) + { + if (File.Exists(Path.Combine(caseDir, name))) + { + File.Copy(Path.Combine(caseDir, name), Path.Combine(work, name)); + } + } + } +} diff --git a/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs b/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs new file mode 100644 index 0000000..559c482 --- /dev/null +++ b/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs @@ -0,0 +1,207 @@ +using System.Security.Cryptography; +using System.Text.Json; +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Parity; + +/// +/// Rebuilds every golden pack set (tools/oracle/build_ampr_goldens.py) with and +/// compares each output file byte for byte (SHA-256), plus the stats and warnings ampr_pack printed. +/// +public sealed class AMPRPackBuilderParityTests +{ + private const long FixedMTime = 1_600_000_000; + + public static TheoryData BuiltCases => AMPRPackFormatParityTests.BuiltCases; + + private sealed record CaseInfo( + List Flags, List Removed, Dictionary Touched, bool HasConfig, bool HasRuntimeConfigStep); + + private static CaseInfo ReadCase(string caseName) + { + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + JsonElement entry = manifest.RootElement.GetProperty("cases").GetProperty(caseName); + return new CaseInfo( + [.. entry.GetProperty("flags").EnumerateArray().Select(e => e.GetString()!)], + [.. entry.GetProperty("remove_before_pack").EnumerateArray().Select(e => e.GetString()!)], + entry.GetProperty("touch_after_index").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetInt64()), + entry.GetProperty("toml").GetBoolean(), + entry.GetProperty("steps").TryGetProperty("runtime_config", out _)); + } + + // The oracle copies trees/ampr_assets, pins mtimes, builds the index, then removes or touches files. + private static string PrepareRoot(CaseInfo info, string work) + { + string tree = Fixtures.PathOrSkip("ampr", "trees", "ampr_assets"); + if (info.Removed.Count == 0 && info.Touched.Count == 0) + { + return tree; + } + + string root = Path.Combine(work, "app0"); + foreach (string file in Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(root, Path.GetRelativePath(tree, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + File.SetLastWriteTimeUtc(target, DateTime.UnixEpoch.AddSeconds(FixedMTime)); + } + + foreach (string removed in info.Removed) + { + File.Delete(Path.Combine(root, removed)); + } + + foreach ((string relative, long mtime) in info.Touched) + { + File.SetLastWriteTimeUtc(Path.Combine(root, relative), DateTime.UnixEpoch.AddSeconds(mtime)); + } + + return root; + } + + private static AMPRBuildResult Run(string caseName, string outDir, string work) + { + CaseInfo info = ReadCase(caseName); + string root = PrepareRoot(info, work); + string caseDir = Fixtures.PathOrSkip("ampr", "goldens", caseName); + AMPRPackConfig config = AMPRPackConfig.Load(info.HasConfig ? Path.Combine(caseDir, "config.toml") : null); + List include = []; + List exclude = []; + bool allowMissing = false; + for (int i = 0; i < info.Flags.Count; i++) + { + switch (info.Flags[i]) + { + case "--workers": + config.Workers = long.Parse(info.Flags[++i], System.Globalization.CultureInfo.InvariantCulture); + break; + case "--self-contained": + config.SelfContained = true; + break; + case "--require-packed": + config.RequiredPacked = [.. config.RequiredPacked, info.Flags[++i]]; + break; + case "--include": + include.Add(info.Flags[++i]); + break; + case "--exclude": + exclude.Add(info.Flags[++i]); + break; + case "--allow-missing": + allowMissing = true; + break; + default: + throw new InvalidOperationException($"unhandled flag {info.Flags[i]}"); + } + } + + return AMPRPackBuilder.Build(root, Path.Combine(caseDir, "ampr_emu.index"), outDir, config, include, exclude, allowMissing); + } + + private static JsonElement OracleOutput(string caseName) + { + string log = File.ReadAllText(Fixtures.PathOrSkip("ampr", "goldens", caseName, "pack.log")); + int start = log.IndexOf("--- stdout\n", StringComparison.Ordinal) + "--- stdout\n".Length; + int end = log.IndexOf("\n--- stderr", StringComparison.Ordinal); + using JsonDocument document = JsonDocument.Parse(log[start..end]); + return document.RootElement.Clone(); + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Pack_set_matches_the_oracle_byte_for_byte(string caseName) + { + string work = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-build-" + Guid.NewGuid().ToString("N")); + string outDir = Path.Combine(work, "out"); + try + { + AMPRBuildResult result = Run(caseName, outDir, work); + + Dictionary expected = OracleHashesUnder(caseName, "out/"); + Dictionary actual = Directory.EnumerateFiles(outDir, "*", SearchOption.AllDirectories).ToDictionary( + p => "out/" + Path.GetRelativePath(outDir, p).Replace('\\', '/'), + p => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(p))), + StringComparer.Ordinal); + if (ReadCase(caseName).HasRuntimeConfigStep) + { + // The oracle's later runtime-config step rewrote .runtime; check the build's own settings instead. + const string runtime = "out/ampr_assets.index.runtime"; + AMPRPackConfig config = AMPRPackConfig.Load(Fixtures.PathOrSkip("ampr", "goldens", caseName, "config.toml")); + byte[] buildId = Core.AMPR.AMPRPackManifest.Load(result.IndexPath).BuildId; + Assert.Equal(config.Runtime!.Encode(buildId), File.ReadAllBytes(Path.Combine(outDir, "ampr_assets.index.runtime"))); + expected.Remove(runtime); + actual.Remove(runtime); + } + + Assert.Equal(expected.OrderBy(kv => kv.Key, StringComparer.Ordinal), actual.OrderBy(kv => kv.Key, StringComparer.Ordinal)); + + JsonElement oracle = OracleOutput(caseName); + AMPRBuildStats stats = result.Stats; + Assert.Equal( + Numbers(oracle), + new Dictionary + { + ["files_total"] = stats.FilesTotal, + ["files_packed"] = stats.FilesPacked, + ["files_loose"] = stats.FilesLoose, + ["files_auto_loose"] = stats.FilesAutoLoose, + ["auto_loose_logical_bytes"] = stats.AutoLooseLogicalBytes, + ["auto_loose_sampled_bytes"] = stats.AutoLooseSampledBytes, + ["chunks"] = stats.Chunks, + ["chunks_lz4"] = stats.ChunksLZ4, + ["chunks_raw"] = stats.ChunksRaw, + ["chunks_shared"] = stats.ChunksShared, + ["logical_bytes"] = stats.LogicalBytes, + ["stored_bytes"] = stats.StoredBytes, + ["padding_bytes"] = stats.PaddingBytes, + ["io_pages_touched"] = stats.IOPagesTouched, + ["io_page_safe_chunks"] = stats.IOPageSafeChunks, + ["dense_streaming_chunks"] = stats.DenseStreamingChunks, + }); + Assert.Equal(Strings(oracle, "loose_paths"), stats.LoosePaths); + Assert.Equal(Strings(oracle, "warnings"), result.Warnings); + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + [Theory] + [InlineData("neg_missing")] + [InlineData("neg_require_packed")] + public void Build_errors_match_the_oracle_and_leave_no_output(string caseName) + { + string log = File.ReadAllText(Fixtures.PathOrSkip("ampr", "goldens", caseName, "pack.log")); + string expected = log.Split('\n').Single(line => line.StartsWith("error: ", StringComparison.Ordinal))["error: ".Length..]; + string work = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-build-" + Guid.NewGuid().ToString("N")); + string outDir = Path.Combine(work, "out"); + try + { + AMPRPackException error = Assert.Throws(() => Run(caseName, outDir, work)); + Assert.Equal(expected, error.Message); + Assert.Empty(Directory.EnumerateFileSystemEntries(outDir)); + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + private static Dictionary OracleHashesUnder(string caseName, string prefix) + { + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + return manifest.RootElement.GetProperty("cases").GetProperty(caseName).GetProperty("sha256").EnumerateObject() + .Where(p => p.Name.StartsWith(prefix, StringComparison.Ordinal)) + .ToDictionary(p => p.Name, p => p.Value.GetString()!, StringComparer.Ordinal); + } + + private static Dictionary Numbers(JsonElement oracle) => oracle.EnumerateObject() + .Where(p => p.Value.ValueKind == JsonValueKind.Number && p.Name != "compression_ratio") + .ToDictionary(p => p.Name, p => p.Value.GetInt64()); + + private static List Strings(JsonElement oracle, string name) => + [.. oracle.GetProperty(name).EnumerateArray().Select(e => e.GetString()!)]; +} diff --git a/tests/MkPFS.Parity/AMPRPackConfigParityTests.cs b/tests/MkPFS.Parity/AMPRPackConfigParityTests.cs new file mode 100644 index 0000000..0d58dc7 --- /dev/null +++ b/tests/MkPFS.Parity/AMPRPackConfigParityTests.cs @@ -0,0 +1,42 @@ +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Parity; + +/// +/// Loads every golden pack configuration (ampr/goldens/<case>/config.toml) and compares the build-id input +/// with the oracle's _canonical_config_bytes dump (config.canonical.json). +/// +public sealed class AMPRPackConfigParityTests +{ + public static TheoryData ConfiguredCases => new( + "default", "unsafe_default", "example", "fast", "fast_accel8", "hc9", "block16k_random", "block1m", "streaming", + "store_movies", "dedup_off", "dedup_group", "dedup_streaming", "lanes_balanced", "lanes_hash", "lanes_round_robin", + "stripe_rollover", "auto_loose", "self_contained", "runtime", "io_page_4k", "mtime_preserved", "no_preserve_mtime", + "cli_include_exclude", "include_from", "allow_missing", "workers1", "workers8"); + + [Theory] + [MemberData(nameof(ConfiguredCases))] + public void Canonical_config_bytes_match_the_oracle(string caseName) + { + string config = Fixtures.PathOrSkip("ampr", "goldens", caseName, "config.toml"); + byte[] expected = File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", caseName, "config.canonical.json")); + + Assert.Equal(System.Text.Encoding.UTF8.GetString(expected), System.Text.Encoding.UTF8.GetString(AMPRPackConfig.Load(config).CanonicalBytes())); + } + + [Theory] + [InlineData("neg_bad_block")] + [InlineData("neg_unknown_group")] + [InlineData("neg_runtime_keys")] + public void Config_errors_match_the_oracle_message(string caseName) + { + string config = Fixtures.PathOrSkip("ampr", "goldens", caseName, "config.toml"); + string log = File.ReadAllText(Fixtures.PathOrSkip("ampr", "goldens", caseName, "pack.log")); + string expected = log.Split('\n').Single(line => line.StartsWith("error: ", StringComparison.Ordinal))["error: ".Length..]; + + Exception error = Assert.ThrowsAny(() => AMPRPackConfig.Load(config)); + Assert.True(error is AMPRPackException or ArgumentException, error.GetType().Name); + Assert.Equal(expected, error.Message); + } +} diff --git a/tests/MkPFS.Parity/AMPRPackFormatParityTests.cs b/tests/MkPFS.Parity/AMPRPackFormatParityTests.cs new file mode 100644 index 0000000..3d6f960 --- /dev/null +++ b/tests/MkPFS.Parity/AMPRPackFormatParityTests.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using System.Text.Json; +using MkPFS.Core.AMPR; +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Parity; + +/// +/// Reads every pack set built by ampr_emu ampr_pack.py (ampr/goldens, see +/// tools/oracle/build_ampr_goldens.py), re-serializes each structure byte for byte, and decodes every +/// packed file against the oracle's unpack hashes and the decoded-chunk CRC sidecar. +/// +public sealed class AMPRPackFormatParityTests +{ + public static TheoryData BuiltCases => new( + "default", "unsafe_default", "no_config", "example", "fast", "fast_accel8", "hc9", "block16k_random", "block1m", + "streaming", "store_movies", "dedup_off", "dedup_group", "dedup_streaming", "lanes_balanced", "lanes_hash", + "lanes_round_robin", "stripe_rollover", "auto_loose", "self_contained", "runtime", "io_page_4k", "mtime_preserved", + "no_preserve_mtime", "cli_include_exclude", "include_from", "allow_missing", "workers1", "workers8"); + + private static Dictionary OracleHashes(string caseName) + { + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + return manifest.RootElement.GetProperty("cases").GetProperty(caseName).GetProperty("sha256") + .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()!, StringComparer.Ordinal); + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Manifest_sidecars_and_volume_headers_round_trip(string caseName) + { + string outDir = Fixtures.PathOrSkip("ampr", "goldens", caseName, "out"); + string indexPath = Path.Combine(outDir, "ampr_assets.index"); + byte[] indexBytes = File.ReadAllBytes(indexPath); + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + Assert.Equal(indexBytes, manifest.Serialize()); + + byte[] crcBytes = File.ReadAllBytes(AMPRPackFormat.ChunkCrcPath(indexPath)); + uint[] crcs = AMPRChunkCrcs.Parse(crcBytes, manifest.BuildId, manifest.Chunks.Count); + Assert.Equal(crcBytes, AMPRChunkCrcs.Build(manifest.BuildId, crcs)); + + string runtimePath = AMPRPackFormat.RuntimePath(indexPath); + AMPRRuntimeSettings? runtime = AMPRRuntimeSettings.Read(runtimePath, manifest.BuildId); + Assert.Equal(File.Exists(runtimePath), runtime is not null); + if (runtime is not null) + { + Assert.Equal(File.ReadAllBytes(runtimePath), runtime.Encode(manifest.BuildId)); + } + + for (int packId = 0; packId < manifest.Packs.Count; packId++) + { + AMPRPackRecord record = manifest.Packs[packId]; + string volume = AMPRAssetPath.SafeOutputPath(outDir, manifest.PackName(packId)); + byte[] head = new byte[AMPRPackFormat.DataHeaderSize]; + using (FileStream stream = File.OpenRead(volume)) + { + stream.ReadExactly(head); + } + + AMPRDataHeader header = AMPRDataHeader.Parse(head, (uint)packId, manifest.BuildId, record.Flags); + Assert.Equal(head, header.ToBytes()); + Assert.Equal(record.FileSize, (ulong)new FileInfo(volume).Length); + Assert.Equal(record.PayloadBytes, header.PayloadBytes); + Assert.Equal(record.FileSize, header.PayloadOffset + header.PayloadBytes); + } + + // AMPRIDX3 rows map one-to-one onto manifest file records (file id = row + 1). + List index = AMPRIndexReader.Read(Fixtures.PathOrSkip("ampr", "goldens", caseName, "ampr_emu.index")); + Assert.Equal(index.Count, manifest.Files.Count); + for (int i = 0; i < index.Count; i++) + { + Assert.Equal(index[i].Path, manifest.FilePath(i + 1)); + Assert.Equal(AMPRAssetPath.Hash(index[i].Path), manifest.Files[i].PathHash); + } + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Decoded_files_match_oracle_unpack_hashes_and_chunk_crcs(string caseName) + { + string outDir = Fixtures.PathOrSkip("ampr", "goldens", caseName, "out"); + string indexPath = Path.Combine(outDir, "ampr_assets.index"); + AMPRPackManifest manifest = AMPRPackManifest.Load(indexPath); + uint[] crcs = AMPRChunkCrcs.Load(AMPRPackFormat.ChunkCrcPath(indexPath), manifest.BuildId, manifest.Chunks.Count); + Dictionary oracle = OracleHashes(caseName); + byte[][] volumes = [.. Enumerable.Range(0, manifest.Packs.Count) + .Select(id => File.ReadAllBytes(AMPRAssetPath.SafeOutputPath(outDir, manifest.PackName(id))))]; + + int packed = 0; + for (int fileId = 1; fileId <= manifest.Files.Count; fileId++) + { + AMPRFileRecord record = manifest.Files[fileId - 1]; + if (!record.IsPacked) + { + continue; + } + + packed++; + using IncrementalHash sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + for (uint local = 0; local < record.ChunkCount; local++) + { + int chunkIndex = (int)(record.FirstChunk + local); + AMPRChunkRecord chunk = manifest.Chunks[chunkIndex]; + ReadOnlySpan stored = volumes[chunk.PackId].AsSpan((int)chunk.Offset, chunk.StoredSize); + byte[] raw = new byte[chunk.RawSize]; + if (chunk.Codec == AMPRPackFormat.ChunkCodecLZ4) + { + Assert.Equal(chunk.RawSize, LZ4Codec.Decompress(stored, raw)); + } + else + { + stored.CopyTo(raw); + } + + Assert.Equal(crcs[chunkIndex], Crc32.Update(0, raw)); + sha.AppendData(raw); + } + + string relative = AMPRAssetPath.Relative(manifest.FilePath(fileId)); + Assert.Equal(oracle[$"unpacked/{relative}"], Convert.ToHexStringLower(sha.GetHashAndReset())); + } + + Assert.Equal(oracle.Keys.Count(k => k.StartsWith("unpacked/", StringComparison.Ordinal)), packed); + } +} diff --git a/tests/MkPFS.Parity/AMPRPackOperationsParityTests.cs b/tests/MkPFS.Parity/AMPRPackOperationsParityTests.cs new file mode 100644 index 0000000..0b6cc75 --- /dev/null +++ b/tests/MkPFS.Parity/AMPRPackOperationsParityTests.cs @@ -0,0 +1,218 @@ +using System.Security.Cryptography; +using System.Text.Json; +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Parity; + +/// +/// Runs verify, list, inspect, unpack, the removal plan and runtime-config on every golden pack set and compares +/// the results with the JSON ampr_pack printed (verify.log, list.log, ...). +/// +public sealed class AMPRPackOperationsParityTests +{ + public static TheoryData BuiltCases => AMPRPackFormatParityTests.BuiltCases; + + private static string Index(string caseName) => Fixtures.PathOrSkip("ampr", "goldens", caseName, "out", "ampr_assets.index"); + + // Removed files only ever affect loose entries, so the shared fixture tree serves as every case's /app0. + private static string Root() => Fixtures.PathOrSkip("ampr", "trees", "ampr_assets"); + + private static JsonElement Stdout(string caseName, string step) + { + string log = File.ReadAllText(Fixtures.PathOrSkip("ampr", "goldens", caseName, step + ".log")); + int start = log.IndexOf("--- stdout\n", StringComparison.Ordinal) + "--- stdout\n".Length; + int end = log.IndexOf("\n--- stderr", StringComparison.Ordinal); + using JsonDocument document = JsonDocument.Parse(log[start..end]); + return document.RootElement.Clone(); + } + + private static string? Error(string caseName, string step) => + File.ReadAllText(Fixtures.PathOrSkip("ampr", "goldens", caseName, step + ".log")).Split('\n') + .FirstOrDefault(line => line.StartsWith("error: ", StringComparison.Ordinal))?["error: ".Length..]; + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Verify_and_source_compare_match(string caseName) + { + JsonElement oracle = Stdout(caseName, "verify"); + AMPRVerifyResult verify = AMPRPackTools.Verify(Index(caseName)); + AMPRSourceCompareResult compare = AMPRPackTools.VerifyAgainstRoot(Index(caseName), Root()); + + Assert.Equal( + (oracle.GetProperty("files").GetInt64(), oracle.GetProperty("physical_chunks").GetInt64(), + oracle.GetProperty("stored_bytes").GetInt64(), oracle.GetProperty("raw_bytes").GetInt64()), + (verify.Files, verify.PhysicalChunks, verify.StoredBytes, verify.RawBytes)); + JsonElement source = oracle.GetProperty("source_compare"); + Assert.Equal( + (source.GetProperty("files").GetInt64(), source.GetProperty("chunks").GetInt64(), source.GetProperty("bytes").GetInt64()), + (compare.Files, compare.Chunks, compare.Bytes)); + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void List_matches(string caseName) + { + JsonElement[] oracle = [.. Stdout(caseName, "list").EnumerateArray()]; + List rows = AMPRPackTools.List(Index(caseName)); + + Assert.Equal(oracle.Length, rows.Count); + for (int i = 0; i < rows.Count; i++) + { + JsonElement o = oracle[i]; + AMPRListEntry r = rows[i]; + Assert.Equal(o.GetProperty("file_id").GetInt32(), r.FileId); + Assert.Equal(o.GetProperty("path").GetString(), r.Path); + Assert.Equal(o.GetProperty("packed").GetBoolean(), r.Packed); + Assert.Equal(o.GetProperty("logical_size").GetUInt64(), r.LogicalSize); + Assert.Equal(o.GetProperty("stored_size").GetInt64(), r.StoredSize); + Assert.Equal(o.GetProperty("block_size").GetInt64(), r.BlockSize); + Assert.Equal(o.GetProperty("chunks").GetUInt32(), r.Chunks); + Assert.Equal(o.GetProperty("codecs").EnumerateArray().Select(e => e.GetString()!), r.Codecs); + Assert.Equal(o.GetProperty("packs").EnumerateArray().Select(e => e.GetInt32()), r.Packs); + Assert.Equal(o.GetProperty("io_page_sizes").EnumerateArray().Select(e => e.GetUInt32()), r.IOPageSizes); + Assert.Equal(o.GetProperty("layout").GetString(), r.Layout); + Assert.Equal(o.GetProperty("streaming").GetBoolean(), r.Streaming); + Assert.Equal(o.GetProperty("random_access").GetBoolean(), r.RandomAccess); + Assert.Equal(o.GetProperty("hot").GetBoolean(), r.Hot); + } + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Inspect_matches(string caseName) + { + JsonElement oracle = Stdout(caseName, "inspect"); + AMPRInspectResult result = AMPRPackTools.Inspect(Index(caseName)); + + Assert.Equal(oracle.GetProperty("build_id").GetString(), result.BuildId); + Assert.Equal( + (oracle.GetProperty("files").GetInt64(), oracle.GetProperty("packed_files").GetInt64(), + oracle.GetProperty("loose_files").GetInt64(), oracle.GetProperty("chunks").GetInt64()), + (result.Files, result.PackedFiles, result.LooseFiles, result.Chunks)); + // The oracle ran inspect before its runtime-config step rewrote .runtime; expect the final settings. + JsonElement runtime = File.Exists(Path.Combine(Fixtures.PathOrSkip("ampr", "goldens", caseName), "runtime_config.log")) + ? Stdout(caseName, "runtime_config").GetProperty("runtime") + : oracle.GetProperty("runtime"); + if (runtime.ValueKind == JsonValueKind.Null) + { + Assert.Null(result.Runtime); + } + else + { + Assert.Equal( + new AMPRRuntimeSettings( + runtime.GetProperty("decoded_cache_bytes").GetInt64(), + runtime.GetProperty("physical_cache_bytes").GetInt64(), + runtime.GetProperty("workers").GetInt64(), + runtime.GetProperty("latency_reserve_workers").GetInt64()), + result.Runtime); + } + + Assert.Equal( + oracle.GetProperty("packs").EnumerateArray().Select(p => new AMPRInspectPack( + p.GetProperty("id").GetInt32(), + p.GetProperty("name").GetString()!, + p.GetProperty("file_size").GetUInt64(), + p.GetProperty("payload_bytes").GetUInt64(), + p.GetProperty("io_page_size").GetUInt32(), + p.GetProperty("io_pages").GetUInt64(), + p.GetProperty("flags").GetUInt32())), + result.Packs); + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Unpack_matches_counts_hashes_and_mtimes(string caseName) + { + JsonElement oracle = Stdout(caseName, "unpack"); + string output = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-unpack-" + Guid.NewGuid().ToString("N")); + try + { + AMPRExtractResult result = AMPRPackTools.Extract(Index(caseName), output); + Assert.Equal((oracle.GetProperty("files").GetInt64(), oracle.GetProperty("bytes").GetInt64()), (result.Files, result.Bytes)); + + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + Dictionary expected = manifest.RootElement.GetProperty("cases").GetProperty(caseName).GetProperty("sha256") + .EnumerateObject().Where(p => p.Name.StartsWith("unpacked/", StringComparison.Ordinal)) + .ToDictionary(p => p.Name["unpacked/".Length..], p => p.Value.GetString()!, StringComparer.Ordinal); + Dictionary actual = Directory.Exists(output) + ? Directory.EnumerateFiles(output, "*", SearchOption.AllDirectories).ToDictionary( + p => Path.GetRelativePath(output, p).Replace('\\', '/'), + p => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(p))), + StringComparer.Ordinal) + : []; + Assert.Equal(expected.OrderBy(kv => kv.Key, StringComparer.Ordinal), actual.OrderBy(kv => kv.Key, StringComparer.Ordinal)); + + AMPRPackManifest pack = AMPRPackManifest.Load(Index(caseName)); + for (int fileId = 1; fileId <= pack.Files.Count; fileId++) + { + if (pack.Files[fileId - 1].IsPacked) + { + string path = Path.Combine(output, AMPRAssetPath.Relative(pack.FilePath(fileId))); + Assert.Equal(DateTime.UnixEpoch.AddSeconds(pack.Files[fileId - 1].MTime), File.GetLastWriteTimeUtc(path)); + } + } + } + finally + { + if (Directory.Exists(output)) + { + Directory.Delete(output, recursive: true); + } + } + } + + [Theory] + [MemberData(nameof(BuiltCases))] + public void Removal_plan_matches(string caseName) + { + string? error = Error(caseName, "remove_plan"); + if (error is not null) + { + Assert.Equal(error, Assert.Throws(() => AMPRPackMaintenance.RemovalPlan(Index(caseName), Root())).Message); + return; + } + + JsonElement oracle = Stdout(caseName, "remove_plan"); + AMPRRemovalPlan plan = AMPRPackMaintenance.RemovalPlan(Index(caseName), Root()); + Assert.True(oracle.GetProperty("dry_run").GetBoolean()); + Assert.Equal( + (oracle.GetProperty("files").GetInt64(), oracle.GetProperty("present_files").GetInt64(), + oracle.GetProperty("missing_files").GetInt64(), oracle.GetProperty("bytes").GetInt64()), + (plan.Files, plan.PresentFiles, plan.MissingFiles, plan.Bytes)); + Assert.Equal(oracle.GetProperty("paths").EnumerateArray().Select(e => e.GetString()!), plan.Paths); + } + + [Fact] + public void Runtime_config_rewrites_the_runtime_like_the_oracle() + { + string caseDir = Fixtures.PathOrSkip("ampr", "goldens", "runtime"); + JsonElement oracle = Stdout("runtime", "runtime_config"); + string work = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-runtime-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(work); + string index = Path.Combine(work, "ampr_assets.index"); + File.Copy(Path.Combine(caseDir, "out", "ampr_assets.index"), index); + + AMPRRuntimeConfigResult result = AMPRPackMaintenance.WriteRuntimeConfig(index, Path.Combine(caseDir, "runtime_alt.toml")); + + Assert.Equal(File.ReadAllBytes(Path.Combine(caseDir, "out", "ampr_assets.index.runtime")), File.ReadAllBytes(index + ".runtime")); + Assert.Equal(oracle.GetProperty("build_id").GetString(), result.BuildId); + JsonElement runtime = oracle.GetProperty("runtime"); + Assert.Equal( + new AMPRRuntimeSettings( + runtime.GetProperty("decoded_cache_bytes").GetInt64(), + runtime.GetProperty("physical_cache_bytes").GetInt64(), + runtime.GetProperty("workers").GetInt64(), + runtime.GetProperty("latency_reserve_workers").GetInt64()), + result.Runtime); + Assert.Empty(Directory.GetFiles(work, ".runtime-*")); + } + finally + { + Directory.Delete(work, recursive: true); + } + } +} diff --git a/tests/MkPFS.Parity/LZ4VectorTests.cs b/tests/MkPFS.Parity/LZ4VectorTests.cs new file mode 100644 index 0000000..d14b569 --- /dev/null +++ b/tests/MkPFS.Parity/LZ4VectorTests.cs @@ -0,0 +1,82 @@ +using System.Buffers.Binary; +using System.Text.Json; +using MkPFS.Core.Compression; + +namespace MkPFS.Parity; + +/// +/// Byte-for-byte check of the native lz4 against the AMPR pack oracle codec (ampr_emu +/// ampr_pack_format.Lz4Codec, python-lz4 4.4.5), recorded by tools/oracle/build_ampr_goldens.py +/// in ampr/vectors/lz4_vectors.bin (format L4VEC001). +/// +public sealed class LZ4VectorTests +{ + private sealed record Vector(int Index, bool HighCompression, int Parameter, byte[] Raw, byte[] Compressed) + { + public override string ToString() => $"#{Index} {(HighCompression ? "hc" : "fast")}{Parameter}/{Raw.Length}"; + } + + private static List LoadVectors() + { + byte[] data = File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "vectors", "lz4_vectors.bin")); + Assert.Equal("L4VEC001"u8.ToArray(), data[..8]); + int count = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(8)); + List vectors = new(count); + int offset = 12; + for (int i = 0; i < count; i++) + { + byte mode = data[offset]; + int parameter = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 4)); + int rawLength = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 8)); + int compressedLength = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 12)); + offset += 16; + byte[] raw = data.AsSpan(offset, rawLength).ToArray(); + offset += rawLength; + byte[] compressed = data.AsSpan(offset, compressedLength).ToArray(); + offset += compressedLength; + vectors.Add(new Vector(i, mode == 1, parameter, raw, compressed)); + } + + Assert.Equal(data.Length, offset); + return vectors; + } + + [Fact] + public void Native_lz4_output_matches_ampr_pack_for_every_vector() + { + List vectors = LoadVectors(); + Assert.NotEmpty(vectors); + using LZ4Encoder encoder = new(); + List mismatches = []; + foreach (Vector vector in vectors) + { + byte[] produced = vector.HighCompression + ? encoder.CompressHC(vector.Raw, vector.Parameter) + : encoder.CompressFast(vector.Raw, vector.Parameter); + if (!produced.AsSpan().SequenceEqual(vector.Compressed)) + { + mismatches.Add(vector); + } + } + + Assert.True(mismatches.Count == 0, $"vectors differing from ampr_pack: {string.Join(", ", mismatches)}"); + } + + [Fact] + public void Native_lz4_decodes_every_ampr_pack_vector() + { + foreach (Vector vector in LoadVectors()) + { + byte[] output = new byte[vector.Raw.Length]; + Assert.Equal(vector.Raw.Length, LZ4Codec.Decompress(vector.Compressed, output)); + Assert.Equal(vector.Raw, output); + } + } + + [Fact] + public void Oracle_used_the_same_lz4_release() + { + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllBytes(Fixtures.PathOrSkip("ampr", "goldens", "manifest.json"))); + Assert.Equal(LZ4Codec.NativeVersion, manifest.RootElement.GetProperty("versions").GetProperty("liblz4").GetString()); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs new file mode 100644 index 0000000..6965675 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs @@ -0,0 +1,100 @@ +using MkPFS.Core.AMPR; +using MkPFS.Core.Util; + +namespace MkPFS.Tests.AMPR; + +/// fnmatch, float repr, splitlines and JSON helpers; expected values come from CPython 3.11. +public sealed class AMPRGlobTests +{ + [Theory] + [InlineData("a/b.txt", "*", true)] + [InlineData("a/b.txt", "*.txt", true)] + [InlineData("a/b.txt", "a/*", true)] + [InlineData("a/b/c", "a/**", true)] + [InlineData("a/b/c", "**/c", true)] + [InlineData("c", "**/c", false)] + [InlineData("x.BIN", "*.bin", false)] + [InlineData("a[1]", "a[[]1]", true)] + [InlineData("a1", "a[0-9]", true)] + [InlineData("a-", "a[a-]", true)] + [InlineData("ab", "a[!b]", false)] + [InlineData("ac", "a[!b]", true)] + [InlineData("a]", "a[]]", true)] + [InlineData("a!", "a[!]", false)] + [InlineData("a[!]", "a[!]", true)] + [InlineData("a", "a[]", false)] + [InlineData("ab", "a?", true)] + [InlineData("a.b", "a.b", true)] + [InlineData("axb", "a.b", false)] + [InlineData("a+b", "a+b", true)] + [InlineData("a\\b", "a\\b", true)] + [InlineData("a^", "a[^]", true)] + [InlineData("a^", "a[\\^]", true)] + [InlineData("a\\", "a[\\^]", true)] + [InlineData("ab", "a[z-a]", false)] + [InlineData("a-", "a[z-a-]", true)] + [InlineData("a&", "a[&&]", true)] + [InlineData("a~", "a[~~]", true)] + [InlineData("a\nb", "a*b", true)] + [InlineData("a/b", "a?b", true)] + [InlineData("a", "[a-c-e]", true)] + [InlineData("d", "[a-c-e]", false)] + [InlineData("-", "[a-c-e]", true)] + [InlineData("\u00e9", "?", true)] + [InlineData("a", "a**", true)] + [InlineData("", "*", true)] + [InlineData("a[", "a[", true)] + [InlineData("a[b", "a[b", true)] + [InlineData("a]x", "a[]x]", false)] + [InlineData("a]", "a[!]x]", false)] + [InlineData("ay", "a[!]x]", true)] + public void FnMatchCase_matches_python(string name, string pattern, bool expected) => + Assert.Equal(expected, AMPRGlob.FnMatchCase(name, pattern)); + + [Fact] + public void Matches_normalizes_separators_and_leading_slashes() + { + Assert.True(AMPRGlob.Matches("\\assets\\a.bin", ["/assets/*.bin"])); + Assert.True(AMPRGlob.Matches("assets/a.bin", ["x", "assets\\*"])); + Assert.False(AMPRGlob.Matches("assets/a.bin", [])); + } + + [Theory] + [InlineData(0.01, "0.01")] + [InlineData(0.125, "0.125")] + [InlineData(1e-05, "1e-05")] + [InlineData(0.0001, "0.0001")] + [InlineData(1e16, "1e+16")] + [InlineData(1e15, "1000000000000000.0")] + [InlineData(123456789012345678.0, "1.2345678901234568e+17")] + [InlineData(0.0, "0.0")] + [InlineData(-0.0, "-0.0")] + [InlineData(1.0, "1.0")] + [InlineData(0.30000000000000004, "0.30000000000000004")] + [InlineData(5e-324, "5e-324")] + [InlineData(1.7976931348623157e308, "1.7976931348623157e+308")] + [InlineData(2.5, "2.5")] + [InlineData(100.0, "100.0")] + [InlineData(1e22, "1e+22")] + [InlineData(-12.5, "-12.5")] + public void FloatRepr_matches_python(double value, string expected) => Assert.Equal(expected, PythonText.FloatRepr(value)); + + [Fact] + public void SplitLines_matches_python() + { + Assert.Equal(["a", "b", "", "c", "d", "e"], PythonText.SplitLines("a\nb\r\n\rc\u2028d\u0085e\n")); + Assert.Empty(PythonText.SplitLines(string.Empty)); + } + + [Fact] + public void Json_matches_python_dumps_sorted_compact() + { + Dictionary value = new() + { + ["b"] = new List { 1L, 0.5, true, null, "\u00e9\"\\\n\u007f" }, + ["a"] = new Dictionary { ["y"] = 1e-05, ["x"] = 0.0 }, + }; + + Assert.Equal("{\"a\":{\"x\":0.0,\"y\":1e-05},\"b\":[1,0.5,true,null,\"\\u00e9\\\"\\\\\\n\\u007f\"]}", PythonSortedJson.Compact(value)); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPackBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPackBuilderTests.cs new file mode 100644 index 0000000..ce11804 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPackBuilderTests.cs @@ -0,0 +1,185 @@ +using System.Text; +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// Builder behavior the golden corpus does not cover: placement, sampling, republishing and failures. +public sealed class AMPRPackBuilderTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-builder-" + Guid.NewGuid().ToString("N")); + + public AMPRPackBuilderTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + [Theory] + [InlineData(0, 5, new long[0])] + [InlineData(3, 5, new long[] { 0, 1, 2 })] + [InlineData(5, 5, new long[] { 0, 1, 2, 3, 4 })] + [InlineData(10, 1, new long[] { 5 })] + [InlineData(10, 3, new long[] { 0, 4, 9 })] + [InlineData(7, 2, new long[] { 0, 6 })] + [InlineData(1000, 7, new long[] { 0, 166, 333, 499, 666, 832, 999 })] + public void Sample_block_indices_match_python(long total, long samples, long[] expected) => + Assert.Equal(expected, AMPRPackPlanner.SampleBlockIndices(total, samples)); + + [Fact] + public void Placement_keeps_small_chunks_inside_one_page_and_large_chunks_page_aligned() + { + AMPRPackConfig config = AMPRPackConfig.Load(null); + AMPRPackLayout layout = new(config, _dir); + AMPRGroupConfig group = new("default"); + try + { + AMPRPackVolumeWriter writer = layout.WriterFor(group, 0, 100, false, "mixed", false); + Assert.Equal(65536, writer.PayloadOffset); + + (long first, int firstFlags) = writer.Write(new byte[60000], "mixed"); + Assert.Equal((65536L, AMPRPackFormat.ChunkFlagPageContained | AMPRPackFormat.ChunkFlagPageAligned), (first, firstFlags)); + + // 60000 rounds up to 60032; 10000 more would cross the page at 131072, so it moves to the next page. + (long second, _) = writer.Write(new byte[10000], "mixed"); + Assert.Equal(131072, second); + + // A full-page chunk starts on a page boundary; a streaming chunk stays dense at 64-byte alignment. + (long third, int thirdFlags) = writer.Write(new byte[65536], "mixed"); + Assert.Equal((196608L, AMPRPackFormat.ChunkFlagPageContained | AMPRPackFormat.ChunkFlagPageAligned), (third, thirdFlags)); + (long fourth, int fourthFlags) = writer.Write(new byte[100], "streaming"); + Assert.Equal((262144L, AMPRPackFormat.ChunkFlagPageContained | AMPRPackFormat.ChunkFlagPageAligned), (fourth, fourthFlags)); + (long fifth, int fifthFlags) = writer.Write(new byte[65536], "streaming"); + Assert.Equal((262144L + 128, 0), (fifth, fifthFlags)); + } + finally + { + layout.Abort(); + } + } + + private string MakeTree(params (string Path, byte[] Data)[] files) + { + string root = Path.Combine(_dir, "app0"); + foreach ((string path, byte[] data) in files) + { + string full = Path.Combine(root, path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllBytes(full, data); + } + + AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); + return root; + } + + private string WriteConfig(string toml) + { + string path = Path.Combine(_dir, "config.toml"); + File.WriteAllText(path, toml, new UTF8Encoding(false)); + return path; + } + + private static byte[] Text(int size) => Encoding.ASCII.GetBytes(string.Concat(Enumerable.Repeat("asset texture mesh ", (size / 19) + 1)))[..size]; + + [Fact] + public void Rebuild_replaces_the_set_removes_a_stale_runtime_and_leaves_no_temporaries() + { + string root = MakeTree(("data/a.txt", Text(200_000)), ("data/b.txt", Text(1000))); + string index = Path.Combine(root, AmprIndex.IndexName); + string output = Path.Combine(_dir, "out"); + AMPRPackConfig withRuntime = AMPRPackConfig.Load(WriteConfig( + "[pack]\ndefault_action = \"compress\"\n[runtime]\ndecoded_cache_bytes = \"64MiB\"\nphysical_cache_bytes = 0\nworkers = 2\nlatency_reserve_workers = 0\n")); + + AMPRBuildResult first = AMPRPackBuilder.Build(root, index, output, withRuntime); + Assert.True(File.Exists(first.IndexPath + ".runtime")); + byte[] firstBuildId = AMPRPackManifest.Load(first.IndexPath).BuildId; + + AMPRPackConfig plain = AMPRPackConfig.Load(WriteConfig("[pack]\ndefault_action = \"compress\"\ndeduplicate = false\n")); + AMPRBuildResult second = AMPRPackBuilder.Build(root, index, output, plain); + + Assert.False(File.Exists(second.IndexPath + ".runtime")); + Assert.Equal(["ampr_assets-000.pak", "ampr_assets.index", "ampr_assets.index.crc"], Directory.GetFiles(output).Select(Path.GetFileName).Order(StringComparer.Ordinal)); + AMPRPackManifest manifest = AMPRPackManifest.Load(second.IndexPath); + Assert.Equal(2, manifest.Files.Count(f => f.IsPacked)); + Assert.NotEqual(firstBuildId, manifest.BuildId); + } + + [Fact] + public void Failed_build_keeps_the_previous_set_and_removes_temporaries() + { + string root = MakeTree(("data/a.txt", Text(100_000)), ("data/b.txt", Text(5000))); + string index = Path.Combine(root, AmprIndex.IndexName); + string output = Path.Combine(_dir, "out"); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig("[pack]\ndefault_action = \"compress\"\n")); + AMPRPackBuilder.Build(root, index, output, config); + Dictionary before = Directory.GetFiles(output).ToDictionary(p => p, File.ReadAllBytes); + + File.Delete(Path.Combine(root, "data", "b.txt")); + AMPRPackException error = Assert.Throws(() => AMPRPackBuilder.Build(root, index, output, config)); + + Assert.Equal("source is missing or unsafe for file id 2: data/b.txt", error.Message); + Assert.Equal(before.Keys.Order(StringComparer.Ordinal), Directory.GetFiles(output).Order(StringComparer.Ordinal)); + Assert.All(before, kv => Assert.Equal(kv.Value, File.ReadAllBytes(kv.Key))); + } + + [Fact] + public void Missing_source_is_left_loose_with_allow_missing() + { + string root = MakeTree(("data/a.txt", Text(100_000)), ("data/b.txt", Text(5000))); + File.Delete(Path.Combine(root, "data", "b.txt")); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig("[pack]\ndefault_action = \"compress\"\n")); + + AMPRBuildResult result = AMPRPackBuilder.Build(root, Path.Combine(root, AmprIndex.IndexName), Path.Combine(_dir, "out"), config, allowMissing: true); + + Assert.Equal(["file id 2 left loose because source is missing: data/b.txt"], result.Warnings); + Assert.Equal(["data/b.txt"], result.Stats.LoosePaths); + } + + [Fact] + public void Size_mismatch_with_the_index_is_rejected() + { + string root = MakeTree(("data/a.txt", Text(1000))); + File.WriteAllBytes(Path.Combine(root, "data", "a.txt"), Text(999)); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig("[pack]\ndefault_action = \"compress\"\n")); + + AMPRPackException error = Assert.Throws( + () => AMPRPackBuilder.Build(root, Path.Combine(root, AmprIndex.IndexName), Path.Combine(_dir, "out"), config)); + Assert.Equal("AMPRIDX3 size mismatch for data/a.txt: index=1000, disk=999", error.Message); + } + + [Fact] + public void Block_larger_than_the_volume_limit_is_rejected() + { + string root = MakeTree(("data/r.bin", [.. Enumerable.Range(0, 1 << 20).Select(i => (byte)(i * 2654435761U >> 24))])); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig( + "[pack]\ndefault_action = \"store\"\ndefault_block_size = \"1MiB\"\n[groups.default]\nmax_pack_size = \"128KiB\"\n")); + + AMPRPackException error = Assert.Throws( + () => AMPRPackBuilder.Build(root, Path.Combine(root, AmprIndex.IndexName), Path.Combine(_dir, "out"), config)); + Assert.Equal("one block (1048576 bytes) exceeds max pack size for group default", error.Message); + Assert.Empty(Directory.GetFiles(Path.Combine(_dir, "out"))); + } + + [Fact] + public void Pattern_without_a_volume_field_fails_on_rollover() + { + string root = MakeTree(("data/a.bin", new byte[300_000]), ("data/b.bin", new byte[300_000])); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig( + "[pack]\ndefault_action = \"store\"\npack_pattern = \"same.pak\"\ndeduplicate = false\n[groups.default]\nmax_pack_size = \"256KiB\"\n")); + + AMPRPackException error = Assert.Throws( + () => AMPRPackBuilder.Build(root, Path.Combine(root, AmprIndex.IndexName), Path.Combine(_dir, "out"), config)); + Assert.Equal("pack_pattern produced duplicate name: same.pak", error.Message); + } + + [Fact] + public void Previous_outputs_inside_the_root_stay_loose() + { + string root = MakeTree(("data/a.txt", Text(10_000)), ("ampr_assets.index", [1, 2, 3]), ("ampr_assets-000.pak", [4]), ("x/ampr_assets.index.crc", [5])); + AMPRPackConfig config = AMPRPackConfig.Load(WriteConfig("[pack]\ndefault_action = \"compress\"\n")); + + AMPRBuildResult result = AMPRPackBuilder.Build(root, Path.Combine(root, AmprIndex.IndexName), Path.Combine(_dir, "out"), config); + + Assert.Equal(["ampr_assets-000.pak", "ampr_assets.index"], result.Stats.LoosePaths.Order(StringComparer.Ordinal)); + Assert.Equal(2, result.Stats.FilesPacked); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPackConfigTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPackConfigTests.cs new file mode 100644 index 0000000..02cd616 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPackConfigTests.cs @@ -0,0 +1,204 @@ +using System.Text; +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// ampr_pack load_config, select_rule and _pack_output_glob; expectations come from Python. +public sealed class AMPRPackConfigTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-config-" + Guid.NewGuid().ToString("N")); + + public AMPRPackConfigTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private AMPRPackConfig Load(string toml) + { + string path = Path.Combine(_dir, "c.toml"); + File.WriteAllText(path, toml + "\n", new UTF8Encoding(false)); + return AMPRPackConfig.Load(path); + } + + [Theory] + [InlineData("ampr_assets-{id:03d}.pak", "ampr_assets-*.pak", "ampr_assets-000.pak", "ampr_assets-007.pak")] + [InlineData("ampr_assets-{group}-lane{lane:02d}-vol{volume:02d}-{id:03d}.pak", "ampr_assets-*-lane*-vol*-*.pak", "ampr_assets-g-lane00-vol00-000.pak", "ampr_assets-assets-lane03-vol12-007.pak")] + [InlineData("{id}", "*", "0", "7")] + [InlineData("a{{b}}{id}", "a{b}*", "a{b}0", "a{b}7")] + [InlineData("{id!r}.pak", "*.pak", "0.pak", "7.pak")] + [InlineData("{group!r}.pak", "*.pak", "'g'.pak", "'assets'.pak")] + [InlineData("{id:>5}", "*", " 0", " 7")] + [InlineData("{group:05}", "*", "g0000", "assets")] + [InlineData("{id:+d}", "*", "+0", "+7")] + [InlineData("{id:x}", "*", "0", "7")] + [InlineData("{id:#x}", "*", "0x0", "0x7")] + [InlineData("{id:.2f}", "*", "0.00", "7.00")] + [InlineData("a/{id}.pak", "a/*.pak", "a/0.pak", "a/7.pak")] + [InlineData("{id:_>4}", "*", "___0", "___7")] + [InlineData("{id:,}", "*", "0", "7")] + [InlineData("sub\\{id}.pak", "sub/*.pak", "sub\\0.pak", "sub\\7.pak")] + [InlineData("{id: 3d}", "*", " 0", " 7")] + [InlineData("{id:=+5d}", "*", "+ 0", "+ 7")] + [InlineData("{id:^5}", "*", " 0 ", " 7 ")] + [InlineData("{group:^5}", "*", " g ", "assets")] + [InlineData("{id!s:>3}", "*", " 0", " 7")] + [InlineData("{id!a}", "*", "0", "7")] + [InlineData("{id:%}", "*", "0.000000%", "700.000000%")] + [InlineData("{id:e}", "*", "0.000000e+00", "7.000000e+00")] + [InlineData("{id:b}", "*", "0", "111")] + [InlineData("{id:#o}", "*", "0o0", "0o7")] + [InlineData("{lane:08_b}", "*", "000_0000", "000_0011")] + public void Pack_pattern_globs_and_renders_like_python(string pattern, string glob, string zero, string sample) + { + AMPRPackPattern parsed = AMPRPackPattern.Parse(pattern); + Assert.Equal(glob, parsed.OutputGlob); + Assert.Equal(zero, parsed.Render("g", 0, 0, 0)); + Assert.Equal(sample, parsed.Render("assets", 3, 12, 7)); + } + + [Theory] + [InlineData("{}", "pack_pattern uses unsupported field ''; allowed fields are group, lane, volume and id")] + [InlineData("{0}", "pack_pattern uses unsupported field '0'; allowed fields are group, lane, volume and id")] + [InlineData("{id.real}", "pack_pattern uses unsupported field 'id.real'; allowed fields are group, lane, volume and id")] + [InlineData("{id[0]}", "pack_pattern uses unsupported field 'id[0]'; allowed fields are group, lane, volume and id")] + [InlineData("{ id }", "pack_pattern uses unsupported field ' id '; allowed fields are group, lane, volume and id")] + [InlineData("x}y", "invalid pack_pattern: 'x}y'")] + [InlineData("x{", "invalid pack_pattern: 'x{'")] + [InlineData("{id", "invalid pack_pattern: '{id'")] + [InlineData("{id!}", "invalid pack_pattern: '{id!}'")] + [InlineData("{id!rr}", "invalid pack_pattern: '{id!rr}'")] + [InlineData("{id!x}.pak", "unsafe or invalid pack_pattern: '{id!x}.pak'")] + [InlineData("{id:{x}}", "unsafe or invalid pack_pattern: '{id:{x}}'")] + [InlineData("{id:s}", "unsafe or invalid pack_pattern: '{id:s}'")] + [InlineData("{group:d}", "unsafe or invalid pack_pattern: '{group:d}'")] + [InlineData("/abs/{id}", "unsafe or invalid pack_pattern: '/abs/{id}'")] + [InlineData("../{id}", "unsafe or invalid pack_pattern: '../{id}'")] + [InlineData("", "pack_pattern cannot be empty")] + public void Pack_pattern_errors_match_python(string pattern, string message) => + Assert.Equal(message, Assert.Throws(() => AMPRPackPattern.Parse(pattern)).Message); + + [Theory] + [InlineData("pack = 1", "[pack] must be a table")] + [InlineData("[pack]\nworkers = 0", "workers must be between 1 and 256")] + [InlineData("[pack]\nworkers = \"4\"\ncompression_level = 13", "compression_level must be between 1 and 12")] + [InlineData("[pack]\nio_page_size = \"3KiB\"", "io_page_size must be a power of two between 4 KiB and 1 MiB")] + [InlineData("[pack]\npayload_alignment = 32", "payload_alignment must be a power of two between 64 B and 1 MiB; the writer automatically raises it to the selected io_page_size")] + [InlineData("[pack]\nchunk_alignment = \"128KiB\"", "chunk_alignment must be a power of two between 64 B and io_page_size")] + [InlineData("[pack]\ncompression_mode = \"zstd\"", "compression_mode must be fast or hc")] + [InlineData("[pack]\ndeduplicate_scope = \"all\"", "deduplicate_scope must be lane or group")] + [InlineData("[pack]\nacceleration = 0", "acceleration must be at least 1")] + [InlineData("[pack]\nmin_savings_ratio = 1.0", "min_savings_ratio must be in [0, 1)")] + [InlineData("[pack]\nio_neutral_min_savings_ratio = -0.1", "io_neutral_min_savings_ratio must be in [0, 1)")] + [InlineData("[pack]\nauto_loose_sample_blocks = 5000", "auto_loose_sample_blocks must be between 1 and 4096")] + [InlineData("[pack]\nauto_loose_sample_bytes = 0", "auto_loose_sample_bytes must be at least 1 byte")] + [InlineData("[pack]\nauto_loose_min_savings_ratio = 2", "auto_loose_min_savings_ratio must be in [0, 1)")] + [InlineData("[pack]\nauto_loose_max_raw_ratio = 1.5", "auto_loose_max_raw_ratio must be in [0, 1]")] + [InlineData("[pack]\nindex_name = \"../x.index\"", "unsafe index_name: '../x.index'")] + [InlineData("[pack]\npack_pattern = \"{name}.pak\"", "pack_pattern uses unsupported field 'name'; allowed fields are group, lane, volume and id")] + [InlineData("[pack]\ndefault_action = \"zip\"", "action must be compress, store or loose; got 'zip'")] + [InlineData("[pack]\ndefault_action = true", "action must be compress, store or loose; got 'true'")] + [InlineData("[pack]\nrequired_packed = [1]", "pack.required_packed must be a string or list of strings")] + [InlineData("groups = 5", "[groups] must be a table")] + [InlineData("[groups]\nbulk = 1", "[groups.bulk] must be a table")] + [InlineData("[groups.bulk]\npack_count = 0", "groups.bulk.pack_count must be between 1 and 65535")] + [InlineData("[groups.bulk]\nassignment = \"random\"", "assignment must be balanced, hash or round_robin; got 'random'")] + [InlineData("[groups.bulk]\nio_page_size = \"3KiB\"", "groups.bulk.io_page_size must be a power of two between 4 KiB and 1 MiB and a multiple of chunk_alignment")] + [InlineData("[groups.bulk]\nmax_pack_size = \"64KiB\"", "groups.bulk.max_pack_size must be at least one complete I/O page beyond the payload offset (131072 bytes)")] + [InlineData("[groups.bulk]\nstripe_group_blocks = 0", "groups.bulk.stripe_group_blocks must be at least 1")] + [InlineData("rule = \"x\"", "[[rule]] entries must form an array")] + [InlineData("rule = [1]", "rule 0 must be a table")] + [InlineData("[[rule]]\ngroup = \"nope\"", "rule 0 references unknown group 'nope'")] + [InlineData("[[rule]]\ninclude = []", "rule 0 include list cannot be empty")] + [InlineData("[[rule]]\ninclude = [1, 2]", "include must be a string or list of strings")] + [InlineData("[[rule]]\ncompression_mode = \"x\"", "rule 0 compression_mode must be fast or hc")] + [InlineData("[[rule]]\ncompression_level = 0", "rule 0 compression_level must be between 1 and 12")] + [InlineData("[[rule]]\nacceleration = 0", "rule 0 acceleration must be at least 1")] + [InlineData("[[rule]]\nmin_savings_ratio = 1", "rule 0 min_savings_ratio must be in [0, 1)")] + [InlineData("[[rule]]\nio_neutral_min_savings_ratio = 1", "rule 0 io_neutral_min_savings_ratio must be in [0, 1)")] + [InlineData("[[rule]]\nlayout = \"linear\"", "layout must be auto, random, mixed or streaming; got 'linear'")] + [InlineData("[[rule]]\naction = \"skip\"", "action must be compress, store or loose; got 'skip'")] + [InlineData("[[rule]]\ninclude_from = [\"../outside.txt\"]", "include_from entry escapes the TOML directory: ../outside.txt")] + [InlineData("[[rule]]\ninclude_from = 5", "include_from must be a string or list of strings")] + public void Config_pack_errors_match_python(string toml, string message) => + Assert.Equal(message, Assert.Throws(() => Load(toml)).Message); + + [Theory] + [InlineData("[pack]\nworkers = \"abc\"", "invalid literal for int() with base 10: 'abc'")] + [InlineData("[pack]\nmin_savings_ratio = \"x\"", "could not convert string to float: 'x'")] + [InlineData("[pack]\nmin_savings_bytes = 1.5", "'float' object has no attribute 'strip'")] + [InlineData("[runtime]\ndecoded_cache_bytes = \"64MiB\"\nphysical_cache_bytes = \"16MiB\"\nworkers = \"4\"\nlatency_reserve_workers = 1", "runtime settings must be integers")] + [InlineData("[runtime]\ndecoded_cache_bytes = \"64MiB\"\nphysical_cache_bytes = 1000\nworkers = 4\nlatency_reserve_workers = 1", "runtime cache sizes must be nonnegative 16 KiB multiples")] + [InlineData("[runtime]\ndecoded_cache_bytes = \"64MiB\"\nphysical_cache_bytes = 0\nworkers = 4\nlatency_reserve_workers = 4", "runtime workers must be 1..16 and reserve must be smaller")] + [InlineData("[[rule]]\nblock_size = \"8KiB\"", "block size must be between 16384 and 1048576 bytes")] + public void Config_value_errors_match_python(string toml, string message) => + Assert.Equal(message, Assert.Throws(() => Load(toml)).Message); + + [Theory] + [InlineData("a = 1\na = 2")] + [InlineData("[pack]\nworkers = 4\n[pack]\nx = 1")] + [InlineData("a.b = 1\n[a]\nc = 2")] + [InlineData("a = {b = 1}\n[a.c]\nd = 1")] + [InlineData("[[r]]\nx = 1\n[r]\ny = 2")] + [InlineData("x = ")] + public void Invalid_toml_is_rejected_like_tomllib(string toml) => + Assert.Throws(() => Load(toml)); + + [Fact] + public void Missing_include_from_file_names_the_file() => + Assert.StartsWith( + "cannot read include_from file missing.txt: ", + Assert.Throws(() => Load("[[rule]]\ninclude_from = [\"missing.txt\"]")).Message); + + [Fact] + public void Default_config_canonical_bytes_match_python() => + Assert.Equal( + "{\"acceleration\":1,\"auto_loose_hot_files\":false,\"auto_loose_large_files\":true,\"auto_loose_max_raw_ratio\":0.9," + + "\"auto_loose_min_file_size\":67108864,\"auto_loose_min_savings_ratio\":0.05,\"auto_loose_sample_blocks\":32," + + "\"auto_loose_sample_bytes\":16777216,\"chunk_alignment\":64,\"compression_level\":12,\"compression_mode\":\"hc\"," + + "\"deduplicate\":true,\"deduplicate_scope\":\"lane\",\"deduplicate_streaming\":false,\"default_action\":\"loose\"," + + "\"default_block_shift\":16,\"groups\":[{\"assignment\":\"balanced\",\"io_page_size\":0,\"max_pack_size\":0," + + "\"name\":\"default\",\"pack_count\":1,\"stripe_group_blocks\":8,\"stripe_large_files\":false,\"stripe_threshold\":268435456}]," + + "\"index_name\":\"ampr_assets.index\",\"io_neutral_min_savings_bytes\":8192,\"io_neutral_min_savings_ratio\":0.125," + + "\"io_page_size\":65536,\"min_savings_bytes\":64,\"min_savings_ratio\":0.01,\"pack_pattern\":\"ampr_assets-{id:03d}.pak\"," + + "\"payload_alignment\":65536,\"rules\":[],\"tool_version\":\"4.0\"}", + Encoding.UTF8.GetString(AMPRPackConfig.Load(null).CanonicalBytes())); + + [Fact] + public void Groups_sort_by_code_point_and_rules_inherit_pack_defaults() + { + AMPRPackConfig config = Load( + "[pack]\nmin_savings_ratio = 0.00001\nworkers = 2\n[groups.\"b\u00e9\"]\npack_count = 2\n[groups.a]\n[[rule]]\ninclude = \"x/\u00e9*\"\nmin_savings_ratio = 0"); + + string canonical = Encoding.UTF8.GetString(config.CanonicalBytes()); + Assert.Contains("\"groups\":[{\"assignment\":\"balanced\",\"io_page_size\":0,\"max_pack_size\":0,\"name\":\"a\"", canonical, StringComparison.Ordinal); + Assert.Contains("\"name\":\"b\\u00e9\",\"pack_count\":2", canonical, StringComparison.Ordinal); + Assert.Contains("\"min_savings_ratio\":1e-05,\"pack_pattern\"", canonical, StringComparison.Ordinal); + Assert.Contains("\"include\":[\"x/\\u00e9*\"]", canonical, StringComparison.Ordinal); + Assert.Contains("\"min_savings_ratio\":0.0,\"mode\":\"hc\"", canonical, StringComparison.Ordinal); + Assert.Equal(["a", "b\u00e9", "default"], config.SortedGroupNames()); + Assert.Equal(2, config.Workers); + } + + [Fact] + public void Last_matching_rule_wins_and_unmatched_files_use_the_default_rule() + { + AMPRPackConfig config = Load( + "[pack]\ndefault_action = \"store\"\n[[rule]]\ninclude = [\"assets/**\"]\nblock_size = \"16KiB\"\n" + + "[[rule]]\naction = \"loose\"\ninclude = [\"assets/*.bik\"]\n[[rule]]\ninclude = \"**/*.lua\"\nhot = true\nexclude = \"x/**\""); + + Assert.Equal(("compress", 14), (config.SelectRule("assets/a.bin").Action, config.SelectRule("assets/a.bin").BlockShift)); + Assert.Equal("loose", config.SelectRule("assets/intro.bik").Action); + Assert.Equal("random", config.SelectRule("scripts/a.lua").ResolvedLayout()); + Assert.Equal(("store", "mixed"), (config.SelectRule("x/a.lua").Action, config.SelectRule("x/a.lua").ResolvedLayout())); + } + + [Fact] + public void Include_from_reads_stripped_patterns_relative_to_the_toml() + { + File.WriteAllText(Path.Combine(_dir, "list.txt"), "# comment\n scripts/** \r\n\r\nassets/text_*.dat\u2028data/*\n", new UTF8Encoding(false)); + AMPRPackConfig config = Load("[[rule]]\ninclude_from = [\"list.txt\"]\nexclude = [\"x\"]\nexclude_from = \"list.txt\""); + + Assert.Equal(["scripts/**", "assets/text_*.dat", "data/*"], config.Rules[0].Include); + Assert.Equal(["x", "scripts/**", "assets/text_*.dat", "data/*"], config.Rules[0].Exclude); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPackFormatTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPackFormatTests.cs new file mode 100644 index 0000000..309cff6 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPackFormatTests.cs @@ -0,0 +1,348 @@ +using System.Buffers.Binary; +using MkPFS.Core.AMPR; +using MkPFS.Core.Util; + +namespace MkPFS.Tests.AMPR; + +/// AMPR pack format helpers (ampr_emu ampr_pack_format.py); expected values come from Python. +public sealed class AMPRPackFormatTests +{ + private static readonly byte[] BuildId = [.. Enumerable.Range(0, 16).Select(i => (byte)i)]; + + [Theory] + [InlineData("64KiB", 65536L)] + [InlineData("1.5MB", 1500000L)] + [InlineData("1_024", 1024L)] + [InlineData(" 8 KiB ", 8192L)] + [InlineData("2g", 2000000000L)] + [InlineData("0", 0L)] + [InlineData("1e3", 1000L)] + [InlineData("10TiB", 10995116277760L)] + [InlineData("3.99b", 3L)] + public void ParseSize_matches_python(string text, long expected) => Assert.Equal(expected, AMPRSize.Parse(text)); + + [Theory] + [InlineData("", "empty size")] + [InlineData("KiB", "invalid size: KiB")] + [InlineData("12xb", "unknown size suffix: xb")] + [InlineData("-1", "size must be non-negative")] + [InlineData("1e400", "invalid size: 1e400")] + [InlineData("abc5", "invalid size: abc5")] + public void ParseSize_errors_match_python(string text, string message) => + Assert.Equal(message, Assert.Throws(() => AMPRSize.Parse(text)).Message); + + [Theory] + [InlineData("16KiB", 14)] + [InlineData("64KiB", 16)] + [InlineData("1MiB", 20)] + public void BlockShift_accepts_supported_sizes(string text, int shift) => Assert.Equal(shift, AMPRSize.BlockShift(text)); + + [Theory] + [InlineData("48KiB", "block size must be a power of two, got 49152")] + [InlineData("8KiB", "block size must be between 16384 and 1048576 bytes")] + [InlineData("2MiB", "block size must be between 16384 and 1048576 bytes")] + public void BlockShift_rejects_other_sizes(string text, string message) => + Assert.Equal(message, Assert.Throws(() => AMPRSize.BlockShift(text)).Message); + + [Fact] + public void Fnv1a64_matches_python() + { + Assert.Equal(0xcbf29ce484222325UL, AMPRAssetPath.Fnv1a64([])); + Assert.Equal(0xaf63dc4c8601ec8cUL, AMPRAssetPath.Fnv1a64("a"u8)); + } + + [Theory] + [InlineData("/app0/Assets/X.bin", "/app0/Assets/X.bin", 0xe7738fe70d6ed8f9UL)] + [InlineData("app0\\data\\a.txt", "/app0/data/a.txt", 0x6d353fb6a0bd22eaUL)] + [InlineData("/app0/donn\u00e9es.txt", "/app0/donn\u00e9es.txt", 0x19e1db4b3788e9f7UL)] + [InlineData("/APP0/./a//b/../c", "/APP0/a/c", 0)] + public void Canonical_path_and_hash_match_python(string path, string canonical, ulong hash) + { + Assert.Equal(canonical, AMPRAssetPath.Canonical(path)); + if (hash != 0) + { + Assert.Equal(hash, AMPRAssetPath.Hash(path)); + } + } + + [Theory] + [InlineData("/app0/../../x", "path escapes root: /app0/../../x")] + [InlineData("/other/x", "asset is outside /app0: /other/x")] + [InlineData("/app0/a\0b", "NUL in path")] + public void Canonical_path_errors_match_python(string path, string message) => + Assert.Equal(message, Assert.Throws(() => AMPRAssetPath.Canonical(path)).Message); + + [Fact] + public void Relative_path_strips_app0() + { + Assert.Equal("a/B.bin", AMPRAssetPath.Relative("/app0/a/B.bin")); + Assert.Equal(string.Empty, AMPRAssetPath.Relative("/App0")); + } + + [Theory] + [InlineData("")] + [InlineData("a\\b")] + [InlineData("/a")] + [InlineData("a/")] + [InlineData("a//b")] + [InlineData("a/./b")] + [InlineData("a/../b")] + public void SafeOutputPath_rejects_unsafe_names(string relative) => + Assert.StartsWith("unsafe output path: ", Assert.Throws(() => AMPRAssetPath.SafeOutputPath(".", relative)).Message); + + [Fact] + public void SafeOutputPath_joins_safe_names() => + Assert.Equal(Path.Combine("root", "a", "b.pak"), AMPRAssetPath.SafeOutputPath("root", "a/b.pak")); + + [Theory] + [InlineData("a'b", "\"a'b\"")] + [InlineData("a\"b", "'a\"b'")] + [InlineData("a'\"b", "'a\\'\"b'")] + [InlineData("\0\n\t\u00e9", "'\\x00\\n\\t\u00e9'")] + [InlineData("\u200b\U0001F600\u007f\u00a0\u2028", "'\\u200b\U0001F600\\x7f\\xa0\\u2028'")] + [InlineData("back\\slash", "'back\\\\slash'")] + public void Repr_matches_python(string value, string expected) => Assert.Equal(expected, PythonText.Repr(value)); + + [Fact] + public void BuildId_matches_python() => + Assert.Equal("481ee681277b00834eea5627b85c71b3", Convert.ToHexStringLower(AMPRBuildId.Compute([[(byte)'x'], []]))); + + [Fact] + public void Runtime_settings_encode_like_python_and_round_trip() + { + AMPRRuntimeSettings settings = new(64 * 1024 * 1024, 16 * 1024 * 1024, 4, 1); + byte[] encoded = settings.Encode(BuildId); + + Assert.Equal( + "414d5052434647310100000040000000000102030405060708090a0b0c0d0e0f0000000400000000000000010000000004000000010000000b640fd700000000", + Convert.ToHexStringLower(encoded)); + Assert.Equal(settings, AMPRRuntimeSettings.Parse(encoded, BuildId)); + } + + [Theory] + [InlineData(0, 0, "runtime workers must be 1..16 and reserve must be smaller")] + [InlineData(17, 0, "runtime workers must be 1..16 and reserve must be smaller")] + [InlineData(4, 4, "runtime workers must be 1..16 and reserve must be smaller")] + [InlineData(4, -1, "runtime workers must be 1..16 and reserve must be smaller")] + public void Runtime_settings_reject_bad_workers(long workers, long reserve, string message) => + Assert.Equal(message, Assert.Throws(() => new AMPRRuntimeSettings(0, 0, workers, reserve).Validate()).Message); + + [Fact] + public void Runtime_settings_reject_unaligned_cache() => + Assert.Equal( + "runtime cache sizes must be nonnegative 16 KiB multiples", + Assert.Throws(() => new AMPRRuntimeSettings(1000, 0, 4, 1).Validate()).Message); + + [Fact] + public void Runtime_settings_reject_other_build_and_corruption() + { + byte[] encoded = new AMPRRuntimeSettings(0, 16384, 1, 0).Encode(BuildId); + byte[] otherBuild = new byte[16]; + Assert.Throws(() => AMPRRuntimeSettings.Parse(encoded, otherBuild)); + encoded[33] ^= 1; + Assert.Equal( + "invalid runtime profile header, build ID or CRC", + Assert.Throws(() => AMPRRuntimeSettings.Parse(encoded, BuildId)).Message); + Assert.Equal("invalid runtime profile size", Assert.Throws(() => AMPRRuntimeSettings.Parse(encoded[..63], BuildId)).Message); + } + + [Fact] + public void Missing_runtime_settings_read_as_null() => + Assert.Null(AMPRRuntimeSettings.Read(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), BuildId)); + + [Fact] + public void Data_header_encodes_like_python_and_round_trips() + { + AMPRDataHeader header = new(2, BuildId, 65536, 4096, 3); + byte[] bytes = header.ToBytes(); + + Assert.Equal( + "414d50524441543303000000400000000200000003000000000102030405060708090a0b0c0d0e0f00000100000000000010000000000000a71d34c200000000", + Convert.ToHexStringLower(bytes)); + AMPRDataHeader parsed = AMPRDataHeader.Parse(bytes, 2, BuildId, 3); + Assert.Equal((2U, 65536UL, 4096UL, 3U), (parsed.PackId, parsed.PayloadOffset, parsed.PayloadBytes, parsed.Flags)); + Assert.Equal(BuildId, parsed.BuildId); + } + + [Fact] + public void Data_header_rejects_mismatches_in_python_order() + { + byte[] bytes = new AMPRDataHeader(2, BuildId, 65536, 4096, 2).ToBytes(); + Assert.Equal("pack id mismatch", Assert.Throws(() => AMPRDataHeader.Parse(bytes, expectedPackId: 1)).Message); + Assert.Equal("pack build id mismatch", Assert.Throws(() => AMPRDataHeader.Parse(bytes, 2, new byte[16])).Message); + Assert.Equal("pack flags mismatch", Assert.Throws(() => AMPRDataHeader.Parse(bytes, 2, BuildId, 3)).Message); + Assert.Equal("pack data header is truncated", Assert.Throws(() => AMPRDataHeader.Parse(bytes[..63])).Message); + bytes[60] = 1; + Assert.Equal("invalid pack data header", Assert.Throws(() => AMPRDataHeader.Parse(bytes)).Message); + bytes[60] = 0; + bytes[41] ^= 1; + Assert.Equal("pack data header CRC mismatch", Assert.Throws(() => AMPRDataHeader.Parse(bytes)).Message); + } + + [Fact] + public void Chunk_crc_sidecar_encodes_like_python_and_round_trips() + { + byte[] bytes = AMPRChunkCrcs.Build(BuildId, [1, 0xFFFFFFFF]); + Assert.Equal( + "414d5052435243310100000030000000000102030405060708090a0b0c0d0e0f020000000000000014ff33775e9fbbb301000000ffffffff", + Convert.ToHexStringLower(bytes)); + Assert.Equal([1U, 0xFFFFFFFFU], AMPRChunkCrcs.Parse(bytes, BuildId, 2)); + Assert.Equal( + "chunk CRC sidecar build ID or chunk count mismatch", + Assert.Throws(() => AMPRChunkCrcs.Parse(bytes, BuildId, 3)).Message); + bytes[^1] ^= 1; + Assert.Equal( + "chunk CRC sidecar payload CRC mismatch", + Assert.Throws(() => AMPRChunkCrcs.Parse(bytes, BuildId, 2)).Message); + } + + [Fact] + public void Chunk_record_packs_bit_fields_and_round_trips() + { + AMPRChunkRecord chunk = new(0x123456789ABC, 65536, 0, 0x0102, AMPRPackFormat.ChunkCodecLZ4, AMPRPackFormat.ChunkFlagPageAligned); + byte[] bytes = new byte[12]; + chunk.Write(bytes); + + Assert.Equal(0x0102123456789ABCUL, BinaryPrimitives.ReadUInt64LittleEndian(bytes)); + Assert.Equal(0xFFFFU | (1U << 20) | (8U << 22), BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(8))); + Assert.Equal(chunk, AMPRChunkRecord.Read(bytes)); + bytes[11] |= 0x40; + Assert.Equal("chunk descriptor contains reserved bits", Assert.Throws(() => AMPRChunkRecord.Read(bytes)).Message); + } + + [Theory] + [InlineData(1UL << 48, 1, 0, 0, 0, "chunk offset exceeds the 48-bit AMPRPAK4 domain")] + [InlineData(0UL, 0, 0, 0, 0, "chunk stored size is outside the AMPRPAK4 domain")] + [InlineData(0UL, (1 << 20) + 1, 0, 0, 0, "chunk stored size is outside the AMPRPAK4 domain")] + [InlineData(0UL, 1, 0x10000, 0, 0, "chunk pack id exceeds uint16")] + [InlineData(0UL, 1, 0, 4, 0, "chunk codec is outside the compact descriptor domain")] + [InlineData(0UL, 1, 0, 0, 0x10, "chunk contains unknown flags")] + public void Chunk_record_rejects_out_of_domain_fields(ulong offset, int stored, int packId, int codec, int flags, string message) => + Assert.Equal(message, Assert.Throws(() => new AMPRChunkRecord(offset, stored, 0, packId, codec, flags).Write(new byte[12])).Message); + + [Fact] + public void String_table_deduplicates_and_terminates() + { + AMPRStringTable table = new(); + Assert.Equal((0U, 3U), table.Add("abc")); + Assert.Equal((4U, 2U), table.Add("\u00e9")); + Assert.Equal((0U, 3U), table.Add("abc")); + Assert.Equal("abc\0\u00e9\0"u8.ToArray(), table.ToArray()); + Assert.Throws(() => table.Add("a\0")); + } + + // One volume with a 64 KiB page, one packed file of 70000 bytes in two LZ4 chunks, one loose file. + private static AMPRPackManifest SampleManifest( + Func? editPacked = null, + Func? editFirstChunk = null, + Func? editPack = null) + { + AMPRStringTable strings = new(); + (uint packedOffset, uint packedLength) = strings.Add("/app0/data/Big.bin"); + (uint looseOffset, uint looseLength) = strings.Add("/app0/eboot.bin"); + (uint nameOffset, uint nameLength) = strings.Add("ampr_assets-000.pak"); + const int contained = AMPRPackFormat.ChunkFlagPageContained | AMPRPackFormat.ChunkFlagPageAligned; + AMPRChunkRecord first = new(65536, 30000, 65536, 0, AMPRPackFormat.ChunkCodecLZ4, contained); + AMPRChunkRecord second = new(65536 + 30016, 4464, 4464, 0, AMPRPackFormat.ChunkCodecRaw, AMPRPackFormat.ChunkFlagPageContained); + AMPRFileRecord packed = new( + AMPRAssetPath.Hash("/app0/data/Big.bin"), 70000, 1600000000, 0, 2, packedOffset, packedLength, + AMPRPackFormat.FileFlagPacked, 16); + AMPRFileRecord loose = new(AMPRAssetPath.Hash("/app0/eboot.bin"), 1000, 1600000000, 0, 0, looseOffset, looseLength, 0, 0); + AMPRPackRecord pack = new(65536, 131072, nameOffset, nameLength, AMPRPackFormat.PackFlagIOPageLayout, 65536); + return new AMPRPackManifest( + BuildId, + 0, + [editPacked?.Invoke(packed) ?? packed, loose], + [editFirstChunk?.Invoke(first) ?? first, second], + [editPack?.Invoke(pack) ?? pack], + strings.ToArray()); + } + + [Fact] + public void Manifest_round_trips_and_derives_raw_sizes() + { + byte[] bytes = SampleManifest().Serialize(); + AMPRPackManifest parsed = AMPRPackManifest.Parse(bytes); + + Assert.Equal(bytes, parsed.Serialize()); + Assert.Equal([65536, 4464], parsed.Chunks.Select(c => c.RawSize)); + Assert.Equal("/app0/data/Big.bin", parsed.FilePath(1)); + Assert.Equal("ampr_assets-000.pak", parsed.PackName(0)); + } + + [Fact] + public void Manifest_rejects_header_and_payload_corruption() + { + byte[] bytes = SampleManifest().Serialize(); + Assert.Equal("pack index is truncated", Assert.Throws(() => AMPRPackManifest.Parse(bytes.AsSpan(0, 127))).Message); + + byte[] header = (byte[])bytes.Clone(); + header[40] ^= 1; + Assert.Equal("pack index header CRC mismatch", Assert.Throws(() => AMPRPackManifest.Parse(header)).Message); + + byte[] payload = (byte[])bytes.Clone(); + payload[^2] ^= 1; + Assert.Equal("pack index payload CRC mismatch", Assert.Throws(() => AMPRPackManifest.Parse(payload)).Message); + + byte[] magic = (byte[])bytes.Clone(); + magic[7] = (byte)'5'; + Assert.Equal("unsupported AMPR pack index", Assert.Throws(() => AMPRPackManifest.Parse(magic)).Message); + } + + public static TheoryData InvalidManifests => new() + { + { "hash", "path hash mismatch for file id 1" }, + { "shift", "invalid block shift for file id 1" }, + { "both", "file id 1 cannot be both streaming and random-access" }, + { "storeonly", "store-only file id 1 contains a compressed chunk" }, + { "misaligned", "chunk range/alignment is invalid for file id 1" }, + { "uncontained", "small page-aligned chunk lacks containment flag for file id 1" }, + { "pagesize", "invalid I/O page size for pack 0" }, + { "nolayout", "pack 0 does not declare page-aware layout" }, + { "payload", "invalid payload offset for pack 0" }, + { "streaming", "streaming flag mismatch for file id 1" }, + { "size", "logical size mismatch for file id 1" }, + }; + + [Theory] + [MemberData(nameof(InvalidManifests))] + public void Manifest_validation_messages_match_python(string edit, string message) + { + AMPRPackManifest manifest = edit switch + { + "hash" => SampleManifest(editPacked: f => f with { PathHash = f.PathHash + 1 }), + "shift" => SampleManifest(editPacked: f => f with { BlockShift = 13 }), + "both" => SampleManifest(editPacked: f => f with { Flags = f.Flags | AMPRPackFormat.FileFlagStreaming | AMPRPackFormat.FileFlagRandomAccess }), + "storeonly" => SampleManifest(editPacked: f => f with { Flags = f.Flags | AMPRPackFormat.FileFlagStoreOnly }), + "misaligned" => SampleManifest(editFirstChunk: c => c with { Offset = c.Offset + 1 }), + "uncontained" => SampleManifest(editFirstChunk: c => c with { Flags = AMPRPackFormat.ChunkFlagPageAligned }), + "pagesize" => SampleManifest(editPack: p => p with { IOPageSize = 3000 }), + "nolayout" => SampleManifest(editPack: p => p with { Flags = 0 }), + "payload" => SampleManifest(editPack: p => p with { PayloadBytes = 1000 }), + "streaming" => SampleManifest(editPacked: f => f with { Flags = f.Flags | AMPRPackFormat.FileFlagStreaming }), + "size" => SampleManifest(editPacked: f => f with { ChunkCount = 0 }), + _ => throw new ArgumentOutOfRangeException(nameof(edit)), + }; + + // Parse derives raw sizes like load_manifest, so the raw sizes always add up unless the chunk count is wrong. + Assert.Equal(message, Assert.Throws(() => AMPRPackManifest.Parse(manifest.Serialize())).Message); + } + + [Fact] + public void Manifest_rejects_extra_chunks_while_deriving_raw_sizes() => + Assert.Equal( + "AMPRPAK4 file has too many chunks", + Assert.Throws(() => AMPRPackManifest.Parse(SampleManifest(editPacked: f => f with { LogicalSize = 65536 }).Serialize())).Message); + + [Fact] + public void Index_reader_parses_the_build_writer_output() + { + byte[] index = MkPFS.Build.AmprIndex.Serialize( + [new(10, 1600000000, "/app0/a/B.bin"), new(0, 1600000001, "/app0/c.txt")]); + + Assert.Equal( + [new AMPRIndexEntry("/app0/a/B.bin", 10, 1600000000), new AMPRIndexEntry("/app0/c.txt", 0, 1600000001)], + AMPRIndexReader.Parse(index)); + Assert.Equal("AMPRIDX3 is truncated", Assert.Throws(() => AMPRIndexReader.Parse(index.AsSpan(0, 47))).Message); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPackOperationsTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPackOperationsTests.cs new file mode 100644 index 0000000..90fd220 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPackOperationsTests.cs @@ -0,0 +1,170 @@ +using System.Text; +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// verify, unpack and source removal on small pack sets built in a temporary directory. +public sealed class AMPRPackOperationsTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-ops-" + Guid.NewGuid().ToString("N")); + + public AMPRPackOperationsTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private string Root => Path.Combine(_dir, "app0"); + + private static byte[] Text(int size, int seed = 0) => + Encoding.ASCII.GetBytes(string.Concat(Enumerable.Range(seed, (size / 8) + 1).Select(i => $"blk{i % 97:D4} ")))[..size]; + + // Packs data/** (two files in nested folders), keeps eboot.bin and sce_sys loose. + private string BuildSet(string extraToml = "") + { + Dictionary files = new() + { + ["eboot.bin"] = Text(3000, 1), + ["sce_sys/param.json"] = Text(100, 2), + ["data/a/one.dat"] = Text(150_000, 3), + ["data/a/b/two.dat"] = Text(70_000, 4), + }; + foreach ((string path, byte[] data) in files) + { + string full = Path.Combine(Root, path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllBytes(full, data); + } + + AmprIndex.Build(Root, Path.Combine(Root, AmprIndex.IndexName)); + string config = Path.Combine(_dir, "config.toml"); + File.WriteAllText(config, "[pack]\ndefault_action = \"loose\"\n[[rule]]\ninclude = \"data/**\"\n" + extraToml, new UTF8Encoding(false)); + return AMPRPackBuilder.Build(Root, Path.Combine(Root, AmprIndex.IndexName), Path.Combine(_dir, "out"), AMPRPackConfig.Load(config)).IndexPath; + } + + [Theory] + [InlineData("eboot.bin", true)] + [InlineData("sce_sys/param.json", true)] + [InlineData("SCE_MODULE/x.bin", true)] + [InlineData("data/lib.SPRX", true)] + [InlineData("data/x.self", true)] + [InlineData("data/ampr_assets-007.pak", true)] + [InlineData("data/ampr_emu.index", true)] + [InlineData("data/.prx", false)] + [InlineData("data/a.prx.bak", false)] + [InlineData("data/eboot.bin.txt", false)] + public void Protected_paths_match_python(string relative, bool expected) => + Assert.Equal(expected, AMPRPackMaintenance.IsProtected(relative)); + + [Fact] + public void Remove_packed_sources_verifies_then_deletes_only_packed_files() + { + string index = BuildSet(); + AMPRRemovalPlan plan = AMPRPackMaintenance.RemovalPlan(index, Root); + Assert.Equal(["data/a/b/two.dat", "data/a/one.dat"], plan.Paths.Order(StringComparer.Ordinal)); + Assert.True(File.Exists(Path.Combine(Root, "data", "a", "one.dat"))); + + AMPRRemovalResult result = AMPRPackMaintenance.RemovePackedSources(index, Root, removeEmptyDirs: true); + + Assert.Equal(new AMPRRemovalResult(2, 220_000, 3, true), result); + Assert.False(Directory.Exists(Path.Combine(Root, "data"))); + Assert.True(File.Exists(Path.Combine(Root, "eboot.bin"))); + Assert.True(File.Exists(Path.Combine(Root, "sce_sys", "param.json"))); + + // The packs still reproduce the removed files exactly. + string restored = Path.Combine(_dir, "restored"); + Assert.Equal(new AMPRExtractResult(2, 220_000), AMPRPackTools.Extract(index, restored)); + Assert.Equal(Text(150_000, 3), File.ReadAllBytes(Path.Combine(restored, "data", "a", "one.dat"))); + Assert.Equal(Text(70_000, 4), File.ReadAllBytes(Path.Combine(restored, "data", "a", "b", "two.dat"))); + } + + [Fact] + public void Remove_packed_sources_refuses_a_changed_source_and_deletes_nothing() + { + string index = BuildSet(); + string one = Path.Combine(Root, "data", "a", "one.dat"); + byte[] changed = File.ReadAllBytes(one); + changed[70_000] ^= 0xFF; + File.WriteAllBytes(one, changed); + + AMPRPackException error = Assert.Throws(() => AMPRPackMaintenance.RemovePackedSources(index, Root)); + + Assert.StartsWith("packed/source mismatch for data/a/one.dat: logical=0x11170, chunk=1, pack=0, physical=0x", error.Message, StringComparison.Ordinal); + Assert.True(File.Exists(one)); + Assert.True(File.Exists(Path.Combine(Root, "data", "a", "b", "two.dat"))); + } + + [Fact] + public void Remove_packed_sources_refuses_when_a_source_is_missing() + { + string index = BuildSet(); + File.Delete(Path.Combine(Root, "data", "a", "b", "two.dat")); + + Assert.Equal(1, AMPRPackMaintenance.RemovalPlan(index, Root).MissingFiles); + Assert.Equal( + "cannot verify before removal: 1 packed source files are missing", + Assert.Throws(() => AMPRPackMaintenance.RemovePackedSources(index, Root)).Message); + Assert.True(File.Exists(Path.Combine(Root, "data", "a", "one.dat"))); + } + + [Fact] + public void Removal_plan_refuses_a_manifest_that_packs_protected_files() + { + string index = BuildSet("[[rule]]\ninclude = \"eboot.bin\"\n"); + Assert.Equal( + "manifest marks a protected game/runtime file as packed: eboot.bin", + Assert.Throws(() => AMPRPackMaintenance.RemovalPlan(index, Root)).Message); + } + + [Fact] + public void Verify_detects_a_corrupted_volume() + { + string index = BuildSet(); + AMPRPackManifest manifest = AMPRPackManifest.Load(index); + AMPRChunkRecord chunk = manifest.Chunks[0]; + string volume = Path.Combine(Path.GetDirectoryName(index)!, manifest.PackName(chunk.PackId)); + byte[] data = File.ReadAllBytes(volume); + data[(int)chunk.Offset + chunk.StoredSize - 1] ^= 0x55; + File.WriteAllBytes(volume, data); + + AMPRPackException error = Assert.Throws(() => AMPRPackTools.Verify(index)); + Assert.Contains(error.Message, new[] { "raw chunk CRC/size mismatch", "LZ4 chunk decompression failed" }); + } + + [Fact] + public void Readers_require_the_crc_sidecar() + { + string index = BuildSet(); + File.Delete(AMPRPackFormat.ChunkCrcPath(index)); + + Assert.StartsWith( + "invalid or missing chunk CRC sidecar: ", + Assert.Throws(() => AMPRPackTools.Verify(index)).Message, + StringComparison.Ordinal); + } + + [Fact] + public void Unpack_selects_files_and_refuses_to_overwrite_unless_asked() + { + string index = BuildSet(); + string output = Path.Combine(_dir, "unpacked"); + + Assert.Equal(new AMPRExtractResult(1, 70_000), AMPRPackTools.Extract(index, output, ["data/a/b/*"])); + AMPRPackException error = Assert.Throws(() => AMPRPackTools.Extract(index, output)); + Assert.Equal($"destination exists: {Path.Combine(output, "data", "a", "b", "two.dat")}", error.Message); + Assert.Equal(new AMPRExtractResult(2, 220_000), AMPRPackTools.Extract(index, output, overwrite: true, preserveMTime: false)); + Assert.Empty(Directory.GetFiles(output, "*.tmp-*", SearchOption.AllDirectories)); + } + + [Fact] + public void Runtime_config_requires_a_runtime_section() + { + string index = BuildSet(); + string config = Path.Combine(_dir, "noruntime.toml"); + File.WriteAllText(config, "[pack]\n"); + + Assert.Equal( + "configuration must contain [runtime]", + Assert.Throws(() => AMPRPackMaintenance.WriteRuntimeConfig(index, config)).Message); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs new file mode 100644 index 0000000..027019c --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -0,0 +1,98 @@ +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Cli; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// The built-in default preset and the CLI around it (extensions over ampr_pack.py). +public sealed class AMPRPresetTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-preset-" + Guid.NewGuid().ToString("N")); + + public AMPRPresetTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + [Theory] + [InlineData("data/level.dat", "compress")] + [InlineData("d/0/texture", "compress")] + [InlineData("eboot.bin", "loose")] + [InlineData("bin/eboot.bin", "loose")] + [InlineData("libcohtml.Prospero.prx", "loose")] + [InlineData("fakelib/libSceAmpr.sprx", "loose")] + [InlineData("sce_sys/param.json", "loose")] + [InlineData("sce_module/libc.prx", "loose")] + [InlineData("tools/run.elf", "loose")] + [InlineData("system/x.bin", "loose")] + [InlineData("ampr_emu.index", "loose")] + [InlineData("Media/Metadata/global-metadata.dat", "loose")] + [InlineData("Media/Metadata/other.dat", "compress")] + public void Default_preset_packs_data_and_keeps_protected_files_loose(string relative, string action) + { + AMPRPackConfig preset = AMPRPackConfig.LoadPreset("default"); + Assert.Equal(action, preset.SelectRule(relative).Action); + // Whatever the preset packs, remove-sources must be allowed to remove. + Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); + } + + [Fact] + public void Unknown_preset_is_rejected() => + Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); + + private string Game() + { + string root = Path.Combine(_dir, "app0"); + foreach ((string path, string text) in new[] { ("eboot.bin", "eboot"), ("sce_sys/param.json", "{}"), ("lib.prx", "prx"), ("data/a.dat", new string('a', 50_000)) }) + { + string full = Path.Combine(root, path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, text); + } + + AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); + return root; + } + + private static (int Exit, string Out, string Err) Run(params string[] args) + { + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + int exit = MkPFSCli.Run(["ampr", .. args], new CliContext(stdout, stderr, useColor: false, utf8: false, progress: false)); + return (exit, stdout.ToString(), stderr.ToString()); + } + + [Fact] + public void Cli_preset_packs_and_the_removal_plan_accepts_it() + { + string root = Game(); + string output = Path.Combine(_dir, "out"); + + (int exit, string stdout, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", output, "--preset", "default"); + + Assert.Equal(0, exit); + Assert.Contains("\"files_packed\": 1,", stdout, StringComparison.Ordinal); + Assert.Empty(stderr); + Assert.Equal(["data/a.dat"], AMPRPackMaintenance.RemovalPlan(Path.Combine(output, "ampr_assets.index"), root).Paths); + } + + [Fact] + public void Cli_warns_when_nothing_is_packed_and_rejects_config_with_preset() + { + string root = Game(); + string index = Path.Combine(root, AmprIndex.IndexName); + string output = Path.Combine(_dir, "out"); + + (int exit, string stdout, string stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output); + Assert.Equal(0, exit); + Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); + Assert.Equal(global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", stderr); + + string config = Path.Combine(_dir, "c.toml"); + File.WriteAllText(config, "[pack]\n"); + (exit, stdout, stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output, "--config", config, "--preset", "default"); + Assert.Equal((2, string.Empty, "error: --config and --preset cannot be used together\n"), (exit, stdout, stderr)); + } +} diff --git a/tests/MkPFS.Tests/Build/AmprIndexTests.cs b/tests/MkPFS.Tests/Build/AmprIndexTests.cs index dfb1cb3..8732389 100644 --- a/tests/MkPFS.Tests/Build/AmprIndexTests.cs +++ b/tests/MkPFS.Tests/Build/AmprIndexTests.cs @@ -158,6 +158,30 @@ public void Create_if_missing_keeps_a_valid_index_and_rebuilds_a_stale_one() Assert.StartsWith("Info: Detected", forced.Lines[0], StringComparison.Ordinal); } + [Fact] + public void An_ampr_pack_set_keeps_its_index_unless_regeneration_is_forced() + { + using TempDir dir = new(); + string source = AmprTree(dir); + AmprIndex.Ensure(source, new ListLog()); + string index = Path.Combine(source, AmprIndex.IndexName); + byte[] original = File.ReadAllBytes(index); + dir.File("src/ampr_assets.index", "manifest"); + dir.File("src/ampr_assets-000.pak", "volume"); + + // Default and create-if-missing both keep the index the packs were built against. + ListLog kept = new(); + Assert.Null(AmprIndex.Ensure(source, kept)); + Assert.Null(AmprIndex.Ensure(source, kept, createIfMissing: true)); + Assert.Equal(original, File.ReadAllBytes(index)); + Assert.All(kept.Lines, line => Assert.StartsWith("Warning: ampr_emu.index kept: the AMPR packs in this folder", line, StringComparison.Ordinal)); + + ListLog forced = new(); + Assert.NotNull(AmprIndex.Ensure(source, forced, forceRegen: true)); + Assert.StartsWith("Warning: Rebuilding ampr_emu.index although ampr_assets.index is present", forced.Lines[0], StringComparison.Ordinal); + Assert.Equal(6, AmprIndex.ReadRows(File.ReadAllBytes(index)).Count); + } + [Fact] public void Pack_folder_writes_the_index_into_the_image_unless_disabled() { diff --git a/tests/MkPFS.Tests/Compression/LZ4Tests.cs b/tests/MkPFS.Tests/Compression/LZ4Tests.cs new file mode 100644 index 0000000..5e5f41f --- /dev/null +++ b/tests/MkPFS.Tests/Compression/LZ4Tests.cs @@ -0,0 +1,108 @@ +using MkPFS.Core.Compression; + +namespace MkPFS.Tests.Compression; + +public sealed class LZ4Tests +{ + private static readonly string[] Words = ["pfs ", "block ", "inode ", "asset ", "texture ", "mesh ", "\n"]; + + private static byte[] Block(int seed) + { + // Random word sequence: LZ4 needs 4-byte repeats, so byte-level noise would not shrink. + Random random = new(seed); + System.Text.StringBuilder text = new(); + while (text.Length < 0x10000) + { + text.Append(Words[random.Next(Words.Length)]); + } + + return System.Text.Encoding.ASCII.GetBytes(text.ToString(0, 0x10000)); + } + + [Fact] + public void Native_library_reports_lz4_1_9_4() => Assert.Equal("1.9.4", LZ4Codec.NativeVersion); + + [Theory] + [InlineData(false, 1)] + [InlineData(false, 8)] + [InlineData(true, 1)] + [InlineData(true, 9)] + [InlineData(true, 12)] + public void Compress_then_decompress_round_trips(bool highCompression, int parameter) + { + byte[] block = Block(parameter); + using LZ4Encoder encoder = new(); + byte[] compressed = highCompression ? encoder.CompressHC(block, parameter) : encoder.CompressFast(block, parameter); + byte[] restored = new byte[block.Length]; + + Assert.True(compressed.Length < block.Length); + Assert.Equal(block.Length, LZ4Codec.Decompress(compressed, restored)); + Assert.Equal(block, restored); + } + + [Fact] + public void Reused_encoder_gives_the_same_bytes_as_a_fresh_one() + { + using LZ4Encoder encoder = new(); + byte[] fast = encoder.CompressFast(Block(1)); + byte[] hc = encoder.CompressHC(Block(1)); + encoder.CompressFast(Block(2)); + encoder.CompressHC(Block(2), 3); + + using LZ4Encoder fresh = new(); + Assert.Equal(fast, encoder.CompressFast(Block(1))); + Assert.Equal(hc, encoder.CompressHC(Block(1))); + Assert.Equal(fast, fresh.CompressFast(Block(1))); + Assert.Equal(hc, fresh.CompressHC(Block(1))); + } + + [Fact] + public void Out_of_range_parameters_are_clamped_like_ampr_pack() + { + using LZ4Encoder encoder = new(); + byte[] block = Block(3); + + Assert.Equal(encoder.CompressFast(block, 1), encoder.CompressFast(block, 0)); + Assert.Equal(encoder.CompressHC(block, 1), encoder.CompressHC(block, 0)); + Assert.Equal(encoder.CompressHC(block, 12), encoder.CompressHC(block, 13)); + } + + [Fact] + public void Empty_input_gives_an_empty_block_like_ampr_pack() + { + using LZ4Encoder encoder = new(); + Assert.Empty(encoder.CompressFast([])); + Assert.Empty(encoder.CompressHC([])); + } + + [Fact] + public void Decompress_rejects_corrupt_and_truncated_blocks() + { + using LZ4Encoder encoder = new(); + byte[] compressed = encoder.CompressHC(Block(4)); + byte[] output = new byte[0x10000]; + + Assert.Throws(() => LZ4Codec.Decompress(compressed.AsSpan(0, compressed.Length / 2), output)); + Assert.Throws(() => LZ4Codec.Decompress([0xF0, 0xFF, 0xFF, 0xFF], output)); + } + + [Fact] + public void Decompress_rejects_too_small_destination() + { + using LZ4Encoder encoder = new(); + byte[] compressed = encoder.CompressFast(Block(5)); + Assert.Throws(() => LZ4Codec.Decompress(compressed, new byte[100])); + } + + [Fact] + public void CompressBound_rejects_negative_length() => + Assert.Throws(() => LZ4Codec.CompressBound(-1)); + + [Fact] + public void Disposed_encoder_throws() + { + LZ4Encoder encoder = new(); + encoder.Dispose(); + Assert.Throws(() => encoder.CompressFast(new byte[16])); + } +} diff --git a/tools/oracle/README.md b/tools/oracle/README.md index 3c0d107..3da74b8 100644 --- a/tools/oracle/README.md +++ b/tools/oracle/README.md @@ -11,6 +11,7 @@ Python MkPFS (`D:\TOOLS\PS5\MkPFS`, expected at `../MkPFS`) stays the oracle unt | `make_fixtures.py` | Generates deterministic source trees (seeded data, mtimes pinned to 1600000000) | | `build_goldens.py` | Builds 33 cases, captures inspect/tree/verify output, writes `manifest.json` and zlib vectors | | `bench.py` | Python baseline timings on a ~330 MiB tree | +| `build_ampr_goldens.py` | AMPR asset-pack corpus from ampr_emu's `ampr_pack.py` (second oracle, see the last section) | ## Run @@ -123,3 +124,57 @@ Images are platform independent. 21. **Bug:** the directory walks recurse once per level, so a crafted image nested about 1000 levels deep stops with `RecursionError`. Port: walks PFS trees without recursion and reports directories nested deeper than 1024 levels (PFS and exFAT) as errors instead of ending the process. + +## AMPR asset-pack oracle (`build_ampr_goldens.py`) + +Second oracle for `docs/AMPR_PACK_PLAN.md`: ampr_emu `tools/ampr_pack.py` (tool version 4.0) at commit +`cfa85df379f6eeeb165d7badf9b648e266fe77b7`, checked out clean at `../ampr_emu`. It needs python-lz4 +4.4.5 (bundles liblz4 1.9.4), which the `../MkPFS` environment lacks, so run it with a Python that has it: + +```bash +python tools/oracle/build_ampr_goldens.py --check +``` + +```bash +uv run --no-project --python 3.11 --with lz4==4.4.5 python tools/oracle/build_ampr_goldens.py --check +``` + +The script refuses another ampr_emu commit and fails on another python-lz4 unless `--allow-lz4-version`. + +Output (`tests/fixtures/generated/ampr/`, about 180 MB): +- `trees/ampr_assets/`: fixture `/app0` (edge sizes, incompressible data, duplicate files, 3 MiB archives, + a movie, 20 scripts, modules; mtimes 1600000000). +- `goldens//`: `config.toml` (+ `list.txt`, `runtime_alt.toml`), `ampr_emu.index` (oracle index, + input for the C# tests), `out/` (manifest, `.pak` volumes, `.crc`, `.runtime`), `config.canonical.json` + (`_canonical_config_bytes`, the build-id input), and logs for `index`, `pack`, `verify --root`, `list`, + `list --json`, `inspect`, `unpack`, `remove-packed-sources` (plan), `runtime-config`. Unpacked files are + checked against the source and hashed, then deleted with the `app0` copy. Rebuild `app0` from + `trees/ampr_assets` plus the case's `remove_before_pack` and `touch_after_index`. +- `goldens/manifest.json`: versions (ampr_emu commit, tool version, Python, python-lz4, liblz4), per-case + argv, exit codes, sha256 of every output. +- `vectors/lz4_vectors.bin`: `L4VEC001`, u32 count, then per entry u8 mode (0 fast, 1 hc), 3 zero bytes, + u32 param (acceleration or level), u32 raw_len, u32 comp_len, raw, comp (81 entries: fast 1/2/8 and HC + 1–12 on five small blocks, fast 1 and HC 9/12 on two 1 MiB blocks). + +Cases (34): default, no config, upstream example TOML, fast (accel 1, 8), HC 9, 16 KiB random hot, 1 MiB, +streaming, store, dedup off/group/streaming, lanes balanced/hash/round-robin, striping with 1 MiB volume +rollover, auto-loose, `--self-contained`, `[runtime]` + `runtime-config`, 4 KiB I/O page, mtime preserved +vs not, CLI `--include`/`--exclude`, `include_from`, `--allow-missing`, `--workers 1` vs `8` (must match), +an unsafe default without the system loose rule, and five error cases. + +Results (2026-10-04, Python 3.11.9, python-lz4 4.4.5, liblz4 1.9.4): 34/34 as expected, two builds +byte-identical, `--workers 1` and `8` produce identical packs. Build time about 2 minutes per pass. + +### Findings to carry into the port + +1. `--include`/`--exclude` only narrow the rule result; with no TOML `default_action` is `loose`, so + packing needs at least `[pack] default_action = "compress"`. +2. The packer has no hard exclusions: `default_action = "compress"` packs `eboot.bin`, PRX/SPRX and + `sce_sys`. Only `remove-packed-sources` refuses them ("manifest marks a protected game/runtime file as + packed"). Every golden config except `unsafe_default` ends with a `loose` rule for those paths. +3. `load_config` reads keys with `dict.get` and ignores unknown keys (only `[runtime]` checks its key + set). A misspelled key silently takes the default. +4. `validate_index_metadata` compares only sizes, not mtimes. `preserve_mtime = true` stores the disk + mtime, `false` the AMPRIDX3 mtime (`mtime_preserved` vs `no_preserve_mtime`). +5. MkPFS's `ampr_emu.index` and ampr_emu's `build_ampr_index.py` produce the same bytes for the same tree + (checked on the `ampr` tree; only mtimes differ when the copy does not keep them). diff --git a/tools/oracle/build_ampr_goldens.py b/tools/oracle/build_ampr_goldens.py new file mode 100644 index 0000000..90f14ca --- /dev/null +++ b/tools/oracle/build_ampr_goldens.py @@ -0,0 +1,423 @@ +"""Build the AMPR asset-pack golden corpus with ampr_emu's ``tools/ampr_pack.py`` (oracle for docs/AMPR_PACK_PLAN.md). + +Layout of ``--out`` (default ``tests/fixtures/generated/ampr``): + trees/ampr_assets/ fixture /app0 source + goldens// app0/ (copy + ampr_emu.index), config.toml, out/, unpacked/, *.log, + config.canonical.json (ampr_pack._canonical_config_bytes) + goldens/manifest.json case → argv, exit codes, sha256 of every output file, versions + vectors/lz4_vectors.bin raw blocks + oracle LZ4 output for codec byte tests + +Needs Python >= 3.11 with python-lz4 (the oracle's encoder), and ampr_emu checked out at +``ORACLE_COMMIT`` in ``../ampr_emu``. The ``../MkPFS`` uv environment has no lz4; use: + python tools/oracle/build_ampr_goldens.py --check + uv run --no-project --python 3.11 --with lz4==4.4.5 python tools/oracle/build_ampr_goldens.py --check +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import random +import shutil +import struct +import subprocess +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +HERE: Path = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +from build_goldens import ( + normalize_log, # ruff: ignore[module-import-not-at-top-of-file] (sibling script) +) +from make_fixtures import ( # ruff: ignore[module-import-not-at-top-of-file] + _param_json, + _semi, + _text, + _write, + pin_mtimes, +) + +ORACLE_COMMIT: str = "cfa85df379f6eeeb165d7badf9b648e266fe77b7" +EXPECTED_LZ4: str = "4.4.5" # python-lz4; bundles liblz4 1.9.4 (see AMPR_PACK_PLAN.md, hazard 1) +AMPR_EMU: Path = HERE.parent.parent.parent / "ampr_emu" +AMPR_TOOLS: Path = AMPR_EMU / "tools" +DEFAULT_OUT: Path = HERE.parent.parent / "tests" / "fixtures" / "generated" / "ampr" +MIB: int = 1024 * 1024 + +BASE: str = '[pack]\ndefault_action = "compress"\n' +# ampr_pack has no hard exclusions (only remove-packed-sources refuses these), so most configs end with +# a loose rule for modules and system files; ``cfg`` appends it (last match wins). +SYSTEM_LOOSE: str = ( + '\n[[rule]]\naction = "loose"\ninclude = ["eboot.bin", "**/*.prx", "**/*.sprx", "sce_sys/**", "sce_module/**", ' + '"fakelib/**"]\n' +) +GROUPS3: str = '\n[groups.default]\npack_count = 3\n' +RUNTIME: str = ( + '\n[runtime]\ndecoded_cache_bytes = "64MiB"\nphysical_cache_bytes = "16MiB"\n' + "workers = 4\nlatency_reserve_workers = 1\n" +) +RUNTIME_ALT: str = ( + '[runtime]\ndecoded_cache_bytes = "128MiB"\nphysical_cache_bytes = "32MiB"\n' + "workers = 8\nlatency_reserve_workers = 2\n" +) +TOUCHED: dict[str, int] = {"assets/text_05.dat": 1_600_000_500} +AUTO_LOOSE: str = ( + 'auto_loose_min_file_size = "2MiB"\nauto_loose_sample_blocks = 8\nauto_loose_sample_bytes = "1MiB"\n' +) + + +def tree_ampr_assets(root: Path) -> None: + """``/app0`` with every packer path: RAW fallback, dedup, auto-loose, store, hot scripts, edge sizes.""" + rng: random.Random = random.Random(6) + _write(root, "sce_sys/param.json", _param_json("PPSA00006")) + _write(root, "eboot.bin", _semi(rng, 90_000)) + _write(root, "sce_module/libfixture.prx", _semi(rng, 40_000)) + _write(root, "fakelib/libSceAmpr.sprx", _semi(rng, 20_000)) + for i, size in enumerate((0, 1, 16_384, 65_536, 65_537, 700_000)): + _write(root, f"assets/text_{i:02d}.dat", _text(rng, size)) + _write(root, "assets/semi.dat", _semi(rng, 300_000)) + _write(root, "assets/random.bin", rng.randbytes(200_000)) + dup: bytes = _text(rng, 200_000) + _write(root, "assets/dup_a.bin", dup) + _write(root, "assets/sub/dup_b.bin", dup) + _write(root, "assets/Sub2/Mixed.BIN", _text(rng, 30_000)) + _write(root, "assets/zeros.bin", bytes(MIB)) + _write(root, "archives/big.pak", rng.randbytes(3 * MIB)) + _write(root, "archives/big_text.pak", _text(rng, 3 * MIB)) + _write(root, "movies/intro.bik", rng.randbytes(3 * MIB // 2)) + for i in range(20): + _write(root, f"scripts/s_{i:03d}.lua", _text(rng, rng.randint(100, 4000))) + _write(root, "localization/en.txt", _text(rng, 50_000)) + + +@dataclass +class Case: + """One ``ampr_pack.py pack`` run plus its follow-up commands.""" + + name: str + toml: str | None = BASE + flags: list[str] = field(default_factory=list) + expect_ok: bool = True + expect_fail_steps: list[str] = field(default_factory=list) # follow-up steps that must fail + remove_before_pack: list[str] = field(default_factory=list) # deleted after the index is built + touch_after_index: dict[str, int] = field(default_factory=dict) # mtime changed after the index is built + extra_files: dict[str, str] = field(default_factory=dict) # written next to config.toml + note: str = "" + + +def cfg(text: str) -> str: + """Return ``text`` with the trailing system loose rule.""" + return text + SYSTEM_LOOSE + + +def cases() -> list[Case]: + """Return the case matrix.""" + example: str = (AMPR_TOOLS / "ampr_pack.example.toml").read_text(encoding="utf-8") + stripe: str = ( + BASE + '\n[groups.default]\npack_count = 2\nmax_pack_size = "1MiB"\nstripe_large_files = true\n' + 'stripe_threshold = "1MiB"\nstripe_group_blocks = 4\n' + ) + return [ + Case("default", cfg(BASE), note="HC 12, 64 KiB, auto layout"), + Case("unsafe_default", BASE, expect_fail_steps=["remove_plan"], + note="no system loose rule: eboot.bin/PRX/sce_sys get packed; remove-packed-sources refuses"), + Case("no_config", toml=None, note="default_action loose: nothing is packed"), + Case("example", toml=example, note="upstream ampr_pack.example.toml"), + Case("fast", cfg(BASE + 'compression_mode = "fast"\n')), + Case("fast_accel8", cfg(BASE + 'compression_mode = "fast"\nacceleration = 8\n')), + Case("hc9", cfg(BASE + "compression_level = 9\n")), + Case("block16k_random", cfg(BASE + '\n[[rule]]\nblock_size = "16KiB"\nlayout = "random"\nhot = true\n')), + Case("block1m", cfg(BASE + 'default_block_size = "1MiB"\n')), + Case("streaming", cfg(BASE + '\n[[rule]]\nlayout = "streaming"\nblock_size = "256KiB"\n')), + Case("store_movies", cfg(BASE + '\n[[rule]]\naction = "store"\ninclude = ["movies/**"]\nlayout = "streaming"\n')), + Case("dedup_off", cfg(BASE + "deduplicate = false\n")), + Case("dedup_group", cfg(BASE + 'deduplicate_scope = "group"\n' + GROUPS3)), + Case("dedup_streaming", cfg(BASE + 'deduplicate_streaming = true\n\n[[rule]]\nlayout = "streaming"\n')), + Case("lanes_balanced", cfg(BASE + GROUPS3)), + Case("lanes_hash", cfg(BASE + GROUPS3 + 'assignment = "hash"\n')), + Case("lanes_round_robin", cfg(BASE + GROUPS3 + 'assignment = "round_robin"\n')), + Case("stripe_rollover", cfg(stripe)), + Case("auto_loose", cfg(BASE + AUTO_LOOSE)), + Case("self_contained", cfg(BASE + AUTO_LOOSE), ["--self-contained"]), + Case("runtime", cfg(BASE + RUNTIME), extra_files={"runtime_alt.toml": RUNTIME_ALT}), + Case("io_page_4k", cfg(BASE + 'io_page_size = "4KiB"\n')), + Case("mtime_preserved", cfg(BASE), touch_after_index=TOUCHED, note="manifest takes the disk mtime"), + Case("no_preserve_mtime", cfg(BASE + "preserve_mtime = false\n"), touch_after_index=TOUCHED, + note="manifest takes the AMPRIDX3 mtime"), + Case("cli_include_exclude", cfg(BASE), ["--include", "assets/**", "--exclude", "**/*.bin"]), + Case( + "include_from", + BASE.replace('"compress"', '"loose"') + '\n[[rule]]\ninclude_from = ["list.txt"]\n', + extra_files={"list.txt": "# scripts and text\nscripts/**\n\nassets/text_*.dat\n"}, + ), + Case("allow_missing", cfg(BASE), ["--allow-missing"], remove_before_pack=["assets/semi.dat"]), + Case("workers1", cfg(BASE), ["--workers", "1"], note="must equal workers8"), + Case("workers8", cfg(BASE), ["--workers", "8"]), + Case("neg_missing", cfg(BASE), expect_ok=False, remove_before_pack=["assets/semi.dat"]), + Case("neg_bad_block", cfg(BASE + 'default_block_size = "48KiB"\n'), expect_ok=False), + Case("neg_unknown_group", cfg(BASE + '\n[[rule]]\ngroup = "nope"\n'), expect_ok=False), + Case("neg_require_packed", cfg(BASE + AUTO_LOOSE), ["--require-packed", "archives/big.pak"], expect_ok=False), + Case("neg_runtime_keys", cfg(BASE + '\n[runtime]\nworkers = 4\n'), expect_ok=False), + ] # fmt: skip + + +def run_tool(script: str, argv: list[str], cwd: Path, log_name: str) -> int: + """Run an ampr_emu tool in ``cwd`` and store normalized combined output in ``log_name``.""" + env: dict[str, str] = {**os.environ, "PYTHONIOENCODING": "utf-8"} + proc: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, str(AMPR_TOOLS / script), *argv], + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + text: str = f"$ {script} {' '.join(argv)}\nexit={proc.returncode}\n--- stdout\n{proc.stdout}\n--- stderr\n{proc.stderr}" + (cwd / log_name).write_text(normalize_log(text, cwd), encoding="utf-8", newline="\n") + return proc.returncode + + +def canonical_config(config_path: Path | None) -> bytes: + """Return ``ampr_pack._canonical_config_bytes`` for a TOML (the build-id input).""" + import ampr_pack # ../ampr_emu/tools is on sys.path (see main) + + return ampr_pack._canonical_config_bytes(ampr_pack.load_config(config_path)) + + +def hash_tree(root: Path, prefix: str) -> dict[str, str]: + """Return ``prefix/relative`` → sha256 for every file under ``root``.""" + out: dict[str, str] = {} + if root.is_dir(): + for path in sorted(root.rglob("*")): + if path.is_file(): + out[f"{prefix}/{path.relative_to(root).as_posix()}"] = hashlib.sha256(path.read_bytes()).hexdigest() + return out + + +def build_case(case: Case, tree: Path, goldens: Path) -> dict[str, object]: + """Build one case and its follow-up outputs; return its manifest entry.""" + work: Path = goldens / case.name + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + shutil.copytree(tree, work / "app0") + pin_mtimes(work / "app0") + for name, text in case.extra_files.items(): + (work / name).write_text(text, encoding="utf-8", newline="\n") + config_args: list[str] = [] + if case.toml is not None: + (work / "config.toml").write_text(case.toml, encoding="utf-8", newline="\n") + config_args = ["--config", "config.toml"] + + exit_codes: dict[str, int] = {"index": run_tool("build_ampr_index.py", ["app0"], work, "index.log")} + for rel in case.remove_before_pack: + (work / "app0" / rel).unlink() + for rel, mtime in case.touch_after_index.items(): + os.utime(work / "app0" / rel, (mtime, mtime)) + pack_argv: list[str] = ["pack", "--root", "app0", "--ampr-index", "app0/ampr_emu.index", "--output", "out", + *config_args, "--no-progress", *case.flags] # fmt: skip + steps: dict[str, list[str]] = {"pack": pack_argv} + exit_codes["pack"] = run_tool("ampr_pack.py", pack_argv, work, "pack.log") + + built: bool = exit_codes["pack"] == 0 + index: str = "out/ampr_assets.index" + if built: + follow: dict[str, list[str]] = { + "verify": ["verify", "--index", index, "--root", "app0"], + "list": ["list", "--index", index, "--json"], + "list_text": ["list", "--index", index], + "inspect": ["inspect", "--index", index], + "unpack": ["unpack", "--index", index, "--output", "unpacked"], + "remove_plan": ["remove-packed-sources", "--index", index, "--root", "app0"], + } + if "runtime_alt.toml" in case.extra_files: + follow["runtime_config"] = ["runtime-config", "--index", index, "--config", "runtime_alt.toml"] + for step, argv in follow.items(): + steps[step] = argv + exit_codes[step] = run_tool("ampr_pack.py", argv, work, f"{step}.log") + if case.toml is not None: + (work / "config.canonical.json").write_bytes(canonical_config(work / "config.toml")) + + # Every unpacked file must equal its source. + unpack_ok: bool = True + for path in sorted((work / "unpacked").rglob("*")) if (work / "unpacked").is_dir() else []: + if path.is_file(): + source: Path = work / "app0" / path.relative_to(work / "unpacked") + unpack_ok = unpack_ok and source.read_bytes() == path.read_bytes() + + hashes: dict[str, str] = {} + for path in sorted(work.iterdir()): + if path.is_file(): + hashes[path.name] = hashlib.sha256(path.read_bytes()).hexdigest() + hashes |= hash_tree(work / "out", "out") + hashes |= hash_tree(work / "unpacked", "unpacked") + # Keep only the oracle index; tests rebuild app0 from trees/ampr_assets plus the recorded edits. + if (work / "app0" / "ampr_emu.index").is_file(): + shutil.copy2(work / "app0" / "ampr_emu.index", work / "ampr_emu.index") + hashes["ampr_emu.index"] = hashlib.sha256((work / "ampr_emu.index").read_bytes()).hexdigest() + shutil.rmtree(work / "app0") + shutil.rmtree(work / "unpacked", ignore_errors=True) + follow_ok: bool = ( + all((code != 0) == (step in case.expect_fail_steps) for step, code in exit_codes.items()) if built else True + ) + return { + "toml": case.toml is not None, + "flags": case.flags, + "remove_before_pack": case.remove_before_pack, + "touch_after_index": case.touch_after_index, + "steps": steps, + "exit_codes": exit_codes, + "built": built, + "expect_ok": case.expect_ok, + "unpack_matches_source": unpack_ok, + "status_ok": built == case.expect_ok and follow_ok and unpack_ok, + "note": case.note, + "sha256": hashes, + } + + +def write_lz4_vectors(tree: Path, out_path: Path) -> int: + """Write ``L4VEC001`` vectors with the oracle codec (``ampr_pack_format.Lz4Codec``). + + Format (little endian): magic[8], u32 count, then per entry u8 mode (0 fast, 1 hc), u8[3] zero, + u32 param (acceleration or level), u32 raw_len, u32 comp_len, raw bytes, comp bytes. + """ + from ampr_pack_format import Lz4Codec + + codec = Lz4Codec() + small: list[bytes] = [ + (tree / "assets/text_05.dat").read_bytes()[:0x10000], + (tree / "assets/semi.dat").read_bytes()[:0x10000], + (tree / "assets/random.bin").read_bytes()[:0x4000], + (tree / "scripts/s_000.lua").read_bytes(), + b"\x00", + ] + large: list[bytes] = [(tree / "archives/big_text.pak").read_bytes()[:MIB], bytes(MIB)] + params: list[tuple[int, int]] = [(0, 1), (0, 2), (0, 8), *((1, level) for level in range(1, 13))] + entries: list[tuple[int, int, bytes, bytes]] = [] + for raw in small: + for mode, param in params: + entries.append((mode, param, raw, codec_compress(codec, raw, mode, param))) + for raw in large: + for mode, param in ((0, 1), (1, 9), (1, 12)): + entries.append((mode, param, raw, codec_compress(codec, raw, mode, param))) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("wb") as fh: + fh.write(b"L4VEC001" + struct.pack(" bytes: + """Compress like ``ampr_pack._compress_block`` does (raw LZ4 block, no size prefix).""" + if mode == 0: + return codec.compress(raw, mode="fast", acceleration=param) # type: ignore[attr-defined] + return codec.compress(raw, mode="hc", level=param) # type: ignore[attr-defined] + + +def oracle_versions() -> dict[str, str]: + """Check the pinned ampr_emu commit and the python-lz4 encoder; return the recorded versions.""" + head: str = subprocess.run( + ["git", "-C", str(AMPR_EMU), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + dirty: str = subprocess.run( + ["git", "-C", str(AMPR_EMU), "status", "--porcelain", "--", "tools"], capture_output=True, text=True, check=True + ).stdout.strip() + if head != ORACLE_COMMIT or dirty: + raise SystemExit(f"error: {AMPR_EMU} must be a clean checkout of {ORACLE_COMMIT} (HEAD {head})") + try: + import lz4 + import lz4.block + except ImportError as exc: + raise SystemExit("error: python-lz4 is required (pip install lz4==4.4.5)") from exc + import ampr_pack + + return { + "ampr_emu_commit": head, + "ampr_pack_tool_version": ampr_pack.TOOL_VERSION, + "python": sys.version, + "python_lz4": lz4.__version__, + "liblz4": lz4.library_version_string(), + } + + +def build_all(out: Path) -> dict[str, object]: + """Recreate the tree, build every case, write manifest and vectors.""" + tree: Path = out / "trees" / "ampr_assets" + goldens: Path = out / "goldens" + if tree.exists(): + shutil.rmtree(tree) + tree.mkdir(parents=True) + tree_ampr_assets(tree) + pin_mtimes(tree) + goldens.mkdir(parents=True, exist_ok=True) + manifest: dict[str, object] = {"versions": oracle_versions(), "fixed_mtime": 1_600_000_000, "cases": {}} + for case in cases(): + entry: dict[str, object] = build_case(case, tree, goldens) + manifest["cases"][case.name] = entry # type: ignore[index] + print(f"{'OK ' if entry['status_ok'] else 'FAIL'} {case.name} {entry['exit_codes']}") + manifest["lz4_vectors"] = write_lz4_vectors(tree, out / "vectors" / "lz4_vectors.bin") + (goldens / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + return manifest + + +def compare(a: dict[str, object], b: dict[str, object]) -> list[str]: + """Return per-file differences between two manifests.""" + diffs: list[str] = [] + for name, ea in a["cases"].items(): # type: ignore[union-attr] + eb = b["cases"][name] # type: ignore[index] + for fname, h in ea["sha256"].items(): + if eb["sha256"].get(fname) != h: + diffs.append(f"{name}/{fname}") + return diffs + + +def main() -> int: + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="Default: tests/fixtures/generated/ampr") + parser.add_argument("--check", action="store_true", help="Build twice and report nondeterministic files") + parser.add_argument("--allow-lz4-version", action="store_true", help=f"Accept python-lz4 other than {EXPECTED_LZ4}") + args: argparse.Namespace = parser.parse_args() + sys.path.insert(0, str(AMPR_TOOLS)) + + first: dict[str, object] = build_all(args.out) + versions: dict[str, str] = first["versions"] # type: ignore[assignment] + print(f"python-lz4 {versions['python_lz4']} (liblz4 {versions['liblz4']}), ampr_pack {versions['ampr_pack_tool_version']}") + failed: list[str] = [n for n, e in first["cases"].items() if not e["status_ok"]] # type: ignore[union-attr] + if versions["python_lz4"] != EXPECTED_LZ4 and not args.allow_lz4_version: + failed.append(f"python-lz4 {versions['python_lz4']} != {EXPECTED_LZ4}") + + cases_: dict[str, dict[str, object]] = first["cases"] # type: ignore[assignment] + w1: dict[str, str] = cases_["workers1"]["sha256"] # type: ignore[assignment] + w8: dict[str, str] = cases_["workers8"]["sha256"] # type: ignore[assignment] + same_workers: bool = {k: v for k, v in w1.items() if k.startswith("out/")} == { + k: v for k, v in w8.items() if k.startswith("out/") + } + print(f"workers1 == workers8 packs: {same_workers}") + if not same_workers: + failed.append("workers1 != workers8") + + if args.check: + second_out: Path = Path(tempfile.mkdtemp(prefix="mkpfs_ampr_check_")) + second: dict[str, object] = build_all(second_out) + diffs: list[str] = compare(first, second) + shutil.rmtree(second_out, ignore_errors=True) + (args.out / "determinism.json").write_text(json.dumps(diffs, indent=2), encoding="utf-8") + print(f"nondeterministic files: {len(diffs)}") + for d in diffs: + print(f" {d}") + if diffs: + failed.append("nondeterministic output") + print(f"failed: {failed}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) From fe1acf48d446caee728b7daeaffb31648318e7d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:22:24 +0000 Subject: [PATCH 02/28] Match ampr_emu's index lookup and flag unusable AMPR pack sets Checked the ampr-pack branch against how ShadowMountPlus and ampr_emu load LZ4 packs on the console: - ampr_emu.index now folds only ASCII letters and hashes UTF-8 bytes with ampr_emu's FNV basis, like build_ampr_index.py and the runtime resolver. Lower-casing every letter and hashing code points (Python MkPFS) left files with non-ASCII names unfindable on the console. The index also skips ampr_commands.bin, apr_emu.log and paths with tabs or line breaks. ASCII-only trees give the same bytes as before. - fakelib2/libSceAmpr.sprx (ShadowMountPlus's exclusive overlay) also triggers the index, the default preset keeps fakelib2/ loose, and --ampr-libs warns when fakelib2/ would hide the copied libraries. - ampr pack warns when a set exceeds the default emulator's limits (2,000,000 files, 16,000,000 chunks, 1,024 volumes), which make the runtime reject the whole manifest, and when --root has no AMPR Emu. - README: APR-only titles, read-only mounts, deploying one build set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 30 ++++++-- src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs | 39 +++++++++- src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs | 2 +- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 2 +- src/MkPFS.Build/AmprIndex.cs | 67 +++++++++++++---- src/MkPFS.Build/AmprLibs.cs | 16 +++- src/MkPFS.Cli/Commands/AmprCommand.cs | 18 +++++ src/MkPFS.Core/AMPR/AMPRAssetPath.cs | 2 +- tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 40 +++++++++- tests/MkPFS.Tests/Build/AmprIndexTests.cs | 81 +++++++++++++++++++++ 10 files changed, 271 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 72a3ab6..44f9568 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,12 @@ Before packing a game folder, `pack folder`, `pack exfat`, and `batch` (folder i 1. With `--ampr-libs`, copy the libraries into `/fakelib/` when the game is an APR title (`sce_sys/playgo-chunk.dat` exists) or `--ampr-title` is given. Identical files are left alone; changed ones are replaced. -2. When `fakelib/libSceAmpr.sprx` exists, write `ampr_emu.index` into the game folder. +2. When `fakelib/libSceAmpr.sprx` or `fakelib2/libSceAmpr.sprx` exists, write `ampr_emu.index` into the + game folder. Paths are hashed and sorted the way the emulator looks them up (only ASCII letters fold + case), so names with accents resolve on the console. + +ShadowMountPlus mounts `fakelib2/` instead of `fakelib/` when both exist, so `--ampr-libs` warns and leaves +a `fakelib2/` folder for you to update. Both steps change the source folder, so the image includes them. An APR title without `fakelib/libSceAmpr.sprx` and without `--ampr-libs` gets a warning and no index. @@ -245,7 +250,9 @@ untouched, even when it no longer matches. A folder that also holds AMPR packs (`ampr_assets.index`, see [`ampr`](#ampr-asset-packs)) always keeps its `ampr_emu.index`, with a warning: the packs address files by their row in that index, and a rebuilt index -renumbers the rows so the emulator fails every packed read. `--ampr-force-regen` still rebuilds it. +renumbers the rows so the emulator fails every packed read. `--ampr-force-regen` still rebuilds it. Keep +ShadowMountPlus's `mount_read_only=1` (its default) for such images: on a writable mount the emulator can +rebuild a missing index from the remaining files, with the same result. | Option | Default | Meaning | |---|---|---| @@ -263,9 +270,16 @@ manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, and an offline C sees LZ4. `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same JSON on standard output, and byte-identical packs. -The released emulator only serves files the game reads through AMPR. Files read another way (for -example `mmap`) must stay loose, so keep the source files until the packed game is tested, and remove -them only after that with `remove-sources --confirm`. +The released emulator only serves files the game reads through AMPR, and it only loads in titles that +use libSceAmpr. Packs for other titles (for example Unity games, which read files directly) are never +read, and `pack` warns when `--root` has no `fakelib/libSceAmpr.sprx` or `fakelib2/libSceAmpr.sprx`. +Files read another way (for example `mmap`) must stay loose, so keep the source files until the packed +game is tested, and remove them only after that with `remove-sources --confirm`. + +The default emulator build loads at most 2,000,000 files, 16,000,000 chunks, and 1,024 volumes, and +rejects the whole set beyond that; `pack` warns on standard error when a set exceeds a limit (larger +blocks or more loose files bring it down). Deploy `ampr_emu.index`, the manifest, its `.runtime`, and every +volume from one build together; the `.crc` sidecar is only for `verify` and `unpack`. | Subcommand | Required options | Purpose | |---|---|---| @@ -282,7 +296,7 @@ them only after that with `remove-sources --confirm`. | Option | Default | Meaning | |---|---|---| | `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | -| `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | +| `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -383,6 +397,10 @@ dotnet run --project src/MkPFS.Gui -c Release - `pack exfat` and `batch` also build `ampr_emu.index` (Python only does it in `pack folder`), and `--ampr-libs`/`--ampr-title` are new. `--ampr-skip-regen-if-exists` checks every path and size, not just the file count. +- `ampr_emu.index` matches ampr_emu's `build_ampr_index.py` and the console lookup: Python MkPFS folds + every letter and hashes code points, so on the console it cannot find files with non-ASCII names. The + index also skips the emulator's `ampr_commands.bin` and `apr_emu.log`, and `fakelib2/libSceAmpr.sprx` + also triggers it. ASCII-only folders give the same index as before. - Bugs found in the Python version while porting are listed in [tools/oracle/README.md](tools/oracle/README.md); some are fixed here. - Switching from Python MkPFS: see [MIGRATION.md](MIGRATION.md). diff --git a/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs b/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs index 5df53cc..8ffb7af 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRBuildTypes.cs @@ -83,4 +83,41 @@ public sealed record AMPRSelectedFile( /// Published manifest. /// Counters. /// Auto-loose and missing-source notes. -public sealed record AMPRBuildResult(string IndexPath, AMPRBuildStats Stats, IReadOnlyList Warnings); +/// Pack volumes named by the manifest. +public sealed record AMPRBuildResult(string IndexPath, AMPRBuildStats Stats, IReadOnlyList Warnings, int Volumes = 0) +{ + /// ampr_emu AMPR_EMU_PACK_MAX_FILES: manifest file records the runtime loads. + public const long RuntimeMaxFiles = 2_000_000; + + /// ampr_emu AMPR_EMU_PACK_MAX_CHUNKS: chunk records the runtime loads. + public const long RuntimeMaxChunks = 16_000_000; + + /// ampr_emu AMPR_EMU_PACK_MAX_PACKS: pack volumes the runtime loads. + public const int RuntimeMaxVolumes = 1024; + + /// + /// Limits of the default ampr_emu build that this set exceeds; the runtime then rejects the whole manifest + /// (apr.pack.index.invalid). ampr_pack.py only checks the format limits, so it publishes such sets. + /// + /// One message per exceeded limit. + public IReadOnlyList RuntimeLimitWarnings() + { + List messages = []; + if (Stats.FilesTotal > RuntimeMaxFiles) + { + messages.Add($"{Stats.FilesTotal} files exceed the AMPR Emu limit of {RuntimeMaxFiles}; the console will reject this pack set"); + } + + if (Stats.Chunks > RuntimeMaxChunks) + { + messages.Add($"{Stats.Chunks} chunks exceed the AMPR Emu limit of {RuntimeMaxChunks}; use larger blocks or leave some files loose"); + } + + if (Volumes > RuntimeMaxVolumes) + { + messages.Add($"{Volumes} pack volumes exceed the AMPR Emu limit of {RuntimeMaxVolumes}; raise max_pack_size or lower pack_count"); + } + + return messages; + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs b/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs index 519a494..ce9213d 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackBuilder.cs @@ -109,7 +109,7 @@ public static AMPRBuildResult Build( PublishTransaction(layout, tempIndex, indexPath, tempCrc, tempRuntime); tempIndex = tempCrc = tempRuntime = null; packing.Report("complete"); - return new AMPRBuildResult(indexPath, stats, warnings); + return new AMPRBuildResult(indexPath, stats, warnings, finalRecords.Count); } catch { diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index 164ebe3..b512594 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -304,7 +304,7 @@ public static AMPRPackConfig Load(string? path) include = [ "eboot.bin", "*/eboot.bin", "param.sfo", "*/param.sfo", "nptitle.dat", "*/nptitle.dat", "ampr_emu.index", "*/ampr_emu.index", "*.prx", "*.sprx", "*.elf", "*.self", - "sce_sys/*", "sce_module/*", "fakelib/*", "mods/*", "save/*", "system/*", + "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "mods/*", "save/*", "system/*", "*/global-metadata.dat", ] """; diff --git a/src/MkPFS.Build/AmprIndex.cs b/src/MkPFS.Build/AmprIndex.cs index 66e4cf5..ac49e0b 100644 --- a/src/MkPFS.Build/AmprIndex.cs +++ b/src/MkPFS.Build/AmprIndex.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Text; +using MkPFS.Core.AMPR; using MkPFS.Core.Diagnostics; using MkPFS.Core.Util; @@ -18,6 +19,26 @@ public static class AmprIndex /// Marker that signals an emulation build (relative to the source root). public const string FakelibMarker = "fakelib/libSceAmpr.sprx"; + /// + /// The AMPR Emu marker present in : , else + /// fakelib2/libSceAmpr.sprx (ShadowMountPlus's exclusive overlay; Python MkPFS only checks fakelib/). + /// + /// Game folder. + /// The marker, relative with /, or . + public static string? FindMarker(string sourceRoot) + { + foreach (string marker in (string[])[FakelibMarker, "fakelib2/libSceAmpr.sprx"]) + { + string path = Path.Combine(sourceRoot, marker); + if (File.Exists(path) || Directory.Exists(path)) + { + return marker; + } + } + + return null; + } + /// Header size (<8sIIQQQII). public const int HeaderSize = 48; @@ -37,20 +58,36 @@ public static class AmprIndex /// /app0/<rel>. public readonly record struct Row(long Size, long MTime, string Path); - /// Hash and collision key: forward slashes, lower case (Python ampr_key_for). + /// + /// Sort and collision key: forward slashes, only ASCII A..Z folded (ampr_emu build_ampr_index.py + /// key_for). Python MkPFS lower-cases every letter, which the console resolver does not do. + /// /// Path. /// Key. - public static string KeyFor(string path) => path.Replace('\\', '/').ToLowerInvariant(); + public static string KeyFor(string path) => + string.Create(path.Length, path, static (span, source) => + { + for (int i = 0; i < source.Length; i++) + { + char c = source[i]; + span[i] = c == '\\' ? '/' : c is >= 'A' and <= 'Z' ? (char)(c + 0x20) : c; + } + }); - /// FNV-1a 64 over the key's code points; 0 becomes 1 so empty slots stay distinct. + /// + /// FNV-1a 64 over the UTF-8 bytes of ; 0 becomes 1 so empty slots stay distinct. This is the + /// hash the ampr_emu resolver computes for a lookup; Python MkPFS hashes lower-cased code points instead, so a + /// non-ASCII path in its index is never found on the console. The offset basis is ampr_emu's + /// 1469598103934665603, not the standard FNV one that the pack manifest uses. + /// /// Path. /// Hash. public static ulong PathHash(string path) { ulong hash = 1469598103934665603UL; - foreach (Rune rune in KeyFor(path).EnumerateRunes()) + foreach (byte b in AMPRAssetPath.AsciiFoldPathBytes(path)) { - hash ^= (ulong)rune.Value; + hash ^= b; hash = unchecked(hash * 1099511628211UL); } @@ -94,8 +131,7 @@ public static int HashSlotCount(int entryCount) } string indexPath = Path.Combine(sourceRoot, IndexName); - string marker = Path.Combine(sourceRoot, "fakelib", "libSceAmpr.sprx"); - if (!File.Exists(marker) && !Directory.Exists(marker)) + if (FindMarker(sourceRoot) is not { } marker) { return null; } @@ -125,7 +161,7 @@ public static int HashSlotCount(int entryCount) log.Warning($"{IndexName} failed validation; regenerating..."); } - log.Info($"Detected {FakelibMarker}; generating {IndexName}..."); + log.Info($"Detected {marker}; generating {IndexName}..."); int count; try { @@ -320,7 +356,8 @@ public static bool Validate(string indexPath, string sourceRoot) live.All(row => indexed.TryGetValue(KeyFor(row.Path), out long size) && size == row.Size); } - // Files the index lists, in walk order: the index and its temp file skipped, case-insensitive duplicates + // Files the index lists, in walk order: the index and its temp file, the emulator's trace and log, and paths with + // tabs or line breaks skipped (as build_ampr_index.py and the runtime scan do); case-insensitive duplicates // dropped (first wins). private static List ScanRows(string root, string indexPath) { @@ -337,7 +374,8 @@ private static List ScanRows(string root, string indexPath) string indexed = "/app0/" + Path.GetRelativePath(root, file).Replace('\\', '/'); string key = KeyFor(indexed); - if (key == $"/app0/{IndexName}" || key == $"/app0/{IndexName}.tmp" || !seen.Add(key)) + if (key == $"/app0/{IndexName}" || key == $"/app0/{IndexName}.tmp" || SkippedRootFiles.Contains(key) || + indexed.AsSpan().IndexOfAny('\t', '\n', '\r') >= 0 || !seen.Add(key)) { continue; } @@ -349,6 +387,9 @@ private static List ScanRows(string root, string indexPath) return rows; } + // Written into /app0 by debug emulator builds; never indexed. + private static readonly HashSet SkippedRootFiles = new(StringComparer.Ordinal) { "/app0/ampr_commands.bin", "/app0/apr_emu.log" }; + private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; private static void TryDelete(string path) @@ -384,7 +425,7 @@ private static void TryDelete(string path) } }); - // Python os.walk top-down with directories and files sorted by name.lower(): ignored names skipped, + // Python os.walk top-down with directories and files sorted by key_for(name): ignored names skipped, // directory links listed but not descended, file links kept when their target is a file. private static IEnumerable WalkFiles(string root) { @@ -396,7 +437,7 @@ private static IEnumerable WalkFiles(string root) string dir = pending.Pop(); List entries = [.. new DirectoryInfo(dir).EnumerateFileSystemInfos("*", options) .Where(e => !NameRules.IsIgnoredName(e.Name))]; - foreach (FileSystemInfo file in entries.Where(e => e is FileInfo).OrderBy(e => e.Name.ToLowerInvariant(), CodePointOrder)) + foreach (FileSystemInfo file in entries.Where(e => e is FileInfo).OrderBy(e => KeyFor(e.Name), CodePointOrder)) { if (file.LinkTarget is null || file.ResolveLinkTarget(returnFinalTarget: true) is FileInfo { Exists: true }) { @@ -406,7 +447,7 @@ private static IEnumerable WalkFiles(string root) // Push in reverse so subdirectories are visited in sorted order. foreach (FileSystemInfo sub in entries.Where(e => e is DirectoryInfo && e.LinkTarget is null) - .OrderByDescending(e => e.Name.ToLowerInvariant(), CodePointOrder)) + .OrderByDescending(e => KeyFor(e.Name), CodePointOrder)) { pending.Push(sub.FullName); } diff --git a/src/MkPFS.Build/AmprLibs.cs b/src/MkPFS.Build/AmprLibs.cs index 278580c..ed1dd0b 100644 --- a/src/MkPFS.Build/AmprLibs.cs +++ b/src/MkPFS.Build/AmprLibs.cs @@ -34,6 +34,14 @@ public static class AmprLibs /// Libraries copied when present, in copy order. public static IReadOnlyList LibraryNames { get; } = [AmprLibrary, "libScePlayGo.sprx"]; + /// + /// when carries AMPR Emu in fakelib/ or in + /// fakelib2/, which ShadowMountPlus mounts instead of fakelib/ when both exist. + /// + /// Game folder. + /// Whether the emulator library is present. + public static bool HasEmulator(string sourceRoot) => AmprIndex.FindMarker(sourceRoot) is not null; + /// when sce_sys holds a PlayGo chunk file (APR title). /// Game folder. /// Whether the title uses PlayGo. @@ -57,6 +65,12 @@ public static int Inject(string sourceRoot, string libsDir, IMkPFSLog log) } string fakelib = Path.Combine(sourceRoot, "fakelib"); + if (Directory.Exists(Path.Combine(sourceRoot, "fakelib2"))) + { + // ShadowMountPlus mounts fakelib2 alone when it exists, so libraries in fakelib would never load. + log.Warning("fakelib2/ exists and ShadowMountPlus mounts it instead of fakelib/; put the AMPR Emu libraries in fakelib2/ yourself"); + } + int copied = 0; try { @@ -100,7 +114,7 @@ public static int Inject(string sourceRoot, string libsDir, IMkPFSLog log) public static void Prepare(string sourceRoot, AmprOptions options, IMkPFSLog log) { bool apr = options.ForceAprTitle || IsAprTitle(sourceRoot); - bool hasLibrary = File.Exists(Path.Combine(sourceRoot, AmprIndex.FakelibMarker)); + bool hasLibrary = HasEmulator(sourceRoot); if (options.LibsDir is { } libs) { if (apr) diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 5462bc0..b2e8f27 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Diagnostics; using System.Globalization; +using MkPFS.Build; using MkPFS.Build.AMPRPack; using MkPFS.Cli.Output; using MkPFS.Core.AMPR; @@ -20,6 +21,11 @@ internal static class AmprCommand internal const string NothingPackedWarning = "warning: no files were packed; without --config or --preset default every file stays loose"; + /// Printed to stderr when files were packed but the root has no AMPR Emu (an addition to the oracle output). + internal const string NoEmulatorWarning = + "warning: --root has no fakelib/libSceAmpr.sprx or fakelib2/libSceAmpr.sprx; only AMPR Emu reads these packs, " + + "and only in titles that load libSceAmpr. Keep the original files until the game runs from the packs"; + public static Command Create(CliContext ctx) { Command command = new("ampr", "Build and manage AMPR Emu seekable LZ4 asset packs for /app0"); @@ -126,6 +132,18 @@ private static Command Pack(CliContext ctx) // Not in ampr_pack.py: a run without rules leaves every file loose and is easy to start by mistake. ctx.Err.WriteLine(NothingPackedWarning); } + + // Not in ampr_pack.py: sets the console cannot load, and packs in a folder without AMPR Emu. + foreach (string message in result.RuntimeLimitWarnings()) + { + ctx.Err.WriteLine($"warning: {message}"); + } + + string rootDir = Resolve(ctx, parse.GetValue(root)!); + if (stats.FilesPacked > 0 && !AmprLibs.HasEmulator(rootDir)) + { + ctx.Err.WriteLine(NoEmulatorWarning); + } })); return command; } diff --git a/src/MkPFS.Core/AMPR/AMPRAssetPath.cs b/src/MkPFS.Core/AMPR/AMPRAssetPath.cs index 421c2be..a42010f 100644 --- a/src/MkPFS.Core/AMPR/AMPRAssetPath.cs +++ b/src/MkPFS.Core/AMPR/AMPRAssetPath.cs @@ -5,7 +5,7 @@ namespace MkPFS.Core.AMPR; /// /// /app0 path rules shared by the pack manifest and AMPRIDX3 (ampr_pack_format.py:170-240). -/// Unlike MkPFS.Build.AmprIndex.PathHash (lower-cased code points), the pack manifest hashes the +/// Like MkPFS.Build.AmprIndex.PathHash and the ampr_emu runtime, the pack manifest hashes the /// UTF-8 bytes and folds only ASCII A..Z. /// public static class AMPRAssetPath diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs index 027019c..ff8ad9e 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -22,6 +22,7 @@ public sealed class AMPRPresetTests : IDisposable [InlineData("bin/eboot.bin", "loose")] [InlineData("libcohtml.Prospero.prx", "loose")] [InlineData("fakelib/libSceAmpr.sprx", "loose")] + [InlineData("fakelib2/libSceAmpr.sprx", "loose")] [InlineData("sce_sys/param.json", "loose")] [InlineData("sce_module/libc.prx", "loose")] [InlineData("tools/run.elf", "loose")] @@ -41,10 +42,16 @@ public void Default_preset_packs_data_and_keeps_protected_files_loose(string rel public void Unknown_preset_is_rejected() => Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); - private string Game() + private string Game(string? emulator = "fakelib") { string root = Path.Combine(_dir, "app0"); - foreach ((string path, string text) in new[] { ("eboot.bin", "eboot"), ("sce_sys/param.json", "{}"), ("lib.prx", "prx"), ("data/a.dat", new string('a', 50_000)) }) + List<(string, string)> files = [("eboot.bin", "eboot"), ("sce_sys/param.json", "{}"), ("lib.prx", "prx"), ("data/a.dat", new string('a', 50_000))]; + if (emulator is not null) + { + files.Add(($"{emulator}/libSceAmpr.sprx", "sprx")); + } + + foreach ((string path, string text) in files) { string full = Path.Combine(root, path); Directory.CreateDirectory(Path.GetDirectoryName(full)!); @@ -95,4 +102,33 @@ public void Cli_warns_when_nothing_is_packed_and_rejects_config_with_preset() (exit, stdout, stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output, "--config", config, "--preset", "default"); Assert.Equal((2, string.Empty, "error: --config and --preset cannot be used together\n"), (exit, stdout, stderr)); } + + [Theory] + [InlineData(null, true)] + [InlineData("fakelib", false)] + [InlineData("fakelib2", false)] + public void Cli_warns_when_packs_are_built_for_a_folder_without_ampr_emu(string? emulator, bool warns) + { + string root = Game(emulator); + + (int exit, _, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "default"); + + Assert.Equal(0, exit); + Assert.Equal(warns ? global::MkPFS.Cli.Commands.AmprCommand.NoEmulatorWarning + "\n" : string.Empty, stderr); + } + + [Fact] + public void Runtime_limits_of_the_default_emulator_build_are_reported() + { + AMPRBuildStats stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles, Chunks = AMPRBuildResult.RuntimeMaxChunks }; + Assert.Empty(new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes).RuntimeLimitWarnings()); + + stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles + 1, Chunks = AMPRBuildResult.RuntimeMaxChunks + 1 }; + IReadOnlyList warnings = new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes + 1).RuntimeLimitWarnings(); + Assert.Equal(3, warnings.Count); + Assert.StartsWith("2000001 files exceed", warnings[0], StringComparison.Ordinal); + Assert.StartsWith("16000001 chunks exceed", warnings[1], StringComparison.Ordinal); + Assert.StartsWith("1025 pack volumes exceed", warnings[2], StringComparison.Ordinal); + } } diff --git a/tests/MkPFS.Tests/Build/AmprIndexTests.cs b/tests/MkPFS.Tests/Build/AmprIndexTests.cs index 8732389..a2f7668 100644 --- a/tests/MkPFS.Tests/Build/AmprIndexTests.cs +++ b/tests/MkPFS.Tests/Build/AmprIndexTests.cs @@ -84,6 +84,87 @@ public void Hash_table_finds_every_row_by_probing() } } + // The ampr_emu resolver (compact_index_hash_key): FNV-1a 64 over UTF-8 bytes, '\' as '/', only ASCII A..Z folded. + private static ulong RuntimeHash(string path) + { + ulong hash = 1469598103934665603UL; + foreach (byte b in System.Text.Encoding.UTF8.GetBytes(path)) + { + byte c = b == (byte)'\\' ? (byte)'/' : b is >= 0x41 and <= 0x5A ? (byte)(b + 0x20) : b; + hash ^= c; + hash = unchecked(hash * 1099511628211UL); + } + + return hash == 0 ? 1 : hash; + } + + [Fact] + public void Non_ascii_paths_use_the_console_resolver_hash_and_folding() + { + using TempDir dir = new(); + string source = AmprTree(dir); + dir.File("src/Données/Écran.bin", "e"); + dir.File("src/zz/ünité.bin", "u"); + AmprIndex.Ensure(source, new ListLog()); + byte[] data = File.ReadAllBytes(Path.Combine(source, AmprIndex.IndexName)); + long hashOffset = BinaryPrimitives.ReadInt64LittleEndian(data.AsSpan(32)); + int slots = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(44)); + List rows = AmprIndex.ReadRows(data); + + Assert.Contains(rows, r => r.Path == "/app0/Données/Écran.bin"); + Assert.Contains(rows, r => r.Path == "/app0/zz/ünité.bin"); + Assert.NotEqual(AmprIndex.KeyFor("/app0/É"), AmprIndex.KeyFor("/app0/é")); // only ASCII folds + for (int i = 0; i < rows.Count; i++) + { + // The game asks with ASCII letters upper-cased; the resolver must still land on this row. + string asked = new([.. rows[i].Path.Select(c => c is >= 'a' and <= 'z' ? char.ToUpperInvariant(c) : c)]); + ulong hash = RuntimeHash(asked); + Assert.Equal(RuntimeHash(rows[i].Path), AmprIndex.PathHash(rows[i].Path)); + int pos = (int)(hash & (ulong)(slots - 1)); + while (BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan((int)hashOffset + (pos * 16) + 8)) != i + 1) + { + Assert.NotEqual(0u, BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan((int)hashOffset + (pos * 16) + 8))); + pos = (pos + 1) & (slots - 1); + } + } + + // Records are sorted by the ASCII-folded UTF-8 bytes, the order the resolver's binary search expects. + List keys = [.. rows.Select(r => System.Text.Encoding.UTF8.GetBytes(AmprIndex.KeyFor(r.Path)))]; + for (int i = 1; i < keys.Count; i++) + { + Assert.True(keys[i - 1].AsSpan().SequenceCompareTo(keys[i]) < 0, $"{rows[i - 1].Path} !< {rows[i].Path}"); + } + } + + [Fact] + public void Emulator_trace_and_log_files_are_not_indexed() + { + using TempDir dir = new(); + string source = AmprTree(dir); + dir.File("src/ampr_commands.bin", "trace"); + dir.File("src/APR_EMU.LOG", "log"); + dir.File("src/data2/ampr_commands.bin", "kept below the root"); + + AmprIndex.Ensure(source, new ListLog()); + + List rows = AmprIndex.ReadRows(File.ReadAllBytes(Path.Combine(source, AmprIndex.IndexName))); + Assert.Equal(["/app0/Data/Level1.pak", "/app0/data2/ampr_commands.bin", "/app0/data2/b.bin", "/app0/eboot.bin", "/app0/fakelib/libSceAmpr.sprx"], rows.Select(r => r.Path)); + } + + [Fact] + public void Fakelib2_also_marks_an_emulation_build() + { + using TempDir dir = new(); + string source = dir.Dir("src"); + dir.File("src/fakelib2/libSceAmpr.sprx", "sprx"); + dir.File("src/eboot.bin", "eboot"); + ListLog log = new(); + + Assert.NotNull(AmprIndex.Ensure(source, log)); + Assert.Equal("Info: Detected fakelib2/libSceAmpr.sprx; generating ampr_emu.index...", log.Lines[0]); + Assert.True(AmprLibs.HasEmulator(source)); + } + [Theory] [InlineData(0, 0)] [InlineData(1, 2)] From 7492822ee8ceea45e77c1399f1fa76f4b7d5923c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:24:11 +0000 Subject: [PATCH 03/28] Skip AMPR builder parity cases cleanly without fixtures When the generated AMPR goldens are absent, Fixtures.PathOrSkip skips the case before the work directory exists, and the finally block's Directory.Delete then threw DirectoryNotFoundException, turning 29 skips into failures. Delete the directory only when it exists, as the other parity tests already do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs b/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs index 559c482..0da9e72 100644 --- a/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs +++ b/tests/MkPFS.Parity/AMPRPackBuilderParityTests.cs @@ -165,7 +165,11 @@ public void Pack_set_matches_the_oracle_byte_for_byte(string caseName) } finally { - Directory.Delete(work, recursive: true); + // Absent when a missing fixture skipped the case before the build created it. + if (Directory.Exists(work)) + { + Directory.Delete(work, recursive: true); + } } } @@ -186,7 +190,11 @@ public void Build_errors_match_the_oracle_and_leave_no_output(string caseName) } finally { - Directory.Delete(work, recursive: true); + // Absent when a missing fixture skipped the case before the build created it. + if (Directory.Exists(work)) + { + Directory.Delete(work, recursive: true); + } } } From a7d1c07a69ff6353b34bdb576c8ecc6c96f19eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:39:33 +0000 Subject: [PATCH 04/28] Add ampr game: build a pack-based game folder ready to copy and play New `ampr game` subcommand and default GUI action "Build playable game" on the AMPR Packs page. From an unpacked game folder (only read) it builds a new folder that runs from AMPR packs as is: 1. Libraries: the game's fakelib/ (or fakelib2/, which ShadowMountPlus mounts instead) plus every file of the selected library folder, skipping identical files. libSceAmpr.sprx must be AMPR Emu 0.4.2.1+, detected by its embedded AMPRPAK4 magic; older builds are rejected before anything is written. 2. ampr_emu.index of the final tree (new libraries with their real sizes), written before packing because the manifest addresses files by index row. Byte-identical to upstream build_ampr_index.py run on the reconstructed final folder. 3. Packs, read from the game folder (default preset unless a TOML or preset is given). 4. Loose files and every folder, copied after packing because the packer's sampling decides which large files stay loose. 5. Verification: every chunk decoded, packed files compared with the source, loose files checked against their indexed sizes. A failed step empties the output so a retry starts clean. --exfat (GUI checkbox) then builds a 64 KiB-cluster exFAT image of the folder; a failed image keeps the finished folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 36 +- src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs | 420 ++++++++++++++++++ src/MkPFS.Build/AmprIndex.cs | 35 +- src/MkPFS.Cli/Commands/AmprCommand.cs | 64 +++ src/MkPFS.Gui/Localization/Strings.resx | 8 + src/MkPFS.Gui/Localization/Strings_de.resx | 8 + src/MkPFS.Gui/Localization/Strings_es.resx | 8 + src/MkPFS.Gui/Localization/Strings_fr.resx | 8 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 8 + src/MkPFS.Gui/Localization/Strings_ro.resx | 8 + .../ViewModels/AmprPackPanelViewModel.cs | 67 ++- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 9 +- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 56 +++ .../MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs | 197 ++++++++ 14 files changed, 915 insertions(+), 17 deletions(-) create mode 100644 src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs diff --git a/README.md b/README.md index 44f9568..7544cf8 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | Subcommand | Required options | Purpose | |---|---|---| +| `game` | `--root --output ` | Build a folder that runs from packs as is (see below; not in `ampr_pack.py`). | | `pack` | `--root --ampr-index --output ` | Build the manifest, volumes, CRC sidecar, and optional runtime settings. | | `verify` | `--index ` | Decode every chunk and check its CRC; `--root ` also compares every byte with the source. | | `unpack` | `--index --output ` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | @@ -305,6 +306,38 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--allow-missing` | off | Leave selected files that are missing from `--root` loose. | | `--no-progress` | off | Hide progress on standard error. | +#### `ampr game`: a folder to copy to the PS5 + +`ampr game` turns an unpacked game folder into a new folder that runs from the packs as is. The game +folder is only read, and the output folder must be new or empty: + +```bash +mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-emu-libs --exfat . +``` + +1. **Libraries.** Copies the game's `fakelib/` into the output, then every file of `--fakelib` over it. + Identical files are left alone, and the log says which ones were added or replaced. If the game has + `fakelib2/`, ShadowMountPlus mounts that one instead of `fakelib/`, so the libraries go there. The + resulting `libSceAmpr.sprx` must be AMPR Emu 0.4.2.1 or newer (the first release that reads packs; its + [release](https://github.com/drakmor/ampr_emu/releases) `libSceAmpr.sprx` does). Older builds are + rejected before anything is written. +2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with + their real sizes. This must come before packing, because the manifest addresses files by their row + in this index. +3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset default`. +4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, + including empty ones. This runs after packing because the packer only then decides which large, + incompressible files to leave loose. +5. **Checks.** Decodes every chunk, compares every packed file with the game folder, and checks that every + loose file is in the output with its indexed size (`--skip-verify` skips this). + +If a step fails, the output folder is emptied so a retry starts clean. `--exfat ` then +builds an exFAT image of the output (64 KiB clusters, `.exfat` in a folder). Use an exFAT +image or the plain folder, and keep ShadowMountPlus's `mount_read_only=1` (the default) so AMPR Emu +never rebuilds the index. A `.ffpfsc` would put zlib (about 150–250 MB/s on the PS5) on top of the LZ4 +packs. The output also keeps `ampr_assets.index.crc`: the game never reads it, and `verify` and +`unpack` need it. + The TOML format is ampr_emu's (see its `tools/ampr_pack.example.toml`). A minimal config: ```toml @@ -384,7 +417,8 @@ dotnet run --project src/MkPFS.Gui -c Release settings for the zlib level, CPU cores, block size, and when to keep blocks uncompressed. - The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its offset, stored size, and largest back-reference distance. -- The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. +- The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. Its + default action, Build playable game, runs `ampr game` with a library folder and an optional exFAT image. Packing without a TOML uses `--preset default`. ## Differences from Python MkPFS diff --git a/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs b/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs new file mode 100644 index 0000000..edca787 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs @@ -0,0 +1,420 @@ +using MkPFS.Build.Exfat; +using MkPFS.Build.PFS; +using MkPFS.Core.AMPR; +using MkPFS.Core.Diagnostics; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// Settings for . +public sealed record AMPRGameOptions +{ + /// Pack rules. + public required AMPRPackConfig Config { get; init; } + + /// Folder whose files (AMPR Emu and other fakelib libraries) go into the output's library folder. + public string? LibsDir { get; init; } + + /// Decode every chunk and compare packed and loose files with the source after the build. + public bool Verify { get; init; } = true; + + /// exFAT image path (or existing folder for <titleId>.exfat) to build from the output. + public string? ExfatImage { get; init; } +} + +/// Result of . +/// Playable /app0 folder. +/// fakelib or fakelib2. +/// Pack build result. +/// Files copied from the source. +/// Bytes of the source files. +/// Bytes of the output folder. +/// exFAT image written, if any. +public sealed record AMPRGameResult( + string OutputDir, string LibraryDir, AMPRBuildResult Pack, int LooseFiles, long SourceBytes, long OutputBytes, string? ExfatImage); + +/// +/// Turns an unpacked game folder into a folder that runs from AMPR packs as is (not in ampr_pack.py): the library +/// folder with the selected AMPR Emu, an ampr_emu.index of that final tree, the packs, and every file that +/// stays loose. The source is only read. +/// +public static class AMPRGameBuilder +{ + /// The emulator library that serves the packs. + public const string EmulatorLibrary = "libSceAmpr.sprx"; + + // Every AMPR Emu build that reads asset packs (0.4.2.1 and newer) embeds its manifest magic; older ones do not. + private static readonly byte[] PackSupportMarker = "AMPRPAK4"u8.ToArray(); + + /// Build the playable folder, then optionally an exFAT image of it. + /// Unpacked game folder (/app0). + /// New or empty output folder. + /// Settings. + /// Step messages. + /// Pack progress. + /// exFAT progress. + /// Summary. + /// The inputs cannot give a playable folder, or a check failed. + public static AMPRGameResult Build( + string sourceRoot, + string outputDir, + AMPRGameOptions options, + IMkPFSLog log, + Action? packProgress = null, + IProgressSink? exfatProgress = null) + { + sourceRoot = Path.GetFullPath(sourceRoot); + outputDir = Path.GetFullPath(outputDir); + string? image = options.ExfatImage is null ? null : Path.GetFullPath(options.ExfatImage); + if (image is not null && Directory.Exists(image)) + { + image = Path.Combine(image, GameParams.DefaultImageBasename(sourceRoot) + ".exfat"); + } + + CheckInputs(sourceRoot, outputDir, options.Config, image, log); + string libraryDir = Directory.Exists(Path.Combine(sourceRoot, "fakelib2")) ? "fakelib2" : "fakelib"; + if (options.LibsDir is { } libsDir && !Directory.Exists(libsDir)) + { + throw new BuildException($"library folder not found: {libsDir}"); + } + + // The selected library replaces the game's own, so check whichever one will end up in the output. + string? selected = options.LibsDir is { } dir && File.Exists(Path.Combine(dir, EmulatorLibrary)) ? Path.Combine(dir, EmulatorLibrary) : null; + CheckEmulator(selected ?? Path.Combine(sourceRoot, libraryDir, EmulatorLibrary), libraryDir); + + bool created = !Directory.Exists(outputDir); + Directory.CreateDirectory(outputDir); + AMPRGameResult result; + try + { + result = BuildInto(sourceRoot, outputDir, libraryDir, options, log, packProgress); + } + catch + { + // The folder was new or empty, so nothing of the user's is lost; a retry then starts clean. + RemoveOutput(outputDir, created); + throw; + } + + if (image is null) + { + return result; + } + + // A failed image leaves the finished folder in place, and only the partial image is removed. + log.Info("Building the exFAT image (64 KiB clusters)"); + string written; + try + { + written = ExfatImageWriter.Write(outputDir, image, null, exfatProgress); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + File.Delete(image); + throw new BuildException($"the game folder is ready, but the exFAT image failed: {ex.Message}"); + } + + log.Info($"exFAT image: {Sizes.HumanReadable(new FileInfo(written).Length)}: {written}"); + log.Info("Mount it read-only (ShadowMountPlus mount_read_only=1, the default) so AMPR Emu never rebuilds the index"); + return result with { ExfatImage = written }; + } + + private static AMPRGameResult BuildInto( + string sourceRoot, + string outputDir, + string libraryDir, + AMPRGameOptions options, + IMkPFSLog log, + Action? packProgress) + { + // 1. Libraries: the source's library folder, then the selected files over it. The index needs their final sizes. + log.Info($"[1/5] Libraries: {libraryDir}/"); + if (libraryDir == "fakelib2") + { + log.Info(" The game has fakelib2/, which ShadowMountPlus mounts instead of fakelib/; using it"); + } + + CopyTree(Path.Combine(sourceRoot, libraryDir), Path.Combine(outputDir, libraryDir)); + if (options.LibsDir is { } libs) + { + AddLibraries(libs, Path.Combine(outputDir, libraryDir), log); + } + + // 2. Index of the final tree, before packing: the pack manifest addresses files by their row in it. + log.Info($"[2/5] Writing {AmprIndex.IndexName}"); + string index = Path.Combine(outputDir, AmprIndex.IndexName); + int rows = AmprIndex.BuildWithOverlay(sourceRoot, outputDir, libraryDir, index); + if (rows == 0) + { + throw new BuildException($"the game folder has no files: {sourceRoot}"); + } + + log.Info($" {rows} files indexed"); + + // 3. Packs, read from the source. + log.Info("[3/5] Packing"); + AMPRBuildResult pack = AMPRPackBuilder.Build(sourceRoot, index, outputDir, options.Config, progress: packProgress); + foreach (string warning in pack.Warnings) + { + log.Warning("warning: " + warning); + } + + foreach (string warning in pack.RuntimeLimitWarnings()) + { + log.Warning("warning: " + warning); + } + + log.Info($" {pack.Stats.FilesPacked} files packed into {pack.Volumes} volume(s), {pack.Stats.FilesLoose} stay loose"); + if (pack.Stats.FilesPacked == 0) + { + log.Warning("warning: no files were packed; the output is a plain copy of the game"); + } + + // 4. Loose files: known only now, because the packer leaves large incompressible files loose by sampling them. + log.Info("[4/5] Copying loose files"); + int loose = CopyLooseFiles(sourceRoot, outputDir, libraryDir, pack.Stats.LoosePaths); + CopyDirectories(sourceRoot, outputDir); + log.Info($" {loose} files copied"); + + // 5. Checks. + if (options.Verify) + { + log.Info("[5/5] Verifying"); + Verify(sourceRoot, outputDir, pack.IndexPath, log); + } + else + { + log.Info("[5/5] Verification skipped"); + } + + long sourceBytes = TreeBytes(sourceRoot); + long outputBytes = TreeBytes(outputDir); + log.Info($"Game folder: {Sizes.HumanReadable(sourceBytes)} -> {Sizes.HumanReadable(outputBytes)}: {outputDir}"); + + return new AMPRGameResult(outputDir, libraryDir, pack, loose, sourceBytes, outputBytes, null); + } + + /// when is an AMPR Emu build that reads asset packs. + /// libSceAmpr.sprx. + /// Whether the build embeds the AMPRPAK4 manifest magic. + public static bool SupportsPacks(string library) => + File.ReadAllBytes(library).AsSpan().IndexOf(PackSupportMarker) >= 0; + + private static void CheckInputs(string source, string output, AMPRPackConfig config, string? image, IMkPFSLog log) + { + if (!Directory.Exists(source)) + { + throw new BuildException($"game folder not found: {source}"); + } + + if (IsSameOrInside(output, source) || IsSameOrInside(source, output)) + { + throw new BuildException("the game folder and the output folder must not contain each other"); + } + + if (File.Exists(output) || (Directory.Exists(output) && Directory.EnumerateFileSystemEntries(output).Any())) + { + throw new BuildException($"the output folder must be new or empty: {output}"); + } + + if (File.Exists(Path.Combine(source, config.IndexName))) + { + throw new BuildException($"the game folder already has {config.IndexName}; start from the unpacked game"); + } + + if (!File.Exists(Path.Combine(source, "sce_sys", "param.json")) && !File.Exists(Path.Combine(source, "sce_sys", "param.sfo"))) + { + log.Warning("warning: the game folder has no sce_sys/param.json; is it the game's /app0 folder?"); + } + + if (image is not null) + { + if (IsSameOrInside(image, output) || IsSameOrInside(image, source)) + { + throw new BuildException("the exFAT image must be outside the game and output folders"); + } + + if (File.Exists(image)) + { + throw new BuildException($"the exFAT image already exists: {image}"); + } + } + } + + private static void RemoveOutput(string outputDir, bool created) + { + try + { + if (created) + { + Directory.Delete(outputDir, recursive: true); + return; + } + + foreach (FileSystemInfo entry in new DirectoryInfo(outputDir).EnumerateFileSystemInfos()) + { + if (entry is DirectoryInfo sub) + { + sub.Delete(recursive: true); + } + else + { + entry.Delete(); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Keep the build error; the leftover folder is reported by the next run's empty-folder check. + } + } + + private static void AddLibraries(string libsDir, string target, IMkPFSLog log) + { + Directory.CreateDirectory(target); + foreach (FileInfo file in new DirectoryInfo(libsDir).EnumerateFiles() + .Where(f => !f.Name.StartsWith('.') && !NameRules.IsIgnoredName(f.Name)) + .OrderBy(f => f.Name, StringComparer.Ordinal)) + { + string destination = Path.Combine(target, file.Name); + if (File.Exists(destination) && SameContent(file.FullName, destination)) + { + log.Info($" {file.Name}: already up to date"); + continue; + } + + string state = File.Exists(destination) ? "replaced" : "added"; + file.CopyTo(destination, overwrite: true); + File.SetLastWriteTimeUtc(destination, file.LastWriteTimeUtc); + log.Info($" {file.Name}: {state}"); + } + } + + private static void CheckEmulator(string library, string libraryDir) + { + if (!File.Exists(library)) + { + throw new BuildException($"no {libraryDir}/{EmulatorLibrary}: select the AMPR Emu library folder"); + } + + if (!SupportsPacks(library)) + { + throw new BuildException( + $"{libraryDir}/{EmulatorLibrary} cannot read asset packs; use AMPR Emu 0.4.2.1 or newer from https://github.com/drakmor/ampr_emu/releases"); + } + } + + private static int CopyLooseFiles(string source, string output, string libraryDir, IReadOnlyList loosePaths) + { + string libraryKey = AmprIndex.KeyFor(libraryDir + "/"); + int copied = 0; + foreach (string relative in loosePaths) + { + if (AmprIndex.KeyFor(relative).StartsWith(libraryKey, StringComparison.Ordinal)) + { + continue; // already final in the output + } + + string from = AMPRAssetPath.SafeOutputPath(source, relative); + string to = AMPRAssetPath.SafeOutputPath(output, relative); + Directory.CreateDirectory(Path.GetDirectoryName(to)!); + File.Copy(from, to, overwrite: false); + File.SetLastWriteTimeUtc(to, File.GetLastWriteTimeUtc(from)); + copied++; + } + + return copied; + } + + // Empty folders too, so the output has the source's full directory tree. + private static void CopyDirectories(string source, string output) + { + foreach (string dir in Directory.EnumerateDirectories(source, "*", new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = 0 })) + { + string relative = Path.GetRelativePath(source, dir); + if (!relative.Split(Path.DirectorySeparatorChar).Any(NameRules.IsIgnoredName)) + { + Directory.CreateDirectory(Path.Combine(output, relative)); + } + } + } + + private static void CopyTree(string from, string to) + { + if (!Directory.Exists(from)) + { + return; + } + + Directory.CreateDirectory(to); + foreach (string dir in Directory.EnumerateDirectories(from, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(to, Path.GetRelativePath(from, dir))); + } + + foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) + { + string destination = Path.Combine(to, Path.GetRelativePath(from, file)); + File.Copy(file, destination); + File.SetLastWriteTimeUtc(destination, File.GetLastWriteTimeUtc(file)); + } + } + + // Every packed file decodes to the source bytes, and every loose index row is in the output with its size. + private static void Verify(string source, string output, string manifest, IMkPFSLog log) + { + try + { + AMPRVerifyResult blocks = AMPRPackTools.Verify(manifest); + AMPRSourceCompareResult compare = AMPRPackTools.VerifyAgainstRoot(manifest, source); + log.Info($" Packs: {blocks.Files} files, {blocks.PhysicalChunks} chunks decoded; {compare.Files} files match the source"); + } + catch (AMPRPackException ex) + { + throw new BuildException($"pack verification failed: {ex.Message}"); + } + + List missing = []; + int loose = 0; + foreach (AMPRListEntry entry in AMPRPackTools.List(manifest)) + { + if (entry.Packed) + { + continue; + } + + FileInfo file = new(AMPRAssetPath.SafeOutputPath(output, AMPRAssetPath.Relative(entry.Path))); + if (!file.Exists || (ulong)file.Length != entry.LogicalSize) + { + missing.Add(AMPRAssetPath.Relative(entry.Path)); + } + + loose++; + } + + if (missing.Count > 0) + { + throw new BuildException($"{missing.Count} loose file(s) are missing or differ in the output, first: {missing[0]}"); + } + + log.Info($" Loose files: {loose} present with their indexed sizes"); + } + + private static bool IsSameOrInside(string path, string folder) + { + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + string a = Path.TrimEndingDirectorySeparator(path); + string b = Path.TrimEndingDirectorySeparator(folder); + return a.Equals(b, comparison) || a.StartsWith(b + Path.DirectorySeparatorChar, comparison); + } + + private static bool SameContent(string a, string b) + { + FileInfo x = new(a); + FileInfo y = new(b); + return x.Length == y.Length && File.ReadAllBytes(a).AsSpan().SequenceEqual(File.ReadAllBytes(b)); + } + + private static long TreeBytes(string root) => + Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length); +} diff --git a/src/MkPFS.Build/AmprIndex.cs b/src/MkPFS.Build/AmprIndex.cs index ac49e0b..4ce1660 100644 --- a/src/MkPFS.Build/AmprIndex.cs +++ b/src/MkPFS.Build/AmprIndex.cs @@ -184,8 +184,12 @@ public static int HashSlotCount(int entryCount) public static int Build(string root, string outputPath) { outputPath = Path.GetFullPath(outputPath); + return Write(ScanRows(root, outputPath), outputPath); + } + + private static int Write(List rows, string outputPath) + { string tmp = outputPath + ".tmp"; - List rows = ScanRows(root, outputPath); if (rows.Count == 0) { return 0; @@ -206,6 +210,31 @@ public static int Build(string root, string outputPath) return rows.Count; } + /// + /// Write the index for the tree a game will have after (for example + /// fakelib) is taken from instead of : the files under + /// that folder are listed with the overlay's sizes and times, everything else with the source's. + /// + /// Source tree. + /// Tree holding the replacement folder. + /// Folder name relative to both roots. + /// Index path. + /// Records written (0 and no file when there are no files). + public static int BuildWithOverlay(string root, string overlayRoot, string overlayDirectory, string outputPath) + { + outputPath = Path.GetFullPath(outputPath); + string prefix = KeyFor($"/app0/{overlayDirectory}/"); + List rows = [.. ScanRows(root, outputPath).Where(r => !KeyFor(r.Path).StartsWith(prefix, StringComparison.Ordinal))]; + string overlay = Path.Combine(Path.GetFullPath(overlayRoot), overlayDirectory); + if (Directory.Exists(overlay)) + { + HashSet seen = [.. rows.Select(r => KeyFor(r.Path))]; + rows.AddRange(ScanRows(overlayRoot, outputPath, overlay).Where(r => seen.Add(KeyFor(r.Path)))); + } + + return Write(rows, outputPath); + } + /// Serialize rows already in record order. /// Rows. /// Index bytes. @@ -359,13 +388,13 @@ public static bool Validate(string indexPath, string sourceRoot) // Files the index lists, in walk order: the index and its temp file, the emulator's trace and log, and paths with // tabs or line breaks skipped (as build_ampr_index.py and the runtime scan do); case-insensitive duplicates // dropped (first wins). - private static List ScanRows(string root, string indexPath) + private static List ScanRows(string root, string indexPath, string? walkFrom = null) { root = Path.GetFullPath(root); string tmp = indexPath + ".tmp"; List rows = []; HashSet seen = new(StringComparer.Ordinal); - foreach (string file in WalkFiles(root)) + foreach (string file in WalkFiles(walkFrom ?? root)) { if (string.Equals(file, indexPath, PathComparison) || string.Equals(file, tmp, PathComparison)) { diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index b2e8f27..3b673ce 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Globalization; using MkPFS.Build; +using MkPFS.Build.PFS; using MkPFS.Build.AMPRPack; using MkPFS.Cli.Output; using MkPFS.Core.AMPR; @@ -30,6 +31,7 @@ public static Command Create(CliContext ctx) { Command command = new("ampr", "Build and manage AMPR Emu seekable LZ4 asset packs for /app0"); command.Subcommands.Add(Pack(ctx)); + command.Subcommands.Add(Game(ctx)); command.Subcommands.Add(Unpack(ctx)); command.Subcommands.Add(Verify(ctx)); command.Subcommands.Add(RemoveSources(ctx)); @@ -148,6 +150,68 @@ private static Command Pack(CliContext ctx) return command; } + private static Command Game(CliContext ctx) + { + Option root = Required("--root", "unpacked game folder (/app0); only read"); + Option output = Required("--output", "new or empty folder for the playable game"); + Option fakelib = new("--fakelib") { Description = "folder with AMPR Emu 0.4.2.1+ libSceAmpr.sprx and other fakelib libraries to add" }; + Option config = new("--config") { Description = "TOML pack configuration (default: --preset default)" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config (default: default)" }; + preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); + Option workers = new("--workers") { Description = "compression workers" }; + Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; + Option exfat = new("--exfat") { Description = "also build an exFAT image of the output (file, or folder for .exfat)" }; + Option skipVerify = new("--skip-verify") { Description = "do not verify the packs and loose files after the build" }; + Option noProgress = new("--no-progress") { Description = "suppress progress on stderr" }; + Command command = new("game", "build a folder that runs from packs as is: libraries, ampr_emu.index, packs and loose files (not in ampr_pack.py)") + { + root, output, fakelib, config, preset, workers, selfContained, exfat, skipVerify, noProgress, + }; + command.SetAction(parse => Run(ctx, () => + { + string? configPath = parse.GetValue(config); + AMPRPackConfig loaded = (configPath, parse.GetValue(preset)) switch + { + (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), + (not null, null) => AMPRPackConfig.Load(Resolve(ctx, configPath)), + (null, var name) => AMPRPackConfig.LoadPreset(name ?? "default"), + }; + if (parse.GetValue(workers) is { } count) + { + loaded.Workers = count is >= 1 and <= 256 ? count : throw new AMPRPackException("--workers must be between 1 and 256"); + } + + if (parse.GetValue(selfContained)) + { + loaded.SelfContained = true; + } + + bool progress = !parse.GetValue(noProgress) && ctx.ProgressEnabled; + AMPRGameOptions options = new() + { + Config = loaded, + LibsDir = parse.GetValue(fakelib) is { } libs ? Resolve(ctx, libs) : null, + Verify = !parse.GetValue(skipVerify), + ExfatImage = parse.GetValue(exfat) is { } image ? Resolve(ctx, image) : null, + }; + try + { + AMPRGameBuilder.Build( + Resolve(ctx, parse.GetValue(root)!), + Resolve(ctx, parse.GetValue(output)!), + options, + ctx.Log, + progress ? ProgressReporter(ctx) : null, + ctx.CreateProgress(progress)); + } + catch (BuildException ex) + { + throw new AMPRPackException(ex.Message); + } + })); + return command; + } + private static Command Unpack(CliContext ctx) { Option index = Required("--index", "pack manifest"); diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 12d5ba7..68d1665 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -32,6 +32,14 @@ Folder with libSceAmpr.sprx… APR Emu Treat as APR title + Build playable game (packs + loose files) + Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. + Library Folder (fakelib) + Folder with libSceAmpr.sprx and other fakelib libraries (optional if the game has them)… + Verify the packs and loose files after building + Also build an exFAT image (64 KiB clusters, mount read-only) + exFAT Image (optional) + Default: <titleId>.exfat next to the output folder Inspect manifest List files Pack game files diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 83a8dc4..5e59b3e 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -32,6 +32,14 @@ Ordner mit libSceAmpr.sprx… APR Emu Als APR-Titel behandeln + Spielfertigen Ordner erstellen (Packs + lose Dateien) + Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. + Bibliotheksordner (fakelib) + Ordner mit libSceAmpr.sprx und weiteren fakelib-Bibliotheken (optional, wenn das Spiel sie hat)… + Packs und lose Dateien nach dem Erstellen prüfen + Zusätzlich ein exFAT-Abbild erstellen (64-KiB-Cluster, schreibgeschützt einbinden) + exFAT-Abbild (optional) + Standard: <titleId>.exfat neben dem Ausgabeordner Manifest untersuchen Dateien auflisten Spieldateien packen diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index ebc806f..615eb4c 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -32,6 +32,14 @@ Carpeta con libSceAmpr.sprx… APR Emu Tratar como título APR + Crear juego listo (packs + archivos sueltos) + Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. + Carpeta de Bibliotecas (fakelib) + Carpeta con libSceAmpr.sprx y otras bibliotecas fakelib (opcional si el juego ya las tiene)… + Verificar los packs y los archivos sueltos al terminar + Crear también una imagen exFAT (clústeres de 64 KiB, montar en solo lectura) + Imagen exFAT (opcional) + Por defecto: <titleId>.exfat junto a la carpeta de salida Inspeccionar manifiesto Listar archivos Empaquetar archivos del juego diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index d6dc651..c43204e 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -32,6 +32,14 @@ Dossier contenant libSceAmpr.sprx… APR Emu Traiter comme un titre APR + Créer le jeu prêt à lancer (packs + fichiers libres) + Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. + Dossier des bibliothèques (fakelib) + Dossier avec libSceAmpr.sprx et d'autres bibliothèques fakelib (facultatif si le jeu les a déjà)… + Vérifier les packs et les fichiers libres après la création + Créer aussi une image exFAT (clusters de 64 Kio, montage en lecture seule) + Image exFAT (facultatif) + Par défaut : <titleId>.exfat à côté du dossier de sortie Inspecter le manifeste Lister les fichiers Empaqueter les fichiers du jeu diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 84e8535..fa08069 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -32,6 +32,14 @@ Pasta com libSceAmpr.sprx… APR Emu Tratar como título APR + Criar jogo pronto (packs + arquivos soltos) + Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. + Pasta de Bibliotecas (fakelib) + Pasta com libSceAmpr.sprx e outras bibliotecas fakelib (opcional se o jogo já as tiver)… + Verificar os packs e os arquivos soltos após criar + Criar também uma imagem exFAT (clusters de 64 KiB, montar somente leitura) + Imagem exFAT (opcional) + Padrão: <titleId>.exfat ao lado da pasta de saída Inspecionar manifesto Listar arquivos Empacotar arquivos do jogo diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index ccc5084..05d85e2 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -32,6 +32,14 @@ Folder cu libSceAmpr.sprx… APR Emu Tratează ca titlu APR + Creează jocul gata de rulat (pack-uri + fișiere libere) + Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. + Folder de Biblioteci (fakelib) + Folder cu libSceAmpr.sprx și alte biblioteci fakelib (opțional dacă jocul le are deja)… + Verifică pack-urile și fișierele libere după creare + Creează și o imagine exFAT (clustere de 64 KiB, montare doar citire) + Imagine exFAT (opțional) + Implicit: <titleId>.exfat lângă folderul de ieșire Inspectează manifestul Listează fișierele Împachetează fișierele jocului diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index c0aa2b8..48580cd 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -7,7 +7,7 @@ namespace MkPFS.Gui.ViewModels; /// -/// AMPR Packs page: runs mkpfs ampr (pack, verify, unpack, list, inspect, runtime-config, remove-sources). +/// AMPR Packs page: runs mkpfs ampr (game, pack, verify, unpack, list, inspect, runtime-config, remove-sources). /// Only the fields the selected action uses are shown. /// /// Accent color. @@ -16,6 +16,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job { private static readonly Choice[] ActionChoices = [ + new("game", "ap_act_game"), new("pack", "ap_act_pack"), new("verify", "ap_act_verify"), new("unpack", "ap_act_unpack"), @@ -32,8 +33,26 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] + [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath))] public partial Choice Action { get; set; } = ActionChoices[0]; + /// --fakelib (game): folder with AMPR Emu and other libraries to add. + [ObservableProperty] + public partial string Libs { get; set; } = string.Empty; + + /// Verify the packs and loose files after a game build (--skip-verify when off). + [ObservableProperty] + public partial bool VerifyGame { get; set; } = true; + + /// Also build an exFAT image of the game folder (--exfat). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExfatPath))] + public partial bool Exfat { get; set; } + + /// exFAT image path; empty for <titleId>.exfat next to the output folder. + [ObservableProperty] + public partial string ExfatPath { get; set; } = string.Empty; + /// --root: the game's /app0 folder. [ObservableProperty] public partial string Root { get; set; } = string.Empty; @@ -79,22 +98,31 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job public partial bool Confirm { get; set; } /// Game folder field (pack, verify, remove-sources). - public bool ShowRoot => Action.Value is "pack" or "verify" or "remove-sources"; + public bool ShowRoot => Action.Value is "game" or "pack" or "verify" or "remove-sources"; /// AMPR index field (pack). public bool ShowPackPaths => Action.Value == "pack"; - /// Output folder field (pack, unpack). - public bool ShowOutput => Action.Value is "pack" or "unpack"; + /// Output folder field (game, pack, unpack). + public bool ShowOutput => Action.Value is "game" or "pack" or "unpack"; - /// Pack manifest field (every action but pack). - public bool ShowManifest => Action.Value != "pack"; + /// Pack manifest field (every action but game and pack). + public bool ShowManifest => Action.Value is not ("game" or "pack"); - /// Configuration field (pack, runtime-config). - public bool ShowConfig => Action.Value is "pack" or "runtime-config"; + /// Configuration field (game, pack, runtime-config). + public bool ShowConfig => Action.Value is "game" or "pack" or "runtime-config"; - /// Workers, self-contained and allow-missing (pack). - public bool ShowPackOptions => Action.Value == "pack"; + /// Workers and self-contained (game, pack). + public bool ShowPackOptions => Action.Value is "game" or "pack"; + + /// Allow-missing (pack). + public bool ShowAllowMissing => Action.Value == "pack"; + + /// Library folder, verification and exFAT image (game). + public bool ShowGameOptions => Action.Value == "game"; + + /// exFAT image path (game with an image). + public bool ShowExfatPath => ShowGameOptions && Exfat; /// Overwrite (unpack). public bool ShowOverwrite => Action.Value == "unpack"; @@ -117,8 +145,9 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job string config = Config.Trim(); error = Action.Value switch { + "game" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], "pack" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], - "pack" when Workers.Trim().Length > 0 && !(int.TryParse(Workers.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n is >= 1 and <= 256) + "game" or "pack" when Workers.Trim().Length > 0 && !(int.TryParse(Workers.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n is >= 1 and <= 256) => Localizer.Instance["ap_err_workers"], "verify" or "list" or "inspect" when manifest.Length == 0 => Localizer.Instance["ap_err_manifest"], "unpack" when manifest.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_unpack"], @@ -134,6 +163,20 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job List args = ["ampr", Action.Value]; switch (Action.Value) { + case "game": + args.AddRange(["--root", root, "--output", output]); + AddOption(args, "--fakelib", Libs); + AddOption(args, "--config", config); + AddOption(args, "--workers", Workers); + AddFlag(args, SelfContained, "--self-contained"); + AddFlag(args, !VerifyGame, "--skip-verify"); + if (Exfat) + { + // An existing folder receives .exfat: by default the folder that holds the output. + args.AddRange(["--exfat", ExfatPath.Trim() is { Length: > 0 } image ? image : Path.GetDirectoryName(Path.GetFullPath(output)) ?? output]); + } + + break; case "pack": args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); // Without a TOML every file would stay loose; the page uses the built-in rules instead. @@ -172,7 +215,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// The manifest a pack run writes, so the other actions can use it right away. partial void OnOutputChanged(string value) { - if (Manifest.Length == 0 && value.Trim().Length > 0 && Action.Value == "pack") + if (Manifest.Length == 0 && value.Trim().Length > 0 && Action.Value is "game" or "pack") { Manifest = Path.Combine(value.Trim(), Build.AMPRPack.AMPRPackConfig.DefaultIndexName); } diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 0e1b39a..598b315 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -14,6 +14,7 @@ + @@ -21,6 +22,7 @@ + @@ -30,7 +32,12 @@ - + + + + + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 813aa3a..66e2065 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -47,6 +47,34 @@ public void Pack_builds_arguments_and_validates() Assert.Equal("✗ Workers must be a whole number from 1 to 256.", error); } + [Fact] + public void Game_is_the_default_action_and_builds_arguments() + { + AmprPackPanelViewModel panel = new(Colors.YellowGreen, Sync()); + Assert.Equal("game", panel.Action.Value); + Assert.Null(panel.BuildArguments(out string? error)); + Assert.Equal("✗ Game folder and output folder are both required.", error); + + panel.Root = "D:/game"; + panel.Output = "D:/games/out"; + Assert.Equal(Path.Combine("D:/games/out", "ampr_assets.index"), panel.Manifest); + Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out"], panel.BuildArguments(out _)); + + panel.Libs = "D:/emu"; + panel.Config = "D:/rules.toml"; + panel.Workers = "4"; + panel.SelfContained = true; + panel.VerifyGame = false; + panel.Exfat = true; + Assert.Equal( + ["ampr", "game", "--root", "D:/game", "--output", "D:/games/out", "--fakelib", "D:/emu", "--config", "D:/rules.toml", "--workers", "4", + "--self-contained", "--skip-verify", "--exfat", Path.GetDirectoryName(Path.GetFullPath("D:/games/out"))!], + panel.BuildArguments(out _)); + + panel.ExfatPath = "E:/PPSA01234.exfat"; + Assert.Equal(["--exfat", "E:/PPSA01234.exfat"], panel.BuildArguments(out _)!.TakeLast(2)); + } + [Fact] public void Other_actions_build_arguments_and_validate() { @@ -90,6 +118,7 @@ public void Other_actions_build_arguments_and_validate() } [Theory] + [InlineData("game", true, false, true, false, true, true, false, false, false)] [InlineData("pack", true, true, true, false, true, true, false, false, false)] [InlineData("verify", true, false, false, true, false, false, false, false, false)] [InlineData("unpack", false, false, true, true, false, false, true, false, false)] @@ -105,6 +134,11 @@ public void Each_action_shows_only_its_fields( (root, packPaths, output, manifest, config, packOptions, overwrite, json, confirm), (panel.ShowRoot, panel.ShowPackPaths, panel.ShowOutput, panel.ShowManifest, panel.ShowConfig, panel.ShowPackOptions, panel.ShowOverwrite, panel.ShowJson, panel.ShowConfirm)); + Assert.Equal(action == "game", panel.ShowGameOptions); + Assert.Equal(action == "pack", panel.ShowAllowMissing); + Assert.False(panel.ShowExfatPath); + panel.Exfat = true; + Assert.Equal(action == "game", panel.ShowExfatPath); } [AvaloniaFact] @@ -128,6 +162,28 @@ public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_ Assert.DoesNotContain(panel.Job.Lines, l => l.Text.StartsWith("✓ planning", StringComparison.Ordinal) || l.Text.StartsWith("✓ packing", StringComparison.Ordinal)); } + [AvaloniaFact] + public async Task Page_builds_a_playable_game_folder() + { + using TempDir dir = new(); + string game = BuildPanelTests.Game(dir); + dir.File("PPSA01234-app/data/level.dat", string.Concat(Enumerable.Repeat("terrain mesh texture ", 20_000))); + dir.File("emu/libSceAmpr.sprx", "AMPR Emu with AMPRPAK4 support"); + + AmprPackPanelViewModel panel = new(Colors.YellowGreen, Sync()); + panel.Root = game; + panel.Output = Path.Combine(dir.Path, "playable"); + panel.Libs = Path.Combine(dir.Path, "emu"); + await panel.RunCommand.ExecuteAsync(null); + + Assert.True(panel.Job.Lines[^1].Text == "✓ Completed successfully.", string.Join('\n', panel.Job.Lines.Select(l => l.Text))); + Assert.Contains(panel.Job.Lines, l => l.Text == "[5/5] Verifying"); + Assert.True(File.Exists(Path.Combine(panel.Output, "fakelib", "libSceAmpr.sprx"))); + Assert.True(File.Exists(Path.Combine(panel.Output, "ampr_emu.index"))); + Assert.True(File.Exists(panel.Manifest)); + Assert.False(File.Exists(Path.Combine(panel.Output, "data", "level.dat"))); + } + [AvaloniaFact] public async Task Page_packs_verifies_and_plans_removal_for_a_real_folder() { diff --git a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs new file mode 100644 index 0000000..8aa1920 --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs @@ -0,0 +1,197 @@ +using System.Security.Cryptography; +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Build.PFS; +using MkPFS.Cli; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; +using MkPFS.Core.Diagnostics; +using MkPFS.Core.Exfat; + +namespace MkPFS.Tests.AMPR; + +/// ampr game: a folder that runs from packs as is (an extension over ampr_pack.py). +public sealed class AMPRGameBuilderTests +{ + private sealed class ListLog : IMkPFSLog + { + public List Lines { get; } = []; + + public void Log(LogLevel level, string message, LogIcon icon = LogIcon.None) => Lines.Add(message); + } + + // A stand-in for AMPR Emu 0.4.2.1+: what matters is the embedded manifest magic. + private const string PackCapable = "ELF...AMPRPAK4.../app0/ampr_assets.index"; + + private static readonly string Level = string.Concat(Enumerable.Repeat("terrain mesh texture vertex ", 20_000)); + + private static string Game(TempDir dir, string library = "fakelib") + { + dir.File("game/sce_sys/param.json", """{"titleId":"PPSA01234","contentVersion":"01.000.000"}"""); + dir.File("game/eboot.bin", "eboot"); + dir.File("game/sce_module/libc.prx", "module"); + dir.File("game/data/level0.dat", Level); + dir.File("game/data/Niveau_é.dat", Level + "é"); + dir.File($"game/{library}/libSceAmpr.sprx", "old emulator without packs"); + dir.File($"game/{library}/libkernel.sprx", "kernel"); + dir.Dir("game/empty/dir"); + dir.File("libs/libSceAmpr.sprx", PackCapable); + dir.File("libs/libScePlayGo.sprx", "playgo"); + dir.File("libs/libkernel.sprx", "kernel"); + dir.File("libs/.DS_Store", "ignored"); + return Path.Combine(dir.Path, "game"); + } + + private static AMPRGameOptions Options(TempDir dir, string? image = null) => new() + { + Config = AMPRPackConfig.LoadPreset("default"), + LibsDir = Path.Combine(dir.Path, "libs"), + ExfatImage = image, + }; + + private static Dictionary Hashes(string root) => + Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).ToDictionary( + f => Path.GetRelativePath(root, f), f => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(f)))); + + [Fact] + public void Output_holds_libraries_index_packs_and_loose_files_and_the_source_is_untouched() + { + using TempDir dir = new(); + string game = Game(dir); + Dictionary before = Hashes(game); + string output = Path.Combine(dir.Path, "out"); + ListLog log = new(); + + AMPRGameResult result = AMPRGameBuilder.Build(game, output, Options(dir), log); + + Assert.Equal(before, Hashes(game)); + Assert.Equal("fakelib", result.LibraryDir); + Assert.Equal(2, result.Pack.Stats.FilesPacked); + Assert.Contains(" libSceAmpr.sprx: replaced", log.Lines); + Assert.Contains(" libScePlayGo.sprx: added", log.Lines); + Assert.Contains(" libkernel.sprx: already up to date", log.Lines); + Assert.Equal(PackCapable, File.ReadAllText(Path.Combine(output, "fakelib", "libSceAmpr.sprx"))); + Assert.False(File.Exists(Path.Combine(output, "fakelib", ".DS_Store"))); + + // Packed files are only in the packs; everything else is copied; empty folders are kept. + Assert.False(File.Exists(Path.Combine(output, "data", "level0.dat"))); + Assert.False(File.Exists(Path.Combine(output, "data", "Niveau_é.dat"))); + foreach (string loose in (string[])["eboot.bin", "sce_module/libc.prx", "sce_sys/param.json"]) + { + Assert.Equal(File.ReadAllBytes(Path.Combine(game, loose)), File.ReadAllBytes(Path.Combine(output, loose))); + } + + Assert.True(Directory.Exists(Path.Combine(output, "empty", "dir"))); + Assert.Contains(log.Lines, l => l.StartsWith(" Loose files: ", StringComparison.Ordinal)); + + // The index is exactly the index of the final tree: unpack the packs over a copy and index that. + string final = dir.Dir("final"); + foreach (string file in Directory.EnumerateFiles(output, "*", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(output, file); + if (!relative.StartsWith("ampr_", StringComparison.Ordinal)) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(final, relative))!); + File.Copy(file, Path.Combine(final, relative)); + File.SetLastWriteTimeUtc(Path.Combine(final, relative), File.GetLastWriteTimeUtc(file)); + } + } + + AMPRPackTools.Extract(result.Pack.IndexPath, final, null, overwrite: false, preserveMTime: true); + AmprIndex.Build(final, Path.Combine(dir.Path, "final.index")); + Assert.Equal(File.ReadAllBytes(Path.Combine(dir.Path, "final.index")), File.ReadAllBytes(Path.Combine(output, AmprIndex.IndexName))); + } + + [Fact] + public void A_game_with_fakelib2_gets_its_libraries_there() + { + using TempDir dir = new(); + string game = Game(dir, "fakelib2"); + string output = Path.Combine(dir.Path, "out"); + + AMPRGameResult result = AMPRGameBuilder.Build(game, output, Options(dir), new ListLog()); + + Assert.Equal("fakelib2", result.LibraryDir); + Assert.Equal(PackCapable, File.ReadAllText(Path.Combine(output, "fakelib2", "libSceAmpr.sprx"))); + Assert.False(Directory.Exists(Path.Combine(output, "fakelib"))); + } + + [Fact] + public void Inputs_that_cannot_give_a_playable_folder_are_rejected_before_anything_is_written() + { + using TempDir dir = new(); + string game = Game(dir); + string output = Path.Combine(dir.Path, "out"); + string Error(AMPRGameOptions options, string target) => + Assert.Throws(() => AMPRGameBuilder.Build(game, target, options, new ListLog())).Message; + + // The game's own emulator cannot read packs. + Assert.StartsWith("fakelib/libSceAmpr.sprx cannot read asset packs", Error(Options(dir) with { LibsDir = null }, output), StringComparison.Ordinal); + Assert.False(Directory.Exists(output)); + + Assert.StartsWith("library folder not found", Error(Options(dir) with { LibsDir = Path.Combine(dir.Path, "nope") }, output), StringComparison.Ordinal); + Assert.Equal("the game folder and the output folder must not contain each other", Error(Options(dir), Path.Combine(game, "out"))); + + dir.File("busy/file.txt"); + Assert.StartsWith("the output folder must be new or empty", Error(Options(dir), Path.Combine(dir.Path, "busy")), StringComparison.Ordinal); + + Assert.Equal("the exFAT image must be outside the game and output folders", Error(Options(dir, Path.Combine(output, "x.exfat")), output)); + + dir.File("game/ampr_assets.index", "old manifest"); + Assert.StartsWith("the game folder already has ampr_assets.index", Error(Options(dir), output), StringComparison.Ordinal); + } + + [Fact] + public void A_failed_build_leaves_the_output_folder_empty() + { + using TempDir dir = new(); + string game = Game(dir); + string output = dir.Dir("out"); + AMPRGameOptions options = Options(dir) with { Config = AMPRPackConfig.LoadPreset("default") }; + options.Config.RequiredPacked = ["eboot.bin"]; // the preset keeps it loose, so the packer fails + + Assert.ThrowsAny(() => AMPRGameBuilder.Build(game, output, options, new ListLog())); + + Assert.True(Directory.Exists(output)); + Assert.Empty(Directory.EnumerateFileSystemEntries(output)); + } + + [Fact] + public void The_exfat_image_holds_the_playable_folder() + { + using TempDir dir = new(); + string game = Game(dir); + string output = Path.Combine(dir.Path, "out"); + + AMPRGameResult result = AMPRGameBuilder.Build(game, output, Options(dir, dir.Path), new ListLog()); + + Assert.Equal(Path.Combine(dir.Path, "PPSA01234.exfat"), result.ExfatImage); + using FileStream stream = File.OpenRead(result.ExfatImage!); + ExfatReader reader = new(stream); + Assert.Equal(64 * 1024, reader.Geometry.ClusterSize); + List names = [.. reader.RootEntries().Select(e => e.Name).Order(StringComparer.Ordinal)]; + Assert.Equal(["ampr_assets-000.pak", "ampr_assets.index", "ampr_assets.index.crc", "ampr_emu.index", "data", "eboot.bin", "empty", "fakelib", "sce_module", "sce_sys"], names); + } + + [Fact] + public void Cli_game_builds_the_folder_and_reports_each_step() + { + using TempDir dir = new(); + string game = Game(dir); + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); + + int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out"), "--fakelib", Path.Combine(dir.Path, "libs")], ctx); + + Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); + foreach (string step in (string[])["[1/5] Libraries: fakelib/", "[2/5] Writing ampr_emu.index", "[3/5] Packing", "[4/5] Copying loose files", "[5/5] Verifying"]) + { + Assert.Contains(step + "\n", stdout.ToString(), StringComparison.Ordinal); + } + + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2")], ctx); + Assert.Equal(2, exit); + Assert.EndsWith("error: fakelib/libSceAmpr.sprx cannot read asset packs; use AMPR Emu 0.4.2.1 or newer from https://github.com/drakmor/ampr_emu/releases\n", stderr.ToString(), StringComparison.Ordinal); + } +} From cdbb3e915b7f07916e6869c49bd17b99b472d83d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:49:17 +0000 Subject: [PATCH 05/28] Clarify that ampr pack writes only the pack set The GUI action "Pack game files" ran `ampr pack`, which (like ampr_pack.py) writes only ampr_assets.index, the .pak volumes and the .crc, and users expected a playable folder. Rename it "Pack files only (manifest and .pak volumes)" and show a note pointing to Build playable game. Also correct the docs: the AMPR Emu 0.4.2.1 release is the PackedStdio build, which serves packed files to ordinary open/read/pread and AIO calls as well as AMPR reads, so Unity titles that load libSceAmpr work; only mmap and writable opens must stay loose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 13 ++++++++----- src/MkPFS.Gui/Localization/Strings.resx | 5 +++-- src/MkPFS.Gui/Localization/Strings_de.resx | 5 +++-- src/MkPFS.Gui/Localization/Strings_es.resx | 5 +++-- src/MkPFS.Gui/Localization/Strings_fr.resx | 5 +++-- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 5 +++-- src/MkPFS.Gui/Localization/Strings_ro.resx | 5 +++-- src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs | 5 ++++- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 1 + tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 1 + 10 files changed, 32 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7544cf8..66979e2 100644 --- a/README.md +++ b/README.md @@ -270,11 +270,14 @@ manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, and an offline C sees LZ4. `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same JSON on standard output, and byte-identical packs. -The released emulator only serves files the game reads through AMPR, and it only loads in titles that -use libSceAmpr. Packs for other titles (for example Unity games, which read files directly) are never -read, and `pack` warns when `--root` has no `fakelib/libSceAmpr.sprx` or `fakelib2/libSceAmpr.sprx`. -Files read another way (for example `mmap`) must stay loose, so keep the source files until the packed -game is tested, and remove them only after that with `remove-sources --confirm`. +The released emulator (0.4.2.1, its PackedStdio build) serves packed files to AMPR reads and to the +game's ordinary `open`/`read`/`pread` and asynchronous reads, so Unity games work too. It only loads in +titles that use libSceAmpr, so `pack` warns when `--root` has no `fakelib/libSceAmpr.sprx` or +`fakelib2/libSceAmpr.sprx`. Memory-mapped files (`mmap`) and files opened for writing are not served and +must stay loose, so keep the source files until the packed game is tested. + +`pack` writes only the pack set (`ampr_assets.index`, `.pak` volumes, `.crc`) into `--output`, exactly +like `ampr_pack.py`. For a folder to copy to the PS5, use [`ampr game`](#ampr-game-a-folder-to-copy-to-the-ps5). The default emulator build loads at most 2,000,000 files, 16,000,000 chunks, and 1,024 volumes, and rejects the whole set beyond that; `pack` warns on standard error when a set exceeds a limit (larger diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 68d1665..c7c630c 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -42,7 +42,7 @@ Default: <titleId>.exfat next to the output folder Inspect manifest List files - Pack game files + Pack files only (manifest and .pak volumes) Remove packed sources Update runtime settings Unpack packed files @@ -63,7 +63,8 @@ JSON output Pack Manifest Select ampr_assets.index… - Packs only serve files the game reads through AMPR. Files read another way (for example mmap) must stay loose, so keep the source files until the packed game is tested. + Writes only ampr_assets.index, the .pak volumes and the .crc file into the output folder, like ampr_pack.py. For a folder you can copy to the PS5 and play, use Build playable game. + Packs serve files the game reads through AMPR and its ordinary file reads (AMPR Emu 0.4.2.1). Memory-mapped files must stay loose, so keep the source files until the packed game is tested. Output Folder Folder for the manifest and .pak volumes… Overwrite existing files diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 5e59b3e..0567b24 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -42,7 +42,7 @@ Standard: <titleId>.exfat neben dem Ausgabeordner Manifest untersuchen Dateien auflisten - Spieldateien packen + Nur packen (Manifest und .pak-Volumes) Gepackte Quelldateien entfernen Laufzeiteinstellungen aktualisieren Gepackte Dateien entpacken @@ -63,7 +63,8 @@ JSON-Ausgabe Paket-Manifest ampr_assets.index auswählen… - Pakete liefern nur Dateien, die das Spiel über AMPR liest. Auf andere Weise gelesene Dateien (zum Beispiel per mmap) müssen lose bleiben; behalte die Quelldateien, bis das gepackte Spiel getestet ist. + Schreibt nur ampr_assets.index, die .pak-Volumes und die .crc-Datei in den Ausgabeordner, wie ampr_pack.py. Für einen Ordner, den du auf die PS5 kopierst und spielst, nutze Spielfertigen Ordner erstellen. + Pakete liefern Dateien, die das Spiel über AMPR und mit normalen Dateizugriffen liest (AMPR Emu 0.4.2.1). Speicherabgebildete Dateien müssen lose bleiben; behalte die Quelldateien, bis das gepackte Spiel getestet ist. Ausgabeordner Ordner für Manifest und .pak-Volumes… Vorhandene Dateien überschreiben diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 615eb4c..8acd281 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -42,7 +42,7 @@ Por defecto: <titleId>.exfat junto a la carpeta de salida Inspeccionar manifiesto Listar archivos - Empaquetar archivos del juego + Solo empaquetar (manifiesto y volúmenes .pak) Eliminar archivos fuente empaquetados Actualizar ajustes de ejecución Desempaquetar archivos @@ -63,7 +63,8 @@ Salida JSON Manifiesto del paquete Selecciona ampr_assets.index… - Los paquetes solo sirven archivos que el juego lee mediante AMPR. Los archivos leídos de otra forma (por ejemplo, mmap) deben quedar sueltos; conserva los archivos fuente hasta probar el juego empaquetado. + Escribe solo ampr_assets.index, los volúmenes .pak y el archivo .crc en la carpeta de salida, como ampr_pack.py. Para una carpeta que copias a la PS5 y juegas, usa Crear juego listo. + Los paquetes sirven los archivos que el juego lee mediante AMPR y con lecturas de archivo normales (AMPR Emu 0.4.2.1). Los archivos mapeados en memoria deben quedar sueltos; conserva los archivos fuente hasta probar el juego empaquetado. Carpeta de salida Carpeta para el manifiesto y los volúmenes .pak… Sobrescribir archivos existentes diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index c43204e..53a2a15 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -42,7 +42,7 @@ Par défaut : <titleId>.exfat à côté du dossier de sortie Inspecter le manifeste Lister les fichiers - Empaqueter les fichiers du jeu + Empaqueter seulement (manifeste et volumes .pak) Supprimer les sources empaquetées Mettre à jour les réglages d’exécution Extraire les fichiers empaquetés @@ -63,7 +63,8 @@ Sortie JSON Manifeste du paquet Sélectionner ampr_assets.index… - Les paquets ne servent que les fichiers que le jeu lit via AMPR. Les fichiers lus autrement (par exemple via mmap) doivent rester libres ; conservez les fichiers sources jusqu’à ce que le jeu empaqueté soit testé. + N’écrit que ampr_assets.index, les volumes .pak et le fichier .crc dans le dossier de sortie, comme ampr_pack.py. Pour un dossier à copier sur la PS5 pour jouer, utilisez Créer le jeu prêt à lancer. + Les paquets servent les fichiers que le jeu lit via AMPR et par des lectures de fichiers ordinaires (AMPR Emu 0.4.2.1). Les fichiers mappés en mémoire doivent rester libres ; conservez les fichiers sources jusqu’à ce que le jeu empaqueté soit testé. Dossier de sortie Dossier du manifeste et des volumes .pak… Écraser les fichiers existants diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index fa08069..590a63e 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -42,7 +42,7 @@ Padrão: <titleId>.exfat ao lado da pasta de saída Inspecionar manifesto Listar arquivos - Empacotar arquivos do jogo + Somente empacotar (manifesto e volumes .pak) Remover arquivos-fonte empacotados Atualizar configurações de execução Desempacotar arquivos @@ -63,7 +63,8 @@ Saída JSON Manifesto do pacote Selecione ampr_assets.index… - Os pacotes só atendem arquivos que o jogo lê pelo AMPR. Arquivos lidos de outra forma (por exemplo, mmap) devem ficar soltos; mantenha os arquivos-fonte até testar o jogo empacotado. + Grava apenas ampr_assets.index, os volumes .pak e o arquivo .crc na pasta de saída, como o ampr_pack.py. Para uma pasta que você copia para o PS5 e joga, use Criar jogo pronto. + Os pacotes atendem os arquivos que o jogo lê pelo AMPR e por leituras comuns de arquivo (AMPR Emu 0.4.2.1). Arquivos mapeados em memória devem ficar soltos; mantenha os arquivos-fonte até testar o jogo empacotado. Pasta de saída Pasta para o manifesto e os volumes .pak… Sobrescrever arquivos existentes diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 05d85e2..50cf2be 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -42,7 +42,7 @@ Implicit: <titleId>.exfat lângă folderul de ieșire Inspectează manifestul Listează fișierele - Împachetează fișierele jocului + Doar împachetare (manifest și volume .pak) Șterge sursele împachetate Actualizează setările de rulare Despachetează fișierele împachetate @@ -63,7 +63,8 @@ Ieșire JSON Manifestul pachetului Selectează ampr_assets.index… - Pachetele servesc doar fișierele pe care jocul le citește prin AMPR. Fișierele citite altfel (de exemplu prin mmap) trebuie să rămână libere; păstrează fișierele sursă până testezi jocul împachetat. + Scrie doar ampr_assets.index, volumele .pak și fișierul .crc în folderul de ieșire, ca ampr_pack.py. Pentru un folder pe care îl copiezi pe PS5 și joci, folosește Creează jocul gata de rulat. + Pachetele servesc fișierele pe care jocul le citește prin AMPR și prin citiri obișnuite de fișiere (AMPR Emu 0.4.2.1). Fișierele mapate în memorie trebuie să rămână libere; păstrează fișierele sursă până testezi jocul împachetat. Folder de ieșire Folder pentru manifest și volumele .pak… Suprascrie fișierele existente diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index 48580cd..cbbaa3a 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -33,7 +33,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] - [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath))] + [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -118,6 +118,9 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// Allow-missing (pack). public bool ShowAllowMissing => Action.Value == "pack"; + /// Note that pack writes only the pack set (pack). + public bool ShowPackNote => Action.Value == "pack"; + /// Library folder, verification and exFAT image (game). public bool ShowGameOptions => Action.Value == "game"; diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 598b315..9789ee7 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -15,6 +15,7 @@ + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 66e2065..bb821cf 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -136,6 +136,7 @@ public void Each_action_shows_only_its_fields( panel.ShowOverwrite, panel.ShowJson, panel.ShowConfirm)); Assert.Equal(action == "game", panel.ShowGameOptions); Assert.Equal(action == "pack", panel.ShowAllowMissing); + Assert.Equal(action == "pack", panel.ShowPackNote); Assert.False(panel.ShowExfatPath); panel.Exfat = true; Assert.Equal(action == "game", panel.ShowExfatPath); From ac6eb018a358872291ecf543f3735a727146d10e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:23:50 +0000 Subject: [PATCH 06/28] Add a unity preset that packs only StreamingAssets Console test with God of War Sons of Sparta (Unity, IL2CPP) on AMPR Emu 0.4.2.1: packing everything except executables and system files made the game abort at startup (SYSTEM_ABNORMAL_TERMINATION_REQUEST), while packing only Media/StreamingAssets (Addressables bundles, FMOD banks) ran normally. Unity's own data files (level*, sharedassets*, globalgamemanagers, .resS) must stay loose, consistent with Unity reading them through a path AMPR Emu does not intercept. `--preset unity` for `ampr pack` and `ampr game`, and a Rules picker (Default / Unity) on the AMPR Packs page when no TOML is chosen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 5 +++- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 25 ++++++++++++++++++- src/MkPFS.Cli/Commands/AmprCommand.cs | 4 +-- src/MkPFS.Gui/Localization/Strings.resx | 5 +++- src/MkPFS.Gui/Localization/Strings_de.resx | 5 +++- src/MkPFS.Gui/Localization/Strings_es.resx | 5 +++- src/MkPFS.Gui/Localization/Strings_fr.resx | 5 +++- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 5 +++- src/MkPFS.Gui/Localization/Strings_ro.resx | 5 +++- .../ViewModels/AmprPackPanelViewModel.cs | 18 ++++++++++--- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 8 ++++++ tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 5 ++++ tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 18 +++++++++++++ 13 files changed, 100 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 66979e2..4f4c265 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp |---|---|---| | `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | | `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | +| `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -327,7 +328,9 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em 2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with their real sizes. This must come before packing, because the manifest addresses files by their row in this index. -3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset default`. +3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset default`. Use + `--preset unity` (GUI: Rules > Unity) for Unity games, which have a `Media/` or `*_Data/` folder with + `StreamingAssets`, `globalgamemanagers` and `Il2CppUserAssemblies.prx`. 4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, including empty ones. This runs after packing because the packer only then decides which large, incompressible files to leave loose. diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index b512594..42b6ae6 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -287,7 +287,7 @@ public static AMPRPackConfig Load(string? path) } /// Built-in rule sets for (an extension; ampr_pack has none). - public static IReadOnlyList PresetNames { get; } = ["default"]; + public static IReadOnlyList PresetNames { get; } = ["default", "unity"]; /// /// The default preset: compress every file, but keep loose everything remove-sources protects @@ -309,6 +309,28 @@ public static AMPRPackConfig Load(string? path) ] """; + /// + /// The unity preset: compress only StreamingAssets (Addressables bundles, FMOD banks, videos), which + /// Unity reads with ordinary file reads that AMPR Emu serves. Unity's own data files next to it (level*, + /// sharedassets*, globalgamemanagers, .resS) stay loose: packing them made a Unity title abort + /// at startup on the console, consistent with Unity reading them through a path AMPR Emu does not intercept. + /// + public const string UnityPresetToml = """ + [pack] + default_action = "loose" + + [[rule]] + action = "compress" + include = ["StreamingAssets/*", "*/StreamingAssets/*"] + + [[rule]] + action = "loose" + include = [ + "eboot.bin", "*/eboot.bin", "*.prx", "*.sprx", "*.elf", "*.self", + "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "*/global-metadata.dat", + ] + """; + /// Load a built-in preset. /// Preset name (see ). /// Configuration. @@ -316,6 +338,7 @@ public static AMPRPackConfig Load(string? path) public static AMPRPackConfig LoadPreset(string name) => name switch { "default" => FromTable(AMPRToml.Parse(DefaultPresetToml, "preset default"), Directory.GetCurrentDirectory()), + "unity" => FromTable(AMPRToml.Parse(UnityPresetToml, "preset unity"), Directory.GetCurrentDirectory()), _ => throw new AMPRPackException($"unknown preset: {name}"), }; diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 3b673ce..14acb03 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -52,7 +52,7 @@ private static Command Pack(CliContext ctx) Option amprIndex = Required("--ampr-index", "AMPRIDX3 file"); Option output = Required("--output", "output directory"); Option config = new("--config") { Description = "TOML pack configuration" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option include = Repeated("--include", "additional include glob"); Option exclude = Repeated("--exclude", "force-loose glob"); @@ -156,7 +156,7 @@ private static Command Game(CliContext ctx) Option output = Required("--output", "new or empty folder for the playable game"); Option fakelib = new("--fakelib") { Description = "folder with AMPR Emu 0.4.2.1+ libSceAmpr.sprx and other fakelib libraries to add" }; Option config = new("--config") { Description = "TOML pack configuration (default: --preset default)" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config (default: default)" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: default (every file but executables, modules and system files) or unity (only StreamingAssets); default: default" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index c7c630c..ccd22b4 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -50,7 +50,7 @@ Action Leave missing source files loose TOML Configuration - Empty: default rules (compress all, keep executables and system files loose)… + Empty: use the Rules above… Delete the packed source files (cannot be undone; unchecked shows the plan only) ✗ Pack manifest is required. ✗ Game folder and output folder are both required. @@ -68,6 +68,9 @@ Output Folder Folder for the manifest and .pak volumes… Overwrite existing files + Rules (when no TOML is chosen) + Default: compress everything except executables, modules and system files + Unity: compress only StreamingAssets (bundles, audio banks) Game Folder (/app0) Folder with eboot.bin and sce_sys… Never leave selected files loose (store incompressible blocks uncompressed) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 0567b24..a484bc8 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -50,7 +50,7 @@ Aktion Fehlende Quelldateien lose lassen TOML-Konfiguration - Leer: Standardregeln (alles komprimieren, ausführbare und Systemdateien lose lassen)… + Leer: die Regeln oben verwenden… Gepackte Quelldateien löschen (nicht rückgängig zu machen; sonst nur den Plan anzeigen) ✗ Paket-Manifest ist erforderlich. ✗ Spielordner und Ausgabeordner sind beide erforderlich. @@ -68,6 +68,9 @@ Ausgabeordner Ordner für Manifest und .pak-Volumes… Vorhandene Dateien überschreiben + Regeln (wenn keine TOML gewählt ist) + Standard: alles komprimieren außer ausführbaren Dateien, Modulen und Systemdateien + Unity: nur StreamingAssets komprimieren (Bundles, Audio-Banks) Spielordner (/app0) Ordner mit eboot.bin und sce_sys… Ausgewählte Dateien nie lose lassen (nicht komprimierbare Blöcke unkomprimiert speichern) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 8acd281..239db64 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -50,7 +50,7 @@ Acción Dejar sueltos los archivos fuente que falten Configuración TOML - Vacío: reglas predeterminadas (comprimir todo, dejar sueltos ejecutables y archivos del sistema)… + Vacío: usar las Reglas de arriba… Eliminar los archivos fuente empaquetados (no se puede deshacer; sin marcar solo muestra el plan) ✗ Se requiere el manifiesto del paquete. ✗ Se requieren la carpeta del juego y la carpeta de salida. @@ -68,6 +68,9 @@ Carpeta de salida Carpeta para el manifiesto y los volúmenes .pak… Sobrescribir archivos existentes + Reglas (si no se elige un TOML) + Predeterminado: comprimir todo excepto ejecutables, módulos y archivos del sistema + Unity: comprimir solo StreamingAssets (bundles, bancos de audio) Carpeta del juego (/app0) Carpeta con eboot.bin y sce_sys… No dejar nunca sueltos los archivos seleccionados (guardar sin comprimir los bloques incompresibles) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 53a2a15..7084f5d 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -50,7 +50,7 @@ Action Laisser libres les fichiers sources manquants Configuration TOML - Vide : règles par défaut (tout compresser, laisser libres exécutables et fichiers système)… + Vide : utiliser les Règles ci-dessus… Supprimer les fichiers sources empaquetés (irréversible ; décoché, affiche seulement le plan) ✗ Le manifeste du paquet est requis. ✗ Le dossier du jeu et le dossier de sortie sont tous deux requis. @@ -68,6 +68,9 @@ Dossier de sortie Dossier du manifeste et des volumes .pak… Écraser les fichiers existants + Règles (si aucun TOML n’est choisi) + Par défaut : tout compresser sauf les exécutables, modules et fichiers système + Unity : compresser seulement StreamingAssets (bundles, banques audio) Dossier du jeu (/app0) Dossier contenant eboot.bin et sce_sys… Ne jamais laisser libres les fichiers sélectionnés (stocker non compressés les blocs incompressibles) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 590a63e..5928552 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -50,7 +50,7 @@ Ação Deixar soltos os arquivos-fonte ausentes Configuração TOML - Vazio: regras padrão (comprimir tudo, deixar soltos executáveis e arquivos do sistema)… + Vazio: usar as Regras acima… Excluir os arquivos-fonte empacotados (não pode ser desfeito; desmarcado mostra apenas o plano) ✗ O manifesto do pacote é obrigatório. ✗ A pasta do jogo e a pasta de saída são obrigatórias. @@ -68,6 +68,9 @@ Pasta de saída Pasta para o manifesto e os volumes .pak… Sobrescrever arquivos existentes + Regras (quando nenhum TOML é escolhido) + Padrão: comprimir tudo exceto executáveis, módulos e arquivos de sistema + Unity: comprimir apenas StreamingAssets (bundles, bancos de áudio) Pasta do jogo (/app0) Pasta com eboot.bin e sce_sys… Nunca deixar soltos os arquivos selecionados (guardar sem compressão os blocos incompressíveis) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 50cf2be..6d16372 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -50,7 +50,7 @@ Acțiune Lasă libere fișierele sursă lipsă Configurație TOML - Gol: reguli implicite (comprimă tot, lasă libere executabilele și fișierele de sistem)… + Gol: folosește Regulile de mai sus… Șterge fișierele sursă împachetate (ireversibil; nebifat arată doar planul) ✗ Manifestul pachetului este necesar. ✗ Folderul jocului și folderul de ieșire sunt ambele necesare. @@ -68,6 +68,9 @@ Folder de ieșire Folder pentru manifest și volumele .pak… Suprascrie fișierele existente + Reguli (când nu este ales un TOML) + Implicit: comprimă tot în afară de executabile, module și fișiere de sistem + Unity: comprimă doar StreamingAssets (bundle-uri, bănci audio) Folderul jocului (/app0) Folder cu eboot.bin și sce_sys… Nu lăsa niciodată libere fișierele selectate (stochează necomprimate blocurile necompresibile) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index cbbaa3a..affe0d9 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -26,6 +26,15 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job new("remove-sources", "ap_act_remove"), ]; + private static readonly Choice[] PresetChoices = [new("default", "ap_preset_default"), new("unity", "ap_preset_unity")]; + + /// Built-in rule sets (--preset), used when no TOML is chosen. + public IReadOnlyList Presets => PresetChoices; + + /// Selected built-in rules. + [ObservableProperty] + public partial Choice Preset { get; set; } = PresetChoices[0]; + /// Actions, in menu order; values are the CLI subcommands. public IReadOnlyList Actions => ActionChoices; @@ -33,7 +42,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] - [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] + [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote), nameof(ShowPreset))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -115,6 +124,9 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// Workers and self-contained (game, pack). public bool ShowPackOptions => Action.Value is "game" or "pack"; + /// Built-in rules picker (game, pack). + public bool ShowPreset => Action.Value is "game" or "pack"; + /// Allow-missing (pack). public bool ShowAllowMissing => Action.Value == "pack"; @@ -169,7 +181,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job case "game": args.AddRange(["--root", root, "--output", output]); AddOption(args, "--fakelib", Libs); - AddOption(args, "--config", config); + args.AddRange(config.Length > 0 ? ["--config", config] : Preset.Value == "default" ? [] : ["--preset", Preset.Value]); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, !VerifyGame, "--skip-verify"); @@ -183,7 +195,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job case "pack": args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); // Without a TOML every file would stay loose; the page uses the built-in rules instead. - args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", "default"]); + args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", Preset.Value]); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, AllowMissing, "--allow-missing"); diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 9789ee7..92e4e19 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -24,6 +24,14 @@ + + + + + + + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index bb821cf..128246c 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -60,6 +60,10 @@ public void Game_is_the_default_action_and_builds_arguments() Assert.Equal(Path.Combine("D:/games/out", "ampr_assets.index"), panel.Manifest); Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out"], panel.BuildArguments(out _)); + panel.Preset = panel.Presets.Single(p => p.Value == "unity"); + Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out", "--preset", "unity"], panel.BuildArguments(out _)); + panel.Preset = panel.Presets[0]; + panel.Libs = "D:/emu"; panel.Config = "D:/rules.toml"; panel.Workers = "4"; @@ -137,6 +141,7 @@ public void Each_action_shows_only_its_fields( Assert.Equal(action == "game", panel.ShowGameOptions); Assert.Equal(action == "pack", panel.ShowAllowMissing); Assert.Equal(action == "pack", panel.ShowPackNote); + Assert.Equal(action is "game" or "pack", panel.ShowPreset); Assert.False(panel.ShowExfatPath); panel.Exfat = true; Assert.Equal(action == "game", panel.ShowExfatPath); diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs index ff8ad9e..899d8fc 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -38,6 +38,24 @@ public void Default_preset_packs_data_and_keeps_protected_files_loose(string rel Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); } + [Theory] + [InlineData("Media/StreamingAssets/aa/PS5/level.bundle", "compress")] + [InlineData("Media/StreamingAssets/Dialogue.assets.bank", "compress")] + [InlineData("StreamingAssets/movie.mp4", "compress")] + [InlineData("Media/sharedassets3.assets.resS", "loose")] + [InlineData("Media/globalgamemanagers", "loose")] + [InlineData("Media/level158", "loose")] + [InlineData("Media/Metadata/global-metadata.dat", "loose")] + [InlineData("Media/StreamingAssets/Plugins/x.prx", "loose")] + [InlineData("eboot.bin", "loose")] + [InlineData("fakelib/libSceAmpr.sprx", "loose")] + public void Unity_preset_packs_only_streaming_assets(string relative, string action) + { + AMPRPackConfig preset = AMPRPackConfig.LoadPreset("unity"); + Assert.Equal(action, preset.SelectRule(relative).Action); + Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); + } + [Fact] public void Unknown_preset_is_rejected() => Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); From e95df0a981c7890fa0daa671ff07020e657e83fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:30:07 +0000 Subject: [PATCH 07/28] Pick Unity or default pack rules automatically Users should not have to know which engine a game uses. New preset `auto` (the default for `ampr game` and the AMPR Packs page, available to `ampr pack`): `unity` when globalgamemanagers, data.unity3d or global-metadata.dat is within four folder levels of the game root, else `default`. The log names the chosen rules and the file that decided them. A TOML file still overrides it. The GUI Rules picker added in the previous commit is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 9 +++-- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 33 +++++++++++++++-- src/MkPFS.Cli/Commands/AmprCommand.cs | 25 ++++++++++--- src/MkPFS.Gui/Localization/Strings.resx | 5 +-- src/MkPFS.Gui/Localization/Strings_de.resx | 5 +-- src/MkPFS.Gui/Localization/Strings_es.resx | 5 +-- src/MkPFS.Gui/Localization/Strings_fr.resx | 5 +-- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 5 +-- src/MkPFS.Gui/Localization/Strings_ro.resx | 5 +-- .../ViewModels/AmprPackPanelViewModel.cs | 19 +++------- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 8 ----- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 7 +--- tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 35 +++++++++++++++++++ 13 files changed, 102 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 4f4c265..b6a53cd 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | | `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | | `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | +| `--preset auto` | none | `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the choice on standard error. The default for `ampr game` and the AMPR Packs page. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -328,9 +329,10 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em 2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with their real sizes. This must come before packing, because the manifest addresses files by their row in this index. -3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset default`. Use - `--preset unity` (GUI: Rules > Unity) for Unity games, which have a `Media/` or `*_Data/` folder with - `StreamingAssets`, `globalgamemanagers` and `Il2CppUserAssemblies.prx`. +3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset auto`, which + picks the rules from the game: `unity` when a Unity file (`globalgamemanagers`, `data.unity3d`, + `global-metadata.dat`) is within four folder levels, else `default`. The log names the rules and the + file that decided them. 4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, including empty ones. This runs after packing because the packer only then decides which large, incompressible files to leave loose. @@ -425,6 +427,7 @@ dotnet run --project src/MkPFS.Gui -c Release offset, stored size, and largest back-reference distance. - The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. Its default action, Build playable game, runs `ampr game` with a library folder and an optional exFAT image. + Without a TOML file, the rules are picked from the game (Unity or not), so there is nothing to choose. Packing without a TOML uses `--preset default`. ## Differences from Python MkPFS diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index 42b6ae6..94164ab 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -286,8 +286,33 @@ public static AMPRPackConfig Load(string? path) return FromTable(raw, configDir); } - /// Built-in rule sets for (an extension; ampr_pack has none). - public static IReadOnlyList PresetNames { get; } = ["default", "unity"]; + /// + /// Built-in rule sets for (an extension; ampr_pack has none). auto + /// picks unity or default from the game folder (). + /// + public static IReadOnlyList PresetNames { get; } = ["auto", "default", "unity"]; + + // Files only Unity players ship; depth-limited so a scan of a large game stays cheap. + private static readonly HashSet UnityMarkers = new(StringComparer.OrdinalIgnoreCase) + { + "globalgamemanagers", "data.unity3d", "global-metadata.dat", + }; + + /// + /// The built-in rules for the game in : unity when a Unity marker file + /// (globalgamemanagers, data.unity3d, global-metadata.dat) is within four folder levels, + /// else default. + /// + /// Game folder. + /// Preset name and the marker that decided it (relative, / separators), if any. + public static (string Preset, string? Marker) DetectPreset(string root) + { + EnumerationOptions options = new() { RecurseSubdirectories = true, MaxRecursionDepth = 3, IgnoreInaccessible = true }; + string? marker = Directory.Exists(root) + ? Directory.EnumerateFiles(root, "*", options).FirstOrDefault(f => UnityMarkers.Contains(Path.GetFileName(f))) + : null; + return marker is null ? ("default", null) : ("unity", Path.GetRelativePath(root, marker).Replace('\\', '/')); + } /// /// The default preset: compress every file, but keep loose everything remove-sources protects @@ -333,10 +358,12 @@ public static AMPRPackConfig Load(string? path) /// Load a built-in preset. /// Preset name (see ). + /// Game folder, required for auto. /// Configuration. /// The preset does not exist. - public static AMPRPackConfig LoadPreset(string name) => name switch + public static AMPRPackConfig LoadPreset(string name, string? root = null) => name switch { + "auto" => LoadPreset(DetectPreset(root ?? throw new AMPRPackException("preset auto needs the game folder")).Preset), "default" => FromTable(AMPRToml.Parse(DefaultPresetToml, "preset default"), Directory.GetCurrentDirectory()), "unity" => FromTable(AMPRToml.Parse(UnityPresetToml, "preset unity"), Directory.GetCurrentDirectory()), _ => throw new AMPRPackException($"unknown preset: {name}"), diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 14acb03..d8fb312 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -52,7 +52,7 @@ private static Command Pack(CliContext ctx) Option amprIndex = Required("--ampr-index", "AMPRIDX3 file"); Option output = Required("--output", "output directory"); Option config = new("--config") { Description = "TOML pack configuration" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; auto picks unity for Unity games, else default" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option include = Repeated("--include", "additional include glob"); Option exclude = Repeated("--exclude", "force-loose glob"); @@ -74,7 +74,7 @@ private static Command Pack(CliContext ctx) AMPRPackConfig loaded = (configPath, presetName) switch { (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), - (null, not null) => AMPRPackConfig.LoadPreset(presetName), + (null, not null) => Preset(presetName, Resolve(ctx, parse.GetValue(root)!), ctx.Err.WriteLine), _ => AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)), }; if (parse.GetValue(workers) is { } count) @@ -155,8 +155,8 @@ private static Command Game(CliContext ctx) Option root = Required("--root", "unpacked game folder (/app0); only read"); Option output = Required("--output", "new or empty folder for the playable game"); Option fakelib = new("--fakelib") { Description = "folder with AMPR Emu 0.4.2.1+ libSceAmpr.sprx and other fakelib libraries to add" }; - Option config = new("--config") { Description = "TOML pack configuration (default: --preset default)" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: default (every file but executables, modules and system files) or unity (only StreamingAssets); default: default" }; + Option config = new("--config") { Description = "TOML pack configuration (default: --preset auto)" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (unity for Unity games, else default), default (every file but executables, modules and system files) or unity (only StreamingAssets); default: auto" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; @@ -174,7 +174,7 @@ private static Command Game(CliContext ctx) { (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), (not null, null) => AMPRPackConfig.Load(Resolve(ctx, configPath)), - (null, var name) => AMPRPackConfig.LoadPreset(name ?? "default"), + (null, var name) => Preset(name ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)), }; if (parse.GetValue(workers) is { } count) { @@ -212,6 +212,21 @@ private static Command Game(CliContext ctx) return command; } + // Built-in rules; auto reports what it found so the choice is visible in the log. + private static AMPRPackConfig Preset(string name, string root, Action report) + { + if (name == "auto") + { + (string detected, string? marker) = AMPRPackConfig.DetectPreset(root); + report(marker is null + ? "Rules: default (no Unity files found)" + : $"Rules: unity (Unity game: {marker}; only StreamingAssets is packed)"); + name = detected; + } + + return AMPRPackConfig.LoadPreset(name); + } + private static Command Unpack(CliContext ctx) { Option index = Required("--index", "pack manifest"); diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index ccd22b4..a476727 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -50,7 +50,7 @@ Action Leave missing source files loose TOML Configuration - Empty: use the Rules above… + Empty: rules chosen from the game (Unity games pack only StreamingAssets)… Delete the packed source files (cannot be undone; unchecked shows the plan only) ✗ Pack manifest is required. ✗ Game folder and output folder are both required. @@ -68,9 +68,6 @@ Output Folder Folder for the manifest and .pak volumes… Overwrite existing files - Rules (when no TOML is chosen) - Default: compress everything except executables, modules and system files - Unity: compress only StreamingAssets (bundles, audio banks) Game Folder (/app0) Folder with eboot.bin and sce_sys… Never leave selected files loose (store incompressible blocks uncompressed) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index a484bc8..6af651e 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -50,7 +50,7 @@ Aktion Fehlende Quelldateien lose lassen TOML-Konfiguration - Leer: die Regeln oben verwenden… + Leer: Regeln nach Spiel (Unity-Spiele packen nur StreamingAssets)… Gepackte Quelldateien löschen (nicht rückgängig zu machen; sonst nur den Plan anzeigen) ✗ Paket-Manifest ist erforderlich. ✗ Spielordner und Ausgabeordner sind beide erforderlich. @@ -68,9 +68,6 @@ Ausgabeordner Ordner für Manifest und .pak-Volumes… Vorhandene Dateien überschreiben - Regeln (wenn keine TOML gewählt ist) - Standard: alles komprimieren außer ausführbaren Dateien, Modulen und Systemdateien - Unity: nur StreamingAssets komprimieren (Bundles, Audio-Banks) Spielordner (/app0) Ordner mit eboot.bin und sce_sys… Ausgewählte Dateien nie lose lassen (nicht komprimierbare Blöcke unkomprimiert speichern) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 239db64..c1666b4 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -50,7 +50,7 @@ Acción Dejar sueltos los archivos fuente que falten Configuración TOML - Vacío: usar las Reglas de arriba… + Vacío: reglas según el juego (los juegos Unity solo empaquetan StreamingAssets)… Eliminar los archivos fuente empaquetados (no se puede deshacer; sin marcar solo muestra el plan) ✗ Se requiere el manifiesto del paquete. ✗ Se requieren la carpeta del juego y la carpeta de salida. @@ -68,9 +68,6 @@ Carpeta de salida Carpeta para el manifiesto y los volúmenes .pak… Sobrescribir archivos existentes - Reglas (si no se elige un TOML) - Predeterminado: comprimir todo excepto ejecutables, módulos y archivos del sistema - Unity: comprimir solo StreamingAssets (bundles, bancos de audio) Carpeta del juego (/app0) Carpeta con eboot.bin y sce_sys… No dejar nunca sueltos los archivos seleccionados (guardar sin comprimir los bloques incompresibles) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 7084f5d..5a5d503 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -50,7 +50,7 @@ Action Laisser libres les fichiers sources manquants Configuration TOML - Vide : utiliser les Règles ci-dessus… + Vide : règles selon le jeu (les jeux Unity n’empaquettent que StreamingAssets)… Supprimer les fichiers sources empaquetés (irréversible ; décoché, affiche seulement le plan) ✗ Le manifeste du paquet est requis. ✗ Le dossier du jeu et le dossier de sortie sont tous deux requis. @@ -68,9 +68,6 @@ Dossier de sortie Dossier du manifeste et des volumes .pak… Écraser les fichiers existants - Règles (si aucun TOML n’est choisi) - Par défaut : tout compresser sauf les exécutables, modules et fichiers système - Unity : compresser seulement StreamingAssets (bundles, banques audio) Dossier du jeu (/app0) Dossier contenant eboot.bin et sce_sys… Ne jamais laisser libres les fichiers sélectionnés (stocker non compressés les blocs incompressibles) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 5928552..08e1102 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -50,7 +50,7 @@ Ação Deixar soltos os arquivos-fonte ausentes Configuração TOML - Vazio: usar as Regras acima… + Vazio: regras escolhidas pelo jogo (jogos Unity empacotam só StreamingAssets)… Excluir os arquivos-fonte empacotados (não pode ser desfeito; desmarcado mostra apenas o plano) ✗ O manifesto do pacote é obrigatório. ✗ A pasta do jogo e a pasta de saída são obrigatórias. @@ -68,9 +68,6 @@ Pasta de saída Pasta para o manifesto e os volumes .pak… Sobrescrever arquivos existentes - Regras (quando nenhum TOML é escolhido) - Padrão: comprimir tudo exceto executáveis, módulos e arquivos de sistema - Unity: comprimir apenas StreamingAssets (bundles, bancos de áudio) Pasta do jogo (/app0) Pasta com eboot.bin e sce_sys… Nunca deixar soltos os arquivos selecionados (guardar sem compressão os blocos incompressíveis) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 6d16372..4d72528 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -50,7 +50,7 @@ Acțiune Lasă libere fișierele sursă lipsă Configurație TOML - Gol: folosește Regulile de mai sus… + Gol: reguli alese după joc (jocurile Unity împachetează doar StreamingAssets)… Șterge fișierele sursă împachetate (ireversibil; nebifat arată doar planul) ✗ Manifestul pachetului este necesar. ✗ Folderul jocului și folderul de ieșire sunt ambele necesare. @@ -68,9 +68,6 @@ Folder de ieșire Folder pentru manifest și volumele .pak… Suprascrie fișierele existente - Reguli (când nu este ales un TOML) - Implicit: comprimă tot în afară de executabile, module și fișiere de sistem - Unity: comprimă doar StreamingAssets (bundle-uri, bănci audio) Folderul jocului (/app0) Folder cu eboot.bin și sce_sys… Nu lăsa niciodată libere fișierele selectate (stochează necomprimate blocurile necompresibile) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index affe0d9..58aac18 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -26,15 +26,6 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job new("remove-sources", "ap_act_remove"), ]; - private static readonly Choice[] PresetChoices = [new("default", "ap_preset_default"), new("unity", "ap_preset_unity")]; - - /// Built-in rule sets (--preset), used when no TOML is chosen. - public IReadOnlyList Presets => PresetChoices; - - /// Selected built-in rules. - [ObservableProperty] - public partial Choice Preset { get; set; } = PresetChoices[0]; - /// Actions, in menu order; values are the CLI subcommands. public IReadOnlyList Actions => ActionChoices; @@ -42,7 +33,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] - [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote), nameof(ShowPreset))] + [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -124,9 +115,6 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// Workers and self-contained (game, pack). public bool ShowPackOptions => Action.Value is "game" or "pack"; - /// Built-in rules picker (game, pack). - public bool ShowPreset => Action.Value is "game" or "pack"; - /// Allow-missing (pack). public bool ShowAllowMissing => Action.Value == "pack"; @@ -181,7 +169,8 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job case "game": args.AddRange(["--root", root, "--output", output]); AddOption(args, "--fakelib", Libs); - args.AddRange(config.Length > 0 ? ["--config", config] : Preset.Value == "default" ? [] : ["--preset", Preset.Value]); + // Without a TOML, ampr game picks the rules from the game (Unity or not). + AddOption(args, "--config", config); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, !VerifyGame, "--skip-verify"); @@ -195,7 +184,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job case "pack": args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); // Without a TOML every file would stay loose; the page uses the built-in rules instead. - args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", Preset.Value]); + args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", "auto"]); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, AllowMissing, "--allow-missing"); diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 92e4e19..9789ee7 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -24,14 +24,6 @@ - - - - - - - - diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 128246c..c8d2997 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -29,7 +29,7 @@ public void Pack_builds_arguments_and_validates() panel.Output = "D:/out"; Assert.Equal(Path.Combine("D:/out", "ampr_assets.index"), panel.Manifest); Assert.Equal( - ["ampr", "pack", "--root", "D:/game", "--ampr-index", Path.Combine("D:/game", "ampr_emu.index"), "--output", "D:/out", "--preset", "default"], + ["ampr", "pack", "--root", "D:/game", "--ampr-index", Path.Combine("D:/game", "ampr_emu.index"), "--output", "D:/out", "--preset", "auto"], panel.BuildArguments(out _)); panel.AmprIndex = "D:/idx/ampr_emu.index"; @@ -60,10 +60,6 @@ public void Game_is_the_default_action_and_builds_arguments() Assert.Equal(Path.Combine("D:/games/out", "ampr_assets.index"), panel.Manifest); Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out"], panel.BuildArguments(out _)); - panel.Preset = panel.Presets.Single(p => p.Value == "unity"); - Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out", "--preset", "unity"], panel.BuildArguments(out _)); - panel.Preset = panel.Presets[0]; - panel.Libs = "D:/emu"; panel.Config = "D:/rules.toml"; panel.Workers = "4"; @@ -141,7 +137,6 @@ public void Each_action_shows_only_its_fields( Assert.Equal(action == "game", panel.ShowGameOptions); Assert.Equal(action == "pack", panel.ShowAllowMissing); Assert.Equal(action == "pack", panel.ShowPackNote); - Assert.Equal(action is "game" or "pack", panel.ShowPreset); Assert.False(panel.ShowExfatPath); panel.Exfat = true; Assert.Equal(action == "game", panel.ShowExfatPath); diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs index 899d8fc..5a3194f 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -56,6 +56,41 @@ public void Unity_preset_packs_only_streaming_assets(string relative, string act Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); } + [Theory] + [InlineData("Media/globalgamemanagers", "unity")] + [InlineData("Game_Data/data.unity3d", "unity")] + [InlineData("Media/Metadata/global-metadata.dat", "unity")] + [InlineData("data/level.dat", "default")] + public void Auto_picks_unity_rules_only_for_unity_games(string file, string expected) + { + string root = Path.Combine(_dir, "game"); + string full = Path.Combine(root, file); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "x"); + + (string preset, string? marker) = AMPRPackConfig.DetectPreset(root); + + Assert.Equal(expected, preset); + Assert.Equal(expected == "unity" ? file : null, marker); + AMPRPackConfig auto = AMPRPackConfig.LoadPreset("auto", root); + Assert.Equal(expected == "unity" ? "loose" : "compress", auto.SelectRule("Media/level158").Action); + } + + [Fact] + public void Cli_auto_reports_the_rules_it_picked() + { + string root = Game(); + File.WriteAllText(Path.Combine(root, "data", "globalgamemanagers"), "unity"); + AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); + + (int exit, string stdout, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "auto"); + + Assert.Equal(0, exit); + Assert.Equal("Rules: unity (Unity game: data/globalgamemanagers; only StreamingAssets is packed)\n" + global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", stderr); + Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); + } + [Fact] public void Unknown_preset_is_rejected() => Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); From 51b17df278cd9527e81d7bebe8ba3d6a3b2d7c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:34:46 +0000 Subject: [PATCH 08/28] Mark AMPR packing as experimental Whether a packed game runs depends on how it reads its files, which only a console test shows, and few games have been tested. Say so: - README: "(experimental)" in the feature list and a notice at the top of the ampr section (keep the original game, report failing titles with the ShadowMountPlus log). - GUI: an amber notice at the top of the AMPR Packs page, in all six languages. - CLI: the ampr help says experimental; `ampr game` prints a warning before it starts. `ampr pack` output is unchanged (ampr_pack.py parity). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 7 ++++++- src/MkPFS.Cli/Commands/AmprCommand.cs | 7 ++++++- src/MkPFS.Gui/Localization/Strings.resx | 1 + src/MkPFS.Gui/Localization/Strings_de.resx | 1 + src/MkPFS.Gui/Localization/Strings_es.resx | 1 + src/MkPFS.Gui/Localization/Strings_fr.resx | 1 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 1 + src/MkPFS.Gui/Localization/Strings_ro.resx | 1 + src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 3 +++ tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs | 1 + 10 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6a53cd..efb7fd8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ offline PFSC block repair ported from PS5 Game Compressor. - **Repair**: find and fix compressed blocks the PS5 may decode wrongly (images made with ISA-L). - **APR Emu**: copies your AMPR Emu libraries into `fakelib/` of APR titles (`--ampr-libs`) and builds `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. -- **AMPR packs**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte +- **AMPR packs (experimental)**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte like ampr_emu's `ampr_pack.py`. - **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. @@ -264,6 +264,11 @@ rebuild a missing index from the remaining files, with the same result. ### `ampr` (asset packs) +> **Experimental.** Whether a packed game runs depends on how it reads its files, and only a test on the +> console shows that. Only a few games have been tested so far. Keep the original, unpacked game until the +> packed one has been played on the PS5, and report games that do not start, together with the +> ShadowMountPlus log. + AMPR Emu (`libSceAmpr.sprx`) can serve selected `/app0` files from seekable LZ4 packs: the pack manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, and an offline CRC sidecar `ampr_assets.index.crc`. The packs sit next to the game files inside a normal image; the PS5 kernel never diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index d8fb312..9ac9ba7 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -22,6 +22,10 @@ internal static class AmprCommand internal const string NothingPackedWarning = "warning: no files were packed; without --config or --preset default every file stays loose"; + /// Printed by ampr game before it starts. + internal const string ExperimentalWarning = + "warning: AMPR packing is experimental; keep the original game until the packed one has been played on the PS5"; + /// Printed to stderr when files were packed but the root has no AMPR Emu (an addition to the oracle output). internal const string NoEmulatorWarning = "warning: --root has no fakelib/libSceAmpr.sprx or fakelib2/libSceAmpr.sprx; only AMPR Emu reads these packs, " + @@ -29,7 +33,7 @@ internal static class AmprCommand public static Command Create(CliContext ctx) { - Command command = new("ampr", "Build and manage AMPR Emu seekable LZ4 asset packs for /app0"); + Command command = new("ampr", "Build and manage AMPR Emu seekable LZ4 asset packs for /app0 (experimental)"); command.Subcommands.Add(Pack(ctx)); command.Subcommands.Add(Game(ctx)); command.Subcommands.Add(Unpack(ctx)); @@ -169,6 +173,7 @@ private static Command Game(CliContext ctx) }; command.SetAction(parse => Run(ctx, () => { + ctx.Log.Warning(ExperimentalWarning); string? configPath = parse.GetValue(config); AMPRPackConfig loaded = (configPath, parse.GetValue(preset)) switch { diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index a476727..9982f7d 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -33,6 +33,7 @@ APR Emu Treat as APR title Build playable game (packs + loose files) + ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. Library Folder (fakelib) Folder with libSceAmpr.sprx and other fakelib libraries (optional if the game has them)… diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 6af651e..00e408b 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -33,6 +33,7 @@ APR Emu Als APR-Titel behandeln Spielfertigen Ordner erstellen (Packs + lose Dateien) + ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. Bibliotheksordner (fakelib) Ordner mit libSceAmpr.sprx und weiteren fakelib-Bibliotheken (optional, wenn das Spiel sie hat)… diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index c1666b4..92a5808 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -33,6 +33,7 @@ APR Emu Tratar como título APR Crear juego listo (packs + archivos sueltos) + ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. Carpeta de Bibliotecas (fakelib) Carpeta con libSceAmpr.sprx y otras bibliotecas fakelib (opcional si el juego ya las tiene)… diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 5a5d503..a87eb2e 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -33,6 +33,7 @@ APR Emu Traiter comme un titre APR Créer le jeu prêt à lancer (packs + fichiers libres) + ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. Dossier des bibliothèques (fakelib) Dossier avec libSceAmpr.sprx et d'autres bibliothèques fakelib (facultatif si le jeu les a déjà)… diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 08e1102..f3cb5bf 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -33,6 +33,7 @@ APR Emu Tratar como título APR Criar jogo pronto (packs + arquivos soltos) + ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. Pasta de Bibliotecas (fakelib) Pasta com libSceAmpr.sprx e outras bibliotecas fakelib (opcional se o jogo já as tiver)… diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 4d72528..2d7fe19 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -33,6 +33,7 @@ APR Emu Tratează ca titlu APR Creează jocul gata de rulat (pack-uri + fișiere libere) + ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. Folder de Biblioteci (fakelib) Folder cu libSceAmpr.sprx și alte biblioteci fakelib (opțional dacă jocul le are deja)… diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 9789ee7..67b6253 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -6,6 +6,9 @@ x:Class="MkPFS.Gui.Views.Panels.AmprPackForm" x:DataType="vm:AmprPackPanelViewModel"> + + + diff --git a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs index 8aa1920..cb62b5d 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs @@ -185,6 +185,7 @@ public void Cli_game_builds_the_folder_and_reports_each_step() int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out"), "--fakelib", Path.Combine(dir.Path, "libs")], ctx); Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); + Assert.StartsWith(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + "\n", stdout.ToString(), StringComparison.Ordinal); foreach (string step in (string[])["[1/5] Libraries: fakelib/", "[2/5] Writing ampr_emu.index", "[3/5] Packing", "[4/5] Copying loose files", "[5/5] Verifying"]) { Assert.Contains(step + "\n", stdout.ToString(), StringComparison.Ordinal); From 3ba0c73e3b73b0c36c702df2c73ce7d7958c651a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:48:38 +0000 Subject: [PATCH 09/28] README: explain AMPR packing and why it is experimental A warning under the intro links to a new explanation at the top of the ampr section: what packing does, what a game needs (ShadowMountPlus, a libSceAmpr title, AMPR Emu 0.4.2.1+, read-only mount), why only a console test shows whether a game runs (reads AMPR Emu does not intercept, such as mmap), how the automatic rules were chosen, the games tested so far, recommended steps, and what to report. The existing reference follows under "How the packs are made" and "Command reference". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index efb7fd8..dde749e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ MkPFS.CSharp is a .NET 10 port of the Python [MkPFS](https://github.com/PSBrew/M as a native executable (no Python needed), writes the same images as the original, and adds an offline PFSC block repair ported from PS5 Game Compressor. +> [!WARNING] +> **AMPR packing (`ampr`, the AMPR Packs page) is experimental.** It can shrink a game a lot, but whether +> the packed game runs depends on how that game reads its files, and only a test on the PS5 shows that. +> Keep the original game until the packed one has been played. See +> [AMPR packing: what it is and why it is experimental](#ampr-packing-what-it-is-and-why-it-is-experimental). + ## Features - **Pack**: a game folder, an exFAT image, or any single file into a compressed `.ffpfsc` (PFSC, zlib), @@ -22,7 +28,8 @@ offline PFSC block repair ported from PS5 Game Compressor. - **APR Emu**: copies your AMPR Emu libraries into `fakelib/` of APR titles (`--ampr-libs`) and builds `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. - **AMPR packs (experimental)**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte - like ampr_emu's `ampr_pack.py`. + like ampr_emu's `ampr_pack.py`, and `ampr game` turns a game folder into a smaller one that runs from + them ([what it is and its limits](#ampr-packing-what-it-is-and-why-it-is-experimental)). - **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. @@ -264,22 +271,81 @@ rebuild a missing index from the remaining files, with the same result. ### `ampr` (asset packs) -> **Experimental.** Whether a packed game runs depends on how it reads its files, and only a test on the -> console shows that. Only a few games have been tested so far. Keep the original, unpacked game until the -> packed one has been played on the PS5, and report games that do not start, together with the -> ShadowMountPlus log. - -AMPR Emu (`libSceAmpr.sprx`) can serve selected `/app0` files from seekable LZ4 packs: the pack -manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, and an offline CRC sidecar -`ampr_assets.index.crc`. The packs sit next to the game files inside a normal image; the PS5 kernel never -sees LZ4. `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, -the same JSON on standard output, and byte-identical packs. - -The released emulator (0.4.2.1, its PackedStdio build) serves packed files to AMPR reads and to the -game's ordinary `open`/`read`/`pread` and asynchronous reads, so Unity games work too. It only loads in -titles that use libSceAmpr, so `pack` warns when `--root` has no `fakelib/libSceAmpr.sprx` or -`fakelib2/libSceAmpr.sprx`. Memory-mapped files (`mmap`) and files opened for writing are not served and -must stay loose, so keep the source files until the packed game is tested. +#### AMPR packing: what it is and why it is experimental + +> [!WARNING] +> Experimental. Only a few games have been tested. Keep the original, unpacked game until the packed one +> has been played on the PS5, and report the games you try (see [Reporting a game](#reporting-a-game)). + +**What it does.** Most of a game's size is data files: levels, textures, audio, video. `ampr` compresses +selected data files with LZ4 into a few large pack files (`ampr_assets-*.pak`) plus a manifest +(`ampr_assets.index`). On the PS5, Drakmor's [AMPR Emu](https://github.com/drakmor/ampr_emu) (the +`libSceAmpr.sprx` in the game's `fakelib/`) sits between the game and the file system: when the game +opens a packed file, AMPR Emu reads the matching blocks from the packs and decompresses them, so the game +sees the original bytes. Nothing else changes: `eboot.bin`, modules and `sce_sys` stay as they are, and +the PS5 kernel never sees LZ4. Packed files are not stored a second time, which is where the space goes. + +**What a game needs.** + +- A jailbroken PS5 running [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus), which mounts + the game's `fakelib/` (or `fakelib2/`) into the running game. +- A game that loads `libSceAmpr` (AMPR/APR titles; these carry `fakelib/libSceAmpr.sprx` once set up for + AMPR Emu). AMPR Emu is only loaded by such games, so packs in any other game are never read. +- AMPR Emu **0.4.2.1 or newer**: the first release that reads packs. `ampr game` checks the library and + refuses older ones. +- The image or folder mounted read-only (ShadowMountPlus `mount_read_only=1`, the default), so AMPR Emu + never rebuilds `ampr_emu.index`. + +**Why it is experimental.** AMPR Emu serves packed files only to the ways of reading files it intercepts: +AMPR reads, `open`/`sceKernelOpen`, `read`/`pread`, `stat`/`fstat`, directory listing, and asynchronous +reads. A game that reads a file any other way, most likely by memory-mapping it (`mmap`), gets nothing +back and usually stops at startup. Which files a game reads which way cannot be seen from the files on a +PC, so the only real check is to start the packed game on the console. `ampr game` verifies on the PC +that every packed byte decodes to the original, but that proves the packs are correct, not that the game +reads them in a supported way. + +The built-in rules come from these tests, and `ampr game` picks them from the game itself (`--preset +auto`, the default; the log line `Rules: ...` says which and why): + +- **Unity games** (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` in the game): only + `StreamingAssets/` is packed (asset bundles, audio banks, videos). Unity's own data files + (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a + Unity game abort at startup. +- **Other games**: every file except executables, modules and system files is packed. No game of this + kind has been confirmed on the console yet. + +**Tested games.** Results on a PS5 with ShadowMountPlus 1.7 beta 4 and AMPR Emu 0.4.2.1: + +| Game | Version | Engine | Rules | Result | +|---|---|---|---|---| +| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `unity` (auto) | Runs: menu, saves, gameplay. | +| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `default` | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | + +**Recommended steps.** + +1. Make sure the unpacked game runs with AMPR Emu 0.4.2.1 in its `fakelib/`. +2. On the AMPR Packs page, choose **Build playable game** (the default), pick the game folder, a new + output folder and the folder with AMPR Emu's `libSceAmpr.sprx`, and leave the TOML field empty. + Optionally tick the exFAT image box. On the command line: + `mkpfs ampr game --root --output --fakelib --exfat .` +3. Copy the output folder or the `.exfat` image to the PS5 and start the game. Play past the menu and load + a save or a level: some files are only read later. +4. Delete the original only after that. + +##### Reporting a game + +Whether it works or not, please report: the game title, ID and version, the `Rules:` line from the build +log, what happened on the console, and for a game that fails, the ShadowMountPlus log +(`/data/shadowmount/debug.log`) and the console log around the crash. A failing game can often still be +packed with a TOML file that leaves more files loose (see the TOML format below); a report with the result +of such a test lets the built-in rules learn that game. + +#### How the packs are made + +`mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same +JSON on standard output, and byte-identical packs. A pack set is the manifest `ampr_assets.index`, data +volumes `ampr_assets-*.pak`, optional runtime settings `ampr_assets.index.runtime`, and an offline CRC +sidecar `ampr_assets.index.crc`. `pack` writes only the pack set (`ampr_assets.index`, `.pak` volumes, `.crc`) into `--output`, exactly like `ampr_pack.py`. For a folder to copy to the PS5, use [`ampr game`](#ampr-game-a-folder-to-copy-to-the-ps5). @@ -289,6 +355,8 @@ rejects the whole set beyond that; `pack` warns on standard error when a set exc blocks or more loose files bring it down). Deploy `ampr_emu.index`, the manifest, its `.runtime`, and every volume from one build together; the `.crc` sidecar is only for `verify` and `unpack`. +#### Command reference + | Subcommand | Required options | Purpose | |---|---|---| | `game` | `--root --output ` | Build a folder that runs from packs as is (see below; not in `ampr_pack.py`). | From 3fe9b2346fe8d53fa2cac24c55ac3a60bc6f5a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:30:54 +0000 Subject: [PATCH 10/28] README: record PPSA03671 crashing with the default rules Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dde749e..ca0065d 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,8 @@ auto`, the default; the log line `Rules: ...` says which and why): (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a Unity game abort at startup. - **Other games**: every file except executables, modules and system files is packed. No game of this - kind has been confirmed on the console yet. + kind has been confirmed on the console yet, and one crashed at startup (see the table), so expect to + need a TOML that leaves more files loose. **Tested games.** Results on a PS5 with ShadowMountPlus 1.7 beta 4 and AMPR Emu 0.4.2.1: @@ -320,6 +321,7 @@ auto`, the default; the log line `Rules: ...` says which and why): |---|---|---|---|---| | God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `unity` (auto) | Runs: menu, saves, gameplay. | | God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `default` | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | +| PPSA03671 | 01.001.005 | Not Unity (Coherent UI) | `default` (auto) | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`); the unpacked game starts. | **Recommended steps.** From 5aaac60c72bac07b2e5751c008b5182a3ab5d0b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:51:59 +0000 Subject: [PATCH 11/28] Say whether a rules profile matched the game before packing The CLI log (ampr game, ampr pack --preset) and the AMPR Packs page now say whether a built-in profile matched the game, which one, the file that identified it, and whether it has run on a PS5 ("Profile found: unity (...). Tested: runs on ..." or "No profile found for this game: generic rules ... Untested"). A TOML file is reported as such. The page shows the result as soon as the game folder is chosen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 20 ++++++--- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 14 ++++++ src/MkPFS.Cli/Commands/AmprCommand.cs | 36 +++++++++++---- src/MkPFS.Gui/Localization/Strings.resx | 7 +++ src/MkPFS.Gui/Localization/Strings_de.resx | 7 +++ src/MkPFS.Gui/Localization/Strings_es.resx | 7 +++ src/MkPFS.Gui/Localization/Strings_fr.resx | 7 +++ src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 7 +++ src/MkPFS.Gui/Localization/Strings_ro.resx | 7 +++ .../ViewModels/AmprPackPanelViewModel.cs | 45 ++++++++++++++++++- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 3 ++ tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 29 ++++++++++++ .../MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs | 8 +++- tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 28 ++++++++++-- 14 files changed, 207 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ca0065d..733343c 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,16 @@ that every packed byte decodes to the original, but that proves the packs are co reads them in a supported way. The built-in rules come from these tests, and `ampr game` picks them from the game itself (`--preset -auto`, the default; the log line `Rules: ...` says which and why): +auto`, the default). Before anything is built, the log and the AMPR Packs page say whether a profile +matched the game, which one, the file that identified it, and whether it has run on a PS5: + +``` +Profile found: unity (Media/globalgamemanagers); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997). +No profile found for this game: generic rules (every file but executables, modules and system files is packed). Untested: the game may not start on the PS5; keep the original. +``` + +A found, tested profile is the best sign that the packed game will run; with no profile, treat the +build as a test. With `--config`, the log says `Rules: your TOML file ...` instead. - **Unity games** (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` in the game): only `StreamingAssets/` is packed (asset bundles, audio banks, videos). Unity's own data files @@ -336,7 +345,8 @@ auto`, the default; the log line `Rules: ...` says which and why): ##### Reporting a game -Whether it works or not, please report: the game title, ID and version, the `Rules:` line from the build +Whether it works or not, please report: the game title, ID and version, the `Profile found` or `No profile +found` line from the build log, what happened on the console, and for a game that fails, the ShadowMountPlus log (`/data/shadowmount/debug.log`) and the console log around the crash. A failing game can often still be packed with a TOML file that leaves more files loose (see the TOML format below); a report with the result @@ -377,7 +387,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | | `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | | `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | -| `--preset auto` | none | `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the choice on standard error. The default for `ampr game` and the AMPR Packs page. | +| `--preset auto` | none | `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -406,8 +416,8 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em in this index. 3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset auto`, which picks the rules from the game: `unity` when a Unity file (`globalgamemanagers`, `data.unity3d`, - `global-metadata.dat`) is within four folder levels, else `default`. The log names the rules and the - file that decided them. + `global-metadata.dat`) is within four folder levels, else `default`. The log says whether a profile + was found, the file that identified it and whether the profile has run on a PS5. 4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, including empty ones. This runs after packing because the packer only then decides which large, incompressible files to leave loose. diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index 94164ab..4c438d6 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -314,6 +314,20 @@ public static (string Preset, string? Marker) DetectPreset(string root) return marker is null ? ("default", null) : ("unity", Path.GetRelativePath(root, marker).Replace('\\', '/')); } + /// + /// What a built-in rule set packs and the games it was played with on a PS5, so a log can say whether the rules + /// are known to work. default is the fallback for games no profile matches and has run no game yet. + /// + /// Preset name (default or unity). + /// Short description of what is packed, and the tested games ( when untested). + /// The preset does not exist. + public static (string Packs, string? TestedOn) PresetInfo(string name) => name switch + { + "default" => ("every file but executables, modules and system files is packed", null), + "unity" => ("only StreamingAssets is packed", "God of War Sons of Sparta (PPSA28997)"), + _ => throw new AMPRPackException($"unknown preset: {name}"), + }; + /// /// The default preset: compress every file, but keep loose everything remove-sources protects /// (executables, modules, system and save folders, indexes) and Unity IL2CPP global-metadata.dat, which diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 9ac9ba7..1e2e850 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -178,7 +178,7 @@ private static Command Game(CliContext ctx) AMPRPackConfig loaded = (configPath, parse.GetValue(preset)) switch { (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), - (not null, null) => AMPRPackConfig.Load(Resolve(ctx, configPath)), + (not null, null) => Config(ctx, Resolve(ctx, configPath)), (null, var name) => Preset(name ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)), }; if (parse.GetValue(workers) is { } count) @@ -217,21 +217,41 @@ private static Command Game(CliContext ctx) return command; } - // Built-in rules; auto reports what it found so the choice is visible in the log. + // Built-in rules. The log says whether a profile matched the game and whether it was played on a PS5, so the + // user can tell how likely the packed game is to run. private static AMPRPackConfig Preset(string name, string root, Action report) { - if (name == "auto") + bool auto = name == "auto"; + string? marker = null; + if (auto) { - (string detected, string? marker) = AMPRPackConfig.DetectPreset(root); - report(marker is null - ? "Rules: default (no Unity files found)" - : $"Rules: unity (Unity game: {marker}; only StreamingAssets is packed)"); - name = detected; + (name, marker) = AMPRPackConfig.DetectPreset(root); } + report(RulesReport(name, marker, auto)); return AMPRPackConfig.LoadPreset(name); } + private static string RulesReport(string preset, string? marker, bool auto) + { + (string packs, string? testedOn) = AMPRPackConfig.PresetInfo(preset); + string tested = testedOn is null + ? "Untested: the game may not start on the PS5; keep the original." + : $"Tested: runs on {testedOn}."; + return (preset, marker, auto) switch + { + (_, not null, _) => $"Profile found: {preset} ({marker}); {packs}. {tested}", + ("default", null, true) => $"No profile found for this game: generic rules ({packs}). {tested}", + _ => $"Rules: {preset} (--preset); {packs}. {tested}", + }; + } + + private static AMPRPackConfig Config(CliContext ctx, string path) + { + ctx.Log.Info($"Rules: your TOML file {path} (no built-in profile)"); + return AMPRPackConfig.Load(path); + } + private static Command Unpack(CliContext ctx) { Option index = Required("--index", "pack manifest"); diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 9982f7d..e15171a 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -33,6 +33,13 @@ APR Emu Treat as APR title Build playable game (packs + loose files) + ✓ Profile found: {0} (identified by {1}). {2} + Only StreamingAssets is packed. + Every file but executables, modules and system files is packed. + Tested: runs on {0}. + Untested: the game may not start on the PS5. + No profile found for this game: generic rules will be used. {0} They are untested, so the game may not start on the PS5; keep the original. + Your TOML file decides what is packed; no built-in profile is used. ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. Library Folder (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 00e408b..ef7fa2e 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -33,6 +33,13 @@ APR Emu Als APR-Titel behandeln Spielfertigen Ordner erstellen (Packs + lose Dateien) + ✓ Profil gefunden: {0} (erkannt an {1}). {2} + Nur StreamingAssets wird gepackt. + Alle Dateien außer ausführbaren Dateien, Modulen und Systemdateien werden gepackt. + Getestet: läuft mit {0}. + Ungetestet: das Spiel startet auf der PS5 eventuell nicht. + Kein Profil für dieses Spiel gefunden: es werden allgemeine Regeln verwendet. {0} Sie sind ungetestet, das Spiel startet auf der PS5 eventuell nicht; behalte das Original. + Deine TOML-Datei bestimmt, was gepackt wird; kein eingebautes Profil wird verwendet. ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. Bibliotheksordner (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 92a5808..32d4e54 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -33,6 +33,13 @@ APR Emu Tratar como título APR Crear juego listo (packs + archivos sueltos) + ✓ Perfil encontrado: {0} (identificado por {1}). {2} + Solo se empaqueta StreamingAssets. + Se empaquetan todos los archivos salvo ejecutables, módulos y archivos del sistema. + Probado: funciona en {0}. + Sin probar: puede que el juego no arranque en la PS5. + No se encontró ningún perfil para este juego: se usarán reglas genéricas. {0} No están probadas, así que puede que el juego no arranque en la PS5; conserva el original. + Tu archivo TOML decide qué se empaqueta; no se usa ningún perfil integrado. ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. Carpeta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index a87eb2e..ef3787b 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -33,6 +33,13 @@ APR Emu Traiter comme un titre APR Créer le jeu prêt à lancer (packs + fichiers libres) + ✓ Profil trouvé : {0} (identifié par {1}). {2} + Seul StreamingAssets est empaqueté. + Tous les fichiers sont empaquetés, sauf les exécutables, les modules et les fichiers système. + Testé : fonctionne avec {0}. + Non testé : le jeu risque de ne pas démarrer sur la PS5. + Aucun profil trouvé pour ce jeu : des règles génériques seront utilisées. {0} Elles ne sont pas testées, le jeu risque donc de ne pas démarrer sur la PS5 ; gardez l’original. + Votre fichier TOML décide de ce qui est empaqueté ; aucun profil intégré n’est utilisé. ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. Dossier des bibliothèques (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index f3cb5bf..f607b83 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -33,6 +33,13 @@ APR Emu Tratar como título APR Criar jogo pronto (packs + arquivos soltos) + ✓ Perfil encontrado: {0} (identificado por {1}). {2} + Só StreamingAssets é empacotado. + Todos os arquivos são empacotados, exceto executáveis, módulos e arquivos de sistema. + Testado: funciona em {0}. + Não testado: o jogo pode não iniciar no PS5. + Nenhum perfil encontrado para este jogo: serão usadas regras genéricas. {0} Elas não foram testadas, então o jogo pode não iniciar no PS5; guarde o original. + O seu arquivo TOML decide o que é empacotado; nenhum perfil embutido é usado. ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. Pasta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 2d7fe19..82481b0 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -33,6 +33,13 @@ APR Emu Tratează ca titlu APR Creează jocul gata de rulat (pack-uri + fișiere libere) + ✓ Profil găsit: {0} (identificat după {1}). {2} + Se împachetează doar StreamingAssets. + Se împachetează toate fișierele, mai puțin executabilele, modulele și fișierele de sistem. + Testat: rulează pe {0}. + Netestat: este posibil ca jocul să nu pornească pe PS5. + Niciun profil găsit pentru acest joc: se vor folosi reguli generice. {0} Nu sunt testate, deci este posibil ca jocul să nu pornească pe PS5; păstrează originalul. + Fișierul tău TOML decide ce se împachetează; nu se folosește niciun profil inclus. ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. Folder de Biblioteci (fakelib) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index 58aac18..acadc65 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -34,6 +34,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -55,6 +56,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// --root: the game's /app0 folder. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(RulesFoundText), nameof(RulesNoneText))] public partial string Root { get; set; } = string.Empty; /// --ampr-index; defaults to <root>/ampr_emu.index. @@ -69,8 +71,9 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] public partial string Manifest { get; set; } = string.Empty; - /// --config; empty packs with --preset default, and runtime-config requires it. + /// --config; empty packs with --preset auto, and runtime-config requires it. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig))] public partial string Config { get; set; } = string.Empty; /// --workers; empty for the configuration's value. @@ -127,6 +130,39 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// exFAT image path (game with an image). public bool ShowExfatPath => ShowGameOptions && Exfat; + // Built-in rules picked for the game folder (as --preset auto does), or null when the folder does not exist. + private (string Preset, string? Marker)? _detected; + + private bool UsesBuiltInRules => ShowPackOptions && Config.Trim().Length == 0 && _detected is not null; + + /// A built-in profile matches the game folder (game, pack without a TOML). + public bool ShowRulesFound => UsesBuiltInRules && _detected!.Value.Marker is not null; + + /// No profile matches the game folder: the generic, untested rules are used. + public bool ShowRulesNone => UsesBuiltInRules && _detected!.Value.Marker is null; + + /// The user's TOML file decides what is packed. + public bool ShowRulesConfig => ShowPackOptions && Config.Trim().Length > 0; + + /// The matched profile, the file that identified it and the games it ran on. + public string RulesFoundText + { + get + { + if (_detected is not (string preset, string marker)) + { + return string.Empty; + } + + string? testedOn = Build.AMPRPack.AMPRPackConfig.PresetInfo(preset).TestedOn; + return Localizer.Instance.Format("ap_rules_found", preset, marker, Localizer.Instance["ap_rules_packs_" + preset]) + + " " + (testedOn is null ? Localizer.Instance["ap_rules_untested"] : Localizer.Instance.Format("ap_rules_tested", testedOn)); + } + } + + /// The generic rules, which no game has run with yet. + public string RulesNoneText => Localizer.Instance.Format("ap_rules_none", Localizer.Instance["ap_rules_packs_default"]); + /// Overwrite (unpack). public bool ShowOverwrite => Action.Value == "unpack"; @@ -216,6 +252,13 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job return args; } + /// Detect the rules for the chosen game folder (a few folder levels, so it stays quick). + partial void OnRootChanged(string value) + { + string root = value.Trim(); + _detected = root.Length > 0 && Directory.Exists(root) ? Build.AMPRPack.AMPRPackConfig.DetectPreset(root) : null; + } + /// The manifest a pack run writes, so the other actions can use it right away. partial void OnOutputChanged(string value) { diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 67b6253..88be325 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -23,6 +23,9 @@ + + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index c8d2997..aefb70d 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -142,6 +142,35 @@ public void Each_action_shows_only_its_fields( Assert.Equal(action == "game", panel.ShowExfatPath); } + [Fact] + public void Page_says_which_profile_matches_the_game_folder() + { + using TempDir dir = new(); + string game = Directory.CreateDirectory(Path.Combine(dir.Path, "game")).FullName; + AmprPackPanelViewModel panel = Panel("game"); + Assert.False(panel.ShowRulesFound || panel.ShowRulesNone || panel.ShowRulesConfig); + + panel.Root = game; + Assert.True(panel.ShowRulesNone); + Assert.False(panel.ShowRulesFound); + Assert.Contains("No profile found", panel.RulesNoneText, StringComparison.Ordinal); + + dir.File("game/Media/globalgamemanagers", "unity"); + panel.Root = game + Path.DirectorySeparatorChar; + Assert.True(panel.ShowRulesFound); + Assert.False(panel.ShowRulesNone); + Assert.Equal( + "✓ Profile found: unity (identified by Media/globalgamemanagers). Only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).", + panel.RulesFoundText); + + panel.Config = "rules.toml"; + Assert.False(panel.ShowRulesFound); + Assert.True(panel.ShowRulesConfig); + + panel.Action = panel.Actions.Single(a => a.Value == "verify"); + Assert.False(panel.ShowRulesFound || panel.ShowRulesNone || panel.ShowRulesConfig); + } + [AvaloniaFact] public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_progress() { diff --git a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs index cb62b5d..96d16b6 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs @@ -185,12 +185,18 @@ public void Cli_game_builds_the_folder_and_reports_each_step() int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out"), "--fakelib", Path.Combine(dir.Path, "libs")], ctx); Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); - Assert.StartsWith(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + "\n", stdout.ToString(), StringComparison.Ordinal); + Assert.StartsWith(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + "\nNo profile found for this game: ", stdout.ToString(), StringComparison.Ordinal); foreach (string step in (string[])["[1/5] Libraries: fakelib/", "[2/5] Writing ampr_emu.index", "[3/5] Packing", "[4/5] Copying loose files", "[5/5] Verifying"]) { Assert.Contains(step + "\n", stdout.ToString(), StringComparison.Ordinal); } + string toml = dir.File("rules.toml", "[pack]\ndefault_action = \"loose\"\n"); + stdout.GetStringBuilder().Clear(); + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out3"), "--fakelib", Path.Combine(dir.Path, "libs"), "--config", toml], ctx); + Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); + Assert.Contains($"\nRules: your TOML file {toml} (no built-in profile)\n", stdout.ToString(), StringComparison.Ordinal); + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2")], ctx); Assert.Equal(2, exit); Assert.EndsWith("error: fakelib/libSceAmpr.sprx cannot read asset packs; use AMPR Emu 0.4.2.1 or newer from https://github.com/drakmor/ampr_emu/releases\n", stderr.ToString(), StringComparison.Ordinal); diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs index 5a3194f..ad0fcb7 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -87,10 +87,29 @@ public void Cli_auto_reports_the_rules_it_picked() "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "auto"); Assert.Equal(0, exit); - Assert.Equal("Rules: unity (Unity game: data/globalgamemanagers; only StreamingAssets is packed)\n" + global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", stderr); + Assert.Equal( + "Profile found: unity (data/globalgamemanagers); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).\n" + + global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", + stderr); Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); } + [Theory] + [InlineData("auto", "No profile found for this game: generic rules (every file but executables, modules and system files is packed). Untested: the game may not start on the PS5; keep the original.")] + [InlineData("default", DefaultRulesLine)] + [InlineData("unity", "Rules: unity (--preset); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).")] + public void Cli_says_when_no_profile_matches_or_the_rules_were_chosen(string preset, string expected) + { + string root = Game(); + AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); + + (int exit, _, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", preset); + + Assert.Equal(0, exit); + Assert.StartsWith(expected + "\n", stderr, StringComparison.Ordinal); + } + [Fact] public void Unknown_preset_is_rejected() => Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); @@ -123,6 +142,9 @@ private static (int Exit, string Out, string Err) Run(params string[] args) return (exit, stdout.ToString(), stderr.ToString()); } + private const string DefaultRulesLine = + "Rules: default (--preset); every file but executables, modules and system files is packed. Untested: the game may not start on the PS5; keep the original."; + [Fact] public void Cli_preset_packs_and_the_removal_plan_accepts_it() { @@ -134,7 +156,7 @@ public void Cli_preset_packs_and_the_removal_plan_accepts_it() Assert.Equal(0, exit); Assert.Contains("\"files_packed\": 1,", stdout, StringComparison.Ordinal); - Assert.Empty(stderr); + Assert.Equal(DefaultRulesLine + "\n", stderr); Assert.Equal(["data/a.dat"], AMPRPackMaintenance.RemovalPlan(Path.Combine(output, "ampr_assets.index"), root).Paths); } @@ -168,7 +190,7 @@ public void Cli_warns_when_packs_are_built_for_a_folder_without_ampr_emu(string? "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "default"); Assert.Equal(0, exit); - Assert.Equal(warns ? global::MkPFS.Cli.Commands.AmprCommand.NoEmulatorWarning + "\n" : string.Empty, stderr); + Assert.Equal(DefaultRulesLine + "\n" + (warns ? global::MkPFS.Cli.Commands.AmprCommand.NoEmulatorWarning + "\n" : string.Empty), stderr); } [Fact] From ff0ce0b3bb908c050587c82f8db2f1bb642cbaf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:48:07 +0000 Subject: [PATCH 12/28] Add the insomniac AMPR rules, picked automatically Insomniac Games titles (toc, dag and d/ at the top) now get rules that pack only the d/ archives and keep the boot, movie, sound bank and streamed audio archives loose. PPSA03671 crashed at startup with the default rules and runs with these. --preset auto detects the layout and reports it as a tested profile; the README records the result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 19 ++++-- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 62 ++++++++++++++++--- src/MkPFS.Cli/Commands/AmprCommand.cs | 4 +- src/MkPFS.Gui/Localization/Strings.resx | 1 + src/MkPFS.Gui/Localization/Strings_de.resx | 1 + src/MkPFS.Gui/Localization/Strings_es.resx | 1 + src/MkPFS.Gui/Localization/Strings_fr.resx | 1 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 1 + src/MkPFS.Gui/Localization/Strings_ro.resx | 1 + tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 31 ++++++++++ 10 files changed, 104 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 733343c..94081cc 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,12 @@ build as a test. With `--config`, the log says `Rules: your TOML file ...` inste `StreamingAssets/` is packed (asset bundles, audio banks, videos). Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a Unity game abort at startup. -- **Other games**: every file except executables, modules and system files is packed. No game of this - kind has been confirmed on the console yet, and one crashed at startup (see the table), so expect to +- **Insomniac Games titles** (`toc` and `dag` files and a `d/` folder at the top of the game): only the + `d/` archives are packed. The boot (`bootload*`, `critbootload*`), movie, sound bank and streamed audio + (`wem*`) archives stay loose, as do `toc`, `dag` and the other top-level files. Packing every file made + such a game crash at startup. +- **Other games** (no profile found): every file except executables, modules and system files is + packed. No game has run with these rules yet, and both games above failed with them, so expect to need a TOML that leaves more files loose. **Tested games.** Results on a PS5 with ShadowMountPlus 1.7 beta 4 and AMPR Emu 0.4.2.1: @@ -330,7 +334,8 @@ build as a test. With `--config`, the log says `Rules: your TOML file ...` inste |---|---|---|---|---| | God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `unity` (auto) | Runs: menu, saves, gameplay. | | God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `default` | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | -| PPSA03671 | 01.001.005 | Not Unity (Coherent UI) | `default` (auto) | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`); the unpacked game starts. | +| PPSA03671 | 01.001.005 | Insomniac | `insomniac` (auto) | Runs. | +| PPSA03671 | 01.001.005 | Insomniac | `default` | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`). | **Recommended steps.** @@ -387,7 +392,8 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | | `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | | `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | -| `--preset auto` | none | `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | +| `--preset insomniac` | none | For Insomniac Games titles: compress only the `d/` archives (128 KiB blocks) and keep the boot, movie, sound bank and `wem*` archives, `toc`, `dag` and the other top-level files loose. Packing every file made such a title crash at startup, while these rules ran it. | +| `--preset auto` | none | `insomniac` when `toc`, `dag` and `d/` are at the top of the game, `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -415,8 +421,9 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em their real sizes. This must come before packing, because the manifest addresses files by their row in this index. 3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset auto`, which - picks the rules from the game: `unity` when a Unity file (`globalgamemanagers`, `data.unity3d`, - `global-metadata.dat`) is within four folder levels, else `default`. The log says whether a profile + picks the rules from the game: `insomniac` when `toc`, `dag` and `d/` are at the top, `unity` when a + Unity file (`globalgamemanagers`, `data.unity3d`, `global-metadata.dat`) is within four folder levels, + else `default`. The log says whether a profile was found, the file that identified it and whether the profile has run on a PS5. 4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, including empty ones. This runs after packing because the packer only then decides which large, diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index 4c438d6..fbb60b6 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -288,9 +288,9 @@ public static AMPRPackConfig Load(string? path) /// /// Built-in rule sets for (an extension; ampr_pack has none). auto - /// picks unity or default from the game folder (). + /// picks insomniac, unity or default from the game folder (). /// - public static IReadOnlyList PresetNames { get; } = ["auto", "default", "unity"]; + public static IReadOnlyList PresetNames { get; } = ["auto", "default", "insomniac", "unity"]; // Files only Unity players ship; depth-limited so a scan of a large game stays cheap. private static readonly HashSet UnityMarkers = new(StringComparer.OrdinalIgnoreCase) @@ -299,18 +299,27 @@ public static AMPRPackConfig Load(string? path) }; /// - /// The built-in rules for the game in : unity when a Unity marker file - /// (globalgamemanagers, data.unity3d, global-metadata.dat) is within four folder levels, - /// else default. + /// The built-in rules for the game in : insomniac when the folder has Insomniac's + /// layout (toc and dag files and a d folder at the top), unity when a Unity marker file + /// (globalgamemanagers, data.unity3d, global-metadata.dat) is within four folder levels, else + /// default. /// /// Game folder. - /// Preset name and the marker that decided it (relative, / separators), if any. + /// Preset name and what decided it (relative, / separators), if anything. public static (string Preset, string? Marker) DetectPreset(string root) { + if (!Directory.Exists(root)) + { + return ("default", null); + } + + if (File.Exists(Path.Combine(root, "toc")) && File.Exists(Path.Combine(root, "dag")) && Directory.Exists(Path.Combine(root, "d"))) + { + return ("insomniac", "toc, dag, d/"); + } + EnumerationOptions options = new() { RecurseSubdirectories = true, MaxRecursionDepth = 3, IgnoreInaccessible = true }; - string? marker = Directory.Exists(root) - ? Directory.EnumerateFiles(root, "*", options).FirstOrDefault(f => UnityMarkers.Contains(Path.GetFileName(f))) - : null; + string? marker = Directory.EnumerateFiles(root, "*", options).FirstOrDefault(f => UnityMarkers.Contains(Path.GetFileName(f))); return marker is null ? ("default", null) : ("unity", Path.GetRelativePath(root, marker).Replace('\\', '/')); } @@ -318,12 +327,13 @@ public static (string Preset, string? Marker) DetectPreset(string root) /// What a built-in rule set packs and the games it was played with on a PS5, so a log can say whether the rules /// are known to work. default is the fallback for games no profile matches and has run no game yet. /// - /// Preset name (default or unity). + /// Preset name (default, insomniac or unity). /// Short description of what is packed, and the tested games ( when untested). /// The preset does not exist. public static (string Packs, string? TestedOn) PresetInfo(string name) => name switch { "default" => ("every file but executables, modules and system files is packed", null), + "insomniac" => ("only the d/ archives are packed, without boot, movie and audio files", "PPSA03671 (01.001.005)"), "unity" => ("only StreamingAssets is packed", "God of War Sons of Sparta (PPSA28997)"), _ => throw new AMPRPackException($"unknown preset: {name}"), }; @@ -370,6 +380,37 @@ public static (string Preset, string? Marker) DetectPreset(string root) ] """; + /// + /// The insomniac preset, for Insomniac Games titles (toc, dag and the d/ archives at + /// the top): compress only the d/ archives, but keep the boot, movie, sound bank and streamed audio + /// (wem) archives loose, like Lazy_AMPR's Spider-Man profile. Packing every file crashed such a title at + /// startup; these rules ran it. + /// + public const string InsomniacPresetToml = """ + [pack] + default_action = "loose" + default_block_size = "128KiB" + + [[rule]] + action = "compress" + include = ["d/*"] + block_size = "128KiB" + + [[rule]] + action = "loose" + include = [ + "d/*/bootload*", "d/*/critbootload*", "d/*/movie*", "d/*/soundbank*", "d/*/wem*", + "toc", "dag", "commandline.txt", "pkg_settings.set", "known_islands.dat", + ] + + [[rule]] + action = "loose" + include = [ + "eboot.bin", "*/eboot.bin", "*.prx", "*.sprx", "*.elf", "*.self", + "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "_DUPLEX_/*", + ] + """; + /// Load a built-in preset. /// Preset name (see ). /// Game folder, required for auto. @@ -379,6 +420,7 @@ public static (string Preset, string? Marker) DetectPreset(string root) { "auto" => LoadPreset(DetectPreset(root ?? throw new AMPRPackException("preset auto needs the game folder")).Preset), "default" => FromTable(AMPRToml.Parse(DefaultPresetToml, "preset default"), Directory.GetCurrentDirectory()), + "insomniac" => FromTable(AMPRToml.Parse(InsomniacPresetToml, "preset insomniac"), Directory.GetCurrentDirectory()), "unity" => FromTable(AMPRToml.Parse(UnityPresetToml, "preset unity"), Directory.GetCurrentDirectory()), _ => throw new AMPRPackException($"unknown preset: {name}"), }; diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 1e2e850..dcef558 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -56,7 +56,7 @@ private static Command Pack(CliContext ctx) Option amprIndex = Required("--ampr-index", "AMPRIDX3 file"); Option output = Required("--output", "output directory"); Option config = new("--config") { Description = "TOML pack configuration" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; auto picks unity for Unity games, else default" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; insomniac compresses only the d/ archives but boot, movie and audio ones; auto picks insomniac or unity from the game, else default" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option include = Repeated("--include", "additional include glob"); Option exclude = Repeated("--exclude", "force-loose glob"); @@ -160,7 +160,7 @@ private static Command Game(CliContext ctx) Option output = Required("--output", "new or empty folder for the playable game"); Option fakelib = new("--fakelib") { Description = "folder with AMPR Emu 0.4.2.1+ libSceAmpr.sprx and other fakelib libraries to add" }; Option config = new("--config") { Description = "TOML pack configuration (default: --preset auto)" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (unity for Unity games, else default), default (every file but executables, modules and system files) or unity (only StreamingAssets); default: auto" }; + Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (insomniac or unity from the game, else default), default (every file but executables, modules and system files), insomniac (the d/ archives but boot, movie and audio ones) or unity (only StreamingAssets); default: auto" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index e15171a..045701b 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -34,6 +34,7 @@ Treat as APR title Build playable game (packs + loose files) ✓ Profile found: {0} (identified by {1}). {2} + Only the d/ archives are packed; boot, movie and audio archives stay loose. Only StreamingAssets is packed. Every file but executables, modules and system files is packed. Tested: runs on {0}. diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index ef7fa2e..5e2c075 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -34,6 +34,7 @@ Als APR-Titel behandeln Spielfertigen Ordner erstellen (Packs + lose Dateien) ✓ Profil gefunden: {0} (erkannt an {1}). {2} + Nur die Archive in d/ werden gepackt; Boot-, Video- und Audioarchive bleiben einzeln. Nur StreamingAssets wird gepackt. Alle Dateien außer ausführbaren Dateien, Modulen und Systemdateien werden gepackt. Getestet: läuft mit {0}. diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 32d4e54..0c72e70 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -34,6 +34,7 @@ Tratar como título APR Crear juego listo (packs + archivos sueltos) ✓ Perfil encontrado: {0} (identificado por {1}). {2} + Solo se empaquetan los archivos de d/; los de arranque, vídeo y audio quedan sueltos. Solo se empaqueta StreamingAssets. Se empaquetan todos los archivos salvo ejecutables, módulos y archivos del sistema. Probado: funciona en {0}. diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index ef3787b..d0e4cdc 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -34,6 +34,7 @@ Traiter comme un titre APR Créer le jeu prêt à lancer (packs + fichiers libres) ✓ Profil trouvé : {0} (identifié par {1}). {2} + Seules les archives de d/ sont empaquetées ; celles de démarrage, vidéo et audio restent à part. Seul StreamingAssets est empaqueté. Tous les fichiers sont empaquetés, sauf les exécutables, les modules et les fichiers système. Testé : fonctionne avec {0}. diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index f607b83..f67d9bc 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -34,6 +34,7 @@ Tratar como título APR Criar jogo pronto (packs + arquivos soltos) ✓ Perfil encontrado: {0} (identificado por {1}). {2} + Só os arquivos de d/ são empacotados; os de inicialização, vídeo e áudio ficam soltos. Só StreamingAssets é empacotado. Todos os arquivos são empacotados, exceto executáveis, módulos e arquivos de sistema. Testado: funciona em {0}. diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 82481b0..36aa442 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -34,6 +34,7 @@ Tratează ca titlu APR Creează jocul gata de rulat (pack-uri + fișiere libere) ✓ Profil găsit: {0} (identificat după {1}). {2} + Se împachetează doar arhivele din d/; cele de pornire, video și audio rămân separate. Se împachetează doar StreamingAssets. Se împachetează toate fișierele, mai puțin executabilele, modulele și fișierele de sistem. Testat: rulează pe {0}. diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs index ad0fcb7..5991cf2 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs @@ -56,6 +56,37 @@ public void Unity_preset_packs_only_streaming_assets(string relative, string act Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); } + [Theory] + [InlineData("d/0/texture", "compress")] + [InlineData("d/1/zonelightbin_12", "compress")] + [InlineData("d/0/bootload", "loose")] + [InlineData("d/0/critbootload", "loose")] + [InlineData("d/1/movie_intro", "loose")] + [InlineData("d/0/soundbank", "loose")] + [InlineData("d/1/wem.3", "loose")] + [InlineData("toc", "loose")] + [InlineData("dag", "loose")] + [InlineData("known_islands.dat", "loose")] + [InlineData("libcohtml.Prospero.prx", "loose")] + [InlineData("eboot.bin", "loose")] + [InlineData("fakelib/libSceAmpr.sprx", "loose")] + [InlineData("_DUPLEX_/x", "loose")] + public void Insomniac_preset_packs_only_the_d_archives(string relative, string action) => + Assert.Equal(action, AMPRPackConfig.LoadPreset("insomniac").SelectRule(relative).Action); + + [Fact] + public void Auto_picks_insomniac_rules_from_the_top_level_layout() + { + string root = Path.Combine(_dir, "game"); + Directory.CreateDirectory(Path.Combine(root, "d", "0")); + File.WriteAllText(Path.Combine(root, "toc"), "x"); + Assert.Equal(("default", null), AMPRPackConfig.DetectPreset(root)); + + File.WriteAllText(Path.Combine(root, "dag"), "x"); + Assert.Equal(("insomniac", "toc, dag, d/"), AMPRPackConfig.DetectPreset(root)); + Assert.Equal("compress", AMPRPackConfig.LoadPreset("auto", root).SelectRule("d/0/texture").Action); + } + [Theory] [InlineData("Media/globalgamemanagers", "unity")] [InlineData("Game_Data/data.unity3d", "unity")] From 19e185468fd73f02f64491dc9b85f44476177e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:28:42 +0000 Subject: [PATCH 13/28] Port ampr_pack_profile.py: pack rules from APR traces AMPR Emu's debug build records every APR read into ampr_commands.bin. `mkpfs ampr profile generate` ports ampr_emu's ampr_pack_profile.py 4.1 (the AMPRCMD1 reader, per-file read metrics, block-size scoring, index budget, decoded-cache model and rule emission): the TOML, the Markdown report and the runtime header match the Python tool byte for byte on 33 synthetic trace cases (tools/oracle/check_ampr_profile.py). --metrics JSON and batch are not ported. `ampr game` and `ampr pack` take --traces (one run per subfolder) and pack from those rules directly; the log says how many runs and files were used. The AMPR Packs page has a matching trace folder field that reports how many runs it found. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 51 +- src/MkPFS.Build/AMPRPack/AMPRCommandLog.cs | 392 +++ src/MkPFS.Build/AMPRPack/AMPRProfiler.cs | 2339 +++++++++++++++++ src/MkPFS.Cli/Commands/AmprCommand.cs | 67 +- src/MkPFS.Cli/Commands/AmprProfileCommand.cs | 228 ++ src/MkPFS.Gui/Localization/Strings.resx | 5 + src/MkPFS.Gui/Localization/Strings_de.resx | 5 + src/MkPFS.Gui/Localization/Strings_es.resx | 5 + src/MkPFS.Gui/Localization/Strings_fr.resx | 5 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 5 + src/MkPFS.Gui/Localization/Strings_ro.resx | 5 + .../ViewModels/AmprPackPanelViewModel.cs | 53 +- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 3 + tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 32 + tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs | 216 ++ tools/oracle/README.md | 1 + tools/oracle/check_ampr_profile.py | 441 ++++ 17 files changed, 3832 insertions(+), 21 deletions(-) create mode 100644 src/MkPFS.Build/AMPRPack/AMPRCommandLog.cs create mode 100644 src/MkPFS.Build/AMPRPack/AMPRProfiler.cs create mode 100644 src/MkPFS.Cli/Commands/AmprProfileCommand.cs create mode 100644 tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs create mode 100644 tools/oracle/check_ampr_profile.py diff --git a/README.md b/README.md index 94081cc..68959c4 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ offline PFSC block repair ported from PS5 Game Compressor. `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. - **AMPR packs (experimental)**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte like ampr_emu's `ampr_pack.py`, and `ampr game` turns a game folder into a smaller one that runs from - them ([what it is and its limits](#ampr-packing-what-it-is-and-why-it-is-experimental)). + them ([what it is and its limits](#ampr-packing-what-it-is-and-why-it-is-experimental)). `ampr profile` + turns traces recorded on the console into pack rules, like ampr_emu's `ampr_pack_profile.py`. - **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. @@ -348,6 +349,39 @@ build as a test. With `--config`, the log says `Rules: your TOML file ...` inste a save or a level: some files are only read later. 4. Delete the original only after that. +##### Rules from traces + +Built-in rules only exist for a few engines. For any other game, the most reliable rules come from +watching the game on the console: the debug build of AMPR Emu (`Prospero_DebugHooksPackedStdio`, for +example the 0.4.2.1 test-debug-pack) records every read the game makes through AMPR into +`/app0/ampr_commands.bin`. MkPFS reads those traces and packs exactly the files the game read that way, +with block sizes fitted to how it read them, like ampr_emu's `ampr_pack_profile.py`. + +1. Put the debug `libSceAmpr.sprx` into the **unpacked** game's `fakelib/` (keep the normal one to put + back), and set `mount_read_only=0` in ShadowMountPlus for this run: the emulator writes its trace into + the game folder. Run from the folder, not an exFAT image. +2. Play a session: start-up to the menu and loading a save, then other sessions for different levels, + cutscenes, fast travel. Quit the game normally from the PS5 menu; a crash can cut the trace short. +3. After each session, copy `ampr_commands.bin` and `ampr_emu.index` (and `ampr_emu.log`) into a + subfolder of one trace folder, for example `traces/startup/`, `traces/level2/`, and delete them from + the game folder. +4. Put the normal emulator back and `mount_read_only=1`. +5. On the AMPR Packs page, pick that trace folder in **Trace folder** (leave the TOML field empty); the + page says how many runs it found. On the command line: `mkpfs ampr game ... --traces traces`. The log + says `Rules: from N trace runs ...; M files the game read through APR are packed`. + +What traces can and cannot show: + +- A file in a trace was read through AMPR, so AMPR Emu can serve it from the packs. +- Files no session touched (other levels, languages, DLC) stay loose: safe, but they save no space. More + sessions cover more. +- Files the game memory-maps (`mmap`) or reads with plain reads never appear in a trace, so they stay loose + too. + +`mkpfs ampr profile generate --output rules.toml --report rules.md` (or `--trace COMMANDS +INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus a report and a runtime +header; the output matches `ampr_pack_profile.py generate` byte for byte. + ##### Reporting a game Whether it works or not, please report: the game title, ID and version, the `Profile found` or `No profile @@ -360,7 +394,8 @@ of such a test lets the built-in rules learn that game. #### How the packs are made `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same -JSON on standard output, and byte-identical packs. A pack set is the manifest `ampr_assets.index`, data +JSON on standard output, and byte-identical packs. `ampr profile generate` is a port of +`tools/ampr_pack_profile.py` (4.1): the same TOML, report and runtime header. A pack set is the manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, optional runtime settings `ampr_assets.index.runtime`, and an offline CRC sidecar `ampr_assets.index.crc`. @@ -384,6 +419,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `inspect` | `--index ` | Manifest summary, volumes, and runtime settings. | | `runtime-config` | `--index --config ` | Replace `.runtime` from a `[runtime]` section without repacking. | | `remove-sources` | `--index --root ` | Show which sources the packs replace; with `--confirm`, verify everything and delete them. Also `remove-packed-sources`. | +| `profile generate` | `[] --output ` | Pack rules from APR traces (`--trace COMMANDS INDEX`, repeatable); `--report`, `--runtime-header` and every tuning option of `ampr_pack_profile.py generate`. Its `--metrics` JSON and `batch` are not ported. | `pack` options: @@ -394,6 +430,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | | `--preset insomniac` | none | For Insomniac Games titles: compress only the `d/` archives (128 KiB blocks) and keep the boot, movie, sound bank and `wem*` archives, `toc`, `dag` and the other top-level files loose. Packing every file made such a title crash at startup, while these rules ran it. | | `--preset auto` | none | `insomniac` when `toc`, `dag` and `d/` are at the top of the game, `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | +| `--traces ` | none | Rules from APR traces instead of `--config` or `--preset` (see [Rules from traces](#rules-from-traces)); also for `ampr game`. | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | @@ -420,7 +457,7 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em 2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with their real sizes. This must come before packing, because the manifest addresses files by their row in this index. -3. **Packs.** Packs from the game folder with `--config`, `--preset`, or by default `--preset auto`, which +3. **Packs.** Packs from the game folder with `--config`, `--traces`, `--preset`, or by default `--preset auto`, which picks the rules from the game: `insomniac` when `toc`, `dag` and `d/` are at the top, `unity` when a Unity file (`globalgamemanagers`, `data.unity3d`, `global-metadata.dat`) is within four folder levels, else `default`. The log says whether a profile @@ -519,6 +556,7 @@ dotnet run --project src/MkPFS.Gui -c Release offset, stored size, and largest back-reference distance. - The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. Its default action, Build playable game, runs `ampr game` with a library folder and an optional exFAT image. + Its optional trace folder takes the rules from console traces. Without a TOML file, the rules are picked from the game (Unity or not), so there is nothing to choose. Packing without a TOML uses `--preset default`. @@ -605,6 +643,13 @@ needs python-lz4 4.4.5): python tools/oracle/build_ampr_goldens.py --check ``` +`ampr profile generate` is checked against `ampr_pack_profile.py` on synthetic traces (every output file, +byte for byte; build MkPFS first): + +```bash +uv run --no-project --python 3.12 --with lz4==4.4.5 python tools/oracle/check_ampr_profile.py +``` + ### Releases Push a version tag to publish a release. The workflow reruns CI, then uploads the archives, diff --git a/src/MkPFS.Build/AMPRPack/AMPRCommandLog.cs b/src/MkPFS.Build/AMPRPack/AMPRCommandLog.cs new file mode 100644 index 0000000..4970081 --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRCommandLog.cs @@ -0,0 +1,392 @@ +using System.Buffers.Binary; +using System.Text; + +namespace MkPFS.Build.AMPRPack; + +/// One AMPRIDX3 row as the trace tools see it (Python IndexEntry). +/// Row number plus one. +/// Indexed path (/app0/...); invalid UTF-8 is replaced. +/// File size. +/// Modification time. +public sealed record AMPRTraceIndexEntry(int FileId, string Path, long Size, long MTime); + +/// APR command fields the profiler uses (Python decode_command keeps more). +/// Command name, as ampr_emu's parser names it. +/// Command length in 32-bit words. +/// File id (AprReadFile). +/// File offset (reads that carry one). +/// Read length (reads). +public readonly record struct AMPRCommand(string Name, int Dwords, long FileId = 0, long FileOffset = 0, long Length = 0); + +/// +/// AMPR Emu's APR submit journal (ampr_commands.bin, AMPRCMD1), written by the debug emulator build: +/// record header, command decoder and the lenient AMPRIDX3 reader of ampr_emu tools/parse_ampr_command_log.py. +/// +public static class AMPRCommandLog +{ + /// Record magic. + public static ReadOnlySpan Magic => "AMPRCMD1"u8; + + /// Supported record version. + public const int Version = 1; + + /// Size of the fixed record header (<8sHHIQQQQQQIIIIIIII). + public const int HeaderSize = 96; + + /// Submit domain of APR commands (2 is AMM). + public const int DomainApr = 1; + + /// Fixed part of one journal record. + /// Record version. + /// Header size, including any extension. + /// Header plus payload. + /// Submit sequence, from 1. + /// Monotonic time of the submit. + /// FNV-1a of the payload (). + /// Payload size. + /// Commands in the payload, or 0 when unknown. + /// Submit priority (the gather/scatter state key). + /// 1 APR, 2 AMM. + public readonly record struct Header( + int Version, int HeaderBytes, long RecordBytes, ulong Sequence, ulong MonotonicNs, ulong PayloadHash, long PayloadBytes, + long CommandCount, long Priority, long Domain); + + /// Parse a record header; the magic is checked by the caller. + /// At least bytes. + /// Header fields. + public static Header ReadHeader(ReadOnlySpan raw) => new( + BinaryPrimitives.ReadUInt16LittleEndian(raw[8..]), + BinaryPrimitives.ReadUInt16LittleEndian(raw[10..]), + BinaryPrimitives.ReadUInt32LittleEndian(raw[12..]), + BinaryPrimitives.ReadUInt64LittleEndian(raw[16..]), + BinaryPrimitives.ReadUInt64LittleEndian(raw[32..]), + BinaryPrimitives.ReadUInt64LittleEndian(raw[56..]), + BinaryPrimitives.ReadUInt32LittleEndian(raw[64..]), + BinaryPrimitives.ReadUInt32LittleEndian(raw[72..]), + BinaryPrimitives.ReadUInt32LittleEndian(raw[76..]), + BinaryPrimitives.ReadUInt32LittleEndian(raw[80..])); + + /// FNV-1a 64 of a payload, never 0 (Python fnv1a64). + /// Payload. + /// Hash. + public static ulong Fnv1a64(ReadOnlySpan data) + { + ulong hash = 1469598103934665603UL; + foreach (byte b in data) + { + hash = (hash ^ b) * 1099511628211UL; + } + + return hash == 0 ? 1 : hash; + } + + /// + /// Read an AMPRIDX3 file the way the trace tools do (Python load_index): bounds are checked, but paths that + /// are not UTF-8 are decoded with replacement characters and the hash table is not used. + /// + /// Index file. + /// Rows by file id. + /// The index is malformed. + public static Dictionary LoadIndex(string path) + { + byte[] data = File.ReadAllBytes(path); + if (data.Length < 48) + { + throw new InvalidDataException($"index is too small: {path}"); + } + + ReadOnlySpan span = data; + if (!span[..8].SequenceEqual("AMPRIDX3"u8)) + { + throw new InvalidDataException($"unsupported index magic {PythonBytesRepr(span[..8])}; expected AMPRIDX3"); + } + + uint version = BinaryPrimitives.ReadUInt32LittleEndian(span[8..]); + uint entrySize = BinaryPrimitives.ReadUInt32LittleEndian(span[12..]); + ulong entryCount = BinaryPrimitives.ReadUInt64LittleEndian(span[16..]); + ulong pathBytes = BinaryPrimitives.ReadUInt64LittleEndian(span[24..]); + ulong hashOffset = BinaryPrimitives.ReadUInt64LittleEndian(span[32..]); + uint hashSlotSize = BinaryPrimitives.ReadUInt32LittleEndian(span[40..]); + uint hashSlotCount = BinaryPrimitives.ReadUInt32LittleEndian(span[44..]); + if (version != 3) + { + throw new InvalidDataException($"unsupported AMPRIDX3 version {version}; expected 3"); + } + + if (entrySize != 24) + { + throw new InvalidDataException($"unsupported AMPRIDX3 entry size {entrySize}; expected 24"); + } + + UInt128 pathStart = 48 + ((UInt128)entryCount * entrySize); + UInt128 pathEnd = pathStart + pathBytes; + if (pathEnd > (UInt128)data.Length) + { + throw new InvalidDataException("AMPRIDX3 path blob exceeds file size"); + } + + if (hashOffset < pathEnd || hashOffset > (ulong)data.Length) + { + throw new InvalidDataException("AMPRIDX3 hash offset is invalid"); + } + + if (hashSlotSize != 16) + { + throw new InvalidDataException($"unsupported AMPRIDX3 hash slot size {hashSlotSize}"); + } + + if (hashOffset + ((UInt128)hashSlotCount * hashSlotSize) > (UInt128)data.Length) + { + throw new InvalidDataException("AMPRIDX3 hash table exceeds file size"); + } + + ReadOnlySpan blob = span[(int)pathStart..(int)pathEnd]; + Dictionary entries = new((int)entryCount); + for (int index = 0; index < (int)entryCount; index++) + { + ReadOnlySpan record = span.Slice(48 + (index * 24), 24); + uint pathOff = BinaryPrimitives.ReadUInt32LittleEndian(record); + uint pathLen = BinaryPrimitives.ReadUInt32LittleEndian(record[4..]); + ulong size = BinaryPrimitives.ReadUInt64LittleEndian(record[8..]); + long mtime = BinaryPrimitives.ReadInt64LittleEndian(record[16..]); + if ((ulong)pathOff + pathLen > (ulong)blob.Length) + { + throw new InvalidDataException($"AMPRIDX3 entry {index} path is out of bounds"); + } + + string decoded = Encoding.UTF8.GetString(blob.Slice((int)pathOff, (int)pathLen)); + entries[index + 1] = new AMPRTraceIndexEntry(index + 1, decoded, (long)size, mtime); + } + + return entries; + } + + /// + /// Decode the command at (Python decode_command): its name and length for every + /// command, and the file fields of APR reads. + /// + /// Submit payload. + /// Byte offset of the command. + /// The command. + /// The command is truncated or has an invalid length (Python + /// ValueError, same message). + public static AMPRCommand Decode(ReadOnlySpan data, int offset) + { + if ((offset & 3) != 0 || offset + 4 > data.Length) + { + throw new InvalidDataException("unaligned or truncated command"); + } + + uint w0 = U32(data, offset); + uint opcode8 = w0 & 0xFF; + uint opcode12 = w0 & 0xFFF; + int dwords; + if (opcode8 == 1) + { + dwords = (int)((w0 >> 8) & 0xF) + 1; + return dwords is >= 2 and <= 4 && Need(data, offset, dwords) + ? new AMPRCommand("WaitOnAddress", dwords) + : throw new InvalidDataException("invalid WaitOnAddress length"); + } + + if (opcode8 == 2) + { + dwords = (int)((w0 >> 8) & 0xF) + 1; + return dwords == 4 || !(dwords is >= 1 and <= 5 && Need(data, offset, dwords)) + ? throw new InvalidDataException("invalid WaitOnCounter length") + : new AMPRCommand("WaitOnCounter", dwords); + } + + if (opcode8 is 5 or 117) + { + dwords = (int)((w0 >> 8) & 3) + 1; + if (!(dwords is >= 2 and <= 4 && Need(data, offset, dwords))) + { + throw new InvalidDataException("invalid WriteAddress length"); + } + + string name = (U32(data, offset + 4) & 7) switch + { + 1 => "WriteAddressFromTimeCounter", + 2 => "WriteAddressFromCounter", + 3 => "WriteAddressFromCounterPair", + _ => "WriteAddress", + }; + return new AMPRCommand(name, dwords); + } + + if (opcode8 is 6 or 118) + { + dwords = (int)((w0 >> 8) & 0xF) + 1; + return dwords is >= 1 and <= 3 && Need(data, offset, dwords) + ? new AMPRCommand("WriteCounter", dwords) + : throw new InvalidDataException("invalid WriteCounter length"); + } + + if (opcode12 is 1032 or 1144) + { + return Need(data, offset, 5) ? new AMPRCommand("WriteKernelEventQueue", 5) : throw new InvalidDataException("truncated WriteKernelEventQueue"); + } + + if ((w0 & 0xFFFF000F) == 0x5452000F) + { + uint markerType = (w0 >> 12) & 0xF; + int payloadDwords = (int)((w0 >> 8) & 0xF); + dwords = payloadDwords + 1; + if (!Need(data, offset, dwords)) + { + throw new InvalidDataException("truncated NOP/marker packet"); + } + + if (w0 == 0x5452300F) + { + return new AMPRCommand("MarkerPop", 1); + } + + if (markerType is 5 or 6 && payloadDwords < 1) + { + throw new InvalidDataException("marker with color has no color dword"); + } + + return new AMPRCommand(markerType switch + { + 1 or 5 => "MarkerSet", + 2 or 6 => "MarkerPush", + 4 => "MarkerContinuation", + _ => "Nop", + }, dwords); + } + + if (opcode8 == 40) + { + dwords = (int)((w0 >> 8) & 7) + 1; + if (!(dwords is >= 5 and <= 6 && Need(data, offset, dwords))) + { + throw new InvalidDataException("invalid AprReadFile length"); + } + + uint w4 = U32(data, offset + 16); + long fileOffset = ((w0 >> 12) & 0x3FFFF) | (w4 & 0xFFFC0000); + if (dwords >= 6) + { + fileOffset |= (long)(U32(data, offset + 20) & 0xFF) << 32; + } + + return new AMPRCommand("AprReadFile", dwords, U32(data, offset + 8) & 0x7FFFFFFF, fileOffset, U32(data, offset + 4) + 1L); + } + + if (opcode8 == 41) + { + dwords = (int)((w0 >> 8) & 3) + 1; + if (!(dwords is >= 2 and <= 3 && Need(data, offset, dwords))) + { + throw new InvalidDataException("invalid AprReadGather length"); + } + + long fileOffset = (w0 >> 12) & 0x3FFFF; + if (dwords >= 3) + { + fileOffset |= (long)(U32(data, offset + 8) & 0x3FFFFF) << 18; + } + + return new AMPRCommand("AprReadGather", dwords, FileOffset: fileOffset, Length: U32(data, offset + 4) + 1L); + } + + if (opcode12 == 0x22A) + { + return Need(data, offset, 3) + ? new AMPRCommand("AprReadScatter", 3, Length: U32(data, offset + 4) + 1L) + : throw new InvalidDataException("truncated AprReadScatter"); + } + + if (opcode8 == 43) + { + dwords = (int)((w0 >> 8) & 7) + 1; + if (!(dwords is >= 4 and <= 5 && Need(data, offset, dwords))) + { + throw new InvalidDataException("invalid AprReadGatherScatter length"); + } + + uint w3 = U32(data, offset + 12); + long fileOffset = ((w0 >> 12) & 0x3FFFF) | (w3 & 0xFFFC0000); + if (dwords >= 5) + { + fileOffset |= (long)(U32(data, offset + 16) & 0xFF) << 32; + } + + return new AMPRCommand("AprReadGatherScatter", dwords, FileOffset: fileOffset, Length: U32(data, offset + 4) + 1L); + } + + if (w0 == 47) + { + return new AMPRCommand("AprResetGatherScatterState", 1); + } + + if (opcode12 == 557) + { + return Need(data, offset, 3) ? new AMPRCommand("AprMapBegin", 3) : throw new InvalidDataException("truncated AprMapBegin"); + } + + if (opcode12 == 813) + { + return Need(data, offset, 4) ? new AMPRCommand("AprMapDirectBegin", 4) : throw new InvalidDataException("truncated AprMapDirectBegin"); + } + + if (w0 == 46) + { + return new AMPRCommand("AprMapEnd", 1); + } + + // AMM commands (Python decode_amm); a truncated one falls through to Unknown. + dwords = opcode12 switch + { + 0x221 => 3, + 0x321 => 4, + 0x325 => 4, + 0x425 => 5, + 0x222 or 0x228 => 3, + 0x323 or 0x324 or 0x327 => 4, + 0x423 or 0x424 => 5, + 0x326 => 4, + 0x426 => 5, + _ => 0, + }; + if (dwords > 0 && Need(data, offset, dwords)) + { + return new AMPRCommand("Amm", dwords); + } + + int guessed = (int)((w0 >> 8) & 0xF) + 1; + return new AMPRCommand("Unknown", Need(data, offset, guessed) ? guessed : 1); + } + + private static uint U32(ReadOnlySpan data, int offset) => BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + + private static bool Need(ReadOnlySpan data, int offset, int dwords) => + dwords > 0 && offset >= 0 && offset + ((long)dwords * 4) <= data.Length; + + // Python repr(bytes): b'...' with printable ASCII kept, \t \n \r and other bytes as \xNN. + private static string PythonBytesRepr(ReadOnlySpan value) + { + bool hasSingle = value.Contains((byte)'\''); + bool hasDouble = value.Contains((byte)'"'); + char quote = hasSingle && !hasDouble ? '"' : '\''; + StringBuilder builder = new("b"); + builder.Append(quote); + foreach (byte b in value) + { + _ = b switch + { + (byte)'\\' => builder.Append(@"\\"), + (byte)'\t' => builder.Append(@"\t"), + (byte)'\n' => builder.Append(@"\n"), + (byte)'\r' => builder.Append(@"\r"), + _ when b == quote => builder.Append('\\').Append(quote), + >= 0x20 and < 0x7F => builder.Append((char)b), + _ => builder.Append(@"\x").Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)), + }; + } + + return builder.Append(quote).ToString(); + } +} diff --git a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs new file mode 100644 index 0000000..e0b949d --- /dev/null +++ b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs @@ -0,0 +1,2339 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using MkPFS.Core.AMPR; +using MkPFS.Core.Compression; +using MkPFS.Core.Util; + +namespace MkPFS.Build.AMPRPack; + +/// One trace run: the APR journal and the index it was recorded with (Python TraceSpec). +/// Run name, used in the report and in warnings. +/// ampr_commands.bin. +/// The run's ampr_emu.index. +public sealed record AMPRTraceSpec(string Name, string Commands, string Index); + +/// Options of ampr_pack_profile.py generate (Python ProfileOptions), with its defaults. +public sealed class AMPRProfileOptions +{ + /// Profile name. + public required string Name { get; set; } + + /// --io-page-size. + public long IOPageSize { get; set; } = AMPRProfiler.DefaultIOPage; + + /// --pack-index-budget. + public long IndexBudget { get; set; } = 96L * 1024 * 1024; + + /// --cache-candidates, ascending. + public IReadOnlyList CacheCandidates { get; set; } = AMPRProfiler.DefaultCacheCandidates; + + /// --lanes; 0 picks 1 to 4. + public int Lanes { get; set; } + + /// --workers written to [pack]. + public int Workers { get; set; } = 8; + + /// --runtime-workers; 0 picks 2 to 8 from the trace. + public int RuntimeWorkers { get; set; } + + /// --strategy: conservative, balanced or aggressive. + public string Strategy { get; set; } = "balanced"; + + /// --pattern-mode: exact, hybrid or directory. + public string PatternMode { get; set; } = "exact"; + + /// --min-reads. + public int MinReads { get; set; } = 1; + + /// --min-requested-bytes. + public long MinRequestedBytes { get; set; } = 1; + + /// --max-rule-files. + public int MaxRuleFiles { get; set; } = 256; + + /// --externalize-paths; 0 keeps every path inline. + public int ExternalizePaths { get; set; } = 128; + + /// --generalize-coverage. + public double GeneralizeCoverage { get; set; } = 0.80; + + /// --generalize-min-files. + public int GeneralizeMinFiles { get; set; } = 4; + + /// --cache-sim / --no-cache-sim. + public bool CacheSimulation { get; set; } = true; + + /// --cache-max-touches; 0 simulates every touch. + public long CacheMaxTouches { get; set; } = AMPRProfiler.DefaultCacheMaxTouches; + + /// --content-root for LZ4 sampling, or . + public string? ContentRoot { get; set; } + + /// --sample-budget; 0 disables sampling. + public long SampleBudget { get; set; } + + /// --sample-blocks-per-file. + public int SampleBlocksPerFile { get; set; } = 4; + + /// --sample-mode: hc or fast. + public string SampleMode { get; set; } = "hc"; + + /// --sample-level. + public int SampleLevel { get; set; } = 12; + + /// --sample-acceleration. + public int SampleAcceleration { get; set; } = 1; + + /// Python _validate_options. + /// An option is out of range. + public void Validate() + { + if (IOPageSize < 4096 || (IOPageSize & (IOPageSize - 1)) != 0) + { + throw new AMPRPackException("io-page-size must be a power of two and at least 4 KiB"); + } + + string? error = this switch + { + { IndexBudget: < 1024 * 1024 } => "pack-index-budget must be at least 1 MiB", + { Lanes: < 0 or > 64 } => "lanes must be between 0 and 64", + { Workers: < 1 or > 256 } => "workers must be between 1 and 256", + { RuntimeWorkers: < 0 or > 16 } => "runtime-workers must be between 0 and 16", + { MinReads: < 1 } => "min-reads must be at least 1", + { MaxRuleFiles: < 1 } => "max-rule-files must be at least 1", + { ExternalizePaths: < 0 } => "externalize-paths must be non-negative", + { GeneralizeCoverage: < 0.0 or > 1.0 } => "generalize-coverage must be in [0,1]", + { GeneralizeMinFiles: < 2 } => "generalize-min-files must be at least 2", + _ => null, + }; + if (error is not null) + { + throw new AMPRPackException(error); + } + } +} + +/// Per-trace statistics (Python TraceSummary). +public sealed class AMPRTraceSummary +{ + /// Run name. + public required string Name { get; init; } + + /// Journal path. + public required string Commands { get; init; } + + /// Index path. + public required string Index { get; init; } + + /// SHA-256 of the journal, lowercase hex. + public required string CommandSha256 { get; init; } + + /// SHA-256 of the index, lowercase hex. + public required string IndexSha256 { get; init; } + + /// Journal records read. + public long Records { get; init; } + + /// APR reads kept. + public long Reads { get; init; } + + /// Parser warnings. + public required List Warnings { get; init; } + + /// Records whose commands could not all be decoded. + public long DecodeErrors { get; init; } + + /// Monotonic time between the first and last record. + public long DurationNs { get; init; } +} + +/// Result of (Python ProfileResult). +public sealed class AMPRProfileResult +{ + internal AMPRProfileResult() + { + } + + /// Profile name. + public required string Name { get; init; } + + /// Trace runs. + public required List Traces { get; init; } + + /// Observed files and what to do with each, by path. + internal List Recommendations { get; init; } = []; + + /// Observed index rows, by relative path, in first-read order. + internal Dictionary IndexEntries { get; init; } = []; + + internal List CacheSimulations { get; init; } = []; + + /// Projected resident size of the pack manifest. + public long ProjectedIndexBytes { get; init; } + + /// The index budget the block sizes were fitted to. + public long IndexBudget { get; init; } + + /// Recommended decoded cache. + public long RecommendedCacheBytes { get; init; } + + /// Recommended physical-page cache. + public long RecommendedPhysicalCacheBytes { get; init; } + + /// Recommended runtime workers. + public int RecommendedRuntimeWorkers { get; init; } + + /// Recommended latency-reserved workers. + public int RecommendedLatencyReserveWorkers { get; init; } + + /// Recommended AMPR internal pool. + public long RecommendedPoolBytes { get; init; } + + /// Warnings from the traces and the budget. + public required List Warnings { get; init; } + + internal List<(string Name, int PackCount, long MaxPackSize)> Groups { get; init; } = []; + + /// Observed files that will be packed (not loose). + public int PackedFiles => Recommendations.Count(r => r.Action != "loose"); + + /// Total size of the observed files that will be packed. + public long PackedFileBytes => Recommendations.Where(r => r.Action != "loose").Sum(r => r.Metrics.FileSize); + + /// Observed files. + public int ObservedFiles => Recommendations.Count; +} + +/// +/// Port of ampr_emu tools/ampr_pack_profile.py 4.1 (generate): turns APR traces recorded by the debug +/// emulator build into an ampr_pack TOML profile. Files the traces show the game reading through APR are +/// packed with block sizes and layouts fitted to the observed reads; every other file stays loose. The TOML, the +/// Markdown report and the runtime header match the Python output. +/// +public static class AMPRProfiler +{ + /// Upstream tool version written into the outputs. + public const string ToolVersion = "4.1"; + + /// Default --io-page-size. + public const long DefaultIOPage = 64 * 1024; + + /// Default --cache-max-touches. + public const long DefaultCacheMaxTouches = 5_000_000; + + /// Default --cache-candidates. + public static readonly IReadOnlyList DefaultCacheCandidates = [32L << 20, 64L << 20, 128L << 20, 256L << 20, 512L << 20]; + + private const int ChunkRecordBytes = 12; + private const int FileRecordBytes = 48; + private const int PackRecordBytes = 32; + private const long RuntimePipelineSlots = 32; + private const long RuntimePipelineIOBytes = 2 * 1024 * 1024; + private const long RuntimeWorkerScratchBytes = 1 * 1024 * 1024; + private const long RuntimePostCacheReserveBytes = 32 * 1024 * 1024; + private const long K64 = 64 * 1024; + + private static readonly long[] BlockSizes = [16 * 1024, 32 * 1024, 64 * 1024, 128 * 1024, 256 * 1024, 512 * 1024, 1024 * 1024]; + private static readonly HashSet KnownLooseBasenames = + ["eboot.bin", "ampr_emu.index", "ampr_assets.index", "ampr_assets.index.crc", "ampr_assets.index.runtime", "param.sfo", "nptitle.dat"]; + private static readonly HashSet KnownLooseSuffixes = [".prx", ".sprx", ".self", ".elf"]; + private static readonly HashSet KnownStreamStoreSuffixes = + [".bik", ".bk2", ".mp4", ".m4v", ".webm", ".m2v", ".mpg", ".mpeg", ".avi", ".wem", ".opus", ".ogg", ".mp3", ".aac", ".flac"]; + + private static readonly string[] StandardLoosePatterns = + [ + "sce_module/**", "system/**", "mods/**", "save/**", "**/*.prx", "**/*.sprx", "eboot.bin", "ampr_emu.index", + "ampr_assets.index", "ampr_assets.index.crc", "ampr_assets.index.runtime", "ampr_assets-*.pak", + ]; + + internal sealed record ReadEvent( + int TraceOrdinal, long Sequence, int CommandOrdinal, long MonotonicNs, long Priority, string Path, string Relative, + int FileId, long FileSize, long Offset, long Length) + { + public long End => Offset + Length; + } + + internal sealed record CandidateMetrics( + long BlockSize, long TouchedBytes, long TotalBlockTouches, long UniqueBlocks, double Amplification, double AverageBlocksPerRead, + double RepeatRatio, double OffsetAlignmentRatio, double LengthAlignmentRatio, long MetadataBytes, double LocalScore); + + internal sealed class FileMetrics + { + public required string Path { get; init; } + public required string Relative { get; init; } + public long FileSize { get; init; } + public int TraceCount { get; init; } + public required List Events { get; init; } + public int ReadCount { get; init; } + public long RequestedBytes { get; init; } + public double P50 { get; init; } + public double P90 { get; init; } + public double Tiny4kRatio { get; init; } + public double Small16kRatio { get; init; } + public double Small64kRatio { get; init; } + public double Large256kRatio { get; init; } + public double ExactSequentialRatio { get; init; } + public double NearSequentialRatio { get; init; } + public double RandomSeekRatio { get; init; } + public required Dictionary Candidates { get; init; } + public double Confidence { get; init; } + public required string ConfidenceLabel { get; init; } + } + + internal sealed class FileRecommendation + { + public required FileMetrics Metrics { get; init; } + public required string Action { get; set; } + public required string Layout { get; set; } + public long BlockSize { get; set; } + public bool Hot { get; set; } + public required string Group { get; set; } + public required string Reason { get; set; } + + public ProfileKey Key => new(Action, Layout, BlockSize, Hot, Group); + + public long MetadataBytes => Action == "loose" ? 0 : CeilDiv(Metrics.FileSize, BlockSize) * ChunkRecordBytes; + } + + // Python tuple (action, layout, block_size, hot, group), compared element by element. + internal readonly record struct ProfileKey(string Action, string Layout, long BlockSize, bool Hot, string Group) : IComparable + { + public int CompareTo(ProfileKey other) + { + int c = PythonCodePointComparer.Instance.Compare(Action, other.Action); + if (c == 0) + { + c = PythonCodePointComparer.Instance.Compare(Layout, other.Layout); + } + + if (c == 0) + { + c = BlockSize.CompareTo(other.BlockSize); + } + + if (c == 0) + { + c = Hot.CompareTo(other.Hot); + } + + return c != 0 ? c : PythonCodePointComparer.Instance.Compare(Group, other.Group); + } + } + + internal sealed record CacheSimulation( + long Capacity, long RequestedBytes, long HitBytes, long Misses, long Hits, long SampledTouches, long TotalTouches, long SampledWindows) + { + public double HitRatio => RequestedBytes != 0 ? (double)HitBytes / RequestedBytes : 0.0; + } + + private sealed record RuleEmission(ProfileKey Key, IReadOnlyList Include, IReadOnlyList Comments); + + /// + /// Trace pairs under a folder (Python discover_trace_pairs): the folder itself when it holds + /// ampr_commands.bin and ampr_emu.index, otherwise every subfolder that does, in path order. + /// + /// Folder. + /// Trace runs. + /// is a file. + public static List DiscoverTracePairs(string root) + { + if (File.Exists(root)) + { + throw new AMPRPackException($"expected a directory, got file: {root}"); + } + + string commands = Path.Combine(root, "ampr_commands.bin"); + string index = Path.Combine(root, "ampr_emu.index"); + if (File.Exists(commands) && File.Exists(index)) + { + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(root)); + return [new AMPRTraceSpec(name.Length > 0 ? name : "trace", commands, index)]; + } + + if (!Directory.Exists(root)) + { + return []; + } + + List pairs = []; + EnumerationOptions options = new() { RecurseSubdirectories = true, IgnoreInaccessible = true, MatchCasing = MatchCasing.CaseSensitive }; + List found = [.. Directory.EnumerateFiles(root, "ampr_commands.bin", options) + .Select(f => Path.GetRelativePath(root, f).Split(Path.DirectorySeparatorChar))]; + found.Sort(ComparePathParts); + foreach (string[] parts in found) + { + string file = Path.Combine([root, .. parts]); + string sibling = Path.Combine(Path.GetDirectoryName(file)!, "ampr_emu.index"); + if (File.Exists(sibling)) + { + string name = string.Join('-', parts[..^1]); + pairs.Add(new AMPRTraceSpec(name.Length > 0 ? name : Path.GetFileName(Path.GetDirectoryName(file)!), file, sibling)); + } + } + + return pairs; + } + + /// Analyse the traces and choose what to pack (Python build_profile). + /// Trace runs, in order. + /// Options. + /// The profile. + /// A journal is not a supported AMPRCMD1 file. + public static AMPRProfileResult Build(IReadOnlyList traces, AMPRProfileOptions options) + { + (List events, List summaries, Dictionary indexEntries, List warnings) = + LoadTraceEvents(traces); + List files = BuildFileMetrics(events); + List recommendations = InitialRecommendations(files, options); + SampleCompressibility(recommendations, options, warnings); + int provisionalLanes = ChooseLanes(recommendations, options.Lanes); + int provisionalPackCount = BuildGroups(recommendations, provisionalLanes).Sum(g => g.PackCount); + long mandatoryIndexBytes = EstimateIndexBytes([], provisionalPackCount, indexEntries); + long chunkBudget = Math.Max(0, options.IndexBudget - mandatoryIndexBytes); + EnforceIndexBudget(recommendations, chunkBudget, options.Strategy); + int lanes = ChooseLanes(recommendations, options.Lanes); + List<(string Name, int PackCount, long MaxPackSize)> groups = BuildGroups(recommendations, lanes); + int packCount = groups.Sum(g => g.PackCount); + long projectedIndex = EstimateIndexBytes(recommendations, packCount, indexEntries); + if (mandatoryIndexBytes > options.IndexBudget) + { + warnings.Add($"mandatory AMPRPAK4 file/string tables {HumanSize(mandatoryIndexBytes)} exceed index budget " + + $"{HumanSize(options.IndexBudget)} before any packed chunks"); + } + + if (projectedIndex > options.IndexBudget) + { + warnings.Add($"projected pack index {HumanSize(projectedIndex)} still exceeds budget {HumanSize(options.IndexBudget)}"); + } + + Dictionary byPath = []; + foreach (FileRecommendation recommendation in recommendations) + { + byPath[recommendation.Metrics.Relative] = recommendation; + } + + List simulations = options.CacheSimulation ? SimulateCache(events, byPath, options.CacheCandidates, options.CacheMaxTouches) : []; + long cacheSize = ChooseCacheSize(simulations); + long physicalPageCache = ChoosePhysicalPageCacheSize(recommendations); + int runtimeWorkers = ChooseRuntimeWorkers(events, recommendations, options.RuntimeWorkers); + int latencyReserveWorkers = ChooseLatencyReserveWorkers(events, recommendations, runtimeWorkers); + long amprIndexBytes = summaries.Where(s => s.Index.Length > 0).Select(s => new FileInfo(s.Index).Length).DefaultIfEmpty(0).Max(); + long poolSize = RoundPoolSize(projectedIndex + amprIndexBytes + cacheSize + physicalPageCache + (RuntimePipelineSlots * RuntimePipelineIOBytes) + + (runtimeWorkers * RuntimeWorkerScratchBytes) + RuntimePostCacheReserveBytes); + return new AMPRProfileResult + { + Name = options.Name, + Traces = summaries, + Recommendations = recommendations, + IndexEntries = indexEntries, + ProjectedIndexBytes = projectedIndex, + IndexBudget = options.IndexBudget, + CacheSimulations = simulations, + RecommendedCacheBytes = cacheSize, + RecommendedPhysicalCacheBytes = physicalPageCache, + RecommendedRuntimeWorkers = runtimeWorkers, + RecommendedLatencyReserveWorkers = latencyReserveWorkers, + RecommendedPoolBytes = poolSize, + Warnings = warnings, + Groups = groups, + }; + } + + /// + /// The TOML profile (Python render_toml). With , exact include lists longer + /// than go to <stem>.rule-NNN.include.txt + /// sidecars (added to the dictionary) referenced by include_from; without it every path is inline. + /// + /// Profile. + /// Options it was built with. + /// Receives sidecar names and contents, or . + /// Sidecar name prefix (the TOML file name without extension). + /// TOML text. + public static string RenderToml(AMPRProfileResult result, AMPRProfileOptions options, Dictionary? externalLists = null, string profileStem = "profile") + { + List emissions = DirectoryGeneralization(result.Recommendations, result.IndexEntries.Keys, options); + string io = SizeLiteral(options.IOPageSize); + List lines = + [ + $"# Generated by ampr_pack_profile.py {ToolVersion} from APR traces.", + "# IMPORTANT: traces contain only APR reads observed in recorded scenarios.", + "# Adapt this config to the real game by adding remaining files/directories", + "# or deliberately keep them loose. A trace cannot prove complete coverage.", + $"# Projected resident pack index: {HumanSize(result.ProjectedIndexBytes)}.", + $"# Recommended decoded cache: {HumanSize(result.RecommendedCacheBytes)}.", + $"# Recommended physical-page cache: {HumanSize(result.RecommendedPhysicalCacheBytes)}.", + $"# Recommended runtime workers: {result.RecommendedRuntimeWorkers}.", + $"# Recommended latency-reserved workers: {result.RecommendedLatencyReserveWorkers}.", + $"# Recommended AMPR internal pool: {HumanSize(result.RecommendedPoolBytes)}.", + "", + "[pack]", + "index_name = \"ampr_assets.index\"", + "pack_pattern = \"ampr_assets-{group}-lane{lane:02d}-vol{volume:02d}-{id:03d}.pak\"", + "default_action = \"loose\"", + "default_block_size = \"64KiB\"", + $"io_page_size = \"{io}\"", + $"payload_alignment = \"{io}\"", + "chunk_alignment = \"64B\"", + $"workers = {options.Workers}", + "compression_mode = \"hc\"", + "compression_level = 12", + "acceleration = 1", + "deduplicate = true", + "deduplicate_scope = \"lane\"", + "deduplicate_streaming = false", + "min_savings_bytes = 64", + "min_savings_ratio = 0.01", + "io_neutral_min_savings_bytes = \"8KiB\"", + "io_neutral_min_savings_ratio = 0.125", + "auto_loose_large_files = true", + "auto_loose_hot_files = false", + "auto_loose_min_file_size = \"64MiB\"", + "auto_loose_sample_blocks = 32", + "auto_loose_sample_bytes = \"16MiB\"", + "auto_loose_min_savings_ratio = 0.05", + "auto_loose_max_raw_ratio = 0.90", + "preserve_mtime = true", + "validate_index_metadata = true", + "", + "[runtime]", + $"decoded_cache_bytes = {result.RecommendedCacheBytes}", + $"physical_cache_bytes = {result.RecommendedPhysicalCacheBytes}", + $"workers = {result.RecommendedRuntimeWorkers}", + $"latency_reserve_workers = {result.RecommendedLatencyReserveWorkers}", + "", + ]; + foreach ((string name, int packCount, long maxPackSize) in result.Groups) + { + lines.AddRange( + [ + $"[groups.{name}]", + $"pack_count = {packCount}", + $"assignment = {TomlString("balanced")}", + $"max_pack_size = {TomlString(SizeLiteral(maxPackSize))}", + "stripe_large_files = false", + $"io_page_size = {TomlString(io)}", + "", + ]); + } + + for (int emissionIndex = 1; emissionIndex <= emissions.Count; emissionIndex++) + { + RuleEmission emission = emissions[emissionIndex - 1]; + ProfileKey key = emission.Key; + lines.AddRange(emission.Comments.Select(c => $"# {c}")); + lines.Add("[[rule]]"); + lines.Add($"action = {TomlString(key.Action)}"); + bool isExactList = emission.Include.All(p => p.IndexOfAny(['*', '?', '[']) < 0); + bool externalize = externalLists is not null && options.ExternalizePaths > 0 && emission.Include.Count > options.ExternalizePaths && isExactList; + if (externalize) + { + string listName = $"{profileStem}.rule-{emissionIndex:000}.include.txt"; + externalLists![listName] = string.Concat(emission.Include.Select(p => p + "\n")); + lines.Add($"include_from = [{TomlString(listName)}]"); + } + else if (emission.Include.Count == 1) + { + lines.Add($"include = [{TomlString(emission.Include[0])}]"); + } + else + { + lines.Add("include = ["); + lines.AddRange(emission.Include.Select(p => $" {TomlString(p)},")); + lines.Add("]"); + } + + lines.Add($"block_size = {TomlString(SizeLiteral(key.BlockSize))}"); + lines.Add($"group = {TomlString(key.Group)}"); + lines.Add($"layout = {TomlString(key.Layout)}"); + if (key.Hot) + { + lines.Add("hot = true"); + if (key.BlockSize <= 32 * 1024) + { + lines.AddRange( + [ + "min_savings_bytes = 16", + "min_savings_ratio = 0.0025", + "io_neutral_min_savings_bytes = \"2KiB\"", + "io_neutral_min_savings_ratio = 0.125", + ]); + } + } + + if (key.Layout == "streaming") + { + lines.Add("streaming = true"); + } + + lines.Add(""); + } + + lines.AddRange(["# Safety exclusions. The last matching rule wins.", "[[rule]]", "action = \"loose\"", "include = ["]); + lines.AddRange(StandardLoosePatterns.Select(p => $" {TomlString(p)},")); + lines.AddRange(["]", ""]); + return string.Join('\n', lines); + } + + /// The pack configuration of with every path inline, without writing it. + /// Profile. + /// Options it was built with. + /// Configuration. + public static AMPRPackConfig ToConfig(AMPRProfileResult result, AMPRProfileOptions options) => + AMPRPackConfig.FromTable(AMPRToml.Parse(RenderToml(result, options), "trace profile"), Directory.GetCurrentDirectory()); + + /// The Markdown report (Python render_report). + /// Profile. + /// Report text. + public static string RenderReport(AMPRProfileResult result) + { + List selected = [.. result.Recommendations.Where(r => r.Action != "loose")]; + long totalReads = selected.Sum(r => (long)r.Metrics.ReadCount); + long submitted = selected.Sum(r => r.Metrics.RequestedBytes); + long fileSize = selected.Sum(r => r.Metrics.FileSize); + List lines = + [ + $"# AMPR trace-derived pack profile: {result.Name}", + "", + "## Summary", + "", + $"- Trace files: **{result.Traces.Count}**", + $"- Selected files: **{selected.Count}**", + $"- Read commands: **{Thousands(totalReads)}**", + $"- Submitted bytes: **{HumanSize(submitted)}**", + $"- Selected logical file size: **{HumanSize(fileSize)}**", + $"- Projected pack index: **{HumanSize(result.ProjectedIndexBytes)}** of {HumanSize(result.IndexBudget)} budget", + $"- Recommended decoded cache: **{HumanSize(result.RecommendedCacheBytes)}**", + $"- Recommended physical-page cache: **{HumanSize(result.RecommendedPhysicalCacheBytes)}**", + $"- Runtime workers: **{result.RecommendedRuntimeWorkers}**", + $"- Latency-reserved workers: **{result.RecommendedLatencyReserveWorkers}**", + $"- Recommended AMPR internal pool: **{HumanSize(result.RecommendedPoolBytes)}**", + "", + "The recommendation describes access behaviour, not entropy. Unless content sampling was enabled, `compress` relies on the packer's per-block RAW fallback.", + "", + "## Profile distribution", + "", + "| Dimension | Distribution |", + "|---|---|", + "| Actions | " + Distribution(selected.Select(r => r.Action), StringKey, k => k) + " |", + "| Layouts | " + Distribution(selected.Select(r => r.Layout), StringKey, k => k) + " |", + "| Block sizes | " + Distribution(selected.Select(r => r.BlockSize), Comparer.Default, SizeLiteral) + " |", + "", + "## Decoded-cache model", + "", + "The model uses byte-weighted LRU with second-touch admission; streaming rules bypass the main cache, matching the runtime policy.", + "", + "| Capacity | Hit bytes | Hit ratio | Block hits | Misses | Touches |", + "|---:|---:|---:|---:|---:|---:|", + ]; + if (result.CacheSimulations.Count > 0) + { + CacheSimulation first = result.CacheSimulations[0]; + if (first.SampledTouches < first.TotalTouches) + { + lines.AddRange( + [ + $"Cache simulation sampled {Thousands(first.SampledTouches)} of {Thousands(first.TotalTouches)} block touches in {first.SampledWindows} contiguous windows.", + "", + ]); + } + + lines.AddRange(result.CacheSimulations.Select(s => + $"| {HumanSize(s.Capacity)} | {HumanSize(s.HitBytes)} | {Percent(s.HitRatio, 1)} | {Thousands(s.Hits)} | {Thousands(s.Misses)} | " + + $"{Thousands(s.SampledTouches)}/{Thousands(s.TotalTouches)} |")); + } + else + { + lines.Add("| disabled | — | — | — | — | — |"); + } + + lines.AddRange( + [ + "", + "## Highest-impact files", + "", + "| Path | Reads | Submitted | p50 | Seq. | Repeat 64K | Recommendation | Amplification | Index | Confidence |", + "|---|---:|---:|---:|---:|---:|---|---:|---:|---|", + ]); + foreach (FileRecommendation recommendation in selected + .OrderByDescending(r => (r.Metrics.RequestedBytes, r.Metrics.ReadCount)).Take(80)) + { + FileMetrics metrics = recommendation.Metrics; + CandidateMetrics candidate = metrics.Candidates[recommendation.BlockSize]; + lines.Add( + $"| `{metrics.Relative.Replace("|", "\\|", StringComparison.Ordinal)}` | {Thousands(metrics.ReadCount)} | {HumanSize(metrics.RequestedBytes)} | " + + $"{HumanSize(metrics.P50)} | {Percent(metrics.ExactSequentialRatio + metrics.NearSequentialRatio, 0)} | " + + $"{Percent(metrics.Candidates[K64].RepeatRatio, 0)} | " + + $"{recommendation.Action}/{recommendation.Layout}/{SizeLiteral(recommendation.BlockSize)}{(recommendation.Hot ? "/hot" : "")} | " + + $"{PythonText.FormatFixed(candidate.Amplification, 2)}x | {HumanSize(recommendation.MetadataBytes)} | {metrics.ConfidenceLabel} |"); + } + + lines.AddRange(["", "## Trace inputs", ""]); + foreach (AMPRTraceSummary trace in result.Traces) + { + lines.AddRange( + [ + $"### {trace.Name}", + "", + $"- Commands: `{trace.Commands}`", + $"- Index: `{trace.Index}`", + $"- Records: {Thousands(trace.Records)}; reads: {Thousands(trace.Reads)}; duration: {PythonText.FormatFixed(trace.DurationNs / 1e9, 1)} s", + $"- Sequence/parser warnings: {trace.Warnings.Count}; decode issues: {trace.DecodeErrors}", + $"- Command SHA-256: `{trace.CommandSha256}`", + $"- Index SHA-256: `{trace.IndexSha256}`", + "", + ]); + } + + if (result.Warnings.Count > 0) + { + lines.AddRange(["## Warnings", ""]); + lines.AddRange(result.Warnings.Take(200).Select(w => $"- {w}")); + if (result.Warnings.Count > 200) + { + lines.Add($"- {result.Warnings.Count - 200} additional warnings omitted"); + } + + lines.Add(""); + } + + lines.AddRange( + [ + "## Operational guidance", + "", + "1. Treat this profile as a starting point: traces contain only observed APR reads. Review the real game and add required files/directories from unrecorded levels, modes, languages and DLC, or deliberately keep them loose.", + "2. Build packs, run `ampr_pack.py verify`, then test with loose source files still available.", + "3. Measure LZ4 ratio. Archives that remain almost entirely RAW and show little cache reuse may be better left loose.", + "4. Re-run this profiler when the game or resource layout changes.", + "", + ]); + return string.Join('\n', lines); + } + + /// The runtime header with the recommended emulator defines (Python render_runtime_header). + /// Profile. + /// C header text. + public static string RenderRuntimeHeader(AMPRProfileResult result) + { + List lines = + [ + "/*", + $" * Generated by ampr_pack_profile.py {ToolVersion}.", + $" * Profile: {result.Name}", + " * Inputs:", + .. result.Traces.Select(t => $" * {t.Name}: commands={t.CommandSha256} index={t.IndexSha256}"), + " */", + "#pragma once", + "", + $"#define AMPR_EMU_INTERNAL_AMM_POOL_SIZE 0x{result.RecommendedPoolBytes:x}ull", + $"#define AMPR_EMU_PACK_DECODED_CACHE_BYTES 0x{result.RecommendedCacheBytes:x}ull", + $"#define AMPR_EMU_PACK_PHYSICAL_CACHE_BYTES 0x{result.RecommendedPhysicalCacheBytes:x}ull", + $"#define AMPR_EMU_PACK_WORKERS {result.RecommendedRuntimeWorkers}u", + $"#define AMPR_EMU_PACK_LATENCY_RESERVE_WORKERS {result.RecommendedLatencyReserveWorkers}u", + "", + ]; + return string.Join('\n', lines); + } + + /// Python _human_size: N B, or two decimals with KiB to TiB. + /// Size. + /// Text. + public static string HumanSize(double value) + { + double size = value; + foreach (string suffix in (string[])["B", "KiB", "MiB", "GiB", "TiB"]) + { + if (Math.Abs(size) < 1024.0 || suffix == "TiB") + { + return suffix == "B" + ? $"{(long)Math.Round(size, MidpointRounding.ToEven)} B" + : $"{PythonText.FormatFixed(size, 2)} {suffix}"; + } + + size /= 1024.0; + } + + return $"{PythonText.FormatFixed(size, 2)} TiB"; + } + + /// Python _size_literal: the largest exact GiB, MiB or KiB unit, else bytes. + /// Size. + /// TOML size literal. + public static string SizeLiteral(long value) + { + foreach ((long divisor, string suffix) in (ReadOnlySpan<(long, string)>)[(1L << 30, "GiB"), (1L << 20, "MiB"), (1024, "KiB")]) + { + if (value != 0 && value % divisor == 0) + { + return $"{value / divisor}{suffix}"; + } + } + + return $"{value}B"; + } + + // ---- trace loading ------------------------------------------------------------------------------------------- + + private static void AppendTraceWarning(List warnings, string message, int limit = 200) + { + if (warnings.Count < limit) + { + warnings.Add(message); + } + else if (warnings.Count == limit) + { + warnings.Add("additional trace warnings omitted"); + } + } + + // Python _canonical_relative: "/app0/" stripped, PurePosixPath parts (no empty or "." parts), ".." rejected. + private static string CanonicalRelative(string path) + { + string value = path.Replace('\\', '/'); + if (value.Length >= 6 && AsciiLower(value[..6]) == "/app0/") + { + value = value[6..]; + } + else if (AsciiLower(value) == "/app0") + { + value = string.Empty; + } + + string[] parts = [.. value.TrimStart('/').Split('/').Where(p => p.Length > 0 && p != ".")]; + if (parts.Contains("..")) + { + throw new AMPRPackException($"unsafe indexed path: {PythonText.Repr(path)}"); + } + + return parts.Length == 0 ? "." : string.Join('/', parts); + } + + private static string AsciiLower(string value) => string.Create(value.Length, value, static (span, source) => + { + for (int i = 0; i < source.Length; i++) + { + char c = source[i]; + span[i] = c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; + } + }); + + private sealed class GatherState + { + public int FileId { get; init; } + public long NextFileOffset { get; set; } + } + + private static (List Events, AMPRTraceSummary Summary, Dictionary Entries, List Warnings) StreamOneTrace( + AMPRTraceSpec spec, int traceOrdinal, Dictionary index) + { + List events = []; + Dictionary pathEntries = []; + List warnings = []; + Dictionary gatherState = []; + long records = 0; + long readCount = 0; + long decodeErrors = 0; + ulong? previousSequence = null; + ulong minMonotonic = 0; + ulong maxMonotonic = 0; + + using (FileStream handle = new(spec.Commands, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20)) + { + long fileOffset = 0; + byte[] rawHeader = new byte[AMPRCommandLog.HeaderSize]; + while (true) + { + int got = handle.ReadAtLeast(rawHeader, rawHeader.Length, throwOnEndOfStream: false); + if (got == 0) + { + break; + } + + if (got != AMPRCommandLog.HeaderSize) + { + AppendTraceWarning(warnings, $"truncated header at 0x{fileOffset:x}: {got}/{AMPRCommandLog.HeaderSize}"); + break; + } + + if (!rawHeader.AsSpan(0, 8).SequenceEqual(AMPRCommandLog.Magic)) + { + throw new AMPRPackException($"{spec.Name}: invalid command-log magic at 0x{fileOffset:x}"); + } + + AMPRCommandLog.Header header = AMPRCommandLog.ReadHeader(rawHeader); + if (header.Version != AMPRCommandLog.Version) + { + throw new AMPRPackException($"{spec.Name}: unsupported command-log version {header.Version}"); + } + + if (header.HeaderBytes < AMPRCommandLog.HeaderSize) + { + throw new AMPRPackException($"{spec.Name}: invalid header size {header.HeaderBytes} at 0x{fileOffset:x}"); + } + + if (header.RecordBytes < header.HeaderBytes || header.RecordBytes - header.HeaderBytes != header.PayloadBytes) + { + throw new AMPRPackException($"{spec.Name}: inconsistent record sizes at 0x{fileOffset:x}"); + } + + int extraBytes = header.HeaderBytes - AMPRCommandLog.HeaderSize; + if (extraBytes > 0) + { + byte[] extra = new byte[extraBytes]; + if (handle.ReadAtLeast(extra, extraBytes, throwOnEndOfStream: false) != extraBytes) + { + AppendTraceWarning(warnings, "truncated extended header"); + break; + } + } + + // A truncated journal: read what is left (Python read(n) returns short) instead of allocating + // the declared size. + long available = handle.Length - handle.Position; + byte[] payload = new byte[Math.Min(header.PayloadBytes, available)]; + int payloadRead = handle.ReadAtLeast(payload, payload.Length, throwOnEndOfStream: false); + if (payloadRead != header.PayloadBytes) + { + AppendTraceWarning(warnings, $"truncated payload for seq={header.Sequence}: {payloadRead}/{header.PayloadBytes}"); + break; + } + + records++; + ulong sequence = header.Sequence; + if (previousSequence is null) + { + if (sequence != 1) + { + AppendTraceWarning(warnings, $"sequence starts at {sequence}, expected 1"); + } + } + else if (sequence != previousSequence.Value + 1) + { + AppendTraceWarning(warnings, $"sequence gap/non-monotonic: previous={previousSequence.Value}, current={sequence}"); + } + + previousSequence = sequence; + if (AMPRCommandLog.Fnv1a64(payload) != header.PayloadHash) + { + AppendTraceWarning(warnings, $"payload hash mismatch for seq={sequence}"); + } + + if (header.MonotonicNs != 0) + { + minMonotonic = minMonotonic == 0 ? header.MonotonicNs : Math.Min(minMonotonic, header.MonotonicNs); + maxMonotonic = Math.Max(maxMonotonic, header.MonotonicNs); + } + + if (header.Domain != AMPRCommandLog.DomainApr) + { + fileOffset += header.RecordBytes; + continue; + } + + int offset = 0; + int ordinal = 0; + while (offset < payload.Length) + { + AMPRCommand command; + try + { + command = AMPRCommandLog.Decode(payload, offset); + } + catch (InvalidDataException exc) + { + decodeErrors++; + AppendTraceWarning(warnings, $"seq={sequence} decode error at 0x{offset:x}: {exc.Message}"); + break; + } + + long commandBytes = command.Dwords * 4L; + if (commandBytes <= 0 || offset + commandBytes > payload.Length) + { + decodeErrors++; + AppendTraceWarning(warnings, $"seq={sequence} decoder returned invalid size {commandBytes}"); + break; + } + + void Emit(int fileId, long fileReadOffset, long length) + { + if (!index.TryGetValue(fileId, out AMPRTraceIndexEntry? entry)) + { + AppendTraceWarning(warnings, $"fileId {fileId} absent from AMPRIDX3 at seq={sequence}"); + return; + } + + string relative; + try + { + relative = CanonicalRelative(entry.Path); + } + catch (AMPRPackException exc) + { + AppendTraceWarning(warnings, exc.Message); + return; + } + + if (length <= 0 || fileReadOffset < 0) + { + return; + } + + if (fileReadOffset >= entry.Size) + { + AppendTraceWarning(warnings, $"read beyond {relative}: offset=0x{fileReadOffset:x} size=0x{entry.Size:x}"); + return; + } + + if (length > entry.Size - fileReadOffset) + { + AppendTraceWarning(warnings, $"clamped read for {relative}: offset=0x{fileReadOffset:x} length=0x{length:x} size=0x{entry.Size:x}"); + length = entry.Size - fileReadOffset; + } + + pathEntries.TryAdd(relative, entry); + events.Add(new ReadEvent(traceOrdinal, (long)sequence, ordinal, (long)header.MonotonicNs, header.Priority, entry.Path, relative, + fileId, entry.Size, fileReadOffset, length)); + readCount++; + } + + switch (command.Name) + { + case "AprReadFile": + gatherState[header.Priority] = new GatherState { FileId = (int)command.FileId, NextFileOffset = command.FileOffset + command.Length }; + Emit((int)command.FileId, command.FileOffset, command.Length); + break; + case "AprReadGather" or "AprReadGatherScatter": + if (gatherState.TryGetValue(header.Priority, out GatherState? state)) + { + Emit(state.FileId, command.FileOffset, command.Length); + state.NextFileOffset = command.FileOffset + command.Length; + } + else + { + AppendTraceWarning(warnings, $"{command.Name} without active state at seq={sequence}"); + } + + break; + case "AprReadScatter": + if (gatherState.TryGetValue(header.Priority, out GatherState? scatter)) + { + long start = scatter.NextFileOffset; + Emit(scatter.FileId, start, command.Length); + scatter.NextFileOffset = start + command.Length; + } + else + { + AppendTraceWarning(warnings, $"AprReadScatter without active state at seq={sequence}"); + } + + break; + case "AprResetGatherScatterState": + gatherState.Remove(header.Priority); + break; + default: + break; + } + + offset += (int)commandBytes; + ordinal++; + } + + if (header.CommandCount != 0 && ordinal != header.CommandCount) + { + decodeErrors++; + AppendTraceWarning(warnings, $"seq={sequence} header command_count={header.CommandCount}, decoded={ordinal}"); + } + + fileOffset += header.RecordBytes; + } + } + + AMPRTraceSummary summary = new() + { + Name = spec.Name, + Commands = spec.Commands, + Index = spec.Index, + CommandSha256 = Sha256(spec.Commands), + IndexSha256 = Sha256(spec.Index), + Records = records, + Reads = readCount, + Warnings = [.. warnings], + DecodeErrors = decodeErrors, + DurationNs = minMonotonic != 0 && maxMonotonic >= minMonotonic ? (long)(maxMonotonic - minMonotonic) : 0, + }; + return (events, summary, pathEntries, warnings); + } + + private static (List, List, Dictionary, List) LoadTraceEvents(IReadOnlyList traces) + { + List allEvents = []; + List summaries = []; + Dictionary pathEntries = []; + List warnings = []; + for (int ordinal = 0; ordinal < traces.Count; ordinal++) + { + AMPRTraceSpec spec = traces[ordinal]; + Dictionary index = AMPRCommandLog.LoadIndex(spec.Index); + (List events, AMPRTraceSummary summary, Dictionary traceEntries, List traceWarnings) = + StreamOneTrace(spec, ordinal, index); + allEvents.AddRange(events); + summaries.Add(summary); + foreach ((string relative, AMPRTraceIndexEntry entry) in traceEntries) + { + if (pathEntries.TryGetValue(relative, out AMPRTraceIndexEntry? existing) && existing.Size != entry.Size) + { + warnings.Add($"{spec.Name}: indexed size changed for {relative}: {existing.Size} -> {entry.Size}; using the larger entry"); + if (entry.Size > existing.Size) + { + pathEntries[relative] = entry; + } + } + else + { + pathEntries.TryAdd(relative, entry); + } + } + + warnings.AddRange(traceWarnings.Select(w => $"{spec.Name}: {w}")); + } + + return ([.. allEvents.OrderBy(e => (e.TraceOrdinal, e.Sequence, e.CommandOrdinal))], summaries, pathEntries, warnings); + } + + // ---- per-file metrics ---------------------------------------------------------------------------------------- + + private static double Percentile(List values, double pct) + { + if (values.Count == 0) + { + return 0.0; + } + + if (values.Count == 1) + { + return values[0]; + } + + List ordered = [.. values.Order()]; + double position = (ordered.Count - 1) * pct / 100.0; + int lower = (int)Math.Floor(position); + int upper = (int)Math.Ceiling(position); + if (lower == upper) + { + return ordered[lower]; + } + + double fraction = position - lower; + return (ordered[lower] * (1.0 - fraction)) + (ordered[upper] * fraction); + } + + private static long UnionLength(IEnumerable<(long Start, long End)> ranges) + { + long total = 0; + long? currentStart = null; + long currentEnd = 0; + foreach ((long start, long end) in ranges.Order()) + { + if (end <= start) + { + continue; + } + + if (currentStart is null) + { + (currentStart, currentEnd) = (start, end); + continue; + } + + if (start <= currentEnd) + { + currentEnd = Math.Max(currentEnd, end); + } + else + { + total += currentEnd - currentStart.Value; + (currentStart, currentEnd) = (start, end); + } + } + + return currentStart is null ? total : total + (currentEnd - currentStart.Value); + } + + private static long CeilDiv(long value, long divisor) => (long)Math.Ceiling((double)value / divisor); + + private static CandidateMetrics CandidateFor(List events, long fileSize, long blockSize, string layout, double randomness) + { + long requestedBytes = events.Sum(e => e.Length); + long touchedBytes = 0; + long totalTouches = 0; + List<(long, long)> blockRanges = new(events.Count); + long offsetAligned = 0; + long lengthAligned = 0; + foreach (ReadEvent e in events) + { + long first = e.Offset / blockSize; + long lastExclusive = (e.End + blockSize - 1) / blockSize; + long start = first * blockSize; + long end = Math.Min(lastExclusive * blockSize, fileSize); + touchedBytes += Math.Max(0, end - start); + totalTouches += Math.Max(0, lastExclusive - first); + blockRanges.Add((first, lastExclusive)); + offsetAligned += e.Offset % blockSize == 0 ? 1 : 0; + lengthAligned += e.Length % blockSize == 0 ? 1 : 0; + } + + long uniqueBlocks = UnionLength(blockRanges); + double amplification = requestedBytes != 0 ? (double)touchedBytes / requestedBytes : 1.0; + double averageBlocks = events.Count != 0 ? (double)totalTouches / events.Count : 0.0; + double repeatRatio = totalTouches != 0 ? Math.Max(0.0, 1.0 - ((double)uniqueBlocks / totalTouches)) : 0.0; + long metadataBytes = CeilDiv(fileSize, blockSize) * ChunkRecordBytes; + + double logAmp = Math.Log2(Math.Max(1.0, amplification)); + double logOps = Math.Log2(Math.Max(1.0, averageBlocks)); + double sizeLog = Math.Log2((double)blockSize / K64); + double score; + if (layout == "random") + { + score = (3.8 * logAmp) + (0.12 * logOps); + if (blockSize > K64) + { + score += 0.55 * Math.Log2((double)blockSize / K64); + } + + if (blockSize < 16 * 1024) + { + score += 10.0; + } + } + else if (layout == "streaming") + { + score = (0.65 * logAmp) + (0.95 * logOps); + if (blockSize < 128 * 1024) + { + score += 0.35 * Math.Log2(128.0 * 1024 / blockSize); + } + + if (blockSize > 512 * 1024) + { + score += 0.15 * Math.Log2((double)blockSize / (512 * 1024)); + } + } + else + { + score = (2.15 * logAmp) + (0.38 * logOps); + score += Math.Abs(sizeLog) * 0.06; + if (blockSize > 128 * 1024) + { + score += 0.28 * Math.Log2((double)blockSize / (128 * 1024)); + } + + if (blockSize > K64) + { + score += randomness * 0.42 * Math.Log2((double)blockSize / K64); + } + + if (blockSize < 32 * 1024) + { + score += 0.20 * Math.Log2(32.0 * 1024 / blockSize); + } + } + + score += (double)metadataBytes / Math.Max(fileSize, 1) * 3.0; + return new CandidateMetrics(blockSize, touchedBytes, totalTouches, uniqueBlocks, amplification, averageBlocks, repeatRatio, + events.Count != 0 ? (double)offsetAligned / events.Count : 0.0, events.Count != 0 ? (double)lengthAligned / events.Count : 0.0, + metadataBytes, score); + } + + private static string ClassifyLayout(FileMetricsInput m) + { + double sequential = m.ExactSequentialRatio + m.NearSequentialRatio; + bool microIndex = m.P50 <= 256 && m.ReadCount >= 64 && (m.Repeat64k >= 0.10 || sequential >= 0.70); + if (microIndex) + { + return "random"; + } + + if (m.P50 >= K64 && sequential >= 0.76 && m.RandomSeekRatio <= 0.20 && m.Repeat64k <= 0.18) + { + return "streaming"; + } + + if (m.P90 >= 256 * 1024 && m.Large256kRatio >= 0.65 && sequential >= 0.86 && m.Repeat64k <= 0.12) + { + return "streaming"; + } + + return m.Tiny4kRatio >= 0.50 || m.Small16kRatio >= 0.70 || (m.Small64kRatio >= 0.82 && m.RandomSeekRatio >= 0.30) + || (m.Repeat64k >= 0.28 && m.P50 <= K64) + ? "random" + : "mixed"; + } + + private readonly record struct FileMetricsInput( + double P50, double P90, double Tiny4kRatio, double Small16kRatio, double Small64kRatio, double Large256kRatio, + double ExactSequentialRatio, double NearSequentialRatio, double RandomSeekRatio, double Repeat64k, int ReadCount); + + private static FileMetricsInput LayoutInput(FileMetrics m) => new( + m.P50, m.P90, m.Tiny4kRatio, m.Small16kRatio, m.Small64kRatio, m.Large256kRatio, m.ExactSequentialRatio, m.NearSequentialRatio, + m.RandomSeekRatio, m.Candidates[K64].RepeatRatio, m.ReadCount); + + private static List BuildFileMetrics(List events) + { + Dictionary> grouped = []; + foreach (ReadEvent e in events) + { + if (!grouped.TryGetValue(e.Relative, out List? list)) + { + grouped[e.Relative] = list = []; + } + + list.Add(e); + } + + List results = []; + foreach (string relative in grouped.Keys.Order(PythonCodePointComparer.Instance)) + { + List fileEvents = [.. grouped[relative].OrderBy(e => (e.TraceOrdinal, e.Sequence, e.CommandOrdinal))]; + List lengths = [.. fileEvents.Select(e => e.Length)]; + long requested = lengths.Sum(); + long fileSize = fileEvents.Max(e => e.FileSize); + + long transitionBytes = 0; + long exactBytes = 0; + long nearBytes = 0; + long randomBytes = 0; + Dictionary previousByTrace = []; + foreach (ReadEvent e in fileEvents) + { + previousByTrace.TryGetValue(e.TraceOrdinal, out ReadEvent? previous); + previousByTrace[e.TraceOrdinal] = e; + if (previous is null) + { + continue; + } + + long weight = e.Length; + transitionBytes += weight; + if (e.Offset == previous.End) + { + exactBytes += weight; + continue; + } + + long gap = e.Offset - previous.End; + long nearLimit = Math.Max(DefaultIOPage, Math.Min(previous.Length, 512 * 1024)); + if (gap >= 0 && gap <= nearLimit) + { + nearBytes += weight; + } + else if (e.Offset < previous.Offset) + { + randomBytes += weight; + } + else if (e.Offset < previous.End) + { + // overlap: counted by Python's overlap ratio, which nothing here uses + } + else + { + randomBytes += weight; + } + } + + double Share(Func predicate) => (double)lengths.Where(predicate).Sum() / requested; + double Ratio(long part) => transitionBytes != 0 ? (double)part / transitionBytes : 0.0; + double tiny4k = Share(l => l <= 4 * 1024); + double small16k = Share(l => l <= 16 * 1024); + double small64k = Share(l => l <= K64); + double large256k = Share(l => l >= 256 * 1024); + double exactRatio = Ratio(exactBytes); + double nearRatio = Ratio(nearBytes); + double randomRatio = Ratio(randomBytes); + + CandidateMetrics provisional64 = CandidateFor(fileEvents, fileSize, K64, "mixed", randomRatio); + double p50 = Percentile(lengths, 50); + double p90 = Percentile(lengths, 90); + string layout = ClassifyLayout(new FileMetricsInput( + p50, p90, tiny4k, small16k, small64k, large256k, exactRatio, nearRatio, randomRatio, provisional64.RepeatRatio, fileEvents.Count)); + Dictionary candidates = BlockSizes.ToDictionary(size => size, size => CandidateFor(fileEvents, fileSize, size, layout, randomRatio)); + + double evidenceReads = Math.Min(1.0, Math.Log10(fileEvents.Count + 1) / 3.0); + double evidenceBytes = Math.Min(1.0, requested / (64.0 * 1024 * 1024)); + int traceCount = fileEvents.Select(e => e.TraceOrdinal).Distinct().Count(); + double multiTrace = Math.Min(1.0, Math.Max(0, traceCount - 1) / 2.0); + double confidence = (0.45 * evidenceReads) + (0.35 * evidenceBytes) + (0.20 * multiTrace); + string confidenceLabel = confidence >= 0.72 ? "high" : confidence >= 0.42 ? "medium" : "low"; + + results.Add(new FileMetrics + { + Path = fileEvents[0].Path, + Relative = relative, + FileSize = fileSize, + TraceCount = traceCount, + Events = fileEvents, + ReadCount = fileEvents.Count, + RequestedBytes = requested, + P50 = p50, + P90 = p90, + Tiny4kRatio = tiny4k, + Small16kRatio = small16k, + Small64kRatio = small64k, + Large256kRatio = large256k, + ExactSequentialRatio = exactRatio, + NearSequentialRatio = nearRatio, + RandomSeekRatio = randomRatio, + Candidates = candidates, + Confidence = confidence, + ConfidenceLabel = confidenceLabel, + }); + } + + return results; + } + + // ---- recommendations ----------------------------------------------------------------------------------------- + + // Python PurePosixPath.suffix: from the last dot, unless the name starts with it or ends with it. + private static string Suffix(string name) + { + int i = name.LastIndexOf('.'); + return i > 0 && i < name.Length - 1 ? name[i..] : string.Empty; + } + + private static string KnownAction(string relative) + { + string name = relative[(relative.LastIndexOf('/') + 1)..]; + string lowerName = name.ToLowerInvariant(); + string suffix = Suffix(name).ToLowerInvariant(); + if (KnownLooseBasenames.Contains(lowerName) || KnownLooseSuffixes.Contains(suffix)) + { + return "loose"; + } + + return KnownStreamStoreSuffixes.Contains(suffix) ? "store" : "compress"; + } + + private static List InitialRecommendations(List files, AMPRProfileOptions options) + { + if (files.Count == 0) + { + return []; + } + + long readHotThreshold = Math.Max(32, (long)Percentile([.. files.Select(f => (long)f.ReadCount)], 90)); + long byteHotThreshold = Math.Max(16L * 1024 * 1024, (long)Percentile([.. files.Select(f => f.RequestedBytes)], 90)); + List recommendations = []; + foreach (FileMetrics metrics in files) + { + if (metrics.ReadCount < options.MinReads || metrics.RequestedBytes < options.MinRequestedBytes) + { + continue; + } + + string layout = ClassifyLayout(LayoutInput(metrics)); + string action = KnownAction(metrics.Relative); + if (action == "store") + { + layout = "streaming"; + } + + double repeat64k = metrics.Candidates[K64].RepeatRatio; + bool hot = layout != "streaming" + && ((metrics.ReadCount >= 16 && repeat64k >= 0.22) || metrics.ReadCount >= readHotThreshold + || (metrics.ReadCount >= 8 && metrics.RequestedBytes >= byteHotThreshold)); + + IEnumerable allowed = layout switch + { + "random" => BlockSizes.Where(s => s <= 128 * 1024), + "mixed" => BlockSizes.Where(s => s is >= 32 * 1024 and <= 256 * 1024), + _ => BlockSizes.Where(s => s >= K64), + }; + long selected = 0; + (double Score, double Distance, long Size)? best = null; + foreach (long size in allowed) + { + (double, double, long) key = (metrics.Candidates[size].LocalScore, Math.Abs(Math.Log2((double)size / K64)), size); + if (best is null || key.CompareTo(best.Value) < 0) + { + best = key; + selected = size; + } + } + + if (metrics.P50 <= 256 && metrics.ReadCount >= 64) + { + selected = 16 * 1024; + layout = "random"; + hot = true; + } + + string group = action == "loose" ? "loose" : action == "store" || layout == "streaming" ? "stream" : layout == "random" ? "metadata" : "bulk"; + CandidateMetrics chosen = metrics.Candidates[selected]; + string reason = $"{layout}; p50={HumanSize(metrics.P50)}, sequential={Percent(metrics.ExactSequentialRatio + metrics.NearSequentialRatio, 0)}, " + + $"repeat64={Percent(repeat64k, 0)}, amp={PythonText.FormatFixed(chosen.Amplification, 2)}x"; + recommendations.Add(new FileRecommendation + { + Metrics = metrics, + Action = action, + Layout = layout, + BlockSize = selected, + Hot = hot, + Group = group, + Reason = reason, + }); + } + + return recommendations; + } + + private static void EnforceIndexBudget(List recommendations, long budget, string strategy) + { + List packed = [.. recommendations.Where(r => r.Action != "loose")]; + if (packed.Count == 0) + { + return; + } + + double strategyWeight = strategy switch + { + "conservative" => 1.35, + "balanced" => 1.0, + "aggressive" => 0.70, + _ => throw new AMPRPackException($"unknown strategy: {strategy}"), + }; + PriorityQueue<(int Index, long NextSize), (double Ratio, int Generation, int Index, long NextSize)> heap = new(Comparer<(double, int, int, long)>.Default); + int[] generations = new int[packed.Count]; + + void Push(int index) + { + FileRecommendation recommendation = packed[index]; + int currentPos = Array.IndexOf(BlockSizes, recommendation.BlockSize); + if (currentPos < 0 || currentPos + 1 >= BlockSizes.Length) + { + return; + } + + long nextSize = BlockSizes[currentPos + 1]; + CandidateMetrics current = recommendation.Metrics.Candidates[recommendation.BlockSize]; + CandidateMetrics next = recommendation.Metrics.Candidates[nextSize]; + long saved = current.MetadataBytes - next.MetadataBytes; + if (saved <= 0) + { + return; + } + + double scoreCost = Math.Max(0.0, next.LocalScore - current.LocalScore); + double importance = 1.0; + if (recommendation.Hot) + { + importance *= 2.5; + } + + if (recommendation.Layout == "random") + { + importance *= 1.8; + } + else if (recommendation.Layout == "streaming") + { + importance *= 0.55; + } + + importance *= 0.75 + (0.5 * recommendation.Metrics.Confidence); + double ratio = scoreCost * importance * strategyWeight / saved; + heap.Enqueue((index, nextSize), (ratio, generations[index], index, nextSize)); + } + + for (int index = 0; index < packed.Count; index++) + { + Push(index); + } + + long total = packed.Sum(r => r.MetadataBytes); + while (total > budget && heap.TryDequeue(out (int Index, long NextSize) item, out (double, int Generation, int, long) priority)) + { + if (priority.Generation != generations[item.Index]) + { + continue; + } + + FileRecommendation recommendation = packed[item.Index]; + if (item.NextSize <= recommendation.BlockSize) + { + continue; + } + + total -= recommendation.MetadataBytes; + recommendation.BlockSize = item.NextSize; + total += recommendation.MetadataBytes; + generations[item.Index]++; + Push(item.Index); + } + } + + private static List SamplePositions(long fileSize, long blockSize, int count) + { + if (fileSize <= 0 || count <= 0) + { + return []; + } + + if (fileSize <= blockSize || count == 1) + { + return [0]; + } + + long maxStart = Math.Max(0, fileSize - blockSize); + SortedSet positions = []; + for (int index = 0; index < count; index++) + { + positions.Add((long)Math.Round((double)maxStart * index / (count - 1), MidpointRounding.ToEven)); + } + + return [.. positions.Select(p => p - (p % blockSize)).Order()]; + } + + private static void SampleCompressibility(List recommendations, AMPRProfileOptions options, List warnings) + { + if (options.ContentRoot is null || options.SampleBudget <= 0) + { + return; + } + + using LZ4Encoder codec = new(); + string root = Path.GetFullPath(options.ContentRoot); + long remaining = options.SampleBudget; + foreach (FileRecommendation recommendation in recommendations.Where(r => r.Action == "compress") + .OrderByDescending(r => (r.Metrics.RequestedBytes, r.Metrics.ReadCount))) + { + if (remaining <= 0) + { + break; + } + + string source = Path.GetFullPath(Path.Combine(root, recommendation.Metrics.Relative)); + string relativeToRoot = Path.GetRelativePath(root, source); + if (relativeToRoot == ".." || relativeToRoot.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot)) + { + warnings.Add($"content sample escaped root: {source}"); + continue; + } + + if (!File.Exists(source)) + { + continue; + } + + long rawTotal = 0; + long storedTotal = 0; + try + { + using FileStream handle = File.OpenRead(source); + foreach (long position in SamplePositions(recommendation.Metrics.FileSize, recommendation.BlockSize, options.SampleBlocksPerFile)) + { + if (remaining <= 0) + { + break; + } + + int length = (int)Math.Min(recommendation.BlockSize, remaining); + handle.Seek(position, SeekOrigin.Begin); + byte[] buffer = new byte[length]; + int got = handle.ReadAtLeast(buffer, length, throwOnEndOfStream: false); + if (got == 0) + { + continue; + } + + ReadOnlySpan raw = buffer.AsSpan(0, got); + byte[] compressed = options.SampleMode == "hc" + ? codec.CompressHC(raw, Math.Clamp(options.SampleLevel, 1, 12)) + : codec.CompressFast(raw, Math.Max(1, options.SampleAcceleration)); + rawTotal += got; + storedTotal += Math.Min(got, compressed.Length); + remaining -= got; + } + } + catch (IOException exc) + { + warnings.Add($"cannot sample {recommendation.Metrics.Relative}: {exc.Message}"); + continue; + } + + if (rawTotal == 0) + { + continue; + } + + double ratio = (double)storedTotal / rawTotal; + if (ratio >= 0.94) + { + if (recommendation.Metrics.FileSize >= 64L * 1024 * 1024) + { + recommendation.Action = "loose"; + recommendation.Hot = false; + recommendation.Reason += $"; sampled LZ4 ratio={Percent(ratio, 1)}, auto-loose"; + } + else + { + recommendation.Action = "store"; + recommendation.Layout = recommendation.Layout == "streaming" ? "streaming" : "mixed"; + recommendation.Group = recommendation.Layout == "streaming" ? "stream" : "bulk"; + recommendation.Hot = recommendation.Layout != "streaming" && recommendation.Hot; + recommendation.Reason += $"; sampled LZ4 ratio={Percent(ratio, 1)}, store"; + } + } + else + { + recommendation.Reason += $"; sampled LZ4 ratio={Percent(ratio, 1)}"; + } + } + } + + private static long EstimateIndexBytes(IReadOnlyList recommendations, int packCount, Dictionary indexEntries) + { + long chunkBytes = recommendations.Where(r => r.Action != "loose").Sum(r => r.MetadataBytes); + long fileBytes = (long)indexEntries.Count * FileRecordBytes; + long stringBytes = indexEntries.Keys.Sum(p => (long)Encoding.UTF8.GetByteCount(p) + 1); + return chunkBytes + fileBytes + ((long)packCount * PackRecordBytes) + stringBytes + 4096; + } + + private static int ChooseLanes(List recommendations, int requested) + { + if (requested > 0) + { + return requested; + } + + List packed = [.. recommendations.Where(r => r.Action != "loose")]; + long totalSize = packed.Sum(r => r.Metrics.FileSize); + int fileCount = packed.Count; + return fileCount <= 1 ? 1 + : totalSize < 2L * 1024 * 1024 * 1024 && fileCount < 64 ? 2 + : totalSize < 16L * 1024 * 1024 * 1024 && fileCount < 1024 ? 3 + : 4; + } + + private static List<(string Name, int PackCount, long MaxPackSize)> BuildGroups(List recommendations, int lanes) + { + List<(string, int, long)> result = []; + foreach (string groupName in (string[])["metadata", "bulk", "stream"]) + { + List items = [.. recommendations.Where(r => r.Action != "loose" && r.Group == groupName)]; + if (items.Count == 0) + { + continue; + } + + long totalSize = items.Sum(i => i.Metrics.FileSize); + (int packCount, long maxPackSize) = groupName switch + { + "bulk" => (Math.Min(lanes, Math.Max(1, items.Count)), 16L << 30), + "metadata" => (Math.Min(Math.Max(1, Math.Min(lanes, totalSize < 8L << 30 ? 2 : 4)), items.Count), 8L << 30), + _ => (Math.Min(Math.Max(1, Math.Min(lanes, 2)), items.Count), 16L << 30), + }; + result.Add((groupName, packCount, maxPackSize)); + } + + return result; + } + + // ---- decoded-cache model ------------------------------------------------------------------------------------- + + private static IEnumerable<(long BlockIndex, long RawSize, long Overlap)> BlockOverlaps(ReadEvent e, long blockSize) + { + long first = e.Offset / blockSize; + long last = (e.End - 1) / blockSize; + for (long blockIndex = first; blockIndex <= last; blockIndex++) + { + long blockStart = blockIndex * blockSize; + long blockEnd = Math.Min(blockStart + blockSize, e.FileSize); + long overlap = Math.Max(0, Math.Min(e.End, blockEnd) - Math.Max(e.Offset, blockStart)); + if (overlap != 0) + { + yield return (blockIndex, blockEnd - blockStart, overlap); + } + } + } + + private static long BlockTouchCount(ReadEvent e, long blockSize) => + e.Length <= 0 || blockSize <= 0 ? 0 : ((e.End - 1) / blockSize) - (e.Offset / blockSize) + 1; + + private sealed class AccessStream + { + public List Keys { get; } = []; + public List RawSizes { get; } = []; + public List Overlaps { get; } = []; + public List Hot { get; } = []; + public List ResetBefore { get; } = []; + public long TotalTouches { get; set; } + public long SampledWindows { get; set; } + } + + private static AccessStream BuildAccessStream(List events, Dictionary byPath, long maxTouches) + { + List<(ReadEvent Event, FileRecommendation Recommendation, long Touches)> eligible = []; + List cumulativeEnds = []; + long totalTouches = 0; + Dictionary fileOrdinals = []; + foreach (ReadEvent e in events) + { + if (!byPath.TryGetValue(e.Relative, out FileRecommendation? recommendation) || recommendation.Action == "loose" || recommendation.Layout == "streaming") + { + continue; + } + + long touches = BlockTouchCount(e, recommendation.BlockSize); + if (touches <= 0) + { + continue; + } + + fileOrdinals.TryAdd(e.Relative, (ulong)fileOrdinals.Count + 1); + totalTouches += touches; + eligible.Add((e, recommendation, touches)); + cumulativeEnds.Add(totalTouches); + } + + AccessStream stream = new(); + if (eligible.Count == 0) + { + return stream; + } + + long AppendSlice(ReadEvent e, FileRecommendation recommendation, long firstTouch, long touchCount, bool reset) + { + long appended = 0; + ulong fileOrdinal = fileOrdinals[e.Relative]; + long stopTouch = firstTouch + touchCount; + long localIndex = 0; + foreach ((long blockIndex, long rawSize, long overlap) in BlockOverlaps(e, recommendation.BlockSize)) + { + if (localIndex >= stopTouch) + { + break; + } + + if (localIndex++ < firstTouch) + { + continue; + } + + if (blockIndex >= 1L << 48) + { + throw new AMPRPackException($"cache simulation block index is too large: {e.Relative} block={blockIndex}"); + } + + stream.Keys.Add((fileOrdinal << 48) | (ulong)blockIndex); + stream.RawSizes.Add(rawSize); + stream.Overlaps.Add(overlap); + stream.Hot.Add(recommendation.Hot); + stream.ResetBefore.Add(reset && appended == 0); + appended++; + } + + return appended; + } + + if (maxTouches <= 0 || totalTouches <= maxTouches) + { + bool first = true; + foreach ((ReadEvent e, FileRecommendation recommendation, long touches) in eligible) + { + AppendSlice(e, recommendation, 0, touches, first); + first = false; + } + + stream.SampledWindows = 1; + } + else + { + long windowCount = Math.Min(64, Math.Max(1, maxTouches / 4096)); + long windowBudget = Math.Max(1, maxTouches / windowCount); + long sampledWindows = 0; + for (long windowIndex = 0; windowIndex < windowCount; windowIndex++) + { + long segmentStart = (long)((Int128)totalTouches * windowIndex / windowCount); + long segmentEnd = (long)((Int128)totalTouches * (windowIndex + 1) / windowCount); + long segmentSize = Math.Max(0, segmentEnd - segmentStart); + if (segmentSize == 0) + { + continue; + } + + long budget = Math.Min(windowBudget, segmentSize); + long startTouch = segmentStart + ((segmentSize - budget) / 2); + long endTouch = startTouch + budget; + int eventIndex = BisectRight(cumulativeEnds, startTouch); + bool windowFirst = true; + while (eventIndex < eligible.Count) + { + long eventStart = eventIndex != 0 ? cumulativeEnds[eventIndex - 1] : 0; + if (eventStart >= endTouch) + { + break; + } + + (ReadEvent e, FileRecommendation recommendation, long touches) = eligible[eventIndex]; + long localStart = Math.Max(0, startTouch - eventStart); + long localEnd = Math.Min(touches, endTouch - eventStart); + if (localEnd > localStart && AppendSlice(e, recommendation, localStart, localEnd - localStart, windowFirst) != 0) + { + windowFirst = false; + } + + eventIndex++; + } + + if (!windowFirst) + { + sampledWindows++; + } + } + + stream.SampledWindows = sampledWindows; + } + + stream.TotalTouches = totalTouches; + return stream; + } + + private static int BisectRight(List values, long x) + { + int lo = 0; + int hi = values.Count; + while (lo < hi) + { + int mid = (lo + hi) / 2; + if (x < values[mid]) + { + hi = mid; + } + else + { + lo = mid + 1; + } + } + + return lo; + } + + private static List SimulateCache(List events, Dictionary byPath, IReadOnlyList capacities, long maxTouches) + { + AccessStream stream = BuildAccessStream(events, byPath, maxTouches); + List simulations = []; + int sampledTouches = stream.Keys.Count; + foreach (long capacity in capacities) + { + LinkedList<(ulong Key, long Size)> order = []; + Dictionary> cache = []; + HashSet seenOnce = []; + long resident = 0; + long requested = 0; + long hitBytes = 0; + long hits = 0; + long misses = 0; + for (int index = 0; index < sampledTouches; index++) + { + if (stream.ResetBefore[index]) + { + order.Clear(); + cache.Clear(); + seenOnce.Clear(); + resident = 0; + } + + ulong key = stream.Keys[index]; + long rawSize = stream.RawSizes[index]; + long overlap = stream.Overlaps[index]; + requested += overlap; + if (cache.TryGetValue(key, out LinkedListNode<(ulong Key, long Size)>? node)) + { + order.Remove(node); + order.AddLast(node); + hitBytes += overlap; + hits++; + continue; + } + + misses++; + bool admit = stream.Hot[index] || seenOnce.Contains(key); + if (!admit) + { + seenOnce.Add(key); + continue; + } + + seenOnce.Remove(key); + if (rawSize > capacity) + { + continue; + } + + while (resident + rawSize > capacity && order.First is { } victim) + { + order.RemoveFirst(); + cache.Remove(victim.Value.Key); + resident -= victim.Value.Size; + } + + if (resident + rawSize <= capacity) + { + cache[key] = order.AddLast((key, rawSize)); + resident += rawSize; + } + } + + simulations.Add(new CacheSimulation(capacity, requested, hitBytes, misses, hits, sampledTouches, stream.TotalTouches, stream.SampledWindows)); + } + + return simulations; + } + + private static long ChooseCacheSize(List simulations) + { + if (simulations.Count == 0) + { + return 64L * 1024 * 1024; + } + + double bestRatio = simulations.Max(s => s.HitRatio); + if (bestRatio < 0.08) + { + return simulations.Where(s => s.Capacity >= 64L * 1024 * 1024).Select(s => (long?)s.Capacity).Min() ?? simulations[0].Capacity; + } + + long Nearest(long target) => simulations.MinBy(s => (Math.Abs(s.Capacity - target), s.Capacity))!.Capacity; + if (bestRatio < 0.15) + { + return Nearest(128L * 1024 * 1024); + } + + if (bestRatio < 0.25) + { + return Nearest(256L * 1024 * 1024); + } + + CacheSimulation? previous = null; + foreach (CacheSimulation simulation in simulations) + { + if (simulation.Capacity < 64L * 1024 * 1024) + { + previous = simulation; + continue; + } + + bool withinBest = bestRatio - simulation.HitRatio <= 0.03; + double marginal = previous is not null ? simulation.HitRatio - previous.HitRatio : simulation.HitRatio; + if (withinBest || (simulation.HitRatio >= 0.30 && marginal < 0.05)) + { + return simulation.Capacity; + } + + previous = simulation; + } + + return simulations[^1].Capacity; + } + + // Python >= 3.12 sum() of floats: Neumaier compensated summation. + private static double PythonFloatSum(IEnumerable values) + { + double result = 0.0; + double compensation = 0.0; + bool first = true; + foreach (double x in values) + { + if (first) + { + result = 0 + x; + first = false; + continue; + } + + double t = result + x; + compensation += Math.Abs(result) >= Math.Abs(x) ? result - t + x : x - t + result; + result = t; + } + + return compensation != 0 && double.IsFinite(compensation) ? result + compensation : result; + } + + private static long ChoosePhysicalPageCacheSize(List recommendations) + { + List eligible = [.. recommendations.Where(r => r.Action != "loose" && r.Layout != "streaming")]; + if (eligible.Count == 0) + { + return 8L * 1024 * 1024; + } + + long requested = eligible.Sum(i => i.Metrics.RequestedBytes); + if (requested <= 0) + { + return 8L * 1024 * 1024; + } + + double weightedRepeat = PythonFloatSum(eligible.Select(i => i.Metrics.RequestedBytes * i.Metrics.Candidates[K64].RepeatRatio)) / requested; + double weightedSmall = PythonFloatSum(eligible.Select(i => i.Metrics.RequestedBytes * i.Metrics.Small64kRatio)) / requested; + double weightedRandom = PythonFloatSum(eligible.Select(i => i.Metrics.RequestedBytes * i.Metrics.RandomSeekRatio)) / requested; + double pressure = weightedRepeat + (0.5 * weightedSmall * weightedRandom); + if (requested >= 16L * 1024 * 1024 * 1024 && pressure >= 0.20) + { + return 64L * 1024 * 1024; + } + + return requested >= 2L * 1024 * 1024 * 1024 || pressure >= 0.10 ? 32L * 1024 * 1024 : 16L * 1024 * 1024; + } + + private static int ChooseRuntimeWorkers(List events, List recommendations, int requested) + { + if (requested > 0) + { + return requested; + } + + HashSet selectedPaths = [.. recommendations.Where(r => r.Action != "loose").Select(r => r.Metrics.Relative)]; + List selectedEvents = [.. events.Where(e => selectedPaths.Contains(e.Relative))]; + if (selectedEvents.Count == 0) + { + return 2; + } + + Dictionary bytesByPath = []; + long switches = 0; + string? previous = null; + foreach (ReadEvent e in selectedEvents) + { + bytesByPath[e.Relative] = bytesByPath.GetValueOrDefault(e.Relative) + e.Length; + if (previous is not null && previous != e.Relative) + { + switches++; + } + + previous = e.Relative; + } + + long totalBytes = bytesByPath.Values.Sum(); + double dominantShare = totalBytes != 0 ? (double)bytesByPath.Values.Max() / totalBytes : 1.0; + double switchRatio = (double)switches / Math.Max(1, selectedEvents.Count - 1); + int fileCount = bytesByPath.Count; + return fileCount >= 1000 && switchRatio >= 0.70 ? 8 + : fileCount >= 128 && switchRatio >= 0.50 ? 6 + : dominantShare >= 0.85 && fileCount <= 8 ? 2 + : 4; + } + + private static int ChooseLatencyReserveWorkers(List events, List recommendations, int runtimeWorkers) + { + if (runtimeWorkers <= 1) + { + return 0; + } + + HashSet selected = [.. recommendations.Where(r => r.Action != "loose").Select(r => r.Metrics.Relative)]; + List selectedEvents = [.. events.Where(e => selected.Contains(e.Relative))]; + if (selectedEvents.Count == 0) + { + return 0; + } + + double total = selectedEvents.Count; + double smallRatio = selectedEvents.Count(e => e.Length <= K64) / total; + double tinyRatio = selectedEvents.Count(e => e.Length <= 16 * 1024) / total; + double bulkRatio = selectedEvents.Count(e => e.Length >= 256 * 1024) / total; + return smallRatio >= 0.45 || tinyRatio >= 0.20 || (smallRatio >= 0.25 && bulkRatio >= 0.20) ? 1 : 0; + } + + private static long RoundPoolSize(long required) + { + const long quantum = 128L * 1024 * 1024; + return Math.Max(256L * 1024 * 1024, CeilDiv(required, quantum) * quantum); + } + + // ---- rule emission ------------------------------------------------------------------------------------------- + + private static List ProfileComment(IEnumerable recommendations) + { + List items = [.. recommendations]; + long reads = items.Sum(r => (long)r.Metrics.ReadCount); + long requested = items.Sum(r => r.Metrics.RequestedBytes); + string confidence = string.Join(", ", items.GroupBy(r => r.Metrics.ConfidenceLabel) + .OrderBy(g => g.Key, PythonCodePointComparer.Instance).Select(g => $"{g.Key}:{g.Count()}")); + return [$"observed files={items.Count}, reads={reads}, submitted={HumanSize(requested)}", "confidence=" + confidence]; + } + + private static List DirectoryGeneralization(List recommendations, IEnumerable allIndexPaths, AMPRProfileOptions options) + { + Dictionary byPath = []; + foreach (FileRecommendation recommendation in recommendations.Where(r => r.Action != "loose")) + { + byPath[recommendation.Metrics.Relative] = recommendation; + } + + List emissions = []; + + void AppendPaths(ProfileKey key, List paths) + { + if (options.ExternalizePaths != 0 && paths.Count > options.ExternalizePaths) + { + emissions.Add(new RuleEmission(key, paths, ProfileComment(paths.Select(p => byPath[p])))); + return; + } + + for (int start = 0; start < paths.Count; start += options.MaxRuleFiles) + { + List subset = paths.GetRange(start, Math.Min(options.MaxRuleFiles, paths.Count - start)); + emissions.Add(new RuleEmission(key, subset, ProfileComment(subset.Select(p => byPath[p])))); + } + } + + void AppendByProfile(IEnumerable items) + { + Dictionary> byProfile = []; + foreach (FileRecommendation recommendation in items) + { + if (!byProfile.TryGetValue(recommendation.Key, out List? list)) + { + byProfile[recommendation.Key] = list = []; + } + + list.Add(recommendation); + } + + foreach (ProfileKey key in byProfile.Keys.Order()) + { + AppendPaths(key, [.. byProfile[key].Select(r => r.Metrics.Relative).Order(PythonCodePointComparer.Instance)]); + } + } + + if (options.PatternMode == "exact") + { + AppendByProfile(byPath.Values); + return emissions; + } + + Dictionary totalByPrefix = []; + foreach (string relative in allIndexPaths) + { + foreach (string prefix in Prefixes(relative)) + { + totalByPrefix[prefix] = totalByPrefix.GetValueOrDefault(prefix) + 1; + } + } + + // Insertion-ordered counters, like Python's dict and Counter. + Dictionary> profileByPrefix = []; + Dictionary<(string, ProfileKey), List> pathsByPrefixProfile = []; + foreach ((string relative, FileRecommendation recommendation) in byPath) + { + foreach (string prefix in Prefixes(relative)) + { + if (!profileByPrefix.TryGetValue(prefix, out List<(ProfileKey Key, int Count)>? counts)) + { + profileByPrefix[prefix] = counts = []; + } + + int at = counts.FindIndex(c => c.Key == recommendation.Key); + if (at < 0) + { + counts.Add((recommendation.Key, 1)); + } + else + { + counts[at] = (recommendation.Key, counts[at].Count + 1); + } + + if (!pathsByPrefixProfile.TryGetValue((prefix, recommendation.Key), out List? paths)) + { + pathsByPrefixProfile[(prefix, recommendation.Key)] = paths = []; + } + + paths.Add(relative); + } + } + + double threshold = options.PatternMode == "directory" ? Math.Min(options.GeneralizeCoverage, 0.50) : options.GeneralizeCoverage; + List<(int Savings, int Depth, string Prefix, ProfileKey Key, HashSet Covered)> candidates = []; + foreach ((string prefix, List<(ProfileKey Key, int Count)> counts) in profileByPrefix) + { + int total = totalByPrefix.GetValueOrDefault(prefix); + if (total <= 0) + { + continue; + } + + (ProfileKey key, int matching) = counts.MaxBy(c => c.Count); + int observed = counts.Sum(c => c.Count); + double coverage = (double)observed / total; + double purity = (double)matching / observed; + if (matching >= options.GeneralizeMinFiles && coverage >= threshold && purity >= 0.90) + { + HashSet covered = [.. pathsByPrefixProfile[(prefix, key)]]; + candidates.Add((covered.Count - 1, prefix.Split('/').Length, prefix, key, covered)); + } + } + + HashSet coveredPaths = []; + foreach ((_, _, string prefix, ProfileKey key, HashSet covered) in candidates.OrderByDescending(c => (c.Savings, c.Depth))) + { + int remaining = covered.Count(p => !coveredPaths.Contains(p)); + if (remaining < options.GeneralizeMinFiles) + { + continue; + } + + emissions.Add(new RuleEmission(key, [$"{prefix}/**"], + [ + $"generalised from {remaining} observed files under {prefix}", + $"indexed coverage={Percent((double)remaining / Math.Max(1, totalByPrefix[prefix]), 0)}", + ])); + coveredPaths.UnionWith(covered); + } + + AppendByProfile(byPath.Where(p => !coveredPaths.Contains(p.Key)).Select(p => p.Value)); + return emissions; + } + + // "a/b/c" -> "a", "a/b" (PurePosixPath parents below the full path). + private static IEnumerable Prefixes(string relative) + { + string[] parts = relative.Split('/'); + for (int depth = 1; depth < parts.Length; depth++) + { + yield return string.Join('/', parts[..depth]); + } + } + + // ---- formatting ---------------------------------------------------------------------------------------------- + + private static readonly PythonCodePointComparer StringKey = PythonCodePointComparer.Instance; + + private static string Distribution(IEnumerable values, IComparer order, Func format) where T : notnull => + string.Join(", ", values.GroupBy(v => v).OrderBy(g => g.Key, order).Select(g => $"{format(g.Key)}: {g.Count()}")); + + private static string Percent(double value, int decimals) => PythonText.FormatFixed(value * 100.0, decimals) + "%"; + + private static string Thousands(long value) => value.ToString("#,0", CultureInfo.InvariantCulture); + + private static string TomlString(string value) => PythonSortedJson.Indented(value); + + private static string Sha256(string path) + { + using FileStream stream = File.OpenRead(path); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + + // Python Path ordering of discovered journals: by path components, code point order. + private static int ComparePathParts(string[] a, string[] b) + { + for (int i = 0; i < Math.Min(a.Length, b.Length); i++) + { + int c = PythonCodePointComparer.Instance.Compare(a[i], b[i]); + if (c != 0) + { + return c; + } + } + + return a.Length.CompareTo(b.Length); + } +} diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index dcef558..48da54e 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -42,6 +42,7 @@ public static Command Create(CliContext ctx) command.Subcommands.Add(List(ctx)); command.Subcommands.Add(Inspect(ctx)); command.Subcommands.Add(RuntimeConfig(ctx)); + command.Subcommands.Add(AmprProfileCommand.Create(ctx)); return command; } @@ -58,6 +59,7 @@ private static Command Pack(CliContext ctx) Option config = new("--config") { Description = "TOML pack configuration" }; Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; insomniac compresses only the d/ archives but boot, movie and audio ones; auto picks insomniac or unity from the game, else default" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); + Option traces = new("--traces") { Description = TracesDescription }; Option include = Repeated("--include", "additional include glob"); Option exclude = Repeated("--exclude", "force-loose glob"); Option includeFrom = new("--include-from") { Description = "newline-delimited include globs" }; @@ -69,16 +71,20 @@ private static Command Pack(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress build progress on stderr; final JSON still goes to stdout" }; Command command = new("pack", "build pack index and data volumes") { - root, amprIndex, output, config, preset, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, + root, amprIndex, output, config, preset, traces, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, }; command.SetAction(parse => Run(ctx, () => { string? configPath = parse.GetValue(config); string? presetName = parse.GetValue(preset); - AMPRPackConfig loaded = (configPath, presetName) switch + string? tracesDir = parse.GetValue(traces); + AMPRPackConfig loaded = (configPath, presetName, tracesDir) switch { - (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), - (null, not null) => Preset(presetName, Resolve(ctx, parse.GetValue(root)!), ctx.Err.WriteLine), + (not null, not null, _) => throw new AMPRPackException("--config and --preset cannot be used together"), + (_, _, not null) when configPath is not null || presetName is not null => + throw new AMPRPackException("--traces cannot be used with --config or --preset"), + (null, null, string dir) => TraceRules(Resolve(ctx, dir), ctx.Err.WriteLine), + (null, not null, _) => Preset(presetName, Resolve(ctx, parse.GetValue(root)!), ctx.Err.WriteLine), _ => AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)), }; if (parse.GetValue(workers) is { } count) @@ -162,6 +168,7 @@ private static Command Game(CliContext ctx) Option config = new("--config") { Description = "TOML pack configuration (default: --preset auto)" }; Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (insomniac or unity from the game, else default), default (every file but executables, modules and system files), insomniac (the d/ archives but boot, movie and audio ones) or unity (only StreamingAssets); default: auto" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); + Option traces = new("--traces") { Description = TracesDescription }; Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; Option exfat = new("--exfat") { Description = "also build an exFAT image of the output (file, or folder for .exfat)" }; @@ -169,18 +176,27 @@ private static Command Game(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress progress on stderr" }; Command command = new("game", "build a folder that runs from packs as is: libraries, ampr_emu.index, packs and loose files (not in ampr_pack.py)") { - root, output, fakelib, config, preset, workers, selfContained, exfat, skipVerify, noProgress, + root, output, fakelib, config, preset, traces, workers, selfContained, exfat, skipVerify, noProgress, }; command.SetAction(parse => Run(ctx, () => { ctx.Log.Warning(ExperimentalWarning); string? configPath = parse.GetValue(config); - AMPRPackConfig loaded = (configPath, parse.GetValue(preset)) switch + string? presetName = parse.GetValue(preset); + string? tracesDir = parse.GetValue(traces); + if (configPath is not null && presetName is not null) { - (not null, not null) => throw new AMPRPackException("--config and --preset cannot be used together"), - (not null, null) => Config(ctx, Resolve(ctx, configPath)), - (null, var name) => Preset(name ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)), - }; + throw new AMPRPackException("--config and --preset cannot be used together"); + } + + if (tracesDir is not null && (configPath is not null || presetName is not null)) + { + throw new AMPRPackException("--traces cannot be used with --config or --preset"); + } + + AMPRPackConfig loaded = tracesDir is not null ? TraceRules(Resolve(ctx, tracesDir), message => ctx.Log.Info(message)) + : configPath is not null ? Config(ctx, Resolve(ctx, configPath)) + : Preset(presetName ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)); if (parse.GetValue(workers) is { } count) { loaded.Workers = count is >= 1 and <= 256 ? count : throw new AMPRPackException("--workers must be between 1 and 256"); @@ -246,6 +262,33 @@ private static string RulesReport(string preset, string? marker, bool auto) }; } + private const string TracesDescription = + "folder with APR traces of the debug emulator build (ampr_commands.bin and ampr_emu.index, one run per subfolder): " + + "pack the files the game read through APR, as ampr_pack_profile.py would, and keep the rest loose"; + + // Not in ampr_pack.py: the rules ampr_pack_profile.py would write for the traces, without the intermediate TOML. + private static AMPRPackConfig TraceRules(string directory, Action report) + { + List runs = AMPRProfiler.DiscoverTracePairs(directory); + if (runs.Count == 0) + { + throw new AMPRPackException($"no traces found in {directory}: each run needs ampr_commands.bin and its ampr_emu.index side by side"); + } + + AMPRProfileOptions options = new() { Name = Path.GetFileName(Path.TrimEndingDirectorySeparator(directory)) }; + AMPRProfileResult profile = AMPRProfiler.Build(runs, options); + string runText = runs.Count == 1 ? "1 trace run" : $"{runs.Count} trace runs"; + string fileText = profile.PackedFiles == 1 ? "1 file" : $"{profile.PackedFiles} files"; + report($"Rules: from {runText} in {directory}; {fileText} the game read through APR are packed " + + $"({AMPRProfiler.HumanSize(profile.PackedFileBytes)}), every other file stays loose"); + if (profile.Warnings.Count > 0) + { + report($"warning: the traces have {profile.Warnings.Count} warnings; `mkpfs ampr profile generate --report` lists them"); + } + + return AMPRProfiler.ToConfig(profile, options); + } + private static AMPRPackConfig Config(CliContext ctx, string path) { ctx.Log.Info($"Rules: your TOML file {path} (no built-in profile)"); @@ -428,7 +471,7 @@ private static Command RuntimeConfig(CliContext ctx) } // Python main(): PackError, ValueError, OSError and RuntimeError print "error: " and exit 2. - private static int Run(CliContext ctx, Action action) + internal static int Run(CliContext ctx, Action action) { try { @@ -453,7 +496,7 @@ private static int Run(CliContext ctx, Action action) ["latency_reserve_workers"] = settings.LatencyReserveWorkers, }; - private static string Resolve(CliContext ctx, string path) => Path.GetFullPath(PathRules.ExpandUser(path), ctx.WorkingDirectory); + internal static string Resolve(CliContext ctx, string path) => Path.GetFullPath(PathRules.ExpandUser(path), ctx.WorkingDirectory); private static List PatternFile(CliContext ctx, string? path) => path is null ? [] : AMPRPackConfig.LoadPatternFile(Resolve(ctx, path)); diff --git a/src/MkPFS.Cli/Commands/AmprProfileCommand.cs b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs new file mode 100644 index 0000000..474796b --- /dev/null +++ b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs @@ -0,0 +1,228 @@ +using System.CommandLine; +using System.Globalization; +using MkPFS.Build.AMPRPack; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; + +namespace MkPFS.Cli.Commands; + +/// +/// ampr profile generate: port of ampr_emu tools/ampr_pack_profile.py generate. Reads APR traces +/// (ampr_commands.bin plus the run's ampr_emu.index) recorded by the debug emulator build and writes a +/// TOML pack profile, optionally the Markdown report and the runtime header. --metrics (JSON) and +/// batch are not ported. +/// +internal static class AmprProfileCommand +{ + public static Command Create(CliContext ctx) + { + Command command = new("profile", "generate pack rules from APR traces of the debug emulator build (ampr_pack_profile.py)"); + command.Subcommands.Add(Generate(ctx)); + return command; + } + + private static Command Generate(CliContext ctx) + { + Argument input = new("input") { Description = "directory containing ampr_commands.bin and ampr_emu.index", Arity = ArgumentArity.ZeroOrOne }; + Option trace = new("--trace") + { + Description = "COMMANDS INDEX; repeat to merge several gameplay traces", + AllowMultipleArgumentsPerToken = true, + Arity = ArgumentArity.OneOrMore, + DefaultValueFactory = _ => [], + }; + Option name = new("--name"); + Option output = new("--output") { Required = true }; + Option report = new("--report"); + Option runtimeHeader = new("--runtime-header") { Description = "optional force-include header with runtime cache/pool/coalescing defines" }; + Option ioPageSize = new("--io-page-size") { DefaultValueFactory = _ => "64KiB" }; + Option indexBudget = new("--pack-index-budget") { DefaultValueFactory = _ => "96MiB" }; + Option cacheCandidates = new("--cache-candidates") { Description = "comma-separated capacities, e.g. 64MiB,128MiB,256MiB,512MiB", HelpName = "LIST" }; + Option lanes = new("--lanes") { Description = "0 selects 1-4 lanes automatically", DefaultValueFactory = _ => 0 }; + Option workers = new("--workers") { DefaultValueFactory = _ => 8 }; + Option runtimeWorkers = new("--runtime-workers") { Description = "0 selects 2-8 runtime decode/I/O workers from the trace", DefaultValueFactory = _ => 0 }; + Option strategy = new("--strategy") { Description = "trade read amplification against resident index size", DefaultValueFactory = _ => "balanced" }; + strategy.AcceptOnlyFromAmong("conservative", "balanced", "aggressive"); + Option patternMode = new("--pattern-mode") { Description = "exact is safest; hybrid generalises only high-coverage directories", DefaultValueFactory = _ => "exact" }; + patternMode.AcceptOnlyFromAmong("exact", "hybrid", "directory"); + Option minReads = new("--min-reads") { DefaultValueFactory = _ => 1 }; + Option minRequestedBytes = new("--min-requested-bytes") { DefaultValueFactory = _ => "1B" }; + Option maxRuleFiles = new("--max-rule-files") { DefaultValueFactory = _ => 256 }; + Option externalizePaths = new("--externalize-paths") + { + Description = "write larger exact include lists to portable include_from sidecars (0 keeps paths inline)", + DefaultValueFactory = _ => 128, + }; + Option generalizeCoverage = new("--generalize-coverage") { DefaultValueFactory = _ => 0.80 }; + Option generalizeMinFiles = new("--generalize-min-files") { DefaultValueFactory = _ => 4 }; + Option cacheSim = new("--cache-sim") { Description = "simulate decoded-cache reuse (default for generate)" }; + Option noCacheSim = new("--no-cache-sim") { Description = "skip the expensive decoded-cache simulation" }; + Option cacheMaxTouches = new("--cache-max-touches") + { + Description = "simulate all decoded-cache block touches up to this limit; larger traces use deterministic contiguous windows (0 = exact)", + DefaultValueFactory = _ => AMPRProfiler.DefaultCacheMaxTouches, + }; + Option contentRoot = new("--content-root") { Description = "optional extracted /app0 root for LZ4 sampling" }; + Option sampleBudget = new("--sample-budget") { DefaultValueFactory = _ => "0B" }; + Option sampleBlocksPerFile = new("--sample-blocks-per-file") { DefaultValueFactory = _ => 4 }; + Option sampleMode = new("--sample-mode") { DefaultValueFactory = _ => "hc" }; + sampleMode.AcceptOnlyFromAmong("fast", "hc"); + Option sampleLevel = new("--sample-level") { DefaultValueFactory = _ => 12 }; + Option sampleAcceleration = new("--sample-acceleration") { DefaultValueFactory = _ => 1 }; + Option overwrite = new("--overwrite"); + Command command = new("generate", "generate one profile") + { + input, trace, name, output, report, runtimeHeader, ioPageSize, indexBudget, cacheCandidates, lanes, workers, runtimeWorkers, strategy, + patternMode, minReads, minRequestedBytes, maxRuleFiles, externalizePaths, generalizeCoverage, generalizeMinFiles, cacheSim, noCacheSim, + cacheMaxTouches, contentRoot, sampleBudget, sampleBlocksPerFile, sampleMode, sampleLevel, sampleAcceleration, overwrite, + }; + command.SetAction(parse => AmprCommand.Run(ctx, () => + { + if (parse.GetValue(cacheSim) && parse.GetValue(noCacheSim)) + { + throw new AMPRPackException("--cache-sim and --no-cache-sim cannot be used together"); + } + + List traces = []; + if (parse.GetValue(input) is { } inputPath) + { + List discovered = AMPRProfiler.DiscoverTracePairs(AmprCommand.Resolve(ctx, inputPath)); + if (discovered.Count != 1) + { + throw new AMPRPackException($"generate input must contain exactly one trace pair; found {discovered.Count}"); + } + + traces.AddRange(discovered); + } + + string[] pairs = parse.GetValue(trace)!; + if (pairs.Length % 2 != 0) + { + throw new AMPRPackException("--trace takes two paths: COMMANDS INDEX"); + } + + for (int i = 0; i < pairs.Length; i += 2) + { + traces.Add(new AMPRTraceSpec($"trace-{(i / 2) + 1}", AmprCommand.Resolve(ctx, pairs[i]), AmprCommand.Resolve(ctx, pairs[i + 1]))); + } + + if (traces.Count == 0) + { + throw new AMPRPackException("provide an input directory or at least one --trace pair"); + } + + foreach (AMPRTraceSpec spec in traces) + { + if (!File.Exists(spec.Commands) || !File.Exists(spec.Index)) + { + throw new AMPRPackException($"missing trace input: {spec.Commands} / {spec.Index}"); + } + } + + AMPRProfileOptions options = new() + { + Name = parse.GetValue(name) ?? traces[0].Name, + IOPageSize = AMPRSize.Parse(parse.GetValue(ioPageSize)!), + IndexBudget = AMPRSize.Parse(parse.GetValue(indexBudget)!), + CacheCandidates = parse.GetValue(cacheCandidates) is { } list ? CacheCandidates(list) : AMPRProfiler.DefaultCacheCandidates, + Lanes = parse.GetValue(lanes), + Workers = parse.GetValue(workers), + RuntimeWorkers = parse.GetValue(runtimeWorkers), + Strategy = parse.GetValue(strategy)!, + PatternMode = parse.GetValue(patternMode)!, + MinReads = parse.GetValue(minReads), + MinRequestedBytes = AMPRSize.Parse(parse.GetValue(minRequestedBytes)!), + MaxRuleFiles = parse.GetValue(maxRuleFiles), + ExternalizePaths = parse.GetValue(externalizePaths), + GeneralizeCoverage = parse.GetValue(generalizeCoverage), + GeneralizeMinFiles = parse.GetValue(generalizeMinFiles), + CacheSimulation = !parse.GetValue(noCacheSim), + CacheMaxTouches = parse.GetValue(cacheMaxTouches), + ContentRoot = parse.GetValue(contentRoot) is { } content ? AmprCommand.Resolve(ctx, content) : null, + SampleBudget = AMPRSize.Parse(parse.GetValue(sampleBudget)!), + SampleBlocksPerFile = parse.GetValue(sampleBlocksPerFile), + SampleMode = parse.GetValue(sampleMode)!, + SampleLevel = parse.GetValue(sampleLevel), + SampleAcceleration = parse.GetValue(sampleAcceleration), + }; + options.Validate(); + AMPRProfileResult result = AMPRProfiler.Build(traces, options); + string outputText = parse.GetValue(output)!; + string outputPath = AmprCommand.Resolve(ctx, outputText); + Dictionary sidecars = []; + string toml = AMPRProfiler.RenderToml(result, options, sidecars, Path.GetFileNameWithoutExtension(outputPath)); + bool replace = parse.GetValue(overwrite); + WriteBundle(outputPath, toml, sidecars, replace); + if (parse.GetValue(report) is { } reportPath) + { + WriteText(AmprCommand.Resolve(ctx, reportPath), AMPRProfiler.RenderReport(result), replace); + } + + if (parse.GetValue(runtimeHeader) is { } headerPath) + { + WriteText(AmprCommand.Resolve(ctx, headerPath), AMPRProfiler.RenderRuntimeHeader(result), replace); + } + + ctx.Out.WriteLine( + $"generated {outputText}: files={result.ObservedFiles} index={AMPRProfiler.HumanSize(result.ProjectedIndexBytes)} " + + $"cache={AMPRProfiler.HumanSize(result.RecommendedCacheBytes)} pool={AMPRProfiler.HumanSize(result.RecommendedPoolBytes)}"); + })); + return command; + } + + // Python _parse_cache_candidates: sizes, sorted, without duplicates. + private static List CacheCandidates(string value) + { + SortedSet result = []; + foreach (string item in value.Split(',')) + { + string text = item.Trim(); + if (text.Length == 0) + { + continue; + } + + long parsed = AMPRSize.Parse(text); + if (parsed <= 0) + { + throw new AMPRPackException("cache capacities must be positive"); + } + + result.Add(parsed); + } + + return result.Count > 0 ? [.. result] : throw new AMPRPackException("at least one cache capacity is required"); + } + + // Python _write_profile_bundle: refuse to replace anything unless --overwrite, sidecars first, the TOML last. + private static void WriteBundle(string tomlPath, string toml, Dictionary sidecars, bool overwrite) + { + string directory = Path.GetDirectoryName(tomlPath)!; + List outputs = [tomlPath, .. sidecars.Keys.Select(name => Path.Combine(directory, name))]; + if (!overwrite && outputs.FirstOrDefault(Path.Exists) is { } existing) + { + throw new AMPRPackException($"refusing to overwrite existing file: {existing}"); + } + + foreach ((string name, string text) in sidecars) + { + WriteText(Path.Combine(directory, name), text, overwrite: true); + } + + WriteText(tomlPath, toml, overwrite: true); + } + + private static void WriteText(string path, string text, bool overwrite) + { + if (Path.Exists(path) && !overwrite) + { + throw new AMPRPackException($"refusing to overwrite existing file: {path}"); + } + + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.tmp-{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}"); + File.WriteAllText(temp, text, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + File.Move(temp, path, overwrite: true); + } +} diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 045701b..e20740e 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -41,6 +41,11 @@ Untested: the game may not start on the PS5. No profile found for this game: generic rules will be used. {0} They are untested, so the game may not start on the PS5; keep the original. Your TOML file decides what is packed; no built-in profile is used. + Trace folder (optional) + Folder with ampr_commands.bin and ampr_emu.index from the debug AMPR Emu, one run per subfolder + ✓ Rules from traces: {0} recorded run(s) found. Only the files the game read through AMPR are packed; every other file stays loose. + No traces in this folder: each run needs ampr_commands.bin and its ampr_emu.index side by side. + Use either a TOML file or a trace folder, not both. ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. Library Folder (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 5e2c075..841add1 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -41,6 +41,11 @@ Ungetestet: das Spiel startet auf der PS5 eventuell nicht. Kein Profil für dieses Spiel gefunden: es werden allgemeine Regeln verwendet. {0} Sie sind ungetestet, das Spiel startet auf der PS5 eventuell nicht; behalte das Original. Deine TOML-Datei bestimmt, was gepackt wird; kein eingebautes Profil wird verwendet. + Trace-Ordner (optional) + Ordner mit ampr_commands.bin und ampr_emu.index vom Debug-AMPR-Emu, ein Lauf pro Unterordner + ✓ Regeln aus Traces: {0} aufgezeichnete(r) Lauf/Läufe gefunden. Nur die Dateien, die das Spiel über AMPR gelesen hat, werden gepackt; alle anderen bleiben einzeln. + Keine Traces in diesem Ordner: jeder Lauf braucht ampr_commands.bin und seine ampr_emu.index nebeneinander. + Verwende entweder eine TOML-Datei oder einen Trace-Ordner, nicht beides. ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. Bibliotheksordner (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 0c72e70..f73388a 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -41,6 +41,11 @@ Sin probar: puede que el juego no arranque en la PS5. No se encontró ningún perfil para este juego: se usarán reglas genéricas. {0} No están probadas, así que puede que el juego no arranque en la PS5; conserva el original. Tu archivo TOML decide qué se empaqueta; no se usa ningún perfil integrado. + Carpeta de trazas (opcional) + Carpeta con ampr_commands.bin y ampr_emu.index del AMPR Emu de depuración, una ejecución por subcarpeta + ✓ Reglas a partir de trazas: {0} ejecución(es) grabada(s). Solo se empaquetan los archivos que el juego leyó mediante AMPR; el resto queda suelto. + No hay trazas en esta carpeta: cada ejecución necesita ampr_commands.bin y su ampr_emu.index juntos. + Usa un archivo TOML o una carpeta de trazas, no ambos. ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. Carpeta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index d0e4cdc..582a306 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -41,6 +41,11 @@ Non testé : le jeu risque de ne pas démarrer sur la PS5. Aucun profil trouvé pour ce jeu : des règles génériques seront utilisées. {0} Elles ne sont pas testées, le jeu risque donc de ne pas démarrer sur la PS5 ; gardez l’original. Votre fichier TOML décide de ce qui est empaqueté ; aucun profil intégré n’est utilisé. + Dossier de traces (facultatif) + Dossier avec ampr_commands.bin et ampr_emu.index de l’AMPR Emu de débogage, une session par sous-dossier + ✓ Règles issues des traces : {0} session(s) enregistrée(s). Seuls les fichiers que le jeu a lus via AMPR sont empaquetés ; les autres restent à part. + Aucune trace dans ce dossier : chaque session a besoin de ampr_commands.bin et de son ampr_emu.index côte à côte. + Utilisez un fichier TOML ou un dossier de traces, pas les deux. ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. Dossier des bibliothèques (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index f67d9bc..6b794d9 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -41,6 +41,11 @@ Não testado: o jogo pode não iniciar no PS5. Nenhum perfil encontrado para este jogo: serão usadas regras genéricas. {0} Elas não foram testadas, então o jogo pode não iniciar no PS5; guarde o original. O seu arquivo TOML decide o que é empacotado; nenhum perfil embutido é usado. + Pasta de traces (opcional) + Pasta com ampr_commands.bin e ampr_emu.index do AMPR Emu de debug, uma execução por subpasta + ✓ Regras a partir dos traces: {0} execução(ões) gravada(s). Só os arquivos que o jogo leu pelo AMPR são empacotados; os demais ficam soltos. + Nenhum trace nesta pasta: cada execução precisa de ampr_commands.bin e do seu ampr_emu.index lado a lado. + Use um arquivo TOML ou uma pasta de traces, não os dois. ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. Pasta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 36aa442..db18f45 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -41,6 +41,11 @@ Netestat: este posibil ca jocul să nu pornească pe PS5. Niciun profil găsit pentru acest joc: se vor folosi reguli generice. {0} Nu sunt testate, deci este posibil ca jocul să nu pornească pe PS5; păstrează originalul. Fișierul tău TOML decide ce se împachetează; nu se folosește niciun profil inclus. + Dosar cu trace-uri (opțional) + Dosar cu ampr_commands.bin și ampr_emu.index de la AMPR Emu de depanare, o rulare pe subdosar + ✓ Reguli din trace-uri: {0} rulare(i) înregistrată(e). Se împachetează doar fișierele citite de joc prin AMPR; restul rămân separate. + Niciun trace în acest dosar: fiecare rulare are nevoie de ampr_commands.bin și de ampr_emu.index alături. + Folosește fie un fișier TOML, fie un dosar cu trace-uri, nu amândouă. ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. Folder de Biblioteci (fakelib) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index acadc65..4770eaa 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -34,7 +34,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig))] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -73,9 +73,15 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// --config; empty packs with --preset auto, and runtime-config requires it. [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig))] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] public partial string Config { get; set; } = string.Empty; + /// --traces (game, pack): folder with APR traces of the debug emulator build. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] + [NotifyPropertyChangedFor(nameof(RulesTracesText))] + public partial string Traces { get; set; } = string.Empty; + /// --workers; empty for the configuration's value. [ObservableProperty] public partial string Workers { get; set; } = string.Empty; @@ -133,7 +139,19 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job // Built-in rules picked for the game folder (as --preset auto does), or null when the folder does not exist. private (string Preset, string? Marker)? _detected; - private bool UsesBuiltInRules => ShowPackOptions && Config.Trim().Length == 0 && _detected is not null; + // Trace runs found in the trace folder, or null when the field is empty. + private int? _traceRuns; + + private bool UsesBuiltInRules => ShowPackOptions && Config.Trim().Length == 0 && Traces.Trim().Length == 0 && _detected is not null; + + /// The trace folder has runs: the rules come from the traces. + public bool ShowRulesTraces => ShowPackOptions && Config.Trim().Length == 0 && _traceRuns > 0; + + /// The trace folder has no runs. + public bool ShowNoTraces => ShowPackOptions && Config.Trim().Length == 0 && _traceRuns == 0; + + /// How many trace runs were found. + public string RulesTracesText => Localizer.Instance.Format("ap_rules_traces", _traceRuns ?? 0); /// A built-in profile matches the game folder (game, pack without a TOML). public bool ShowRulesFound => UsesBuiltInRules && _detected!.Value.Marker is not null; @@ -142,7 +160,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job public bool ShowRulesNone => UsesBuiltInRules && _detected!.Value.Marker is null; /// The user's TOML file decides what is packed. - public bool ShowRulesConfig => ShowPackOptions && Config.Trim().Length > 0; + public bool ShowRulesConfig => ShowPackOptions && Config.Trim().Length > 0 && Traces.Trim().Length == 0; /// The matched profile, the file that identified it and the games it ran on. public string RulesFoundText @@ -182,8 +200,10 @@ public string RulesFoundText string manifest = Manifest.Trim(); string output = Output.Trim(); string config = Config.Trim(); + string traces = Traces.Trim(); error = Action.Value switch { + "game" or "pack" when config.Length > 0 && traces.Length > 0 => Localizer.Instance["ap_err_traces_config"], "game" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], "pack" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], "game" or "pack" when Workers.Trim().Length > 0 && !(int.TryParse(Workers.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n is >= 1 and <= 256) @@ -205,8 +225,9 @@ public string RulesFoundText case "game": args.AddRange(["--root", root, "--output", output]); AddOption(args, "--fakelib", Libs); - // Without a TOML, ampr game picks the rules from the game (Unity or not). + // Without a TOML or traces, ampr game picks the rules from the game. AddOption(args, "--config", config); + AddOption(args, "--traces", traces); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, !VerifyGame, "--skip-verify"); @@ -220,7 +241,7 @@ public string RulesFoundText case "pack": args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); // Without a TOML every file would stay loose; the page uses the built-in rules instead. - args.AddRange(config.Length > 0 ? ["--config", config] : ["--preset", "auto"]); + args.AddRange(config.Length > 0 ? ["--config", config] : traces.Length > 0 ? ["--traces", traces] : ["--preset", "auto"]); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, AllowMissing, "--allow-missing"); @@ -259,6 +280,26 @@ partial void OnRootChanged(string value) _detected = root.Length > 0 && Directory.Exists(root) ? Build.AMPRPack.AMPRPackConfig.DetectPreset(root) : null; } + /// Count the trace runs in the chosen folder (each needs ampr_commands.bin and ampr_emu.index). + partial void OnTracesChanged(string value) + { + string folder = value.Trim(); + if (folder.Length == 0) + { + _traceRuns = null; + return; + } + + try + { + _traceRuns = Directory.Exists(folder) ? Build.AMPRPack.AMPRProfiler.DiscoverTracePairs(Path.GetFullPath(folder)).Count : 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Core.AMPR.AMPRPackException) + { + _traceRuns = 0; + } + } + /// The manifest a pack run writes, so the other actions can use it right away. partial void OnOutputChanged(string value) { diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 88be325..3127989 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -31,6 +31,9 @@ + + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index aefb70d..15ede96 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -171,6 +171,38 @@ public void Page_says_which_profile_matches_the_game_folder() Assert.False(panel.ShowRulesFound || panel.ShowRulesNone || panel.ShowRulesConfig); } + [Fact] + public void Page_takes_rules_from_a_trace_folder() + { + using TempDir dir = new(); + AmprPackPanelViewModel panel = Panel("game"); + panel.Root = Directory.CreateDirectory(Path.Combine(dir.Path, "game")).FullName; + panel.Output = Path.Combine(dir.Path, "out"); + string traces = Directory.CreateDirectory(Path.Combine(dir.Path, "traces")).FullName; + + panel.Traces = traces; + Assert.True(panel.ShowNoTraces); + Assert.False(panel.ShowRulesTraces || panel.ShowRulesNone || panel.ShowRulesFound); + + dir.File("traces/startup/ampr_commands.bin"); + dir.File("traces/startup/ampr_emu.index"); + dir.File("traces/level1/ampr_commands.bin"); + dir.File("traces/level1/ampr_emu.index"); + panel.Traces = traces + Path.DirectorySeparatorChar; + Assert.True(panel.ShowRulesTraces); + Assert.False(panel.ShowNoTraces); + Assert.StartsWith("✓ Rules from traces: 2 recorded run(s) found.", panel.RulesTracesText, StringComparison.Ordinal); + Assert.Equal(["--traces", traces + Path.DirectorySeparatorChar], panel.BuildArguments(out _)!.SkipWhile(a => a != "--traces").Take(2)); + + panel.Action = panel.Actions.Single(a => a.Value == "pack"); + Assert.DoesNotContain("--preset", panel.BuildArguments(out _)!); + + panel.Config = "rules.toml"; + Assert.False(panel.ShowRulesTraces || panel.ShowRulesConfig); + Assert.Null(panel.BuildArguments(out string? error)); + Assert.Equal("Use either a TOML file or a trace folder, not both.", error); + } + [AvaloniaFact] public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_progress() { diff --git a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs new file mode 100644 index 0000000..e6fc09d --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs @@ -0,0 +1,216 @@ +using System.Buffers.Binary; +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Cli; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// +/// ampr profile and --traces: the port of ampr_pack_profile.py. Byte parity with the Python tool is +/// checked by tools/oracle/check_ampr_profile.py; these tests cover the decoder and the integration. +/// +public sealed class AMPRProfilerTests +{ + /// Writes AMPRCMD1 journals like the debug emulator build. + internal sealed class TraceWriter + { + private readonly MemoryStream _data = new(); + private ulong _sequence; + + public static byte[] ReadFile(int fileId, long offset, long length) + { + bool wide = offset >= 1L << 32; + byte[] command = new byte[wide ? 24 : 20]; + uint dwords = wide ? 6u : 5u; + BinaryPrimitives.WriteUInt32LittleEndian(command, 40 | ((dwords - 1) << 8) | (uint)((offset & 0x3FFFF) << 12)); + BinaryPrimitives.WriteUInt32LittleEndian(command.AsSpan(4), (uint)(length - 1)); + BinaryPrimitives.WriteUInt32LittleEndian(command.AsSpan(8), (uint)fileId); + BinaryPrimitives.WriteUInt32LittleEndian(command.AsSpan(16), (uint)(offset & 0xFFFC0000)); + if (wide) + { + BinaryPrimitives.WriteUInt32LittleEndian(command.AsSpan(20), (uint)((offset >> 32) & 0xFF)); + } + + return command; + } + + public TraceWriter Record(byte[] payload, uint priority = 0, uint domain = 1) + { + byte[] header = new byte[AMPRCommandLog.HeaderSize]; + "AMPRCMD1"u8.CopyTo(header); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(8), 1); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(10), AMPRCommandLog.HeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(12), (uint)(AMPRCommandLog.HeaderSize + payload.Length)); + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(16), ++_sequence); + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(32), 1_000_000 * _sequence); + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(56), AMPRCommandLog.Fnv1a64(payload)); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(64), (uint)payload.Length); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(76), priority); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(80), domain); + _data.Write(header); + _data.Write(payload); + return this; + } + + public void Save(string path) => File.WriteAllBytes(path, _data.ToArray()); + } + + private static readonly string Level = string.Concat(Enumerable.Repeat("terrain mesh texture vertex ", 20_000)); + + // A game folder and one trace run of it: the game read data/level0.dat (randomly) and eboot.bin, nothing else. + private static (string Game, string Traces) TracedGame(TempDir dir) + { + dir.File("game/sce_sys/param.json", """{"titleId":"PPSA01234","contentVersion":"01.000.000"}"""); + dir.File("game/eboot.bin", "eboot"); + dir.File("game/data/level0.dat", Level); + dir.File("game/data/level1.dat", Level + "1"); + dir.File("game/fakelib/libSceAmpr.sprx", "ELF...AMPRPAK4.../app0/ampr_assets.index"); + string game = Path.Combine(dir.Path, "game"); + string run = dir.Dir("traces/startup"); + AmprIndex.Build(game, Path.Combine(run, "ampr_emu.index")); + Dictionary index = AMPRCommandLog.LoadIndex(Path.Combine(run, "ampr_emu.index")); + int Id(string path) => index.Single(e => e.Value.Path == path).Key; + TraceWriter trace = new(); + Random rng = new(7); + for (int i = 0; i < 40; i++) + { + trace.Record(TraceWriter.ReadFile(Id("/app0/data/level0.dat"), rng.Next(0, Level.Length - 9000), 8192)); + } + + trace.Record(TraceWriter.ReadFile(Id("/app0/eboot.bin"), 0, 5)); + trace.Save(Path.Combine(run, "ampr_commands.bin")); + return (game, Path.Combine(dir.Path, "traces")); + } + + [Theory] + [InlineData(new uint[] { 0x12345428, 99, 7, 0, 0xABCC1234 }, "AprReadFile", 5, 7, 0xABCD2345L, 100)] + [InlineData(new uint[] { 0x1528, 0, 0x80000003, 0, 0x40000, 0x12 }, "AprReadFile", 6, 3, 0x12_0004_0001L, 1)] + [InlineData(new uint[] { 0x5229, 9, 3 }, "AprReadGather", 3, 0, 5 | (3L << 18), 10)] + [InlineData(new uint[] { 0x22A, 15, 0 }, "AprReadScatter", 3, 0, 0, 16)] + [InlineData(new uint[] { 0x32B, 0, 0, 0x40000 }, "AprReadGatherScatter", 4, 0, 0x40000, 1)] + [InlineData(new uint[] { 47 }, "AprResetGatherScatterState", 1, 0, 0, 0)] + [InlineData(new uint[] { 0x5452110F, 0x6968 }, "MarkerSet", 2, 0, 0, 0)] + [InlineData(new uint[] { 0x325, 1, 2, 3 }, "Amm", 4, 0, 0, 0)] + [InlineData(new uint[] { 0xBBC }, "Unknown", 1, 0, 0, 0)] + public void Commands_decode_like_parse_ampr_command_log(uint[] words, string name, int dwords, long fileId, long fileOffset, long length) + { + byte[] data = new byte[words.Length * 4]; + for (int i = 0; i < words.Length; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(i * 4), words[i]); + } + + Assert.Equal(new AMPRCommand(name, dwords, fileId, fileOffset, length), AMPRCommandLog.Decode(data, 0)); + } + + [Theory] + [InlineData(new uint[] { 0x228, 0, 0 }, "invalid AprReadFile length")] + [InlineData(new uint[] { 0x701 }, "invalid WaitOnAddress length")] + [InlineData(new uint[] { 0x302, 0, 0, 0 }, "invalid WaitOnCounter length")] + [InlineData(new uint[] { 1032, 0 }, "truncated WriteKernelEventQueue")] + [InlineData(new uint[] { 0x5452500F }, "marker with color has no color dword")] + public void Invalid_commands_raise_the_parser_message(uint[] words, string message) + { + byte[] data = new byte[words.Length * 4]; + for (int i = 0; i < words.Length; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(i * 4), words[i]); + } + + Assert.Equal(message, Assert.Throws(() => AMPRCommandLog.Decode(data, 0)).Message); + Assert.Equal("unaligned or truncated command", Assert.Throws(() => AMPRCommandLog.Decode(data, 2)).Message); + } + + [Fact] + public void A_profile_packs_only_what_the_game_read_and_keeps_executables_loose() + { + using TempDir dir = new(); + (_, string traces) = TracedGame(dir); + List runs = AMPRProfiler.DiscoverTracePairs(traces); + Assert.Equal("startup", Assert.Single(runs).Name); + + AMPRProfileOptions options = new() { Name = "game" }; + AMPRProfileResult result = AMPRProfiler.Build(runs, options); + + Assert.Equal(2, result.ObservedFiles); + Assert.Equal(1, result.PackedFiles); + string toml = AMPRProfiler.RenderToml(result, options); + Assert.Contains("include = [\"data/level0.dat\"]", toml, StringComparison.Ordinal); + Assert.DoesNotContain("level1", toml, StringComparison.Ordinal); + AMPRPackConfig config = AMPRProfiler.ToConfig(result, options); + Assert.Equal("compress", config.SelectRule("data/level0.dat").Action); + Assert.Equal("loose", config.SelectRule("data/level1.dat").Action); + Assert.Equal("loose", config.SelectRule("eboot.bin").Action); + Assert.NotNull(config.Runtime); + } + + [Fact] + public void Trace_folders_are_found_in_path_order_and_named_after_their_folders() + { + using TempDir dir = new(); + foreach (string run in (string[])["b/2", "a", "b/10", "c"]) + { + dir.File($"t/{run}/ampr_commands.bin"); + if (run != "c") + { + dir.File($"t/{run}/ampr_emu.index"); + } + } + + Assert.Equal(["a", "b-10", "b-2"], AMPRProfiler.DiscoverTracePairs(Path.Combine(dir.Path, "t")).Select(r => r.Name)); + Assert.Empty(AMPRProfiler.DiscoverTracePairs(Path.Combine(dir.Path, "missing"))); + } + + [Fact] + public void Cli_game_with_traces_packs_the_traced_files_and_says_so() + { + using TempDir dir = new(); + (string game, string traces) = TracedGame(dir); + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); + string output = Path.Combine(dir.Path, "out"); + + int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", output, "--traces", traces], ctx); + + Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); + Assert.Contains($"\nRules: from 1 trace run in {traces}; 1 file the game read through APR are packed (", stdout.ToString(), StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(output, "data", "level0.dat"))); + Assert.True(File.Exists(Path.Combine(output, "data", "level1.dat"))); + Assert.True(File.Exists(Path.Combine(output, "ampr_assets.index.runtime"))); + + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2"), "--traces", traces, "--preset", "unity"], ctx); + Assert.Equal(2, exit); + Assert.EndsWith("error: --traces cannot be used with --config or --preset\n", stderr.ToString(), StringComparison.Ordinal); + + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out3"), "--traces", game], ctx); + Assert.Equal(2, exit); + Assert.EndsWith($"error: no traces found in {game}: each run needs ampr_commands.bin and its ampr_emu.index side by side\n", stderr.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Cli_profile_generate_writes_the_toml_report_and_header_and_refuses_to_overwrite() + { + using TempDir dir = new(); + (_, string traces) = TracedGame(dir); + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); + string toml = Path.Combine(dir.Path, "p", "game.toml"); + string[] args = ["ampr", "profile", "generate", Path.Combine(traces, "startup"), "--output", toml, "--report", Path.Combine(dir.Path, "p", "game.md"), + "--runtime-header", Path.Combine(dir.Path, "p", "game.h")]; + + Assert.Equal(0, MkPFSCli.Run(args, ctx)); + Assert.StartsWith($"generated {toml}: files=2 index=", stdout.ToString(), StringComparison.Ordinal); + Assert.StartsWith("# Generated by ampr_pack_profile.py 4.1 from APR traces.\n", File.ReadAllText(toml), StringComparison.Ordinal); + Assert.Contains("# AMPR trace-derived pack profile: startup", File.ReadAllText(Path.Combine(dir.Path, "p", "game.md")), StringComparison.Ordinal); + Assert.Contains("#define AMPR_EMU_PACK_WORKERS ", File.ReadAllText(Path.Combine(dir.Path, "p", "game.h")), StringComparison.Ordinal); + Assert.Equal("compress", AMPRPackConfig.Load(toml).SelectRule("data/level0.dat").Action); + + Assert.Equal(2, MkPFSCli.Run(args, ctx)); + Assert.EndsWith($"error: refusing to overwrite existing file: {toml}\n", stderr.ToString(), StringComparison.Ordinal); + Assert.Equal(0, MkPFSCli.Run([.. args, "--overwrite"], ctx)); + } +} diff --git a/tools/oracle/README.md b/tools/oracle/README.md index 3da74b8..103b684 100644 --- a/tools/oracle/README.md +++ b/tools/oracle/README.md @@ -12,6 +12,7 @@ Python MkPFS (`D:\TOOLS\PS5\MkPFS`, expected at `../MkPFS`) stays the oracle unt | `build_goldens.py` | Builds 33 cases, captures inspect/tree/verify output, writes `manifest.json` and zlib vectors | | `bench.py` | Python baseline timings on a ~330 MiB tree | | `build_ampr_goldens.py` | AMPR asset-pack corpus from ampr_emu's `ampr_pack.py` (second oracle, see the last section) | +| `check_ampr_profile.py` | Runs ampr_emu's `ampr_pack_profile.py generate` and `mkpfs ampr profile generate` on synthetic APR traces and compares every output byte for byte (33 cases) | ## Run diff --git a/tools/oracle/check_ampr_profile.py b/tools/oracle/check_ampr_profile.py new file mode 100644 index 0000000..59c90c0 --- /dev/null +++ b/tools/oracle/check_ampr_profile.py @@ -0,0 +1,441 @@ +"""Check ``mkpfs ampr profile generate`` against ampr_emu's ``tools/ampr_pack_profile.py`` (the oracle). + +Writes synthetic APR traces (``AMPRCMD1`` journals and their ``AMPRIDX3`` indexes: mixed read patterns, several +runs, gather/scatter state, corrupt records, an unsafe path, content for LZ4 sampling), runs both tools on the same +inputs with option sets that reach every branch (budget enforcement, cache-sampling windows, pattern modes, include +sidecars, sampling, option errors) and compares exit codes, stdout, the last stderr line and every output file +byte for byte. + +Needs ampr_emu at ``ORACLE_COMMIT`` in ``../ampr_emu`` and a Python with python-lz4 4.4.5 for the oracle; build +MkPFS first. From the repo root: + dotnet build src/MkPFS.Cli + uv run --no-project --python 3.12 --with lz4==4.4.5 python tools/oracle/check_ampr_profile.py +""" + +from __future__ import annotations + +import argparse +import filecmp +import random +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +HERE: Path = Path(__file__).resolve().parent +REPO: Path = HERE.parent.parent +AMPR_EMU: Path = REPO.parent / "ampr_emu" +DEFAULT_OUT: Path = REPO / "tests" / "fixtures" / "generated" / "ampr_profile" +DEFAULT_MKPFS: Path = REPO / "src" / "MkPFS.Cli" / "bin" / "Debug" / "net10.0" / "mkpfs" + +HEADER = struct.Struct("<8sHHIQQQQQQIIIIIIII") + + +def fnv(data: bytes) -> int: + h = 1469598103934665603 + for b in data: + h ^= b + h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF + return h or 1 + + +def write_index(path: Path, entries: list[tuple[str, int]]) -> None: + blob = b"" + records = b"" + for p, size in entries: + raw = p.encode("utf-8", "surrogateescape") + records += struct.pack(" bytes: + return struct.pack(" bytes: + dwords = 6 if (wide or offset >= 1 << 32) else 5 + w0 = 40 | ((dwords - 1) << 8) | ((offset & 0x3FFFF) << 12) + w4 = (offset & 0xFFFC0000) | 0x1234 + out = w(w0) + w(length - 1) + w(file_id) + w(0xDEAD0000) + w(w4) + if dwords == 6: + out += w((offset >> 32) & 0xFF) + return out + + +def gather(offset: int, length: int) -> bytes: + dwords = 3 if offset >= 1 << 18 else 2 + w0 = 41 | ((dwords - 1) << 8) | ((offset & 0x3FFFF) << 12) + out = w(w0) + w(length - 1) + if dwords == 3: + out += w((offset >> 18) & 0x3FFFFF) + return out + + +def scatter(length: int) -> bytes: + return w(0x22A | (0x12 << 12)) + w(length - 1) + w(0x1000) + + +def gather_scatter(offset: int, length: int, wide: bool = False) -> bytes: + dwords = 5 if (wide or offset >= 1 << 32) else 4 + w0 = 43 | ((dwords - 1) << 8) | ((offset & 0x3FFFF) << 12) + out = w(w0) + w(length - 1) + w(0x2000) + w((offset & 0xFFFC0000) | 7) + if dwords == 5: + out += w((offset >> 32) & 0xFF) + return out + + +RESET = w(47) +MAP_END = w(46) + + +def noise(rng: random.Random) -> bytes: + choice = rng.randrange(9) + if choice == 0: # WaitOnAddress 3 dwords + return w(1 | (2 << 8)) + w(0x1000) + w(5) + if choice == 1: # WaitOnCounter 2 dwords with extra + return w(2 | (1 << 8)) + w(0x31000005) + if choice == 2: # WriteAddress 3 dwords + return w(5 | (2 << 8)) + w(0x2000 | 2) + w(9) + if choice == 3: # WriteCounter 3 dwords + return w(118 | (2 << 8)) + w(0x00100001) + w(3) + if choice == 4: # WriteKernelEventQueue + return w(1032) + w(1) + w(2) + w(3) + w(4) + if choice == 5: # marker set "hi" + return w(0x5452000F | (1 << 12) | (1 << 8)) + b"hi\0\0" + if choice == 6: # MarkerPop + return w(0x5452300F) + if choice == 7: # AprMapBegin + end + return w(557) + w(1) + w(2) + MAP_END + return w(0x325) + w(1) + w(2) + w(3) # AmmMapDirect + + +class Trace: + def __init__(self, path: Path, rng: random.Random) -> None: + self.path = path + self.rng = rng + self.data = bytearray() + self.seq = 0 + self.time = 1_000_000_000 + + def record(self, commands: list[bytes], priority: int = 0, domain: int = 1, *, seq: int | None = None, + bad_hash: bool = False, count: int | None = None, extra: bytes = b"", noise_rate: float = 0.2) -> None: + parts: list[bytes] = [] + for c in commands: + if self.rng.random() < noise_rate: + parts.append(noise(self.rng)) + parts.append(c) + payload = b"".join(parts) + self.seq = self.seq + 1 if seq is None else seq + self.time += self.rng.randrange(1000, 5_000_000) + n = count if count is not None else 0 + h = fnv(payload) ^ (1 if bad_hash else 0) + header_bytes = HEADER.size + len(extra) + head = HEADER.pack(b"AMPRCMD1", 1, header_bytes, header_bytes + len(payload), self.seq, 0, self.time, 0, 0, h, + len(payload), 4096, n, priority, domain, 0, 0, 0) + self.data += head + extra + payload + + def raw(self, data: bytes) -> None: + self.data += data + + def save(self) -> None: + self.path.write_bytes(bytes(self.data)) + + +def make_files(rng: random.Random, count: int, prefix: str = "") -> list[tuple[str, int]]: + exts = [".dat", ".bin", ".pak", ".bik", ".wem", ".prx", ".assets", ".bank", ".arc", ""] + out = [] + for i in range(count): + d = rng.choice(["data", "data/levels", "audio", "video", "bin", "data/levels/l1", "ui"]) + name = f"/app0/{prefix}{d}/file_{i:04d}{rng.choice(exts)}" + out.append((name, rng.choice([300, 4096, 70_000, 1 << 20, 5 << 20, 40 << 20, 200 << 20, 3 << 30]))) + return out + + +def workload(t: Trace, rng: random.Random, entries: list[tuple[str, int]], reads: int) -> None: + """Mix of random, streaming, gather/scatter and tiny-read patterns.""" + styles = {fid: rng.choice(["random", "stream", "tiny", "mixed", "gather"]) for fid in range(1, len(entries) + 1)} + cursor = {fid: 0 for fid in styles} + batch: list[bytes] = [] + priority = rng.randrange(3) + for _ in range(reads): + fid = rng.randrange(1, len(entries) + 1) + size = entries[fid - 1][1] + style = styles[fid] + if style == "stream": + length = rng.choice([256 << 10, 512 << 10, 1 << 20]) + off = cursor[fid] % max(1, size) + cursor[fid] = off + length + batch.append(read_file(fid, off, length)) + elif style == "tiny": + off = rng.randrange(0, max(1, min(size, 1 << 16))) + batch.append(read_file(fid, off, rng.choice([16, 64, 128, 200]))) + elif style == "random": + off = rng.randrange(0, max(1, size)) + batch.append(read_file(fid, off, rng.choice([4096, 8192, 16384, 32768]))) + elif style == "gather": + off = rng.randrange(0, max(1, size)) + batch.append(read_file(fid, off, 4096)) + for _ in range(rng.randrange(1, 4)): + if rng.random() < 0.5: + batch.append(scatter(rng.choice([4096, 65536]))) + else: + batch.append(gather(rng.randrange(0, max(1, min(size, 1 << 40))), rng.choice([2048, 65536]))) + if rng.random() < 0.3: + batch.append(gather_scatter(rng.randrange(0, max(1, size)), 8192, wide=rng.random() < 0.3)) + if rng.random() < 0.2: + batch.append(RESET) + else: + off = rng.randrange(0, max(1, size)) + batch.append(read_file(fid, off, rng.choice([65536, 131072, 300_000]), wide=rng.random() < 0.1)) + if len(batch) >= rng.randrange(1, 12): + t.record(batch, priority=priority, count=len(batch) if rng.random() < 0.3 else None, noise_rate=0.0) + batch = [] + priority = rng.randrange(3) + if batch: + t.record(batch, priority=priority) + + +def case_dir(root: Path, name: str) -> Path: + d = root / name + d.mkdir(parents=True, exist_ok=True) + return d + + +def build(root: Path) -> None: + rng = random.Random(1234) + + # basic: one run, mixed patterns, unicode + '|' names, prx / eboot (known loose), streaming suffixes + d = case_dir(root, "basic") + entries = make_files(rng, 40) + entries += [("/app0/eboot.bin", 5 << 20), ("/APP0/Data/ünïcode|name.dat", 3 << 20), ("/app0/sce_module/libc.prx", 1 << 20), + ("/app0/./odd//double/slash.dat", 2 << 20), ("/app0", 10)] + write_index(d / "ampr_emu.index", entries) + t = Trace(d / "ampr_commands.bin", rng) + workload(t, rng, entries, 3000) + t.record([read_file(len(entries) - 3, 0, 4096), read_file(len(entries) - 2, 100, 8192), read_file(len(entries) - 1, 0, 4096)]) + t.save() + + # multi: three runs merged with --trace, sizes changing between runs, unseen ids, warnings + d = case_dir(root, "multi") + base = make_files(rng, 60, "m") + for run in range(3): + rd = case_dir(d, f"run{run}") + ents = [(p, s + (4096 * run if i % 7 == 0 else 0)) for i, (p, s) in enumerate(base)] + write_index(rd / "ampr_emu.index", ents) + t = Trace(rd / "ampr_commands.bin", rng) + workload(t, rng, ents, 1500) + t.record([read_file(9999, 0, 10)]) # absent fileId + t.record([read_file(1, ents[0][1] + 10, 10)]) # beyond size + t.record([read_file(2, max(0, ents[1][1] - 5), 100)]) # clamped + t.record([gather(0, 10)], priority=7) # no state (fresh priority) + t.record([scatter(10)], priority=8) + t.record([gather_scatter(0, 10)], priority=9) + t.save() + + # many: 400 small files read randomly -> externalized / chunked rules + d = case_dir(root, "many") + ents = [(f"/app0/pak/{i // 50:02d}/chunk_{i:05d}.bin", 256 << 10) for i in range(400)] + write_index(d / "ampr_emu.index", ents) + t = Trace(d / "ampr_commands.bin", rng) + for _ in range(4000): + fid = rng.randrange(1, 401) + t.record([read_file(fid, rng.randrange(0, 256 << 10), rng.choice([4096, 16384]))], noise_rate=0.05) + t.save() + + # big: many touches for windowed cache sampling, plus lots of files (1000+ / switches -> 8 workers) + d = case_dir(root, "big") + ents = [(f"/app0/big/{i // 100:03d}/b{i:05d}.dat", rng.choice([1 << 20, 4 << 20])) for i in range(1200)] + write_index(d / "ampr_emu.index", ents) + t = Trace(d / "ampr_commands.bin", rng) + batch = [] + for _ in range(40000): + fid = rng.randrange(1, 1201) + batch.append(read_file(fid, rng.randrange(0, ents[fid - 1][1]), rng.choice([4096, 16384, 70000]))) + if len(batch) == 8: + t.record(batch, noise_rate=0) + batch = [] + t.save() + + # dirs: directory generalization (hybrid / directory) + d = case_dir(root, "dirs") + ents = [] + for dn in ["tex/a", "tex/b", "snd", "geo/x/y", "misc"]: + for i in range(rng.randrange(5, 30)): + ents.append((f"/app0/{dn}/f{i}.bin", rng.choice([1 << 20, 8 << 20]))) + write_index(d / "ampr_emu.index", ents) + t = Trace(d / "ampr_commands.bin", rng) + for _ in range(5000): + fid = rng.randrange(1, len(ents) + 1) + if ents[fid - 1][0].startswith("/app0/snd"): + t.record([read_file(fid, rng.randrange(0, 4) << 18, 1 << 18)], noise_rate=0) + elif rng.random() < 0.85: + t.record([read_file(fid, rng.randrange(0, ents[fid - 1][1]), 8192)], noise_rate=0) + t.save() + + # broken: corrupt journal pieces (decode errors, sequence gap, bad hash, AMM domain, truncation) + d = case_dir(root, "broken") + ents = make_files(rng, 10, "b") + [("/app0/../escape.dat", 4096)] + write_index(d / "ampr_emu.index", ents) + t = Trace(d / "ampr_commands.bin", rng) + t.record([read_file(1, 0, 4096)], seq=5) + t.record([read_file(2, 0, 4096)], seq=9, bad_hash=True) + t.record([read_file(3, 0, 4096)], domain=2) + t.record([w(40 | (2 << 8)) + w(0) + w(0)], count=3) # invalid AprReadFile length -> decode error + t.record([read_file(4, 0, 4096), read_file(5, 0, 4096)], count=5) # count mismatch + t.record([read_file(11, 0, 100)]) # unsafe path + t.record([read_file(6, 0, 4096)], extra=b"\x01" * 8) # extended header + t.record([w(1 | (7 << 8))]) # WaitOnAddress invalid + t.record([w(0xABC | (3 << 8))]) # Unknown, guessed 4 dwords but only 1 present -> 1 + t.record([read_file(7, 0, 4096)]) + for i in range(250): + t.record([read_file(9999, i, 1)], noise_rate=0) # >200 warnings -> omitted marker + t.save() + data = (d / "ampr_commands.bin").read_bytes() + (d / "ampr_commands.bin").write_bytes(data + data[:HEADER.size + 10]) # truncated payload at the end + + # trunchead: truncated header + d = case_dir(root, "trunchead") + write_index(d / "ampr_emu.index", ents[:5]) + t = Trace(d / "ampr_commands.bin", rng) + t.record([read_file(1, 0, 4096)]) + t.raw(b"AMPRCMD1" + b"\0" * 20) + t.save() + + # badmagic + d = case_dir(root, "badmagic") + write_index(d / "ampr_emu.index", ents[:5]) + (d / "ampr_commands.bin").write_bytes(b"NOTMAGIC" + b"\0" * 88) + + # empty journal + d = case_dir(root, "empty") + write_index(d / "ampr_emu.index", ents[:5]) + (d / "ampr_commands.bin").write_bytes(b"") + + # sample: content root with compressible / incompressible files + d = case_dir(root, "sample") + content = d / "app0" + ents = [] + for i in range(14): + name = f"data/s{i}.bin" + size = rng.choice([200 << 10, 1 << 20, 3 << 20]) if i < 12 else (65 << 20) + i + p = content / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(rng.randbytes(size) if i % 2 else (b"abcdefgh" * (size // 8 + 1))[:size]) + ents.append((f"/app0/{name}", size)) + write_index(d / "ampr_emu.index", ents) + t = Trace(d / "ampr_commands.bin", rng) + workload(t, rng, ents, 800) + t.save() + + +CASES: list[tuple[str, list[str]]] = [ + ("basic", ["data/basic"]), + ("basic-nocache", ["data/basic", "--no-cache-sim", "--name", "x y"]), + ("basic-budget", ["data/basic", "--pack-index-budget", "1MiB", "--strategy", "conservative"]), + ("basic-aggr", ["data/basic", "--pack-index-budget", "1MiB", "--strategy", "aggressive", "--lanes", "3", "--runtime-workers", "5"]), + ("basic-touches", ["data/basic", "--cache-max-touches", "5000", "--cache-candidates", "1MiB,4MiB,64MiB,64MiB,300MiB"]), + ("basic-touches2", ["data/basic", "--cache-max-touches", "100000"]), + ("basic-minreads", ["data/basic", "--min-reads", "5", "--min-requested-bytes", "64KiB", "--io-page-size", "16KiB"]), + ("multi", ["--trace", "data/multi/run0/ampr_commands.bin", "data/multi/run0/ampr_emu.index", + "--trace", "data/multi/run1/ampr_commands.bin", "data/multi/run1/ampr_emu.index", + "--trace", "data/multi/run2/ampr_commands.bin", "data/multi/run2/ampr_emu.index"]), + ("multi-hybrid", ["--trace", "data/multi/run0/ampr_commands.bin", "data/multi/run0/ampr_emu.index", + "--trace", "data/multi/run2/ampr_commands.bin", "data/multi/run2/ampr_emu.index", "--pattern-mode", "hybrid"]), + ("multi-windows", ["--trace", "data/multi/run0/ampr_commands.bin", "data/multi/run0/ampr_emu.index", + "--trace", "data/multi/run1/ampr_commands.bin", "data/multi/run1/ampr_emu.index", + "--trace", "data/multi/run2/ampr_commands.bin", "data/multi/run2/ampr_emu.index", "--cache-max-touches", "8192"]), + ("big-windows", ["data/big", "--cache-max-touches", "16384"]), + ("big", ["data/big", "--pattern-mode", "hybrid"]), + ("many-windows", ["data/many", "--cache-max-touches", "4100"]), + ("many", ["data/many"]), + ("many-inline", ["data/many", "--externalize-paths", "0", "--max-rule-files", "70"]), + ("many-ext10", ["data/many", "--externalize-paths", "10"]), + ("many-hybrid", ["data/many", "--pattern-mode", "hybrid"]), + ("dirs-hybrid", ["data/dirs", "--pattern-mode", "hybrid"]), + ("dirs-directory", ["data/dirs", "--pattern-mode", "directory", "--generalize-min-files", "3", "--generalize-coverage", "0.9"]), + ("dirs-exact", ["data/dirs"]), + ("broken", ["data/broken"]), + ("trunchead", ["data/trunchead"]), + ("empty", ["data/empty"]), + ("badmagic", ["data/badmagic"]), + ("sample", ["data/sample", "--content-root", "data/sample/app0", "--sample-budget", "8MiB"]), + ("sample-fast", ["data/sample", "--content-root", "data/sample/app0", "--sample-budget", "300KiB", "--sample-mode", "fast", + "--sample-blocks-per-file", "1"]), + ("err-none", []), + ("err-dir", ["data/multi"]), + ("err-missing", ["--trace", "data/nope.bin", "data/nope.index"]), + ("err-io", ["data/basic", "--io-page-size", "3000"]), + ("err-lanes", ["data/basic", "--lanes", "65"]), + ("err-cov", ["data/basic", "--generalize-coverage", "1.5"]), + ("err-file", ["data/basic/ampr_emu.index"]), +] + + +def run(cmd: list[str], cwd: Path) -> tuple[int, str, str]: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + + +def check(work: Path, python: str, mkpfs: Path, ampr_emu: Path) -> int: + tool = ampr_emu / "tools" / "ampr_pack_profile.py" + failures = 0 + for name, args in CASES: + outs = {} + for side in ("py", "cs"): + out = work / "out" / side / name + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + extra = ["--output", f"out/{side}/{name}/profile.toml", "--report", f"out/{side}/{name}/report.md", + "--runtime-header", f"out/{side}/{name}/runtime.h"] + cmd = [python, str(tool), "generate", *args, *extra] if side == "py" else [str(mkpfs), "ampr", "profile", "generate", *args, *extra] + code, so, se = run(cmd, work) + outs[side] = (code, so.replace(f"out/{side}/", "out/X/"), se.strip().splitlines()[-1:] if se.strip() else []) + (pc, pso, pse), (cc, cso, cse) = outs["py"], outs["cs"] + problems = [] + if pc != cc: + problems.append(f"exit {pc} vs {cc}") + if pso != cso: + problems.append(f"stdout\n py: {pso!r}\n cs: {cso!r}") + if pse != cse: + problems.append(f"stderr\n py: {pse!r}\n cs: {cse!r}") + pdir, cdir = work / "out" / "py" / name, work / "out" / "cs" / name + pfiles = sorted(p.name for p in pdir.iterdir()) + cfiles = sorted(p.name for p in cdir.iterdir()) + if pfiles != cfiles: + problems.append(f"files {pfiles} vs {cfiles}") + for f in sorted(set(pfiles) & set(cfiles)): + if not filecmp.cmp(pdir / f, cdir / f, shallow=False): + diff = subprocess.run(["diff", str(pdir / f), str(cdir / f)], capture_output=True, text=True).stdout + problems.append(f"{f} differs:\n{diff[:3000]}") + print(f"{'OK ' if not problems else 'FAIL'} {name} (exit {pc})") + for p in problems: + print(" ", p) + failures += bool(problems) + print(f"{failures} failing case(s) of {len(CASES)}") + return 1 if failures else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="work folder (default: tests/fixtures/generated/ampr_profile)") + parser.add_argument("--python", default=sys.executable, help="interpreter for ampr_pack_profile.py (needs python-lz4)") + parser.add_argument("--mkpfs", type=Path, default=DEFAULT_MKPFS, help="mkpfs executable") + parser.add_argument("--ampr-emu", type=Path, default=AMPR_EMU, help="ampr_emu checkout (default: ../ampr_emu)") + args = parser.parse_args() + work: Path = args.out.resolve() + if work.exists(): + shutil.rmtree(work) + build(work / "data") + return check(work, args.python, args.mkpfs.resolve(), args.ampr_emu.resolve()) + + +if __name__ == "__main__": + raise SystemExit(main()) From a93b37fc858faa8f8a27e5450780e830cdb9d4fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:35:49 +0000 Subject: [PATCH 14/28] Port ampr_pack_profile.py's metrics JSON and batch command `ampr profile generate --metrics` (and --full-metrics) writes the upstream metrics document, and `ampr profile batch` writes one profile per trace run of a folder or ZIP support bundle (TOML, report, metrics JSON, runtime header and summary.json), with the upstream batch defaults (hybrid patterns, no cache simulation) and --batch-jobs. The JSON writer gains insertion-ordered objects for documents dumped without sort_keys. tools/oracle/check_ampr_profile.py now covers both: 45 cases, every output byte for byte against the Python tool. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 15 +- src/MkPFS.Build/AMPRPack/AMPRProfiler.cs | 221 +++++++++++++- src/MkPFS.Cli/Commands/AmprProfileCommand.cs | 301 ++++++++++++++----- src/MkPFS.Core/Util/PythonSortedJson.cs | 21 +- tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs | 69 +++++ tools/oracle/README.md | 2 +- tools/oracle/check_ampr_profile.py | 110 +++++-- 7 files changed, 630 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 68959c4..f946734 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,9 @@ What traces can and cannot show: too. `mkpfs ampr profile generate --output rules.toml --report rules.md` (or `--trace COMMANDS -INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus a report and a runtime -header; the output matches `ampr_pack_profile.py generate` byte for byte. +INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus a report, a metrics JSON +and a runtime header; `mkpfs ampr profile batch` makes one profile per run of a folder or ZIP bundle. The +output matches `ampr_pack_profile.py` byte for byte. ##### Reporting a game @@ -394,8 +395,9 @@ of such a test lets the built-in rules learn that game. #### How the packs are made `mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same -JSON on standard output, and byte-identical packs. `ampr profile generate` is a port of -`tools/ampr_pack_profile.py` (4.1): the same TOML, report and runtime header. A pack set is the manifest `ampr_assets.index`, data +JSON on standard output, and byte-identical packs. `ampr profile` is a port of +`tools/ampr_pack_profile.py` (4.1, `generate` and `batch`): the same TOML, report, metrics JSON and runtime +header. A pack set is the manifest `ampr_assets.index`, data volumes `ampr_assets-*.pak`, optional runtime settings `ampr_assets.index.runtime`, and an offline CRC sidecar `ampr_assets.index.crc`. @@ -419,7 +421,8 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `inspect` | `--index ` | Manifest summary, volumes, and runtime settings. | | `runtime-config` | `--index --config ` | Replace `.runtime` from a `[runtime]` section without repacking. | | `remove-sources` | `--index --root ` | Show which sources the packs replace; with `--confirm`, verify everything and delete them. Also `remove-packed-sources`. | -| `profile generate` | `[] --output ` | Pack rules from APR traces (`--trace COMMANDS INDEX`, repeatable); `--report`, `--runtime-header` and every tuning option of `ampr_pack_profile.py generate`. Its `--metrics` JSON and `batch` are not ported. | +| `profile generate` | `[] --output ` | Pack rules from APR traces (`--trace COMMANDS INDEX`, repeatable); `--report`, `--metrics` (JSON, `--full-metrics` for every block-size candidate), `--runtime-header` and every tuning option of `ampr_pack_profile.py generate`. | +| `profile batch` | ` --output-dir ` | One profile per trace run found (TOML, report, metrics JSON, runtime header) and a `summary.json`; a ZIP support bundle is read for its trace files only. Defaults to `--pattern-mode hybrid` without the cache simulation; `--batch-jobs`, `--summary`. | `pack` options: @@ -643,7 +646,7 @@ needs python-lz4 4.4.5): python tools/oracle/build_ampr_goldens.py --check ``` -`ampr profile generate` is checked against `ampr_pack_profile.py` on synthetic traces (every output file, +`ampr profile` is checked against `ampr_pack_profile.py` on synthetic traces (every output file, byte for byte; build MkPFS first): ```bash diff --git a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs index e0b949d..d8547c5 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.IO.Compression; using System.Security.Cryptography; using System.Text; using MkPFS.Core.AMPR; @@ -206,10 +207,10 @@ internal AMPRProfileResult() } /// -/// Port of ampr_emu tools/ampr_pack_profile.py 4.1 (generate): turns APR traces recorded by the debug +/// Port of ampr_emu tools/ampr_pack_profile.py 4.1: turns APR traces recorded by the debug /// emulator build into an ampr_pack TOML profile. Files the traces show the game reading through APR are /// packed with block sizes and layouts fitted to the observed reads; every other file stays loose. The TOML, the -/// Markdown report and the runtime header match the Python output. +/// Markdown report, the metrics JSON and the runtime header match the Python output. /// public static class AMPRProfiler { @@ -267,8 +268,13 @@ internal sealed class FileMetrics public required List Events { get; init; } public int ReadCount { get; init; } public long RequestedBytes { get; init; } + public long UniqueRequestedBytes { get; init; } + public double CoverageRatio { get; init; } public double P50 { get; init; } + public double P75 { get; init; } public double P90 { get; init; } + public double P95 { get; init; } + public double P99 { get; init; } public double Tiny4kRatio { get; init; } public double Small16kRatio { get; init; } public double Small64kRatio { get; init; } @@ -290,6 +296,8 @@ internal sealed class FileRecommendation public bool Hot { get; set; } public required string Group { get; set; } public required string Reason { get; set; } + public double? SampledRatio { get; set; } + public long SampledBytes { get; set; } public ProfileKey Key => new(Action, Layout, BlockSize, Hot, Group); @@ -710,6 +718,207 @@ public static string RenderReport(AMPRProfileResult result) return string.Join('\n', lines); } + /// + /// The metrics document (Python json.dumps(result_as_json(...), ensure_ascii=False, indent=2) plus a + /// newline): profile settings, traces, cache simulations, warnings and every observed file. + /// + /// Profile. + /// Include all seven block-size candidates per file (--full-metrics). + /// JSON text. + public static string RenderMetricsJson(AMPRProfileResult result, bool fullCandidates = false) + { + PythonJsonObject groups = []; + foreach ((string name, int packCount, long maxPackSize) in result.Groups) + { + groups.Add(name, new PythonJsonObject + { + { "pack_count", packCount }, + { "assignment", "balanced" }, + { "max_pack_size", maxPackSize }, + { "stripe_large_files", false }, + }); + } + + PythonJsonObject document = new() + { + { "tool_version", ToolVersion }, + { "name", result.Name }, + { "traces", result.Traces.Select(t => new PythonJsonObject + { + { "name", t.Name }, + { "commands", t.Commands }, + { "index", t.Index }, + { "command_sha256", t.CommandSha256 }, + { "index_sha256", t.IndexSha256 }, + { "records", t.Records }, + { "reads", t.Reads }, + { "warnings", t.Warnings }, + { "decode_errors", t.DecodeErrors }, + { "duration_ns", t.DurationNs }, + }).ToList() }, + { "projected_index_bytes", result.ProjectedIndexBytes }, + { "index_budget", result.IndexBudget }, + { "recommended_cache_bytes", result.RecommendedCacheBytes }, + { "recommended_physical_cache_bytes", result.RecommendedPhysicalCacheBytes }, + { "recommended_runtime_workers", result.RecommendedRuntimeWorkers }, + { "recommended_latency_reserve_workers", result.RecommendedLatencyReserveWorkers }, + { "recommended_pool_bytes", result.RecommendedPoolBytes }, + { "groups", groups }, + { "cache_simulations", result.CacheSimulations.Select(c => new PythonJsonObject + { + { "capacity", c.Capacity }, + { "requested_bytes", c.RequestedBytes }, + { "hit_bytes", c.HitBytes }, + { "misses", c.Misses }, + { "hits", c.Hits }, + { "sampled_touches", c.SampledTouches }, + { "total_touches", c.TotalTouches }, + { "sampled_windows", c.SampledWindows }, + { "hit_ratio", c.HitRatio }, + }).ToList() }, + { "warnings", result.Warnings }, + { "files", result.Recommendations.OrderByDescending(r => r.Metrics.RequestedBytes).Select(r => RecommendationJson(r, fullCandidates)).ToList() }, + }; + return PythonSortedJson.Indented(document) + "\n"; + } + + // Python _recommendation_json. + private static PythonJsonObject RecommendationJson(FileRecommendation recommendation, bool fullCandidates) + { + FileMetrics m = recommendation.Metrics; + CandidateMetrics selected = m.Candidates[recommendation.BlockSize]; + PythonJsonObject json = new() + { + { "path", m.Path }, + { "relative", m.Relative }, + { "file_size", m.FileSize }, + { "read_count", m.ReadCount }, + { "requested_bytes", m.RequestedBytes }, + { "unique_requested_bytes", m.UniqueRequestedBytes }, + { "coverage_ratio", m.CoverageRatio }, + { "request_percentiles", new PythonJsonObject { { "p50", m.P50 }, { "p75", m.P75 }, { "p90", m.P90 }, { "p95", m.P95 }, { "p99", m.P99 } } }, + { "sequential_ratio", m.ExactSequentialRatio + m.NearSequentialRatio }, + { "random_seek_ratio", m.RandomSeekRatio }, + { "repeat_ratio_64k", m.Candidates[K64].RepeatRatio }, + { "confidence", m.Confidence }, + { "confidence_label", m.ConfidenceLabel }, + { "recommendation", new PythonJsonObject + { + { "action", recommendation.Action }, + { "layout", recommendation.Layout }, + { "block_size", recommendation.BlockSize }, + { "hot", recommendation.Hot }, + { "group", recommendation.Group }, + { "reason", recommendation.Reason }, + { "metadata_bytes", recommendation.MetadataBytes }, + { "amplification", selected.Amplification }, + { "average_blocks_per_read", selected.AverageBlocksPerRead }, + { "sampled_ratio", recommendation.SampledRatio }, + { "sampled_bytes", recommendation.SampledBytes }, + } }, + }; + if (fullCandidates) + { + PythonJsonObject candidates = []; + foreach (CandidateMetrics c in m.Candidates.Values.OrderBy(c => c.BlockSize)) + { + candidates.Add(c.BlockSize.ToString(CultureInfo.InvariantCulture), new PythonJsonObject + { + { "block_size", c.BlockSize }, + { "touched_bytes", c.TouchedBytes }, + { "total_block_touches", c.TotalBlockTouches }, + { "unique_blocks", c.UniqueBlocks }, + { "amplification", c.Amplification }, + { "average_blocks_per_read", c.AverageBlocksPerRead }, + { "repeat_ratio", c.RepeatRatio }, + { "offset_alignment_ratio", c.OffsetAlignmentRatio }, + { "length_alignment_ratio", c.LengthAlignmentRatio }, + { "metadata_bytes", c.MetadataBytes }, + { "local_score", c.LocalScore }, + }); + } + + json.Add("candidates", candidates); + } + + return json; + } + + /// One row of the batch summary.json (Python _build_batch_profile). + /// Profile. + /// JSON object. + public static PythonJsonObject BatchSummaryRow(AMPRProfileResult result) + { + CacheSimulation? first = result.CacheSimulations.Count > 0 ? result.CacheSimulations[0] : null; + return new PythonJsonObject + { + { "name", result.Name }, + { "files", result.Recommendations.Count }, + { "reads", result.Recommendations.Sum(r => (long)r.Metrics.ReadCount) }, + { "submitted_bytes", result.Recommendations.Sum(r => r.Metrics.RequestedBytes) }, + { "selected_file_bytes", result.Recommendations.Sum(r => r.Metrics.FileSize) }, + { "projected_index_bytes", result.ProjectedIndexBytes }, + { "recommended_cache_bytes", result.RecommendedCacheBytes }, + { "recommended_physical_cache_bytes", result.RecommendedPhysicalCacheBytes }, + { "recommended_runtime_workers", result.RecommendedRuntimeWorkers }, + { "recommended_latency_reserve_workers", result.RecommendedLatencyReserveWorkers }, + { "recommended_pool_bytes", result.RecommendedPoolBytes }, + { "cache_sampled_touches", first?.SampledTouches ?? 0 }, + { "cache_total_touches", first?.TotalTouches ?? 0 }, + { "warnings", result.Warnings.Count }, + }; + } + + /// + /// Extract the trace inputs of a ZIP support bundle (Python _extract_trace_archive): only + /// ampr_commands.bin and ampr_emu.index members, under <tempRoot>/traces. + /// + /// ZIP file. + /// Scratch folder. + /// The folder to scan for trace pairs. + /// Not a ZIP, an unsafe member, or no trace inputs. + public static string ExtractTraceArchive(string path, string tempRoot) + { + ZipArchive archive; + try + { + archive = ZipFile.OpenRead(path); + } + catch (InvalidDataException) + { + throw new AMPRPackException($"unsupported archive (only ZIP is accepted): {path}"); + } + + string destination = Path.Combine(tempRoot, "traces"); + Directory.CreateDirectory(destination); + int extracted = 0; + using (archive) + { + foreach (ZipArchiveEntry member in archive.Entries) + { + string name = member.FullName; + string[] parts = [.. name.Split('/').Where(p => p.Length > 0 && p != ".")]; + if (name.StartsWith('/') || parts.Contains("..")) + { + throw new AMPRPackException($"unsafe archive member: {PythonText.Repr(name)}"); + } + + // Support bundles also carry multi-gigabyte decoded logs; the profiler needs only these two. + if (name.EndsWith('/') || parts.Length == 0 || parts[^1] is not ("ampr_commands.bin" or "ampr_emu.index")) + { + continue; + } + + string target = Path.Combine([destination, .. parts]); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + member.ExtractToFile(target, overwrite: true); + extracted++; + } + } + + return extracted > 0 ? destination : throw new AMPRPackException($"archive contains no AMPR trace inputs: {path}"); + } + /// The runtime header with the recommended emulator defines (Python render_runtime_header). /// Profile. /// C header text. @@ -1297,6 +1506,7 @@ private static List BuildFileMetrics(List events) List lengths = [.. fileEvents.Select(e => e.Length)]; long requested = lengths.Sum(); long fileSize = fileEvents.Max(e => e.FileSize); + long uniqueRequested = UnionLength(fileEvents.Select(e => (e.Offset, e.End))); long transitionBytes = 0; long exactBytes = 0; @@ -1373,8 +1583,13 @@ private static List BuildFileMetrics(List events) Events = fileEvents, ReadCount = fileEvents.Count, RequestedBytes = requested, + UniqueRequestedBytes = uniqueRequested, + CoverageRatio = fileSize != 0 ? (double)uniqueRequested / fileSize : 0.0, P50 = p50, + P75 = Percentile(lengths, 75), P90 = p90, + P95 = Percentile(lengths, 95), + P99 = Percentile(lengths, 99), Tiny4kRatio = tiny4k, Small16kRatio = small16k, Small64kRatio = small64k, @@ -1665,6 +1880,8 @@ private static void SampleCompressibility(List recommendatio } double ratio = (double)storedTotal / rawTotal; + recommendation.SampledRatio = ratio; + recommendation.SampledBytes = rawTotal; if (ratio >= 0.94) { if (recommendation.Metrics.FileSize >= 64L * 1024 * 1024) diff --git a/src/MkPFS.Cli/Commands/AmprProfileCommand.cs b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs index 474796b..8293d6b 100644 --- a/src/MkPFS.Cli/Commands/AmprProfileCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs @@ -1,26 +1,129 @@ using System.CommandLine; using System.Globalization; +using System.Runtime.ExceptionServices; using MkPFS.Build.AMPRPack; using MkPFS.Cli.Output; using MkPFS.Core.AMPR; +using MkPFS.Core.Util; namespace MkPFS.Cli.Commands; /// -/// ampr profile generate: port of ampr_emu tools/ampr_pack_profile.py generate. Reads APR traces +/// ampr profile: port of ampr_emu tools/ampr_pack_profile.py. generate reads APR traces /// (ampr_commands.bin plus the run's ampr_emu.index) recorded by the debug emulator build and writes a -/// TOML pack profile, optionally the Markdown report and the runtime header. --metrics (JSON) and -/// batch are not ported. +/// TOML pack profile, optionally the Markdown report, the metrics JSON and the runtime header; batch writes +/// one profile per trace run found in a folder or ZIP support bundle. /// internal static class AmprProfileCommand { + /// Python DEFAULT_BATCH_JOBS. + private static readonly int DefaultBatchJobs = Math.Min(4, Math.Max(1, Environment.ProcessorCount / 2)); + public static Command Create(CliContext ctx) { Command command = new("profile", "generate pack rules from APR traces of the debug emulator build (ampr_pack_profile.py)"); command.Subcommands.Add(Generate(ctx)); + command.Subcommands.Add(Batch(ctx)); return command; } + // Python _add_common_options; batch defaults to hybrid patterns without the cache simulation. + private sealed class CommonOptions + { + private readonly Option _ioPageSize = new("--io-page-size") { DefaultValueFactory = _ => "64KiB" }; + private readonly Option _indexBudget = new("--pack-index-budget") { DefaultValueFactory = _ => "96MiB" }; + private readonly Option _cacheCandidates = new("--cache-candidates") { Description = "comma-separated capacities, e.g. 64MiB,128MiB,256MiB,512MiB", HelpName = "LIST" }; + private readonly Option _lanes = new("--lanes") { Description = "0 selects 1-4 lanes automatically", DefaultValueFactory = _ => 0 }; + private readonly Option _workers = new("--workers") { DefaultValueFactory = _ => 8 }; + private readonly Option _runtimeWorkers = new("--runtime-workers") { Description = "0 selects 2-8 runtime decode/I/O workers from the trace", DefaultValueFactory = _ => 0 }; + private readonly Option _strategy = new("--strategy") { Description = "trade read amplification against resident index size", DefaultValueFactory = _ => "balanced" }; + private readonly Option _patternMode; + private readonly Option _minReads = new("--min-reads") { DefaultValueFactory = _ => 1 }; + private readonly Option _minRequestedBytes = new("--min-requested-bytes") { DefaultValueFactory = _ => "1B" }; + private readonly Option _maxRuleFiles = new("--max-rule-files") { DefaultValueFactory = _ => 256 }; + private readonly Option _externalizePaths = new("--externalize-paths") + { + Description = "write larger exact include lists to portable include_from sidecars (0 keeps paths inline)", + DefaultValueFactory = _ => 128, + }; + private readonly Option _generalizeCoverage = new("--generalize-coverage") { DefaultValueFactory = _ => 0.80 }; + private readonly Option _generalizeMinFiles = new("--generalize-min-files") { DefaultValueFactory = _ => 4 }; + private readonly Option _cacheSim = new("--cache-sim") { Description = "simulate decoded-cache reuse (default for generate)" }; + private readonly Option _noCacheSim = new("--no-cache-sim") { Description = "skip the expensive decoded-cache simulation" }; + private readonly Option _cacheMaxTouches = new("--cache-max-touches") + { + Description = "simulate all decoded-cache block touches up to this limit; larger traces use deterministic contiguous windows (0 = exact)", + DefaultValueFactory = _ => AMPRProfiler.DefaultCacheMaxTouches, + }; + private readonly Option _contentRoot = new("--content-root") { Description = "optional extracted /app0 root for LZ4 sampling" }; + private readonly Option _sampleBudget = new("--sample-budget") { DefaultValueFactory = _ => "0B" }; + private readonly Option _sampleBlocksPerFile = new("--sample-blocks-per-file") { DefaultValueFactory = _ => 4 }; + private readonly Option _sampleMode = new("--sample-mode") { DefaultValueFactory = _ => "hc" }; + private readonly Option _sampleLevel = new("--sample-level") { DefaultValueFactory = _ => 12 }; + private readonly Option _sampleAcceleration = new("--sample-acceleration") { DefaultValueFactory = _ => 1 }; + private readonly bool _cacheSimulationDefault; + + public CommonOptions(string patternModeDefault, bool cacheSimulationDefault) + { + _patternMode = new("--pattern-mode") { Description = "exact is safest; hybrid generalises only high-coverage directories", DefaultValueFactory = _ => patternModeDefault }; + _patternMode.AcceptOnlyFromAmong("exact", "hybrid", "directory"); + _strategy.AcceptOnlyFromAmong("conservative", "balanced", "aggressive"); + _sampleMode.AcceptOnlyFromAmong("fast", "hc"); + _cacheSimulationDefault = cacheSimulationDefault; + } + + public Option FullMetrics { get; } = new("--full-metrics") { Description = "include all seven block-size candidates for every file in JSON" }; + + public Option Overwrite { get; } = new("--overwrite"); + + public void AddTo(Command command) + { + foreach (Option option in (Option[])[ + _ioPageSize, _indexBudget, _cacheCandidates, _lanes, _workers, _runtimeWorkers, _strategy, _patternMode, _minReads, _minRequestedBytes, + _maxRuleFiles, _externalizePaths, _generalizeCoverage, _generalizeMinFiles, _cacheSim, _noCacheSim, _cacheMaxTouches, _contentRoot, + _sampleBudget, _sampleBlocksPerFile, _sampleMode, _sampleLevel, _sampleAcceleration, FullMetrics, Overwrite]) + { + command.Options.Add(option); + } + } + + // Python _profile_options_from_args. + public AMPRProfileOptions Read(CliContext ctx, ParseResult parse, string name) + { + if (parse.GetValue(_cacheSim) && parse.GetValue(_noCacheSim)) + { + throw new AMPRPackException("--cache-sim and --no-cache-sim cannot be used together"); + } + + return new AMPRProfileOptions + { + Name = name, + IOPageSize = AMPRSize.Parse(parse.GetValue(_ioPageSize)!), + IndexBudget = AMPRSize.Parse(parse.GetValue(_indexBudget)!), + CacheCandidates = parse.GetValue(_cacheCandidates) is { } list ? CacheCandidates(list) : AMPRProfiler.DefaultCacheCandidates, + Lanes = parse.GetValue(_lanes), + Workers = parse.GetValue(_workers), + RuntimeWorkers = parse.GetValue(_runtimeWorkers), + Strategy = parse.GetValue(_strategy)!, + PatternMode = parse.GetValue(_patternMode)!, + MinReads = parse.GetValue(_minReads), + MinRequestedBytes = AMPRSize.Parse(parse.GetValue(_minRequestedBytes)!), + MaxRuleFiles = parse.GetValue(_maxRuleFiles), + ExternalizePaths = parse.GetValue(_externalizePaths), + GeneralizeCoverage = parse.GetValue(_generalizeCoverage), + GeneralizeMinFiles = parse.GetValue(_generalizeMinFiles), + CacheSimulation = parse.GetValue(_cacheSim) || (!parse.GetValue(_noCacheSim) && _cacheSimulationDefault), + CacheMaxTouches = parse.GetValue(_cacheMaxTouches), + ContentRoot = parse.GetValue(_contentRoot) is { } content ? AmprCommand.Resolve(ctx, content) : null, + SampleBudget = AMPRSize.Parse(parse.GetValue(_sampleBudget)!), + SampleBlocksPerFile = parse.GetValue(_sampleBlocksPerFile), + SampleMode = parse.GetValue(_sampleMode)!, + SampleLevel = parse.GetValue(_sampleLevel), + SampleAcceleration = parse.GetValue(_sampleAcceleration), + }; + } + } + private static Command Generate(CliContext ctx) { Argument input = new("input") { Description = "directory containing ampr_commands.bin and ampr_emu.index", Arity = ArgumentArity.ZeroOrOne }; @@ -34,55 +137,13 @@ private static Command Generate(CliContext ctx) Option name = new("--name"); Option output = new("--output") { Required = true }; Option report = new("--report"); + Option metrics = new("--metrics"); Option runtimeHeader = new("--runtime-header") { Description = "optional force-include header with runtime cache/pool/coalescing defines" }; - Option ioPageSize = new("--io-page-size") { DefaultValueFactory = _ => "64KiB" }; - Option indexBudget = new("--pack-index-budget") { DefaultValueFactory = _ => "96MiB" }; - Option cacheCandidates = new("--cache-candidates") { Description = "comma-separated capacities, e.g. 64MiB,128MiB,256MiB,512MiB", HelpName = "LIST" }; - Option lanes = new("--lanes") { Description = "0 selects 1-4 lanes automatically", DefaultValueFactory = _ => 0 }; - Option workers = new("--workers") { DefaultValueFactory = _ => 8 }; - Option runtimeWorkers = new("--runtime-workers") { Description = "0 selects 2-8 runtime decode/I/O workers from the trace", DefaultValueFactory = _ => 0 }; - Option strategy = new("--strategy") { Description = "trade read amplification against resident index size", DefaultValueFactory = _ => "balanced" }; - strategy.AcceptOnlyFromAmong("conservative", "balanced", "aggressive"); - Option patternMode = new("--pattern-mode") { Description = "exact is safest; hybrid generalises only high-coverage directories", DefaultValueFactory = _ => "exact" }; - patternMode.AcceptOnlyFromAmong("exact", "hybrid", "directory"); - Option minReads = new("--min-reads") { DefaultValueFactory = _ => 1 }; - Option minRequestedBytes = new("--min-requested-bytes") { DefaultValueFactory = _ => "1B" }; - Option maxRuleFiles = new("--max-rule-files") { DefaultValueFactory = _ => 256 }; - Option externalizePaths = new("--externalize-paths") - { - Description = "write larger exact include lists to portable include_from sidecars (0 keeps paths inline)", - DefaultValueFactory = _ => 128, - }; - Option generalizeCoverage = new("--generalize-coverage") { DefaultValueFactory = _ => 0.80 }; - Option generalizeMinFiles = new("--generalize-min-files") { DefaultValueFactory = _ => 4 }; - Option cacheSim = new("--cache-sim") { Description = "simulate decoded-cache reuse (default for generate)" }; - Option noCacheSim = new("--no-cache-sim") { Description = "skip the expensive decoded-cache simulation" }; - Option cacheMaxTouches = new("--cache-max-touches") - { - Description = "simulate all decoded-cache block touches up to this limit; larger traces use deterministic contiguous windows (0 = exact)", - DefaultValueFactory = _ => AMPRProfiler.DefaultCacheMaxTouches, - }; - Option contentRoot = new("--content-root") { Description = "optional extracted /app0 root for LZ4 sampling" }; - Option sampleBudget = new("--sample-budget") { DefaultValueFactory = _ => "0B" }; - Option sampleBlocksPerFile = new("--sample-blocks-per-file") { DefaultValueFactory = _ => 4 }; - Option sampleMode = new("--sample-mode") { DefaultValueFactory = _ => "hc" }; - sampleMode.AcceptOnlyFromAmong("fast", "hc"); - Option sampleLevel = new("--sample-level") { DefaultValueFactory = _ => 12 }; - Option sampleAcceleration = new("--sample-acceleration") { DefaultValueFactory = _ => 1 }; - Option overwrite = new("--overwrite"); - Command command = new("generate", "generate one profile") - { - input, trace, name, output, report, runtimeHeader, ioPageSize, indexBudget, cacheCandidates, lanes, workers, runtimeWorkers, strategy, - patternMode, minReads, minRequestedBytes, maxRuleFiles, externalizePaths, generalizeCoverage, generalizeMinFiles, cacheSim, noCacheSim, - cacheMaxTouches, contentRoot, sampleBudget, sampleBlocksPerFile, sampleMode, sampleLevel, sampleAcceleration, overwrite, - }; + CommonOptions common = new("exact", cacheSimulationDefault: true); + Command command = new("generate", "generate one profile") { input, trace, name, output, report, metrics, runtimeHeader }; + common.AddTo(command); command.SetAction(parse => AmprCommand.Run(ctx, () => { - if (parse.GetValue(cacheSim) && parse.GetValue(noCacheSim)) - { - throw new AMPRPackException("--cache-sim and --no-cache-sim cannot be used together"); - } - List traces = []; if (parse.GetValue(input) is { } inputPath) { @@ -119,45 +180,25 @@ private static Command Generate(CliContext ctx) } } - AMPRProfileOptions options = new() - { - Name = parse.GetValue(name) ?? traces[0].Name, - IOPageSize = AMPRSize.Parse(parse.GetValue(ioPageSize)!), - IndexBudget = AMPRSize.Parse(parse.GetValue(indexBudget)!), - CacheCandidates = parse.GetValue(cacheCandidates) is { } list ? CacheCandidates(list) : AMPRProfiler.DefaultCacheCandidates, - Lanes = parse.GetValue(lanes), - Workers = parse.GetValue(workers), - RuntimeWorkers = parse.GetValue(runtimeWorkers), - Strategy = parse.GetValue(strategy)!, - PatternMode = parse.GetValue(patternMode)!, - MinReads = parse.GetValue(minReads), - MinRequestedBytes = AMPRSize.Parse(parse.GetValue(minRequestedBytes)!), - MaxRuleFiles = parse.GetValue(maxRuleFiles), - ExternalizePaths = parse.GetValue(externalizePaths), - GeneralizeCoverage = parse.GetValue(generalizeCoverage), - GeneralizeMinFiles = parse.GetValue(generalizeMinFiles), - CacheSimulation = !parse.GetValue(noCacheSim), - CacheMaxTouches = parse.GetValue(cacheMaxTouches), - ContentRoot = parse.GetValue(contentRoot) is { } content ? AmprCommand.Resolve(ctx, content) : null, - SampleBudget = AMPRSize.Parse(parse.GetValue(sampleBudget)!), - SampleBlocksPerFile = parse.GetValue(sampleBlocksPerFile), - SampleMode = parse.GetValue(sampleMode)!, - SampleLevel = parse.GetValue(sampleLevel), - SampleAcceleration = parse.GetValue(sampleAcceleration), - }; + AMPRProfileOptions options = common.Read(ctx, parse, parse.GetValue(name) ?? traces[0].Name); options.Validate(); AMPRProfileResult result = AMPRProfiler.Build(traces, options); string outputText = parse.GetValue(output)!; string outputPath = AmprCommand.Resolve(ctx, outputText); Dictionary sidecars = []; string toml = AMPRProfiler.RenderToml(result, options, sidecars, Path.GetFileNameWithoutExtension(outputPath)); - bool replace = parse.GetValue(overwrite); + bool replace = parse.GetValue(common.Overwrite); WriteBundle(outputPath, toml, sidecars, replace); if (parse.GetValue(report) is { } reportPath) { WriteText(AmprCommand.Resolve(ctx, reportPath), AMPRProfiler.RenderReport(result), replace); } + if (parse.GetValue(metrics) is { } metricsPath) + { + WriteText(AmprCommand.Resolve(ctx, metricsPath), AMPRProfiler.RenderMetricsJson(result, parse.GetValue(common.FullMetrics)), replace); + } + if (parse.GetValue(runtimeHeader) is { } headerPath) { WriteText(AmprCommand.Resolve(ctx, headerPath), AMPRProfiler.RenderRuntimeHeader(result), replace); @@ -170,6 +211,114 @@ private static Command Generate(CliContext ctx) return command; } + private static Command Batch(CliContext ctx) + { + Argument input = new("input"); + Option outputDir = new("--output-dir") { Required = true }; + Option summary = new("--summary"); + Option batchJobs = new("--batch-jobs") + { + Description = $"parallel title profiles (0 = auto, currently {DefaultBatchJobs}); reduce this for memory-constrained hosts", + DefaultValueFactory = _ => 0, + }; + CommonOptions common = new("hybrid", cacheSimulationDefault: false); + Command command = new("batch", "scan a directory or ZIP and generate one profile per subdirectory") { input, outputDir, summary, batchJobs }; + common.AddTo(command); + command.SetAction(parse => AmprCommand.Run(ctx, () => + { + string inputPath = AmprCommand.Resolve(ctx, parse.GetValue(input)!); + string output = AmprCommand.Resolve(ctx, parse.GetValue(outputDir)!); + Directory.CreateDirectory(output); + string temp = Directory.CreateTempSubdirectory("ampr-profile-").FullName; + try + { + string scanRoot = File.Exists(inputPath) ? AMPRProfiler.ExtractTraceArchive(inputPath, temp) : inputPath; + List pairs = AMPRProfiler.DiscoverTracePairs(scanRoot); + if (pairs.Count == 0) + { + throw new AMPRPackException($"no trace pairs found under {inputPath}"); + } + + Dictionary usedNames = []; + List<(AMPRTraceSpec Pair, AMPRProfileOptions Options)> tasks = []; + foreach (AMPRTraceSpec pair in pairs) + { + string baseName = pair.Name.Length > 0 ? pair.Name : Path.GetFileName(Path.GetDirectoryName(pair.Commands)!) is { Length: > 0 } dir ? dir : "profile"; + int count = usedNames[baseName] = usedNames.GetValueOrDefault(baseName) + 1; + AMPRProfileOptions options = common.Read(ctx, parse, count == 1 ? baseName : $"{baseName}-{count}"); + // Batch content roots are ambiguous; sampling needs generate with one trace. + options.ContentRoot = null; + options.SampleBudget = 0; + options.Validate(); + tasks.Add((pair, options)); + } + + bool fullMetrics = parse.GetValue(common.FullMetrics); + bool replace = parse.GetValue(common.Overwrite); + int jobs = parse.GetValue(batchJobs) is > 0 and int requested ? requested : DefaultBatchJobs; + jobs = Math.Min(jobs, tasks.Count); + PythonJsonObject[] rows = new PythonJsonObject[tasks.Count]; + if (jobs <= 1) + { + for (int i = 0; i < tasks.Count; i++) + { + rows[i] = BuildBatchProfile(tasks[i].Pair, tasks[i].Options, output, fullMetrics, replace); + ctx.Out.WriteLine($"generated {tasks[i].Options.Name}.toml"); + } + } + else + { + // Titles are independent; results and the first error come in input order, like + // ProcessPoolExecutor.map. + Exception?[] errors = new Exception?[tasks.Count]; + Parallel.For(0, tasks.Count, new ParallelOptions { MaxDegreeOfParallelism = jobs }, i => + { + try + { + rows[i] = BuildBatchProfile(tasks[i].Pair, tasks[i].Options, output, fullMetrics, replace); + } + catch (Exception exc) + { + errors[i] = exc; + } + }); + for (int i = 0; i < tasks.Count; i++) + { + if (errors[i] is { } error) + { + ExceptionDispatchInfo.Throw(error); + } + + ctx.Out.WriteLine($"generated {tasks[i].Options.Name}.toml"); + } + } + + string summaryPath = parse.GetValue(summary) is { } path ? AmprCommand.Resolve(ctx, path) : Path.Combine(output, "summary.json"); + WriteText(summaryPath, PythonSortedJson.Indented(rows) + "\n", replace); + } + finally + { + Directory.Delete(temp, recursive: true); + } + })); + return command; + } + + // Python _build_batch_profile: TOML (+ sidecars), report, metrics JSON and runtime header per title. + private static PythonJsonObject BuildBatchProfile(AMPRTraceSpec pair, AMPRProfileOptions options, string output, bool fullMetrics, bool overwrite) + { + AMPRProfileResult result = AMPRProfiler.Build([pair], options); + string name = options.Name; + string tomlPath = Path.Combine(output, $"{name}.toml"); + Dictionary sidecars = []; + string toml = AMPRProfiler.RenderToml(result, options, sidecars, Path.GetFileNameWithoutExtension(tomlPath)); + WriteBundle(tomlPath, toml, sidecars, overwrite); + WriteText(Path.Combine(output, $"{name}.md"), AMPRProfiler.RenderReport(result), overwrite); + WriteText(Path.Combine(output, $"{name}.json"), AMPRProfiler.RenderMetricsJson(result, fullMetrics), overwrite); + WriteText(Path.Combine(output, $"{name}.runtime.h"), AMPRProfiler.RenderRuntimeHeader(result), overwrite); + return AMPRProfiler.BatchSummaryRow(result); + } + // Python _parse_cache_candidates: sizes, sorted, without duplicates. private static List CacheCandidates(string value) { diff --git a/src/MkPFS.Core/Util/PythonSortedJson.cs b/src/MkPFS.Core/Util/PythonSortedJson.cs index 96ac878..d6d5b7d 100644 --- a/src/MkPFS.Core/Util/PythonSortedJson.cs +++ b/src/MkPFS.Core/Util/PythonSortedJson.cs @@ -7,7 +7,8 @@ namespace MkPFS.Core.Util; /// Python json.dumps(value, sort_keys=True, ...) for byte-exact output: keys sorted by code point, floats as /// Python repr. Values: , , integers, , /// , with string keys, and other -/// as arrays. +/// as arrays. A keeps its insertion +/// order, like a dict dumped without sort_keys. /// public static class PythonSortedJson { @@ -51,6 +52,14 @@ private static void Write(StringBuilder builder, object? value, int? indent, int case string s: WriteString(builder, s, ensureAscii); break; + case PythonJsonObject ordered: + WriteContainer(builder, '{', '}', ordered.Count, indent, level, i => + { + WriteString(builder, ordered[i].Key, ensureAscii); + builder.Append(indent is null ? ":" : ": "); + Write(builder, ordered[i].Value, indent, level + 1, ensureAscii); + }); + break; case IReadOnlyDictionary map: List keys = [.. map.Keys.OrderBy(k => k, PythonCodePointComparer.Instance)]; WriteContainer(builder, '{', '}', keys.Count, indent, level, i => @@ -148,3 +157,13 @@ private static void WriteString(StringBuilder builder, string value, bool ensure builder.Append('"'); } } + + +/// A JSON object written in insertion order (a Python dict dumped without sort_keys). +public sealed class PythonJsonObject : List> +{ + /// Append a member. + /// Key. + /// Value. + public void Add(string key, object? value) => Add(new KeyValuePair(key, value)); +} \ No newline at end of file diff --git a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs index e6fc09d..9600806 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs @@ -1,4 +1,6 @@ using System.Buffers.Binary; +using System.IO.Compression; +using System.Text.Json; using MkPFS.Build; using MkPFS.Build.AMPRPack; using MkPFS.Cli; @@ -213,4 +215,71 @@ public void Cli_profile_generate_writes_the_toml_report_and_header_and_refuses_t Assert.EndsWith($"error: refusing to overwrite existing file: {toml}\n", stderr.ToString(), StringComparison.Ordinal); Assert.Equal(0, MkPFSCli.Run([.. args, "--overwrite"], ctx)); } + + [Fact] + public void Cli_profile_generate_writes_metrics_in_upstream_key_order() + { + using TempDir dir = new(); + (_, string traces) = TracedGame(dir); + CliContext ctx = new(new StringWriter(), new StringWriter(), useColor: false, utf8: false, progress: false); + string metrics = Path.Combine(dir.Path, "m.json"); + + Assert.Equal(0, MkPFSCli.Run(["ampr", "profile", "generate", Path.Combine(traces, "startup"), "--output", Path.Combine(dir.Path, "p.toml"), + "--metrics", metrics, "--full-metrics"], ctx)); + + string text = File.ReadAllText(metrics); + Assert.StartsWith("{\n \"tool_version\": \"4.1\",\n \"name\": \"startup\",\n \"traces\": [", text, StringComparison.Ordinal); + Assert.EndsWith("}\n", text, StringComparison.Ordinal); + using JsonDocument doc = JsonDocument.Parse(text); + JsonElement files = doc.RootElement.GetProperty("files"); + Assert.Equal(["data/level0.dat", "eboot.bin"], files.EnumerateArray().Select(f => f.GetProperty("relative").GetString())); + JsonElement first = files[0]; + Assert.Equal("compress", first.GetProperty("recommendation").GetProperty("action").GetString()); + Assert.Equal(JsonValueKind.Null, first.GetProperty("recommendation").GetProperty("sampled_ratio").ValueKind); + Assert.Equal(["16384", "32768", "65536", "131072", "262144", "524288", "1048576"], + first.GetProperty("candidates").EnumerateObject().Select(c => c.Name)); + } + + [Fact] + public void Cli_profile_batch_writes_one_profile_per_run_from_a_folder_or_zip() + { + using TempDir dir = new(); + (_, string traces) = TracedGame(dir); + foreach (string file in (string[])["ampr_commands.bin", "ampr_emu.index"]) + { + Directory.CreateDirectory(Path.Combine(traces, "level1")); + File.Copy(Path.Combine(traces, "startup", file), Path.Combine(traces, "level1", file)); + } + + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); + string output = Path.Combine(dir.Path, "profiles"); + + Assert.Equal(0, MkPFSCli.Run(["ampr", "profile", "batch", traces, "--output-dir", output, "--batch-jobs", "2"], ctx)); + Assert.Equal("generated level1.toml\ngenerated startup.toml\n", stdout.ToString()); + foreach (string name in (string[])["level1", "startup"]) + { + foreach (string extension in (string[])[".toml", ".md", ".json", ".runtime.h"]) + { + Assert.True(File.Exists(Path.Combine(output, name + extension)), name + extension); + } + } + + using (JsonDocument summary = JsonDocument.Parse(File.ReadAllText(Path.Combine(output, "summary.json")))) + { + Assert.Equal(["level1", "startup"], summary.RootElement.EnumerateArray().Select(r => r.GetProperty("name").GetString())); + } + + // Batch defaults: hybrid patterns, no cache simulation. + Assert.Contains("| disabled |", File.ReadAllText(Path.Combine(output, "startup.md")), StringComparison.Ordinal); + + // A support bundle: only the trace files are extracted; an existing profile is not replaced. + string zip = Path.Combine(dir.Path, "bundle.zip"); + ZipFile.CreateFromDirectory(traces, zip); + Assert.Equal(2, MkPFSCli.Run(["ampr", "profile", "batch", zip, "--output-dir", output], ctx)); + Assert.EndsWith($"error: refusing to overwrite existing file: {Path.Combine(output, "level1.toml")}\n", stderr.ToString(), StringComparison.Ordinal); + Assert.Equal(0, MkPFSCli.Run(["ampr", "profile", "batch", zip, "--output-dir", Path.Combine(dir.Path, "fromzip")], ctx)); + Assert.True(File.Exists(Path.Combine(dir.Path, "fromzip", "startup.toml"))); + } } diff --git a/tools/oracle/README.md b/tools/oracle/README.md index 103b684..1142a02 100644 --- a/tools/oracle/README.md +++ b/tools/oracle/README.md @@ -12,7 +12,7 @@ Python MkPFS (`D:\TOOLS\PS5\MkPFS`, expected at `../MkPFS`) stays the oracle unt | `build_goldens.py` | Builds 33 cases, captures inspect/tree/verify output, writes `manifest.json` and zlib vectors | | `bench.py` | Python baseline timings on a ~330 MiB tree | | `build_ampr_goldens.py` | AMPR asset-pack corpus from ampr_emu's `ampr_pack.py` (second oracle, see the last section) | -| `check_ampr_profile.py` | Runs ampr_emu's `ampr_pack_profile.py generate` and `mkpfs ampr profile generate` on synthetic APR traces and compares every output byte for byte (33 cases) | +| `check_ampr_profile.py` | Runs ampr_emu's `ampr_pack_profile.py` and `mkpfs ampr profile` (`generate` and `batch`) on synthetic APR traces and ZIP bundles and compares every output byte for byte (45 cases) | ## Run diff --git a/tools/oracle/check_ampr_profile.py b/tools/oracle/check_ampr_profile.py index 59c90c0..555e646 100644 --- a/tools/oracle/check_ampr_profile.py +++ b/tools/oracle/check_ampr_profile.py @@ -1,10 +1,11 @@ -"""Check ``mkpfs ampr profile generate`` against ampr_emu's ``tools/ampr_pack_profile.py`` (the oracle). +"""Check ``mkpfs ampr profile`` against ampr_emu's ``tools/ampr_pack_profile.py`` (the oracle). Writes synthetic APR traces (``AMPRCMD1`` journals and their ``AMPRIDX3`` indexes: mixed read patterns, several runs, gather/scatter state, corrupt records, an unsafe path, content for LZ4 sampling), runs both tools on the same inputs with option sets that reach every branch (budget enforcement, cache-sampling windows, pattern modes, include -sidecars, sampling, option errors) and compares exit codes, stdout, the last stderr line and every output file -byte for byte. +sidecars, sampling, option errors; ``batch`` on folders and ZIP bundles) and compares exit codes, stdout, the last +stderr line and every output file (TOML, sidecars, report, metrics JSON, runtime header, batch summary) byte for +byte. Only the random temporary folder of an extracted ZIP is normalized. Needs ampr_emu at ``ORACLE_COMMIT`` in ``../ampr_emu`` and a Python with python-lz4 4.4.5 for the oracle; build MkPFS first. From the repo root: @@ -15,12 +16,13 @@ from __future__ import annotations import argparse -import filecmp import random +import re import shutil import struct import subprocess import sys +import zipfile from pathlib import Path HERE: Path = Path(__file__).resolve().parent @@ -335,9 +337,37 @@ def build(root: Path) -> None: t.save() -CASES: list[tuple[str, list[str]]] = [ + +def build_batch_inputs(root: Path) -> None: + """Folders and ZIP bundles for ``batch``: duplicate names, junk members, unsafe and empty archives.""" + dups = root / "dups" + for run in ("x/y", "x-y", "z"): + target = dups / run + target.mkdir(parents=True, exist_ok=True) + for f in ("ampr_commands.bin", "ampr_emu.index"): + shutil.copy2(root / "multi" / "run1" / f, target / f) + (dups / "orphan").mkdir() + (dups / "orphan" / "ampr_commands.bin").write_bytes(b"") + with zipfile.ZipFile(root / "bundle.zip", "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("support/", b"") + z.writestr("support/logs/decoded.txt", b"x" * 1000) + for run in ("run0", "run2"): + for f in ("ampr_commands.bin", "ampr_emu.index", "ampr_emu.log"): + source = root / "multi" / run / f + z.writestr(f"support/{run}/{f}", source.read_bytes() if source.exists() else b"log") + z.writestr("./support/./many/ampr_commands.bin", (root / "many" / "ampr_commands.bin").read_bytes()) + z.writestr("support/many/ampr_emu.index", (root / "many" / "ampr_emu.index").read_bytes()) + with zipfile.ZipFile(root / "unsafe.zip", "w") as z: + z.writestr("ok/readme.txt", b"x") + z.writestr("../evil/ampr_commands.bin", b"x") + with zipfile.ZipFile(root / "notrace.zip", "w") as z: + z.writestr("logs/a.txt", b"x") + (root / "notzip.bin").write_bytes(b"PK\x03\x04 not really a zip" * 10) + (root / "nothing").mkdir() + +GENERATE: list[tuple[str, list[str]]] = [ ("basic", ["data/basic"]), - ("basic-nocache", ["data/basic", "--no-cache-sim", "--name", "x y"]), + ("basic-nocache", ["data/basic", "--no-cache-sim", "--name", "x y", "--full-metrics"]), ("basic-budget", ["data/basic", "--pack-index-budget", "1MiB", "--strategy", "conservative"]), ("basic-aggr", ["data/basic", "--pack-index-budget", "1MiB", "--strategy", "aggressive", "--lanes", "3", "--runtime-workers", "5"]), ("basic-touches", ["data/basic", "--cache-max-touches", "5000", "--cache-candidates", "1MiB,4MiB,64MiB,64MiB,300MiB"]), @@ -354,7 +384,7 @@ def build(root: Path) -> None: ("big-windows", ["data/big", "--cache-max-touches", "16384"]), ("big", ["data/big", "--pattern-mode", "hybrid"]), ("many-windows", ["data/many", "--cache-max-touches", "4100"]), - ("many", ["data/many"]), + ("many", ["data/many", "--full-metrics"]), ("many-inline", ["data/many", "--externalize-paths", "0", "--max-rule-files", "70"]), ("many-ext10", ["data/many", "--externalize-paths", "10"]), ("many-hybrid", ["data/many", "--pattern-mode", "hybrid"]), @@ -365,7 +395,7 @@ def build(root: Path) -> None: ("trunchead", ["data/trunchead"]), ("empty", ["data/empty"]), ("badmagic", ["data/badmagic"]), - ("sample", ["data/sample", "--content-root", "data/sample/app0", "--sample-budget", "8MiB"]), + ("sample", ["data/sample", "--content-root", "data/sample/app0", "--sample-budget", "8MiB", "--full-metrics"]), ("sample-fast", ["data/sample", "--content-root", "data/sample/app0", "--sample-budget", "300KiB", "--sample-mode", "fast", "--sample-blocks-per-file", "1"]), ("err-none", []), @@ -378,24 +408,65 @@ def build(root: Path) -> None: ] +BATCH: list[tuple[str, list[str]]] = [ + ("batch-multi", ["data/multi", "--batch-jobs", "1"]), + ("batch-multi-par", ["data/multi", "--batch-jobs", "3", "--full-metrics"]), + ("batch-exact-cache", ["data/multi", "--pattern-mode", "exact", "--cache-sim", "--batch-jobs", "1"]), + ("batch-dups", ["data/dups", "--batch-jobs", "1"]), + ("batch-single", ["data/many", "--batch-jobs", "1", "--summary", "SUMMARY"]), + ("batch-zip", ["data/bundle.zip", "--batch-jobs", "2", "--content-root", "data/sample/app0", "--sample-budget", "1MiB"]), + ("batch-err-unsafe", ["data/unsafe.zip"]), + ("batch-err-notrace", ["data/notrace.zip"]), + ("batch-err-notzip", ["data/notzip.bin"]), + ("batch-err-empty", ["data/nothing"]), + ("batch-err-missing", ["data/missing-folder"]), + ("batch-err-lanes", ["data/many", "--lanes", "99"]), +] + + def run(cmd: list[str], cwd: Path) -> tuple[int, str, str]: p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) return p.returncode, p.stdout, p.stderr +def normalized(path: Path) -> bytes: + # Batch ZIP inputs are extracted to a random temporary folder that the report and metrics name. + return re.sub(rb"ampr-profile-[A-Za-z0-9_]+", b"ampr-profile-TEMP", path.read_bytes()) + + +def compare_trees(pdir: Path, cdir: Path) -> list[str]: + problems = [] + pfiles = sorted(str(p.relative_to(pdir)) for p in pdir.rglob("*") if p.is_file()) + cfiles = sorted(str(p.relative_to(cdir)) for p in cdir.rglob("*") if p.is_file()) + if pfiles != cfiles: + problems.append(f"files {pfiles} vs {cfiles}") + for f in sorted(set(pfiles) & set(cfiles)): + if normalized(pdir / f) != normalized(cdir / f): + diff = subprocess.run(["diff", str(pdir / f), str(cdir / f)], capture_output=True, text=True).stdout + problems.append(f"{f} differs:\n{diff[:3000]}") + return problems + + def check(work: Path, python: str, mkpfs: Path, ampr_emu: Path) -> int: tool = ampr_emu / "tools" / "ampr_pack_profile.py" + cases = [("generate", n, a) for n, a in GENERATE] + [("batch", n, a) for n, a in BATCH] failures = 0 - for name, args in CASES: + for kind, name, args in cases: outs = {} for side in ("py", "cs"): out = work / "out" / side / name if out.exists(): shutil.rmtree(out) out.mkdir(parents=True) - extra = ["--output", f"out/{side}/{name}/profile.toml", "--report", f"out/{side}/{name}/report.md", - "--runtime-header", f"out/{side}/{name}/runtime.h"] - cmd = [python, str(tool), "generate", *args, *extra] if side == "py" else [str(mkpfs), "ampr", "profile", "generate", *args, *extra] + o = f"out/{side}/{name}" + if kind == "generate": + extra = ["--output", f"{o}/profile.toml", "--report", f"{o}/report.md", "--metrics", f"{o}/metrics.json", + "--runtime-header", f"{o}/runtime.h"] + case_args = args + else: + extra = ["--output-dir", f"{o}/profiles"] + case_args = [f"{o}/summary-custom.json" if a == "SUMMARY" else a for a in args] + cmd = [python, str(tool), kind, *case_args, *extra] if side == "py" else [str(mkpfs), "ampr", "profile", kind, *case_args, *extra] code, so, se = run(cmd, work) outs[side] = (code, so.replace(f"out/{side}/", "out/X/"), se.strip().splitlines()[-1:] if se.strip() else []) (pc, pso, pse), (cc, cso, cse) = outs["py"], outs["cs"] @@ -406,20 +477,12 @@ def check(work: Path, python: str, mkpfs: Path, ampr_emu: Path) -> int: problems.append(f"stdout\n py: {pso!r}\n cs: {cso!r}") if pse != cse: problems.append(f"stderr\n py: {pse!r}\n cs: {cse!r}") - pdir, cdir = work / "out" / "py" / name, work / "out" / "cs" / name - pfiles = sorted(p.name for p in pdir.iterdir()) - cfiles = sorted(p.name for p in cdir.iterdir()) - if pfiles != cfiles: - problems.append(f"files {pfiles} vs {cfiles}") - for f in sorted(set(pfiles) & set(cfiles)): - if not filecmp.cmp(pdir / f, cdir / f, shallow=False): - diff = subprocess.run(["diff", str(pdir / f), str(cdir / f)], capture_output=True, text=True).stdout - problems.append(f"{f} differs:\n{diff[:3000]}") - print(f"{'OK ' if not problems else 'FAIL'} {name} (exit {pc})") + problems += compare_trees(work / "out" / "py" / name, work / "out" / "cs" / name) + print(f"{'OK ' if not problems else 'FAIL'} {kind} {name} (exit {pc})") for p in problems: print(" ", p) failures += bool(problems) - print(f"{failures} failing case(s) of {len(CASES)}") + print(f"{failures} failing case(s) of {len(cases)}") return 1 if failures else 0 @@ -434,6 +497,7 @@ def main() -> int: if work.exists(): shutil.rmtree(work) build(work / "data") + build_batch_inputs(work / "data") return check(work, args.python, args.mkpfs.resolve(), args.ampr_emu.resolve()) From 75e07075cc68f3e26d20fbddbd9c014933999a5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:20:58 +0000 Subject: [PATCH 15/28] Find trace runs with hidden files and re-check folders before a run The trace folder search skipped files with the Hidden or System attribute (the .NET default), which copies off the console can carry; Python's rglob counts them, and names now follow the platform's case rules like rglob. The AMPR Packs page also looked at the game and trace folders only when a path was picked, so runs copied in later kept the "No traces in this folder" warning; it now looks again before each run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- src/MkPFS.Build/AMPRPack/AMPRProfiler.cs | 4 ++- .../ViewModels/AmprPackPanelViewModel.cs | 26 ++++++++++++++++--- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 8 +++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs index d8547c5..dca3138 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs @@ -365,7 +365,9 @@ public static List DiscoverTracePairs(string root) } List pairs = []; - EnumerationOptions options = new() { RecurseSubdirectories = true, IgnoreInaccessible = true, MatchCasing = MatchCasing.CaseSensitive }; + // Like Path.rglob: hidden and system files count too (copies off the console can carry those attributes), + // and names match with the platform's case rules. + EnumerationOptions options = new() { RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = 0, MatchCasing = MatchCasing.PlatformDefault }; List found = [.. Directory.EnumerateFiles(root, "ampr_commands.bin", options) .Select(f => Path.GetRelativePath(root, f).Split(Path.DirectorySeparatorChar))]; found.Sort(ComparePathParts); diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index 4770eaa..ce54bb1 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -196,6 +196,8 @@ public string RulesFoundText /// protected internal override IReadOnlyList? BuildArguments(out string? error) { + // Files may have been copied into the folders since they were picked. + RefreshDetection(); string root = Root.Trim(); string manifest = Manifest.Trim(); string output = Output.Trim(); @@ -274,14 +276,32 @@ public string RulesFoundText } /// Detect the rules for the chosen game folder (a few folder levels, so it stays quick). - partial void OnRootChanged(string value) + partial void OnRootChanged(string value) => DetectRules(value); + + /// Count the trace runs in the chosen folder (each needs ampr_commands.bin and ampr_emu.index). + partial void OnTracesChanged(string value) => CountTraceRuns(value); + + /// Look at the game and trace folders again and update the rules lines. + public void RefreshDetection() + { + DetectRules(Root); + CountTraceRuns(Traces); + OnPropertyChanged(nameof(ShowRulesFound)); + OnPropertyChanged(nameof(ShowRulesNone)); + OnPropertyChanged(nameof(RulesFoundText)); + OnPropertyChanged(nameof(ShowRulesTraces)); + OnPropertyChanged(nameof(ShowNoTraces)); + OnPropertyChanged(nameof(RulesTracesText)); + } + + // The built-in rules for the game folder (a few folder levels, so it stays quick). + private void DetectRules(string value) { string root = value.Trim(); _detected = root.Length > 0 && Directory.Exists(root) ? Build.AMPRPack.AMPRPackConfig.DetectPreset(root) : null; } - /// Count the trace runs in the chosen folder (each needs ampr_commands.bin and ampr_emu.index). - partial void OnTracesChanged(string value) + private void CountTraceRuns(string value) { string folder = value.Trim(); if (folder.Length == 0) diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 15ede96..710b3b7 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -184,8 +184,14 @@ public void Page_takes_rules_from_a_trace_folder() Assert.True(panel.ShowNoTraces); Assert.False(panel.ShowRulesTraces || panel.ShowRulesNone || panel.ShowRulesFound); + // Runs copied in after the folder was picked are found when the page runs; hidden files count. dir.File("traces/startup/ampr_commands.bin"); - dir.File("traces/startup/ampr_emu.index"); + string index = dir.File("traces/startup/ampr_emu.index"); + File.SetAttributes(index, FileAttributes.Hidden); + Assert.NotNull(panel.BuildArguments(out _)); + Assert.True(panel.ShowRulesTraces); + Assert.StartsWith("✓ Rules from traces: 1 recorded run(s) found.", panel.RulesTracesText, StringComparison.Ordinal); + dir.File("traces/level1/ampr_commands.bin"); dir.File("traces/level1/ampr_emu.index"); panel.Traces = traces + Path.DirectorySeparatorChar; From 9692c012e429927a5ac1010ed0f41d955d3f700b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:45:27 +0000 Subject: [PATCH 16/28] Optionally pack untraced files of the traced file types A trace session rarely reaches every level or mode: with two UFC 6 sessions, 111 of its .cas archives (24 GiB) were never read and stayed loose. --pack-untraced-types (ampr game, ampr pack, profile generate) and a checkbox on the AMPR Packs page add every untraced file of the traced games' indexes whose extension a packed traced file has, with the layout, block size and group most traced files of that type got. Files without an extension, executables, modules, sce_sys and fakelib are never added; the rules go before the upstream safety exclusions, and the upstream part of the TOML is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 8 ++ src/MkPFS.Build/AMPRPack/AMPRProfiler.cs | 136 +++++++++++++++++- src/MkPFS.Cli/Commands/AmprCommand.cs | 50 +++++-- src/MkPFS.Cli/Commands/AmprProfileCommand.cs | 23 ++- src/MkPFS.Gui/Localization/Strings.resx | 1 + src/MkPFS.Gui/Localization/Strings_de.resx | 1 + src/MkPFS.Gui/Localization/Strings_es.resx | 1 + src/MkPFS.Gui/Localization/Strings_fr.resx | 1 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 1 + src/MkPFS.Gui/Localization/Strings_ro.resx | 1 + .../ViewModels/AmprPackPanelViewModel.cs | 6 + src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 3 + tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 5 + tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs | 32 +++++ 14 files changed, 257 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f946734..cea5210 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,13 @@ What traces can and cannot show: - Files the game memory-maps (`mmap`) or reads with plain reads never appear in a trace, so they stay loose too. +To pack the rest as well, tick **Also pack files no session read…** under the trace folder +(`--pack-untraced-types`, a MkPFS extension). Every untraced file whose type (extension) a traced file has is +packed with the settings most traced files of that type got: with traces of an EA SPORTS UFC 6 session, the +111 `.cas` archives no session reached are packed like the 181 that were, and only executables, modules, +`sce_sys`, `fakelib` and small files of other types stay loose. Those files were not seen being read, so play +the parts of the game they belong to before deleting the original. Files without an extension are never added. + `mkpfs ampr profile generate --output rules.toml --report rules.md` (or `--trace COMMANDS INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus a report, a metrics JSON and a runtime header; `mkpfs ampr profile batch` makes one profile per run of a folder or ZIP bundle. The @@ -434,6 +441,7 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | `--preset insomniac` | none | For Insomniac Games titles: compress only the `d/` archives (128 KiB blocks) and keep the boot, movie, sound bank and `wem*` archives, `toc`, `dag` and the other top-level files loose. Packing every file made such a title crash at startup, while these rules ran it. | | `--preset auto` | none | `insomniac` when `toc`, `dag` and `d/` are at the top of the game, `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | | `--traces ` | none | Rules from APR traces instead of `--config` or `--preset` (see [Rules from traces](#rules-from-traces)); also for `ampr game`. | +| `--pack-untraced-types` | off | With `--traces`: also pack untraced files of the traced file types, like the traced ones (also for `ampr game` and `profile generate`). | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | | `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | diff --git a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs index dca3138..57bef18 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs @@ -585,12 +585,144 @@ public static string RenderToml(AMPRProfileResult result, AMPRProfileOptions opt return string.Join('\n', lines); } + /// Untraced files added. + /// Files added. + /// Their total size. + /// Their chunk records in the pack manifest. + public readonly record struct UntracedAddition(int Files, long Bytes, long IndexBytes); + + /// + /// plus a + /// MkPFS extension (not in ampr_pack_profile.py): every file of the traced games' indexes that no trace read, but + /// whose extension a packed traced file has, is packed too, with the layout, block size and group most common for + /// that extension among the traced files. A trace session rarely visits every level or mode, and archives of the + /// same type are read the same way. Files without an extension, executables and modules, sce_sys, + /// sce_module and fakelib are never added. The rules go before the safety exclusions. + /// + /// Profile. + /// Options it was built with. + /// The trace runs; their indexes list the game's files. + /// Receives sidecars, as for the upstream rules, or . + /// Sidecar name prefix. + /// What was added. + /// TOML text. + public static string RenderToml( + AMPRProfileResult result, AMPRProfileOptions options, IReadOnlyList traces, Dictionary? externalLists, + string profileStem, out UntracedAddition added) + { + string toml = RenderToml(result, options, externalLists, profileStem); + + // The most common packing of each extension among the packed traced files (ties: smaller block first). + Dictionary byType = []; + foreach (IGrouping type in result.Recommendations + .Where(r => r.Action != "loose") + .GroupBy(r => Suffix(r.Metrics.Relative[(r.Metrics.Relative.LastIndexOf('/') + 1)..]).ToLowerInvariant()) + .Where(g => g.Key.Length > 0)) + { + byType[type.Key] = type + .GroupBy(r => new ProfileKey(r.Action, r.Layout, r.BlockSize, false, r.Group)) + .OrderByDescending(g => g.Count()).ThenBy(g => g.Key.BlockSize).ThenBy(g => g.Key) + .First().Key; + } + + Dictionary files = []; + foreach (AMPRTraceSpec spec in traces) + { + foreach (AMPRTraceIndexEntry entry in AMPRCommandLog.LoadIndex(spec.Index).Values) + { + try + { + string relative = CanonicalRelative(entry.Path); + files[relative] = Math.Max(files.GetValueOrDefault(relative), entry.Size); + } + catch (AMPRPackException) + { + // Unsafe paths are reported by the trace reader already. + } + } + } + + Dictionary> rules = []; + long bytes = 0; + long indexBytes = 0; + foreach ((string relative, long size) in files.OrderBy(f => f.Key, PythonCodePointComparer.Instance)) + { + string name = relative[(relative.LastIndexOf('/') + 1)..]; + string top = relative.Split('/')[0]; + if (result.IndexEntries.ContainsKey(relative) || KnownAction(relative) == "loose" + || top is "sce_sys" or "sce_module" or "fakelib" or "fakelib2" + || !byType.TryGetValue(Suffix(name).ToLowerInvariant(), out ProfileKey key)) + { + continue; + } + + if (!rules.TryGetValue(key, out List? list)) + { + rules[key] = list = []; + } + + list.Add(relative); + bytes += size; + indexBytes += CeilDiv(size, key.BlockSize) * ChunkRecordBytes; + } + + added = new UntracedAddition(rules.Values.Sum(l => l.Count), bytes, indexBytes); + if (rules.Count == 0) + { + return toml; + } + + StringBuilder text = new(); + int ruleNumber = 0; + foreach ((ProfileKey key, List paths) in rules.OrderBy(r => r.Key)) + { + ruleNumber++; + string types = string.Join(", ", paths.Select(p => Suffix(p[(p.LastIndexOf('/') + 1)..]).ToLowerInvariant()).Distinct().Order(StringComparer.Ordinal)); + text.Append($"# MkPFS: {paths.Count} untraced {types} files ({HumanSize(paths.Sum(p => files[p]))}), packed like most traced {types} files\n"); + text.Append("[[rule]]\n").Append($"action = {TomlString(key.Action)}\n"); + if (externalLists is not null && options.ExternalizePaths > 0 && paths.Count > options.ExternalizePaths) + { + string listName = $"{profileStem}.untraced-{ruleNumber:000}.include.txt"; + externalLists[listName] = string.Concat(paths.Select(p => p + "\n")); + text.Append($"include_from = [{TomlString(listName)}]\n"); + } + else + { + text.Append("include = [\n"); + foreach (string path in paths) + { + text.Append($" {TomlString(path)},\n"); + } + + text.Append("]\n"); + } + + text.Append($"block_size = {TomlString(SizeLiteral(key.BlockSize))}\n").Append($"group = {TomlString(key.Group)}\n") + .Append($"layout = {TomlString(key.Layout)}\n"); + if (key.Layout == "streaming") + { + text.Append("streaming = true\n"); + } + + text.Append('\n'); + } + + const string safety = "# Safety exclusions. The last matching rule wins.\n"; + int at = toml.LastIndexOf(safety, StringComparison.Ordinal); + return toml.Insert(at, text.ToString()); + } + /// The pack configuration of with every path inline, without writing it. /// Profile. /// Options it was built with. /// Configuration. - public static AMPRPackConfig ToConfig(AMPRProfileResult result, AMPRProfileOptions options) => - AMPRPackConfig.FromTable(AMPRToml.Parse(RenderToml(result, options), "trace profile"), Directory.GetCurrentDirectory()); + public static AMPRPackConfig ToConfig(AMPRProfileResult result, AMPRProfileOptions options) => ToConfig(RenderToml(result, options)); + + /// Parse rendered profile TOML that has every path inline. + /// TOML text from . + /// Configuration. + public static AMPRPackConfig ToConfig(string toml) => + AMPRPackConfig.FromTable(AMPRToml.Parse(toml, "trace profile"), Directory.GetCurrentDirectory()); /// The Markdown report (Python render_report). /// Profile. diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 48da54e..8d55dfa 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -60,6 +60,7 @@ private static Command Pack(CliContext ctx) Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; insomniac compresses only the d/ archives but boot, movie and audio ones; auto picks insomniac or unity from the game, else default" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option traces = new("--traces") { Description = TracesDescription }; + Option untracedTypes = new("--pack-untraced-types") { Description = UntracedTypesDescription }; Option include = Repeated("--include", "additional include glob"); Option exclude = Repeated("--exclude", "force-loose glob"); Option includeFrom = new("--include-from") { Description = "newline-delimited include globs" }; @@ -71,19 +72,24 @@ private static Command Pack(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress build progress on stderr; final JSON still goes to stdout" }; Command command = new("pack", "build pack index and data volumes") { - root, amprIndex, output, config, preset, traces, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, + root, amprIndex, output, config, preset, traces, untracedTypes, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, }; command.SetAction(parse => Run(ctx, () => { string? configPath = parse.GetValue(config); string? presetName = parse.GetValue(preset); string? tracesDir = parse.GetValue(traces); + if (parse.GetValue(untracedTypes) && tracesDir is null) + { + throw new AMPRPackException("--pack-untraced-types needs --traces"); + } + AMPRPackConfig loaded = (configPath, presetName, tracesDir) switch { (not null, not null, _) => throw new AMPRPackException("--config and --preset cannot be used together"), (_, _, not null) when configPath is not null || presetName is not null => throw new AMPRPackException("--traces cannot be used with --config or --preset"), - (null, null, string dir) => TraceRules(Resolve(ctx, dir), ctx.Err.WriteLine), + (null, null, string dir) => TraceRules(Resolve(ctx, dir), parse.GetValue(untracedTypes), ctx.Err.WriteLine), (null, not null, _) => Preset(presetName, Resolve(ctx, parse.GetValue(root)!), ctx.Err.WriteLine), _ => AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)), }; @@ -169,6 +175,7 @@ private static Command Game(CliContext ctx) Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (insomniac or unity from the game, else default), default (every file but executables, modules and system files), insomniac (the d/ archives but boot, movie and audio ones) or unity (only StreamingAssets); default: auto" }; preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option traces = new("--traces") { Description = TracesDescription }; + Option untracedTypes = new("--pack-untraced-types") { Description = UntracedTypesDescription }; Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; Option exfat = new("--exfat") { Description = "also build an exFAT image of the output (file, or folder for .exfat)" }; @@ -176,7 +183,7 @@ private static Command Game(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress progress on stderr" }; Command command = new("game", "build a folder that runs from packs as is: libraries, ampr_emu.index, packs and loose files (not in ampr_pack.py)") { - root, output, fakelib, config, preset, traces, workers, selfContained, exfat, skipVerify, noProgress, + root, output, fakelib, config, preset, traces, untracedTypes, workers, selfContained, exfat, skipVerify, noProgress, }; command.SetAction(parse => Run(ctx, () => { @@ -189,12 +196,17 @@ private static Command Game(CliContext ctx) throw new AMPRPackException("--config and --preset cannot be used together"); } + if (parse.GetValue(untracedTypes) && tracesDir is null) + { + throw new AMPRPackException("--pack-untraced-types needs --traces"); + } + if (tracesDir is not null && (configPath is not null || presetName is not null)) { throw new AMPRPackException("--traces cannot be used with --config or --preset"); } - AMPRPackConfig loaded = tracesDir is not null ? TraceRules(Resolve(ctx, tracesDir), message => ctx.Log.Info(message)) + AMPRPackConfig loaded = tracesDir is not null ? TraceRules(Resolve(ctx, tracesDir), parse.GetValue(untracedTypes), message => ctx.Log.Info(message)) : configPath is not null ? Config(ctx, Resolve(ctx, configPath)) : Preset(presetName ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)); if (parse.GetValue(workers) is { } count) @@ -266,8 +278,12 @@ private static string RulesReport(string preset, string? marker, bool auto) "folder with APR traces of the debug emulator build (ampr_commands.bin and ampr_emu.index, one run per subfolder): " + "pack the files the game read through APR, as ampr_pack_profile.py would, and keep the rest loose"; + internal const string UntracedTypesDescription = + "with --traces: also pack the files no trace read whose type (extension) a traced file has, packed like most traced " + + "files of that type (not for files without an extension, executables, modules, sce_sys or fakelib)"; + // Not in ampr_pack.py: the rules ampr_pack_profile.py would write for the traces, without the intermediate TOML. - private static AMPRPackConfig TraceRules(string directory, Action report) + private static AMPRPackConfig TraceRules(string directory, bool untracedTypes, Action report) { List runs = AMPRProfiler.DiscoverTracePairs(directory); if (runs.Count == 0) @@ -279,14 +295,32 @@ private static AMPRPackConfig TraceRules(string directory, Action report AMPRProfileResult profile = AMPRProfiler.Build(runs, options); string runText = runs.Count == 1 ? "1 trace run" : $"{runs.Count} trace runs"; string fileText = profile.PackedFiles == 1 ? "1 file" : $"{profile.PackedFiles} files"; - report($"Rules: from {runText} in {directory}; {fileText} the game read through APR are packed " + - $"({AMPRProfiler.HumanSize(profile.PackedFileBytes)}), every other file stays loose"); + string toml; + if (untracedTypes) + { + toml = AMPRProfiler.RenderToml(profile, options, runs, null, "profile", out AMPRProfiler.UntracedAddition added); + report($"Rules: from {runText} in {directory}; {fileText} the game read through APR are packed " + + $"({AMPRProfiler.HumanSize(profile.PackedFileBytes)}), plus {added.Files} untraced files of the same types " + + $"({AMPRProfiler.HumanSize(added.Bytes)}); every other file stays loose"); + if (profile.ProjectedIndexBytes + added.IndexBytes > options.IndexBudget) + { + report($"warning: the pack manifest grows to about {AMPRProfiler.HumanSize(profile.ProjectedIndexBytes + added.IndexBytes)}, " + + $"over the {AMPRProfiler.HumanSize(options.IndexBudget)} the emulator keeps resident"); + } + } + else + { + toml = AMPRProfiler.RenderToml(profile, options); + report($"Rules: from {runText} in {directory}; {fileText} the game read through APR are packed " + + $"({AMPRProfiler.HumanSize(profile.PackedFileBytes)}), every other file stays loose"); + } + if (profile.Warnings.Count > 0) { report($"warning: the traces have {profile.Warnings.Count} warnings; `mkpfs ampr profile generate --report` lists them"); } - return AMPRProfiler.ToConfig(profile, options); + return AMPRProfiler.ToConfig(toml); } private static AMPRPackConfig Config(CliContext ctx, string path) diff --git a/src/MkPFS.Cli/Commands/AmprProfileCommand.cs b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs index 8293d6b..94599d8 100644 --- a/src/MkPFS.Cli/Commands/AmprProfileCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprProfileCommand.cs @@ -139,8 +139,9 @@ private static Command Generate(CliContext ctx) Option report = new("--report"); Option metrics = new("--metrics"); Option runtimeHeader = new("--runtime-header") { Description = "optional force-include header with runtime cache/pool/coalescing defines" }; + Option untracedTypes = new("--pack-untraced-types") { Description = AmprCommand.UntracedTypesDescription + " (MkPFS extension)" }; CommonOptions common = new("exact", cacheSimulationDefault: true); - Command command = new("generate", "generate one profile") { input, trace, name, output, report, metrics, runtimeHeader }; + Command command = new("generate", "generate one profile") { input, trace, name, output, report, metrics, runtimeHeader, untracedTypes }; common.AddTo(command); command.SetAction(parse => AmprCommand.Run(ctx, () => { @@ -186,7 +187,18 @@ private static Command Generate(CliContext ctx) string outputText = parse.GetValue(output)!; string outputPath = AmprCommand.Resolve(ctx, outputText); Dictionary sidecars = []; - string toml = AMPRProfiler.RenderToml(result, options, sidecars, Path.GetFileNameWithoutExtension(outputPath)); + AMPRProfiler.UntracedAddition? added = null; + string toml; + if (parse.GetValue(untracedTypes)) + { + toml = AMPRProfiler.RenderToml(result, options, traces, sidecars, Path.GetFileNameWithoutExtension(outputPath), out AMPRProfiler.UntracedAddition addition); + added = addition; + } + else + { + toml = AMPRProfiler.RenderToml(result, options, sidecars, Path.GetFileNameWithoutExtension(outputPath)); + } + bool replace = parse.GetValue(common.Overwrite); WriteBundle(outputPath, toml, sidecars, replace); if (parse.GetValue(report) is { } reportPath) @@ -207,6 +219,13 @@ private static Command Generate(CliContext ctx) ctx.Out.WriteLine( $"generated {outputText}: files={result.ObservedFiles} index={AMPRProfiler.HumanSize(result.ProjectedIndexBytes)} " + $"cache={AMPRProfiler.HumanSize(result.RecommendedCacheBytes)} pool={AMPRProfiler.HumanSize(result.RecommendedPoolBytes)}"); + if (added is { } extra) + { + // Not in ampr_pack_profile.py. + ctx.Out.WriteLine( + $"added {extra.Files} untraced files of traced types: {AMPRProfiler.HumanSize(extra.Bytes)}, " + + $"index +{AMPRProfiler.HumanSize(extra.IndexBytes)}"); + } })); return command; } diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index e20740e..1d74755 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -46,6 +46,7 @@ ✓ Rules from traces: {0} recorded run(s) found. Only the files the game read through AMPR are packed; every other file stays loose. No traces in this folder: each run needs ampr_commands.bin and its ampr_emu.index side by side. Use either a TOML file or a trace folder, not both. + Also pack files no session read, when a traced file has the same type (for example the remaining .cas archives). They were not seen being read, so test the game with them. ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. Library Folder (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 841add1..cb2da54 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -46,6 +46,7 @@ ✓ Regeln aus Traces: {0} aufgezeichnete(r) Lauf/Läufe gefunden. Nur die Dateien, die das Spiel über AMPR gelesen hat, werden gepackt; alle anderen bleiben einzeln. Keine Traces in diesem Ordner: jeder Lauf braucht ampr_commands.bin und seine ampr_emu.index nebeneinander. Verwende entweder eine TOML-Datei oder einen Trace-Ordner, nicht beides. + Auch Dateien packen, die keine Sitzung gelesen hat, wenn eine aufgezeichnete Datei denselben Typ hat (zum Beispiel die übrigen .cas-Archive). Ihr Lesen wurde nicht beobachtet, also das Spiel damit testen. ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. Bibliotheksordner (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index f73388a..aef8014 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -46,6 +46,7 @@ ✓ Reglas a partir de trazas: {0} ejecución(es) grabada(s). Solo se empaquetan los archivos que el juego leyó mediante AMPR; el resto queda suelto. No hay trazas en esta carpeta: cada ejecución necesita ampr_commands.bin y su ampr_emu.index juntos. Usa un archivo TOML o una carpeta de trazas, no ambos. + Empaquetar también los archivos que ninguna sesión leyó, si un archivo trazado tiene el mismo tipo (por ejemplo, los .cas restantes). No se vio cómo se leían, así que prueba el juego con ellos. ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. Carpeta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 582a306..e8246f4 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -46,6 +46,7 @@ ✓ Règles issues des traces : {0} session(s) enregistrée(s). Seuls les fichiers que le jeu a lus via AMPR sont empaquetés ; les autres restent à part. Aucune trace dans ce dossier : chaque session a besoin de ampr_commands.bin et de son ampr_emu.index côte à côte. Utilisez un fichier TOML ou un dossier de traces, pas les deux. + Empaqueter aussi les fichiers qu’aucune session n’a lus, quand un fichier tracé a le même type (par exemple les archives .cas restantes). Leur lecture n’a pas été observée : testez le jeu avec eux. ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. Dossier des bibliothèques (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 6b794d9..050d0f6 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -46,6 +46,7 @@ ✓ Regras a partir dos traces: {0} execução(ões) gravada(s). Só os arquivos que o jogo leu pelo AMPR são empacotados; os demais ficam soltos. Nenhum trace nesta pasta: cada execução precisa de ampr_commands.bin e do seu ampr_emu.index lado a lado. Use um arquivo TOML ou uma pasta de traces, não os dois. + Empacotar também os arquivos que nenhuma sessão leu, quando um arquivo rastreado tem o mesmo tipo (por exemplo, os .cas restantes). Eles não foram vistos sendo lidos, então teste o jogo com eles. ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. Pasta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index db18f45..f628498 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -46,6 +46,7 @@ ✓ Reguli din trace-uri: {0} rulare(i) înregistrată(e). Se împachetează doar fișierele citite de joc prin AMPR; restul rămân separate. Niciun trace în acest dosar: fiecare rulare are nevoie de ampr_commands.bin și de ampr_emu.index alături. Folosește fie un fișier TOML, fie un dosar cu trace-uri, nu amândouă. + Împachetează și fișierele pe care nicio sesiune nu le-a citit, dacă un fișier urmărit are același tip (de exemplu, restul arhivelor .cas). Nu s-a văzut cum sunt citite, așa că testează jocul cu ele. ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. Folder de Biblioteci (fakelib) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index ce54bb1..d53fc2b 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -82,6 +82,10 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [NotifyPropertyChangedFor(nameof(RulesTracesText))] public partial string Traces { get; set; } = string.Empty; + /// --pack-untraced-types: also pack untraced files of the traced file types. + [ObservableProperty] + public partial bool UntracedTypes { get; set; } + /// --workers; empty for the configuration's value. [ObservableProperty] public partial string Workers { get; set; } = string.Empty; @@ -230,6 +234,7 @@ public string RulesFoundText // Without a TOML or traces, ampr game picks the rules from the game. AddOption(args, "--config", config); AddOption(args, "--traces", traces); + AddFlag(args, traces.Length > 0 && UntracedTypes, "--pack-untraced-types"); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, !VerifyGame, "--skip-verify"); @@ -244,6 +249,7 @@ public string RulesFoundText args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); // Without a TOML every file would stay loose; the page uses the built-in rules instead. args.AddRange(config.Length > 0 ? ["--config", config] : traces.Length > 0 ? ["--traces", traces] : ["--preset", "auto"]); + AddFlag(args, config.Length == 0 && traces.Length > 0 && UntracedTypes, "--pack-untraced-types"); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, AllowMissing, "--allow-missing"); diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 3127989..ce24653 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -34,6 +34,9 @@ + + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 710b3b7..16b4df5 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -200,8 +200,13 @@ public void Page_takes_rules_from_a_trace_folder() Assert.StartsWith("✓ Rules from traces: 2 recorded run(s) found.", panel.RulesTracesText, StringComparison.Ordinal); Assert.Equal(["--traces", traces + Path.DirectorySeparatorChar], panel.BuildArguments(out _)!.SkipWhile(a => a != "--traces").Take(2)); + Assert.DoesNotContain("--pack-untraced-types", panel.BuildArguments(out _)!); + panel.UntracedTypes = true; + Assert.Contains("--pack-untraced-types", panel.BuildArguments(out _)!); + panel.Action = panel.Actions.Single(a => a.Value == "pack"); Assert.DoesNotContain("--preset", panel.BuildArguments(out _)!); + Assert.Contains("--pack-untraced-types", panel.BuildArguments(out _)!); panel.Config = "rules.toml"; Assert.False(panel.ShowRulesTraces || panel.ShowRulesConfig); diff --git a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs index 9600806..96c695f 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs @@ -282,4 +282,36 @@ public void Cli_profile_batch_writes_one_profile_per_run_from_a_folder_or_zip() Assert.Equal(0, MkPFSCli.Run(["ampr", "profile", "batch", zip, "--output-dir", Path.Combine(dir.Path, "fromzip")], ctx)); Assert.True(File.Exists(Path.Combine(dir.Path, "fromzip", "startup.toml"))); } + + [Fact] + public void Untraced_files_of_traced_types_are_packed_like_them_on_request() + { + using TempDir dir = new(); + (string game, string traces) = TracedGame(dir); + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); + string output = Path.Combine(dir.Path, "out"); + + int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", output, "--traces", traces, "--pack-untraced-types"], ctx); + + Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); + // level0.dat was traced; level1.dat has the same type, so it is packed too; eboot.bin stays loose. + Assert.Contains("1 file the game read through APR are packed (", stdout.ToString(), StringComparison.Ordinal); + Assert.Contains("), plus 1 untraced files of the same types (", stdout.ToString(), StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(output, "data", "level1.dat"))); + Assert.True(File.Exists(Path.Combine(output, "eboot.bin"))); + + List runs = AMPRProfiler.DiscoverTracePairs(traces); + AMPRProfileOptions options = new() { Name = "game" }; + AMPRProfileResult result = AMPRProfiler.Build(runs, options); + string toml = AMPRProfiler.RenderToml(result, options, runs, null, "p", out AMPRProfiler.UntracedAddition added); + Assert.Equal(1, added.Files); + Assert.StartsWith(AMPRProfiler.RenderToml(result, options).Split("# Safety exclusions.")[0], toml, StringComparison.Ordinal); + Assert.Contains("# MkPFS: 1 untraced .dat files (", toml, StringComparison.Ordinal); + Assert.EndsWith(AMPRProfiler.RenderToml(result, options)[AMPRProfiler.RenderToml(result, options).IndexOf("# Safety exclusions.", StringComparison.Ordinal)..], toml, StringComparison.Ordinal); + + Assert.Equal(2, MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "o2"), "--pack-untraced-types"], ctx)); + Assert.EndsWith("error: --pack-untraced-types needs --traces\n", stderr.ToString(), StringComparison.Ordinal); + } } From a0d5cd8497cedd409b2a04908e69075451575f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:34:25 +0000 Subject: [PATCH 17/28] Leave optional free space inside exFAT images Images were always exactly full (no free cluster), so nothing could write to a mounted image even read-write: AMPR Emu's debug log stayed empty when a packed game was tested. pack exfat --free-space, ampr game --exfat-free-space and a field on the AMPR Packs page reserve zeroed clusters at the end of the heap; only the used ones are marked in the allocation bitmap. With the default 0 the image is unchanged. fsck.exfat (exfatprogs 1.2.2) reports both kinds of image clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 14 +++++- src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs | 9 +++- src/MkPFS.Build/Exfat/ExfatImageWriter.cs | 45 +++++++++++++------ src/MkPFS.Cli/Commands/AmprCommand.cs | 6 ++- src/MkPFS.Cli/Commands/PackCommands.cs | 21 ++++++++- src/MkPFS.Gui/Localization/Strings.resx | 1 + src/MkPFS.Gui/Localization/Strings_de.resx | 1 + src/MkPFS.Gui/Localization/Strings_es.resx | 1 + src/MkPFS.Gui/Localization/Strings_fr.resx | 1 + src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 1 + src/MkPFS.Gui/Localization/Strings_ro.resx | 1 + .../ViewModels/AmprPackPanelViewModel.cs | 5 +++ src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 4 ++ .../MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs | 21 +++++++++ 14 files changed, 112 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index cea5210..2191d7b 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ accepted and receives that derived filename. | Option | Default | Meaning | |---|---|---| | `--cluster-size ` | `auto` (`65536`) | exFAT cluster size. The automatic 64 KiB value is optimized for SMP/LVD. | +| `--free-space ` | `0` | Free space to leave inside the image, for example `2GiB`. Images are otherwise exactly full, so nothing can write to them even on a read-write mount (AMPR Emu's debug log and traces, a game writing to `/app0`). | | `--overwrite` | off | Replace an existing output image. | | `--verbose` | off | Print detailed packing output. | | `--no-progress` | off | Hide the progress bar written to standard error. | @@ -390,6 +391,15 @@ INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus and a runtime header; `mkpfs ampr profile batch` makes one profile per run of a folder or ZIP bundle. The output matches `ampr_pack_profile.py` byte for byte. +##### When a packed game crashes + +The release AMPR Emu logs nothing, so the crash log alone does not say which file failed. Build the packed game +again with **Free space inside the image** (`--exfat-free-space 2GiB`), put the debug `libSceAmpr.sprx` into the +packed game's `fakelib/`, mount the image read-write (`image_rw=` in ShadowMountPlus's +`config.ini`) and start it until it crashes. `ampr_emu.log` in the image then lists every packed file the game +opened (`apr.pack.open`) and every open it refused (`apr.pack.open.fail … reason=…`), which points at the file +to leave loose. + ##### Reporting a game Whether it works or not, please report: the game title, ID and version, the `Profile found` or `No profile @@ -480,7 +490,9 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em loose file is in the output with its indexed size (`--skip-verify` skips this). If a step fails, the output folder is emptied so a retry starts clean. `--exfat ` then -builds an exFAT image of the output (64 KiB clusters, `.exfat` in a folder). Use an exFAT +builds an exFAT image of the output (64 KiB clusters, `.exfat` in a folder); `--exfat-free-space 2GiB` +leaves room inside it, which a test run with the debug AMPR Emu needs for its log (the image is otherwise +exactly full). Use an exFAT image or the plain folder, and keep ShadowMountPlus's `mount_read_only=1` (the default) so AMPR Emu never rebuilds the index. A `.ffpfsc` would put zlib (about 150–250 MB/s on the PS5) on top of the LZ4 packs. The output also keeps `ampr_assets.index.crc`: the game never reads it, and `verify` and diff --git a/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs b/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs index edca787..f66d798 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRGameBuilder.cs @@ -20,6 +20,9 @@ public sealed record AMPRGameOptions /// exFAT image path (or existing folder for <titleId>.exfat) to build from the output. public string? ExfatImage { get; init; } + + /// Free space to leave inside the exFAT image (0: tight). A debug run of AMPR Emu writes its log there. + public long ExfatFreeBytes { get; init; } } /// Result of . @@ -102,11 +105,13 @@ public static AMPRGameResult Build( } // A failed image leaves the finished folder in place, and only the partial image is removed. - log.Info("Building the exFAT image (64 KiB clusters)"); + log.Info(options.ExfatFreeBytes > 0 + ? $"Building the exFAT image (64 KiB clusters, {Sizes.HumanReadable(options.ExfatFreeBytes)} free)" + : "Building the exFAT image (64 KiB clusters)"); string written; try { - written = ExfatImageWriter.Write(outputDir, image, null, exfatProgress); + written = ExfatImageWriter.Write(outputDir, image, null, exfatProgress, options.ExfatFreeBytes); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { diff --git a/src/MkPFS.Build/Exfat/ExfatImageWriter.cs b/src/MkPFS.Build/Exfat/ExfatImageWriter.cs index 0e469c0..447107e 100644 --- a/src/MkPFS.Build/Exfat/ExfatImageWriter.cs +++ b/src/MkPFS.Build/Exfat/ExfatImageWriter.cs @@ -35,15 +35,18 @@ public sealed class ExfatImageWriter private readonly long _bitmapClusters; private readonly long _upcaseClusters; private readonly long _clusterCount; + private readonly long _usedClusters; private readonly long _fatLengthSectors; private readonly long _heapOffsetSectors; - private ExfatImageWriter(Node root, int clusterSize) + private ExfatImageWriter(Node root, int clusterSize, long freeBytes) { _root = root; ClusterSize = clusterSize; long sectorsPerCluster = clusterSize / BytesPerSector; - (_bitmapClusters, _upcaseClusters, _clusterCount) = AssignClusters(root, clusterSize); + long freeClusters = Sizes.CeilDiv(Math.Max(0, freeBytes), clusterSize); + (_bitmapClusters, _upcaseClusters, _usedClusters) = AssignClusters(root, clusterSize, freeClusters); + _clusterCount = _usedClusters + freeClusters; if (_clusterCount > uint.MaxValue - 16) { throw new InvalidOperationException("source tree is too large for an exFAT volume with this cluster size"); @@ -60,12 +63,20 @@ private ExfatImageWriter(Node root, int clusterSize) /// Total image size in bytes. public long ImageSize { get; } + /// Free space left in the volume, in bytes (whole clusters). + public long FreeBytes => (_clusterCount - _usedClusters) * ClusterSize; + /// Scan and compute the layout. /// Directory whose contents become the volume root. /// Cluster size in bytes, or for . + /// + /// Free space to leave in the volume (rounded up to clusters). 0, the default, gives a tight image with no free + /// cluster, like Python MkPFS; anything that writes to the mounted image (a game, or AMPR Emu's debug log and trace + /// on a read-write mount) needs some. + /// /// Writer ready to emit the image. - public static ExfatImageWriter Plan(string sourceRoot, int? clusterSize = null) => - new(ScanTree(sourceRoot), clusterSize ?? DefaultClusterSize); + public static ExfatImageWriter Plan(string sourceRoot, int? clusterSize = null, long freeBytes = 0) => + new(ScanTree(sourceRoot), clusterSize ?? DefaultClusterSize, freeBytes); /// /// Write a complete image (Python write_exfat_image). An existing directory as @@ -75,15 +86,16 @@ public static ExfatImageWriter Plan(string sourceRoot, int? clusterSize = null) /// Image path or existing directory. /// Cluster size, or for the default. /// Optional progress, phase exfat. + /// Free space to leave in the volume (see ). /// The path written. - public static string Write(string sourceRoot, string outputPath, int? clusterSize = null, IProgressSink? progress = null) + public static string Write(string sourceRoot, string outputPath, int? clusterSize = null, IProgressSink? progress = null, long freeBytes = 0) { if (Directory.Exists(outputPath)) { outputPath = Path.Combine(outputPath, GameParams.DefaultImageBasename(sourceRoot) + ".exfat"); } - ExfatImageWriter writer = Plan(sourceRoot, clusterSize); + ExfatImageWriter writer = Plan(sourceRoot, clusterSize, freeBytes); string tempPath = outputPath + ".tmp"; try { @@ -164,6 +176,13 @@ public IEnumerable> Chunks() { yield return chunk; } + + // 4. Free clusters, zero-filled. + byte[] zeros = new byte[FileReadChunk]; + for (long left = FreeBytes; left > 0; left -= zeros.Length) + { + yield return zeros.AsMemory(0, (int)Math.Min(zeros.Length, left)); + } } private IEnumerable> EmitChildren(Node node, byte[] buffer) @@ -253,16 +272,16 @@ private static long DirectoryEntryCount(Node node, bool isRoot) private static long NodeClusters(Node node, bool isRoot, int clusterSize) => node.IsDir ? Math.Max(1, Sizes.CeilDiv(DirectoryEntryCount(node, isRoot) * 32, clusterSize)) : Sizes.CeilDiv(node.Size, clusterSize); - private static (long Bitmap, long Upcase, long Total) AssignClusters(Node root, int clusterSize) + private static (long Bitmap, long Upcase, long Used) AssignClusters(Node root, int clusterSize, long freeClusters) { long upcaseClusters = Sizes.CeilDiv(ExfatUpcase.Table.Length, clusterSize); long contentClusters = NodeClusters(root, isRoot: true, clusterSize) + Accumulate(root) + upcaseClusters; - // The bitmap must cover itself plus all content. + // The bitmap must cover itself, all content and the free clusters. long bitmapClusters = 1; while (true) { - long need = Sizes.CeilDiv(Sizes.CeilDiv(bitmapClusters + contentClusters, 8), clusterSize); + long need = Sizes.CeilDiv(Sizes.CeilDiv(bitmapClusters + contentClusters + freeClusters, 8), clusterSize); if (need == bitmapClusters) { break; @@ -495,14 +514,14 @@ void Chain(long first, long count) } } - // Every heap cluster is allocated (tight image). + // Every used heap cluster is allocated; the free ones come last (a tight image has none). private byte[] BuildBitmap() { byte[] bitmap = new byte[AlignUp(Sizes.CeilDiv(_clusterCount, 8), ClusterSize)]; - bitmap.AsSpan(0, (int)(_clusterCount / 8)).Fill(0xFF); - if (_clusterCount % 8 != 0) + bitmap.AsSpan(0, (int)(_usedClusters / 8)).Fill(0xFF); + if (_usedClusters % 8 != 0) { - bitmap[_clusterCount / 8] = (byte)((1 << (int)(_clusterCount % 8)) - 1); + bitmap[_usedClusters / 8] = (byte)((1 << (int)(_usedClusters % 8)) - 1); } return bitmap; diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 8d55dfa..8295bab 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -179,11 +179,12 @@ private static Command Game(CliContext ctx) Option workers = new("--workers") { Description = "compression workers" }; Option selfContained = new("--self-contained") { Description = "do not auto-loose explicitly packed files; store incompressible blocks RAW" }; Option exfat = new("--exfat") { Description = "also build an exFAT image of the output (file, or folder for .exfat)" }; + Option exfatFree = new("--exfat-free-space") { Description = "free space to leave inside the exFAT image, e.g. 2GiB (default: 0); a debug run of AMPR Emu writes its log there" }; Option skipVerify = new("--skip-verify") { Description = "do not verify the packs and loose files after the build" }; Option noProgress = new("--no-progress") { Description = "suppress progress on stderr" }; Command command = new("game", "build a folder that runs from packs as is: libraries, ampr_emu.index, packs and loose files (not in ampr_pack.py)") { - root, output, fakelib, config, preset, traces, untracedTypes, workers, selfContained, exfat, skipVerify, noProgress, + root, output, fakelib, config, preset, traces, untracedTypes, workers, selfContained, exfat, exfatFree, skipVerify, noProgress, }; command.SetAction(parse => Run(ctx, () => { @@ -226,6 +227,9 @@ private static Command Game(CliContext ctx) LibsDir = parse.GetValue(fakelib) is { } libs ? Resolve(ctx, libs) : null, Verify = !parse.GetValue(skipVerify), ExfatImage = parse.GetValue(exfat) is { } image ? Resolve(ctx, image) : null, + ExfatFreeBytes = parse.GetValue(exfatFree) is { } free + ? parse.GetValue(exfat) is not null ? AMPRSize.Parse(free) : throw new AMPRPackException("--exfat-free-space needs --exfat") + : 0, }; try { diff --git a/src/MkPFS.Cli/Commands/PackCommands.cs b/src/MkPFS.Cli/Commands/PackCommands.cs index f01efdc..8655a92 100644 --- a/src/MkPFS.Cli/Commands/PackCommands.cs +++ b/src/MkPFS.Cli/Commands/PackCommands.cs @@ -494,12 +494,18 @@ private static Command Exfat(CliContext ctx) Description = "exFAT cluster size in bytes or 'auto' (default: 65536 - SMP/LVD-optimal 64 KiB)", DefaultValueFactory = _ => "auto", }; + Option freeSpace = new("--free-space") + { + Description = "free space to leave inside the image, e.g. 2GiB (default: 0, a tight image); needed by anything " + + "that writes to the mounted image, like AMPR Emu's debug log and traces on a read-write mount", + DefaultValueFactory = _ => "0", + }; Option overwrite = new("--overwrite") { Description = "Overwrite an existing output file" }; Option verbose = new("--verbose") { Description = "Verbose output" }; Option noProgress = new("--no-progress") { Description = "Disable the exFAT packing progress bar on stderr" }; Command command = new("exfat", "Build a raw exFAT image from a source directory") { - sourceDir, output, clusterSize, overwrite, verbose, noProgress, + sourceDir, output, clusterSize, freeSpace, overwrite, verbose, noProgress, }; AmprCliOptions ampr = new(command); command.SetAction(parse => @@ -517,6 +523,17 @@ private static Command Exfat(CliContext ctx) return 1; } + long freeBytes; + try + { + freeBytes = Core.AMPR.AMPRSize.Parse(parse.GetValue(freeSpace)!); + } + catch (ArgumentException ex) + { + ctx.Log.Error($"--free-space: {ex.Message}"); + return 1; + } + // Resolve the final output path first so overwrite can be checked and the path reported. string basename = GameParams.DefaultImageBasename(source) + ".exfat"; string target; @@ -553,7 +570,7 @@ private static Command Exfat(CliContext ctx) } IProgressSink? progress = ctx.CreateProgress(!parse.GetValue(noProgress)); - string written = ExfatImageWriter.Write(source, target, cluster, progress); + string written = ExfatImageWriter.Write(source, target, cluster, progress, freeBytes); ctx.Info($"Successfully wrote {Sizes.HumanReadable(new FileInfo(written).Length)} exFAT image: {written}"); return 0; }); diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 1d74755..0d006f9 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -47,6 +47,7 @@ No traces in this folder: each run needs ampr_commands.bin and its ampr_emu.index side by side. Use either a TOML file or a trace folder, not both. Also pack files no session read, when a traced file has the same type (for example the remaining .cas archives). They were not seen being read, so test the game with them. + Free space inside the image (optional, e.g. 2GiB): needed for a test run with the debug AMPR Emu, which writes its log into the game folder ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. Library Folder (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index cb2da54..95a0bfb 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -47,6 +47,7 @@ Keine Traces in diesem Ordner: jeder Lauf braucht ampr_commands.bin und seine ampr_emu.index nebeneinander. Verwende entweder eine TOML-Datei oder einen Trace-Ordner, nicht beides. Auch Dateien packen, die keine Sitzung gelesen hat, wenn eine aufgezeichnete Datei denselben Typ hat (zum Beispiel die übrigen .cas-Archive). Ihr Lesen wurde nicht beobachtet, also das Spiel damit testen. + Freier Platz im Abbild (optional, z. B. 2GiB): nötig für einen Test mit dem Debug-AMPR-Emu, der sein Protokoll in den Spielordner schreibt ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. Bibliotheksordner (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index aef8014..98608c0 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -47,6 +47,7 @@ No hay trazas en esta carpeta: cada ejecución necesita ampr_commands.bin y su ampr_emu.index juntos. Usa un archivo TOML o una carpeta de trazas, no ambos. Empaquetar también los archivos que ninguna sesión leyó, si un archivo trazado tiene el mismo tipo (por ejemplo, los .cas restantes). No se vio cómo se leían, así que prueba el juego con ellos. + Espacio libre dentro de la imagen (opcional, p. ej. 2GiB): necesario para una prueba con el AMPR Emu de depuración, que escribe su registro en la carpeta del juego ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. Carpeta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index e8246f4..fdcfb66 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -47,6 +47,7 @@ Aucune trace dans ce dossier : chaque session a besoin de ampr_commands.bin et de son ampr_emu.index côte à côte. Utilisez un fichier TOML ou un dossier de traces, pas les deux. Empaqueter aussi les fichiers qu’aucune session n’a lus, quand un fichier tracé a le même type (par exemple les archives .cas restantes). Leur lecture n’a pas été observée : testez le jeu avec eux. + Espace libre dans l’image (facultatif, ex. 2GiB) : nécessaire pour un test avec l’AMPR Emu de débogage, qui écrit son journal dans le dossier du jeu ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. Dossier des bibliothèques (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 050d0f6..6d6e9bf 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -47,6 +47,7 @@ Nenhum trace nesta pasta: cada execução precisa de ampr_commands.bin e do seu ampr_emu.index lado a lado. Use um arquivo TOML ou uma pasta de traces, não os dois. Empacotar também os arquivos que nenhuma sessão leu, quando um arquivo rastreado tem o mesmo tipo (por exemplo, os .cas restantes). Eles não foram vistos sendo lidos, então teste o jogo com eles. + Espaço livre dentro da imagem (opcional, ex. 2GiB): necessário para um teste com o AMPR Emu de debug, que grava o log na pasta do jogo ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. Pasta de Bibliotecas (fakelib) diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index f628498..8df49cd 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -47,6 +47,7 @@ Niciun trace în acest dosar: fiecare rulare are nevoie de ampr_commands.bin și de ampr_emu.index alături. Folosește fie un fișier TOML, fie un dosar cu trace-uri, nu amândouă. Împachetează și fișierele pe care nicio sesiune nu le-a citit, dacă un fișier urmărit are același tip (de exemplu, restul arhivelor .cas). Nu s-a văzut cum sunt citite, așa că testează jocul cu ele. + Spațiu liber în imagine (opțional, ex. 2GiB): necesar pentru un test cu AMPR Emu de depanare, care își scrie jurnalul în dosarul jocului ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. Folder de Biblioteci (fakelib) diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index d53fc2b..9ffc2e3 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -54,6 +54,10 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] public partial string ExfatPath { get; set; } = string.Empty; + /// --exfat-free-space: free space inside the image, e.g. 2GiB; empty for none. + [ObservableProperty] + public partial string ExfatFree { get; set; } = string.Empty; + /// --root: the game's /app0 folder. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(RulesFoundText), nameof(RulesNoneText))] @@ -242,6 +246,7 @@ public string RulesFoundText { // An existing folder receives .exfat: by default the folder that holds the output. args.AddRange(["--exfat", ExfatPath.Trim() is { Length: > 0 } image ? image : Path.GetDirectoryName(Path.GetFullPath(output)) ?? output]); + AddOption(args, "--exfat-free-space", ExfatFree); } break; diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index ce24653..9d29d2f 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -51,6 +51,10 @@ + + + + diff --git a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs index 96d16b6..f21fdb4 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using MkPFS.Build; using MkPFS.Build.AMPRPack; +using MkPFS.Build.Exfat; using MkPFS.Build.PFS; using MkPFS.Cli; using MkPFS.Cli.Output; @@ -173,6 +174,26 @@ public void The_exfat_image_holds_the_playable_folder() Assert.Equal(["ampr_assets-000.pak", "ampr_assets.index", "ampr_assets.index.crc", "ampr_emu.index", "data", "eboot.bin", "empty", "fakelib", "sce_module", "sce_sys"], names); } + [Fact] + public void The_exfat_image_can_keep_free_space_for_writes() + { + using TempDir dir = new(); + string game = Game(dir); + string output = Path.Combine(dir.Path, "out"); + + AMPRGameResult result = AMPRGameBuilder.Build(game, output, Options(dir, dir.Path) with { ExfatFreeBytes = 3 * 1024 * 1024 + 1 }, new ListLog()); + + using FileStream stream = File.OpenRead(result.ExfatImage!); + ExfatReader reader = new(stream); + Assert.Contains(reader.RootEntries(), e => e.Name == "eboot.bin"); + ExfatImageWriter tight = ExfatImageWriter.Plan(output); + ExfatImageWriter roomy = ExfatImageWriter.Plan(output, null, 3 * 1024 * 1024 + 1); + Assert.Equal(0, tight.FreeBytes); + Assert.Equal(49L * 64 * 1024, roomy.FreeBytes); // 3 MiB + 1 byte, rounded up to 64 KiB clusters + Assert.Equal(stream.Length, roomy.ImageSize); + Assert.True(roomy.ImageSize >= tight.ImageSize + roomy.FreeBytes); + } + [Fact] public void Cli_game_builds_the_folder_and_reports_each_step() { From 7472199fc2b849f63ff6c4f73322a7dae2dd26d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:56:14 +0000 Subject: [PATCH 18/28] Move the built-in pack rules out into shared TOML profiles The default, insomniac and unity presets, their auto-detection and --preset are removed. ampr game and the AMPR Packs page now need the game's TOML profile (--config) or a trace folder (--traces); the profiles are published as separate files, one per game. The README describes what the tested profiles learned and adds EA SPORTS UFC 6 to the tested games. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 86 +++---- src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs | 139 ---------- src/MkPFS.Cli/Commands/AmprCommand.cs | 84 ++---- src/MkPFS.Gui/Localization/Strings.resx | 13 +- src/MkPFS.Gui/Localization/Strings_de.resx | 13 +- src/MkPFS.Gui/Localization/Strings_es.resx | 13 +- src/MkPFS.Gui/Localization/Strings_fr.resx | 13 +- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 13 +- src/MkPFS.Gui/Localization/Strings_ro.resx | 13 +- .../ViewModels/AmprPackPanelViewModel.cs | 64 +---- src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml | 5 +- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 54 ++-- .../MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs | 29 ++- tests/MkPFS.Tests/AMPR/AMPRPackCliTests.cs | 133 ++++++++++ tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs | 240 ------------------ tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs | 4 +- 16 files changed, 279 insertions(+), 637 deletions(-) create mode 100644 tests/MkPFS.Tests/AMPR/AMPRPackCliTests.cs delete mode 100644 tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs diff --git a/README.md b/README.md index 2191d7b..2044e72 100644 --- a/README.md +++ b/README.md @@ -306,53 +306,56 @@ PC, so the only real check is to start the packed game on the console. `ampr gam that every packed byte decodes to the original, but that proves the packs are correct, not that the game reads them in a supported way. -The built-in rules come from these tests, and `ampr game` picks them from the game itself (`--preset -auto`, the default). Before anything is built, the log and the AMPR Packs page say whether a profile -matched the game, which one, the file that identified it, and whether it has run on a PS5: +**Game profiles.** What to pack is decided per game by a TOML profile (`--config`, the **TOML +Configuration** field), or by the traces of the game recorded on the console (see [Rules from +traces](#rules-from-traces)). MkPFS has no built-in rules: `ampr game` needs one or the other. Profiles are +plain TOML files in ampr_emu's format (see below), one per game, shared and improved by the people +who test them. A profile's header says which game and version it was tested with and whether it runs. +The build log names the rules used: ``` -Profile found: unity (Media/globalgamemanagers); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997). -No profile found for this game: generic rules (every file but executables, modules and system files is packed). Untested: the game may not start on the PS5; keep the original. +Rules: TOML profile D:\PS5\profiles\ufc6-PPSA23566.toml ``` -A found, tested profile is the best sign that the packed game will run; with no profile, treat the -build as a test. With `--config`, the log says `Rules: your TOML file ...` instead. - -- **Unity games** (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` in the game): only - `StreamingAssets/` is packed (asset bundles, audio banks, videos). Unity's own data files - (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a - Unity game abort at startup. -- **Insomniac Games titles** (`toc` and `dag` files and a `d/` folder at the top of the game): only the - `d/` archives are packed. The boot (`bootload*`, `critbootload*`), movie, sound bank and streamed audio - (`wem*`) archives stay loose, as do `toc`, `dag` and the other top-level files. Packing every file made - such a game crash at startup. -- **Other games** (no profile found): every file except executables, modules and system files is - packed. No game has run with these rules yet, and both games above failed with them, so expect to - need a TOML that leaves more files loose. +What the profiles of the tested games learned: + +- **Unity games**: pack only `StreamingAssets/` (asset bundles, audio banks, videos). Unity's own data + files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a + Unity game abort at startup; `global-metadata.dat` is memory-mapped and must stay loose. +- **Insomniac Games titles** (`toc` and `dag` files and a `d/` folder at the top of the game): pack only the + `d/` archives. The boot (`bootload*`, `critbootload*`), movie, sound bank and streamed audio (`wem*`) + archives stay loose, as do `toc`, `dag` and the other top-level files. Packing every file made such a game + crash at startup. +- **Frostbite games** (EA SPORTS UFC 6): pack the `.cas` archives and keep the small `.toc` files, + `initfs_Ps5` and `chunkmanifest` loose; packing those too crashed the game about 40 seconds in. +- **Every file but executables, modules and system files**: no game has run with this yet, and both the + Unity and the Insomniac game failed with it. **Tested games.** Results on a PS5 with ShadowMountPlus 1.7 beta 4 and AMPR Emu 0.4.2.1: | Game | Version | Engine | Rules | Result | |---|---|---|---|---| -| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `unity` (auto) | Runs: menu, saves, gameplay. | -| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `default` | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | -| PPSA03671 | 01.001.005 | Insomniac | `insomniac` (auto) | Runs. | -| PPSA03671 | 01.001.005 | Insomniac | `default` | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`). | +| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `StreamingAssets/` only | Runs: menu, saves, gameplay. | +| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | every file but executables | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | +| PPSA03671 | 01.001.005 | Insomniac | `d/` archives but boot, movie and audio | Runs. | +| PPSA03671 | 01.001.005 | Insomniac | every file but executables | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`). | +| EA SPORTS UFC 6 (PPSA23566) | | Frostbite | all 292 `.cas` archives (traces plus untraced `.cas`) | Runs; 166 GB to about 98 GB. | +| EA SPORTS UFC 6 (PPSA23566) | | Frostbite | traces plus untraced types, `.toc`, `initfs_Ps5`, `chunkmanifest` packed | Crashes about 40 s after start (`SIGSEGV` in `Job0`). | **Recommended steps.** 1. Make sure the unpacked game runs with AMPR Emu 0.4.2.1 in its `fakelib/`. 2. On the AMPR Packs page, choose **Build playable game** (the default), pick the game folder, a new - output folder and the folder with AMPR Emu's `libSceAmpr.sprx`, and leave the TOML field empty. - Optionally tick the exFAT image box. On the command line: - `mkpfs ampr game --root --output --fakelib --exfat .` + output folder, the folder with AMPR Emu's `libSceAmpr.sprx` and the game's TOML profile (no profile yet: + record traces, see below). Optionally tick the exFAT image box. On the command line: + `mkpfs ampr game --root --output --fakelib --config --exfat .` 3. Copy the output folder or the `.exfat` image to the PS5 and start the game. Play past the menu and load a save or a level: some files are only read later. 4. Delete the original only after that. ##### Rules from traces -Built-in rules only exist for a few engines. For any other game, the most reliable rules come from +For a game without a profile, the most reliable rules come from watching the game on the console: the debug build of AMPR Emu (`Prospero_DebugHooksPackedStdio`, for example the 0.4.2.1 test-debug-pack) records every read the game makes through AMPR into `/app0/ampr_commands.bin`. MkPFS reads those traces and packs exactly the files the game read that way, @@ -387,7 +390,7 @@ packed with the settings most traced files of that type got: with traces of an E the parts of the game they belong to before deleting the original. Files without an extension are never added. `mkpfs ampr profile generate --output rules.toml --report rules.md` (or `--trace COMMANDS -INDEX`, repeatable) writes the same rules as a TOML file to review or edit, plus a report, a metrics JSON +INDEX`, repeatable) writes the same rules as a TOML file to review, edit and share as the game's profile, plus a report, a metrics JSON and a runtime header; `mkpfs ampr profile batch` makes one profile per run of a folder or ZIP bundle. The output matches `ampr_pack_profile.py` byte for byte. @@ -402,12 +405,11 @@ to leave loose. ##### Reporting a game -Whether it works or not, please report: the game title, ID and version, the `Profile found` or `No profile -found` line from the build -log, what happened on the console, and for a game that fails, the ShadowMountPlus log +Whether it works or not, please report: the game title, ID and version, the profile or traces used, +what happened on the console, and for a game that fails, the ShadowMountPlus log (`/data/shadowmount/debug.log`) and the console log around the crash. A failing game can often still be -packed with a TOML file that leaves more files loose (see the TOML format below); a report with the result -of such a test lets the built-in rules learn that game. +packed with a TOML file that leaves more files loose (see the TOML format below); share the profile that +works so others can use it. #### How the packs are made @@ -445,12 +447,8 @@ volume from one build together; the `.crc` sidecar is only for `verify` and `unp | Option | Default | Meaning | |---|---|---| -| `--config ` | none | Pack rules. Without a config or preset every file stays loose, and `pack` prints a warning on standard error. | -| `--preset default` | none | Built-in rules instead of `--config` (not in `ampr_pack.py`): compress every file but keep loose everything `remove-sources` protects (`eboot.bin`, `*.prx`/`*.sprx`/`*.elf`/`*.self`, `sce_sys/`, `sce_module/`, `fakelib/`, `fakelib2/`, `mods/`, `save/`, `system/`, `param.sfo`, `nptitle.dat`, `ampr_emu.index`) and Unity IL2CPP `global-metadata.dat`, which is memory-mapped. | -| `--preset unity` | none | For Unity games: compress only `StreamingAssets/` (Addressables bundles, FMOD banks, videos) and keep Unity's own data files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) loose. Packing those made a Unity title abort at startup on the console, while packing only `StreamingAssets` ran normally. | -| `--preset insomniac` | none | For Insomniac Games titles: compress only the `d/` archives (128 KiB blocks) and keep the boot, movie, sound bank and `wem*` archives, `toc`, `dag` and the other top-level files loose. Packing every file made such a title crash at startup, while these rules ran it. | -| `--preset auto` | none | `insomniac` when `toc`, `dag` and `d/` are at the top of the game, `unity` for Unity games (a `globalgamemanagers`, `data.unity3d` or `global-metadata.dat` within four folder levels), else `default`; prints the profile found (or that none was) on standard error. The default for `ampr game` and the AMPR Packs page. | -| `--traces ` | none | Rules from APR traces instead of `--config` or `--preset` (see [Rules from traces](#rules-from-traces)); also for `ampr game`. | +| `--config ` | none | Pack rules (the game's profile). Without a config or `--traces` every file stays loose, and `pack` prints a warning on standard error. | +| `--traces ` | none | Rules from APR traces instead of `--config` (see [Rules from traces](#rules-from-traces)); also for `ampr game`. | | `--pack-untraced-types` | off | With `--traces`: also pack untraced files of the traced file types, like the traced ones (also for `ampr game` and `profile generate`). | | `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | | `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | @@ -478,11 +476,8 @@ mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-em 2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with their real sizes. This must come before packing, because the manifest addresses files by their row in this index. -3. **Packs.** Packs from the game folder with `--config`, `--traces`, `--preset`, or by default `--preset auto`, which - picks the rules from the game: `insomniac` when `toc`, `dag` and `d/` are at the top, `unity` when a - Unity file (`globalgamemanagers`, `data.unity3d`, `global-metadata.dat`) is within four folder levels, - else `default`. The log says whether a profile - was found, the file that identified it and whether the profile has run on a PS5. +3. **Packs.** Packs from the game folder with the rules of `--config` (the game's TOML profile) or + `--traces`; one of them is required. 4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, including empty ones. This runs after packing because the packer only then decides which large, incompressible files to leave loose. @@ -580,8 +575,7 @@ dotnet run --project src/MkPFS.Gui -c Release - The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. Its default action, Build playable game, runs `ampr game` with a library folder and an optional exFAT image. Its optional trace folder takes the rules from console traces. - Without a TOML file, the rules are picked from the game (Unity or not), so there is nothing to choose. - Packing without a TOML uses `--preset default`. + Building and packing need the game's TOML profile or a trace folder; the page asks for one until it is set. ## Differences from Python MkPFS diff --git a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs index fbb60b6..98ef3b7 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRPackConfig.cs @@ -286,145 +286,6 @@ public static AMPRPackConfig Load(string? path) return FromTable(raw, configDir); } - /// - /// Built-in rule sets for (an extension; ampr_pack has none). auto - /// picks insomniac, unity or default from the game folder (). - /// - public static IReadOnlyList PresetNames { get; } = ["auto", "default", "insomniac", "unity"]; - - // Files only Unity players ship; depth-limited so a scan of a large game stays cheap. - private static readonly HashSet UnityMarkers = new(StringComparer.OrdinalIgnoreCase) - { - "globalgamemanagers", "data.unity3d", "global-metadata.dat", - }; - - /// - /// The built-in rules for the game in : insomniac when the folder has Insomniac's - /// layout (toc and dag files and a d folder at the top), unity when a Unity marker file - /// (globalgamemanagers, data.unity3d, global-metadata.dat) is within four folder levels, else - /// default. - /// - /// Game folder. - /// Preset name and what decided it (relative, / separators), if anything. - public static (string Preset, string? Marker) DetectPreset(string root) - { - if (!Directory.Exists(root)) - { - return ("default", null); - } - - if (File.Exists(Path.Combine(root, "toc")) && File.Exists(Path.Combine(root, "dag")) && Directory.Exists(Path.Combine(root, "d"))) - { - return ("insomniac", "toc, dag, d/"); - } - - EnumerationOptions options = new() { RecurseSubdirectories = true, MaxRecursionDepth = 3, IgnoreInaccessible = true }; - string? marker = Directory.EnumerateFiles(root, "*", options).FirstOrDefault(f => UnityMarkers.Contains(Path.GetFileName(f))); - return marker is null ? ("default", null) : ("unity", Path.GetRelativePath(root, marker).Replace('\\', '/')); - } - - /// - /// What a built-in rule set packs and the games it was played with on a PS5, so a log can say whether the rules - /// are known to work. default is the fallback for games no profile matches and has run no game yet. - /// - /// Preset name (default, insomniac or unity). - /// Short description of what is packed, and the tested games ( when untested). - /// The preset does not exist. - public static (string Packs, string? TestedOn) PresetInfo(string name) => name switch - { - "default" => ("every file but executables, modules and system files is packed", null), - "insomniac" => ("only the d/ archives are packed, without boot, movie and audio files", "PPSA03671 (01.001.005)"), - "unity" => ("only StreamingAssets is packed", "God of War Sons of Sparta (PPSA28997)"), - _ => throw new AMPRPackException($"unknown preset: {name}"), - }; - - /// - /// The default preset: compress every file, but keep loose everything remove-sources protects - /// (executables, modules, system and save folders, indexes) and Unity IL2CPP global-metadata.dat, which - /// IL2CPP memory-maps (ampr_emu does not intercept mmap). * also matches /, so *.prx covers - /// every depth. - /// - public const string DefaultPresetToml = """ - [pack] - default_action = "compress" - - [[rule]] - action = "loose" - include = [ - "eboot.bin", "*/eboot.bin", "param.sfo", "*/param.sfo", "nptitle.dat", "*/nptitle.dat", - "ampr_emu.index", "*/ampr_emu.index", "*.prx", "*.sprx", "*.elf", "*.self", - "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "mods/*", "save/*", "system/*", - "*/global-metadata.dat", - ] - """; - - /// - /// The unity preset: compress only StreamingAssets (Addressables bundles, FMOD banks, videos), which - /// Unity reads with ordinary file reads that AMPR Emu serves. Unity's own data files next to it (level*, - /// sharedassets*, globalgamemanagers, .resS) stay loose: packing them made a Unity title abort - /// at startup on the console, consistent with Unity reading them through a path AMPR Emu does not intercept. - /// - public const string UnityPresetToml = """ - [pack] - default_action = "loose" - - [[rule]] - action = "compress" - include = ["StreamingAssets/*", "*/StreamingAssets/*"] - - [[rule]] - action = "loose" - include = [ - "eboot.bin", "*/eboot.bin", "*.prx", "*.sprx", "*.elf", "*.self", - "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "*/global-metadata.dat", - ] - """; - - /// - /// The insomniac preset, for Insomniac Games titles (toc, dag and the d/ archives at - /// the top): compress only the d/ archives, but keep the boot, movie, sound bank and streamed audio - /// (wem) archives loose, like Lazy_AMPR's Spider-Man profile. Packing every file crashed such a title at - /// startup; these rules ran it. - /// - public const string InsomniacPresetToml = """ - [pack] - default_action = "loose" - default_block_size = "128KiB" - - [[rule]] - action = "compress" - include = ["d/*"] - block_size = "128KiB" - - [[rule]] - action = "loose" - include = [ - "d/*/bootload*", "d/*/critbootload*", "d/*/movie*", "d/*/soundbank*", "d/*/wem*", - "toc", "dag", "commandline.txt", "pkg_settings.set", "known_islands.dat", - ] - - [[rule]] - action = "loose" - include = [ - "eboot.bin", "*/eboot.bin", "*.prx", "*.sprx", "*.elf", "*.self", - "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "_DUPLEX_/*", - ] - """; - - /// Load a built-in preset. - /// Preset name (see ). - /// Game folder, required for auto. - /// Configuration. - /// The preset does not exist. - public static AMPRPackConfig LoadPreset(string name, string? root = null) => name switch - { - "auto" => LoadPreset(DetectPreset(root ?? throw new AMPRPackException("preset auto needs the game folder")).Preset), - "default" => FromTable(AMPRToml.Parse(DefaultPresetToml, "preset default"), Directory.GetCurrentDirectory()), - "insomniac" => FromTable(AMPRToml.Parse(InsomniacPresetToml, "preset insomniac"), Directory.GetCurrentDirectory()), - "unity" => FromTable(AMPRToml.Parse(UnityPresetToml, "preset unity"), Directory.GetCurrentDirectory()), - _ => throw new AMPRPackException($"unknown preset: {name}"), - }; - /// /// Read pattern lines: stripped, without blank lines and # comments (Python _load_pattern_file). /// The file is decoded as UTF-8 without BOM detection, like Python read_text(encoding="utf-8"). diff --git a/src/MkPFS.Cli/Commands/AmprCommand.cs b/src/MkPFS.Cli/Commands/AmprCommand.cs index 8295bab..c8525f5 100644 --- a/src/MkPFS.Cli/Commands/AmprCommand.cs +++ b/src/MkPFS.Cli/Commands/AmprCommand.cs @@ -20,7 +20,7 @@ internal static class AmprCommand { /// Printed to stderr when a pack run packs nothing (an addition to the oracle output). internal const string NothingPackedWarning = - "warning: no files were packed; without --config or --preset default every file stays loose"; + "warning: no files were packed; without --config or --traces every file stays loose"; /// Printed by ampr game before it starts. internal const string ExperimentalWarning = @@ -57,8 +57,6 @@ private static Command Pack(CliContext ctx) Option amprIndex = Required("--ampr-index", "AMPRIDX3 file"); Option output = Required("--output", "output directory"); Option config = new("--config") { Description = "TOML pack configuration" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: default compresses every file and keeps executables, modules and system files loose; unity compresses only StreamingAssets; insomniac compresses only the d/ archives but boot, movie and audio ones; auto picks insomniac or unity from the game, else default" }; - preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); Option traces = new("--traces") { Description = TracesDescription }; Option untracedTypes = new("--pack-untraced-types") { Description = UntracedTypesDescription }; Option include = Repeated("--include", "additional include glob"); @@ -72,27 +70,16 @@ private static Command Pack(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress build progress on stderr; final JSON still goes to stdout" }; Command command = new("pack", "build pack index and data volumes") { - root, amprIndex, output, config, preset, traces, untracedTypes, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, + root, amprIndex, output, config, traces, untracedTypes, include, exclude, includeFrom, excludeFrom, workers, selfContained, requirePacked, allowMissing, noProgress, }; command.SetAction(parse => Run(ctx, () => { string? configPath = parse.GetValue(config); - string? presetName = parse.GetValue(preset); string? tracesDir = parse.GetValue(traces); - if (parse.GetValue(untracedTypes) && tracesDir is null) - { - throw new AMPRPackException("--pack-untraced-types needs --traces"); - } - - AMPRPackConfig loaded = (configPath, presetName, tracesDir) switch - { - (not null, not null, _) => throw new AMPRPackException("--config and --preset cannot be used together"), - (_, _, not null) when configPath is not null || presetName is not null => - throw new AMPRPackException("--traces cannot be used with --config or --preset"), - (null, null, string dir) => TraceRules(Resolve(ctx, dir), parse.GetValue(untracedTypes), ctx.Err.WriteLine), - (null, not null, _) => Preset(presetName, Resolve(ctx, parse.GetValue(root)!), ctx.Err.WriteLine), - _ => AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)), - }; + CheckRuleOptions(configPath, tracesDir, parse.GetValue(untracedTypes)); + AMPRPackConfig loaded = tracesDir is not null + ? TraceRules(Resolve(ctx, tracesDir), parse.GetValue(untracedTypes), ctx.Err.WriteLine) + : AMPRPackConfig.Load(configPath is null ? null : Resolve(ctx, configPath)); if (parse.GetValue(workers) is { } count) { loaded.Workers = count is >= 1 and <= 256 ? count : throw new AMPRPackException("--workers must be between 1 and 256"); @@ -171,9 +158,7 @@ private static Command Game(CliContext ctx) Option root = Required("--root", "unpacked game folder (/app0); only read"); Option output = Required("--output", "new or empty folder for the playable game"); Option fakelib = new("--fakelib") { Description = "folder with AMPR Emu 0.4.2.1+ libSceAmpr.sprx and other fakelib libraries to add" }; - Option config = new("--config") { Description = "TOML pack configuration (default: --preset auto)" }; - Option preset = new("--preset") { Description = "built-in rules instead of --config: auto (insomniac or unity from the game, else default), default (every file but executables, modules and system files), insomniac (the d/ archives but boot, movie and audio ones) or unity (only StreamingAssets); default: auto" }; - preset.AcceptOnlyFromAmong([.. AMPRPackConfig.PresetNames]); + Option config = new("--config") { Description = "TOML pack profile for the game (or --traces)" }; Option traces = new("--traces") { Description = TracesDescription }; Option untracedTypes = new("--pack-untraced-types") { Description = UntracedTypesDescription }; Option workers = new("--workers") { Description = "compression workers" }; @@ -184,32 +169,17 @@ private static Command Game(CliContext ctx) Option noProgress = new("--no-progress") { Description = "suppress progress on stderr" }; Command command = new("game", "build a folder that runs from packs as is: libraries, ampr_emu.index, packs and loose files (not in ampr_pack.py)") { - root, output, fakelib, config, preset, traces, untracedTypes, workers, selfContained, exfat, exfatFree, skipVerify, noProgress, + root, output, fakelib, config, traces, untracedTypes, workers, selfContained, exfat, exfatFree, skipVerify, noProgress, }; command.SetAction(parse => Run(ctx, () => { ctx.Log.Warning(ExperimentalWarning); string? configPath = parse.GetValue(config); - string? presetName = parse.GetValue(preset); string? tracesDir = parse.GetValue(traces); - if (configPath is not null && presetName is not null) - { - throw new AMPRPackException("--config and --preset cannot be used together"); - } - - if (parse.GetValue(untracedTypes) && tracesDir is null) - { - throw new AMPRPackException("--pack-untraced-types needs --traces"); - } - - if (tracesDir is not null && (configPath is not null || presetName is not null)) - { - throw new AMPRPackException("--traces cannot be used with --config or --preset"); - } - + CheckRuleOptions(configPath, tracesDir, parse.GetValue(untracedTypes)); AMPRPackConfig loaded = tracesDir is not null ? TraceRules(Resolve(ctx, tracesDir), parse.GetValue(untracedTypes), message => ctx.Log.Info(message)) : configPath is not null ? Config(ctx, Resolve(ctx, configPath)) - : Preset(presetName ?? "auto", Resolve(ctx, parse.GetValue(root)!), message => ctx.Log.Info(message)); + : throw new AMPRPackException(NoRulesError); if (parse.GetValue(workers) is { } count) { loaded.Workers = count is >= 1 and <= 256 ? count : throw new AMPRPackException("--workers must be between 1 and 256"); @@ -249,33 +219,21 @@ private static Command Game(CliContext ctx) return command; } - // Built-in rules. The log says whether a profile matched the game and whether it was played on a PS5, so the - // user can tell how likely the packed game is to run. - private static AMPRPackConfig Preset(string name, string root, Action report) + /// ampr game without rules: nothing would be packed. + internal const string NoRulesError = + "ampr game needs the rules to pack with: --config for the game, or --traces of APR traces"; + + private static void CheckRuleOptions(string? configPath, string? tracesDir, bool untracedTypes) { - bool auto = name == "auto"; - string? marker = null; - if (auto) + if (untracedTypes && tracesDir is null) { - (name, marker) = AMPRPackConfig.DetectPreset(root); + throw new AMPRPackException("--pack-untraced-types needs --traces"); } - report(RulesReport(name, marker, auto)); - return AMPRPackConfig.LoadPreset(name); - } - - private static string RulesReport(string preset, string? marker, bool auto) - { - (string packs, string? testedOn) = AMPRPackConfig.PresetInfo(preset); - string tested = testedOn is null - ? "Untested: the game may not start on the PS5; keep the original." - : $"Tested: runs on {testedOn}."; - return (preset, marker, auto) switch + if (tracesDir is not null && configPath is not null) { - (_, not null, _) => $"Profile found: {preset} ({marker}); {packs}. {tested}", - ("default", null, true) => $"No profile found for this game: generic rules ({packs}). {tested}", - _ => $"Rules: {preset} (--preset); {packs}. {tested}", - }; + throw new AMPRPackException("--traces cannot be used with --config"); + } } private const string TracesDescription = @@ -329,7 +287,7 @@ private static AMPRPackConfig TraceRules(string directory, bool untracedTypes, A private static AMPRPackConfig Config(CliContext ctx, string path) { - ctx.Log.Info($"Rules: your TOML file {path} (no built-in profile)"); + ctx.Log.Info($"Rules: TOML profile {path}"); return AMPRPackConfig.Load(path); } diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 0d006f9..a0d47af 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -33,18 +33,13 @@ APR Emu Treat as APR title Build playable game (packs + loose files) - ✓ Profile found: {0} (identified by {1}). {2} - Only the d/ archives are packed; boot, movie and audio archives stay loose. - Only StreamingAssets is packed. - Every file but executables, modules and system files is packed. - Tested: runs on {0}. - Untested: the game may not start on the PS5. - No profile found for this game: generic rules will be used. {0} They are untested, so the game may not start on the PS5; keep the original. - Your TOML file decides what is packed; no built-in profile is used. + Choose the TOML profile for this game, or a folder of APR traces to build the rules from. Profiles are TOML files shared for each game. + Your TOML profile decides what is packed. Trace folder (optional) Folder with ampr_commands.bin and ampr_emu.index from the debug AMPR Emu, one run per subfolder ✓ Rules from traces: {0} recorded run(s) found. Only the files the game read through AMPR are packed; every other file stays loose. No traces in this folder: each run needs ampr_commands.bin and its ampr_emu.index side by side. + ✗ Choose a TOML profile or a trace folder: without rules nothing is packed. Use either a TOML file or a trace folder, not both. Also pack files no session read, when a traced file has the same type (for example the remaining .cas archives). They were not seen being read, so test the game with them. Free space inside the image (optional, e.g. 2GiB): needed for a test run with the debug AMPR Emu, which writes its log into the game folder @@ -66,7 +61,7 @@ Action Leave missing source files loose TOML Configuration - Empty: rules chosen from the game (Unity games pack only StreamingAssets)… + The game's TOML profile… Delete the packed source files (cannot be undone; unchecked shows the plan only) ✗ Pack manifest is required. ✗ Game folder and output folder are both required. diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 95a0bfb..40ef613 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -33,18 +33,13 @@ APR Emu Als APR-Titel behandeln Spielfertigen Ordner erstellen (Packs + lose Dateien) - ✓ Profil gefunden: {0} (erkannt an {1}). {2} - Nur die Archive in d/ werden gepackt; Boot-, Video- und Audioarchive bleiben einzeln. - Nur StreamingAssets wird gepackt. - Alle Dateien außer ausführbaren Dateien, Modulen und Systemdateien werden gepackt. - Getestet: läuft mit {0}. - Ungetestet: das Spiel startet auf der PS5 eventuell nicht. - Kein Profil für dieses Spiel gefunden: es werden allgemeine Regeln verwendet. {0} Sie sind ungetestet, das Spiel startet auf der PS5 eventuell nicht; behalte das Original. - Deine TOML-Datei bestimmt, was gepackt wird; kein eingebautes Profil wird verwendet. + Wähle das TOML-Profil für dieses Spiel oder einen Ordner mit APR-Traces, aus dem die Regeln erstellt werden. Profile sind TOML-Dateien, die pro Spiel geteilt werden. + Dein TOML-Profil bestimmt, was gepackt wird. Trace-Ordner (optional) Ordner mit ampr_commands.bin und ampr_emu.index vom Debug-AMPR-Emu, ein Lauf pro Unterordner ✓ Regeln aus Traces: {0} aufgezeichnete(r) Lauf/Läufe gefunden. Nur die Dateien, die das Spiel über AMPR gelesen hat, werden gepackt; alle anderen bleiben einzeln. Keine Traces in diesem Ordner: jeder Lauf braucht ampr_commands.bin und seine ampr_emu.index nebeneinander. + ✗ Wähle ein TOML-Profil oder einen Trace-Ordner: ohne Regeln wird nichts gepackt. Verwende entweder eine TOML-Datei oder einen Trace-Ordner, nicht beides. Auch Dateien packen, die keine Sitzung gelesen hat, wenn eine aufgezeichnete Datei denselben Typ hat (zum Beispiel die übrigen .cas-Archive). Ihr Lesen wurde nicht beobachtet, also das Spiel damit testen. Freier Platz im Abbild (optional, z. B. 2GiB): nötig für einen Test mit dem Debug-AMPR-Emu, der sein Protokoll in den Spielordner schreibt @@ -66,7 +61,7 @@ Aktion Fehlende Quelldateien lose lassen TOML-Konfiguration - Leer: Regeln nach Spiel (Unity-Spiele packen nur StreamingAssets)… + TOML-Profil des Spiels… Gepackte Quelldateien löschen (nicht rückgängig zu machen; sonst nur den Plan anzeigen) ✗ Paket-Manifest ist erforderlich. ✗ Spielordner und Ausgabeordner sind beide erforderlich. diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 98608c0..8b469d9 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -33,18 +33,13 @@ APR Emu Tratar como título APR Crear juego listo (packs + archivos sueltos) - ✓ Perfil encontrado: {0} (identificado por {1}). {2} - Solo se empaquetan los archivos de d/; los de arranque, vídeo y audio quedan sueltos. - Solo se empaqueta StreamingAssets. - Se empaquetan todos los archivos salvo ejecutables, módulos y archivos del sistema. - Probado: funciona en {0}. - Sin probar: puede que el juego no arranque en la PS5. - No se encontró ningún perfil para este juego: se usarán reglas genéricas. {0} No están probadas, así que puede que el juego no arranque en la PS5; conserva el original. - Tu archivo TOML decide qué se empaqueta; no se usa ningún perfil integrado. + Elige el perfil TOML de este juego, o una carpeta de trazas APR para crear las reglas. Los perfiles son archivos TOML que se comparten para cada juego. + Tu perfil TOML decide qué se empaqueta. Carpeta de trazas (opcional) Carpeta con ampr_commands.bin y ampr_emu.index del AMPR Emu de depuración, una ejecución por subcarpeta ✓ Reglas a partir de trazas: {0} ejecución(es) grabada(s). Solo se empaquetan los archivos que el juego leyó mediante AMPR; el resto queda suelto. No hay trazas en esta carpeta: cada ejecución necesita ampr_commands.bin y su ampr_emu.index juntos. + ✗ Elige un perfil TOML o una carpeta de trazas: sin reglas no se empaqueta nada. Usa un archivo TOML o una carpeta de trazas, no ambos. Empaquetar también los archivos que ninguna sesión leyó, si un archivo trazado tiene el mismo tipo (por ejemplo, los .cas restantes). No se vio cómo se leían, así que prueba el juego con ellos. Espacio libre dentro de la imagen (opcional, p. ej. 2GiB): necesario para una prueba con el AMPR Emu de depuración, que escribe su registro en la carpeta del juego @@ -66,7 +61,7 @@ Acción Dejar sueltos los archivos fuente que falten Configuración TOML - Vacío: reglas según el juego (los juegos Unity solo empaquetan StreamingAssets)… + Perfil TOML del juego… Eliminar los archivos fuente empaquetados (no se puede deshacer; sin marcar solo muestra el plan) ✗ Se requiere el manifiesto del paquete. ✗ Se requieren la carpeta del juego y la carpeta de salida. diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index fdcfb66..99828be 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -33,18 +33,13 @@ APR Emu Traiter comme un titre APR Créer le jeu prêt à lancer (packs + fichiers libres) - ✓ Profil trouvé : {0} (identifié par {1}). {2} - Seules les archives de d/ sont empaquetées ; celles de démarrage, vidéo et audio restent à part. - Seul StreamingAssets est empaqueté. - Tous les fichiers sont empaquetés, sauf les exécutables, les modules et les fichiers système. - Testé : fonctionne avec {0}. - Non testé : le jeu risque de ne pas démarrer sur la PS5. - Aucun profil trouvé pour ce jeu : des règles génériques seront utilisées. {0} Elles ne sont pas testées, le jeu risque donc de ne pas démarrer sur la PS5 ; gardez l’original. - Votre fichier TOML décide de ce qui est empaqueté ; aucun profil intégré n’est utilisé. + Choisissez le profil TOML de ce jeu, ou un dossier de traces APR pour en tirer les règles. Les profils sont des fichiers TOML partagés pour chaque jeu. + Votre profil TOML décide de ce qui est empaqueté. Dossier de traces (facultatif) Dossier avec ampr_commands.bin et ampr_emu.index de l’AMPR Emu de débogage, une session par sous-dossier ✓ Règles issues des traces : {0} session(s) enregistrée(s). Seuls les fichiers que le jeu a lus via AMPR sont empaquetés ; les autres restent à part. Aucune trace dans ce dossier : chaque session a besoin de ampr_commands.bin et de son ampr_emu.index côte à côte. + ✗ Choisissez un profil TOML ou un dossier de traces : sans règles, rien n’est empaqueté. Utilisez un fichier TOML ou un dossier de traces, pas les deux. Empaqueter aussi les fichiers qu’aucune session n’a lus, quand un fichier tracé a le même type (par exemple les archives .cas restantes). Leur lecture n’a pas été observée : testez le jeu avec eux. Espace libre dans l’image (facultatif, ex. 2GiB) : nécessaire pour un test avec l’AMPR Emu de débogage, qui écrit son journal dans le dossier du jeu @@ -66,7 +61,7 @@ Action Laisser libres les fichiers sources manquants Configuration TOML - Vide : règles selon le jeu (les jeux Unity n’empaquettent que StreamingAssets)… + Profil TOML du jeu… Supprimer les fichiers sources empaquetés (irréversible ; décoché, affiche seulement le plan) ✗ Le manifeste du paquet est requis. ✗ Le dossier du jeu et le dossier de sortie sont tous deux requis. diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 6d6e9bf..15cf209 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -33,18 +33,13 @@ APR Emu Tratar como título APR Criar jogo pronto (packs + arquivos soltos) - ✓ Perfil encontrado: {0} (identificado por {1}). {2} - Só os arquivos de d/ são empacotados; os de inicialização, vídeo e áudio ficam soltos. - Só StreamingAssets é empacotado. - Todos os arquivos são empacotados, exceto executáveis, módulos e arquivos de sistema. - Testado: funciona em {0}. - Não testado: o jogo pode não iniciar no PS5. - Nenhum perfil encontrado para este jogo: serão usadas regras genéricas. {0} Elas não foram testadas, então o jogo pode não iniciar no PS5; guarde o original. - O seu arquivo TOML decide o que é empacotado; nenhum perfil embutido é usado. + Escolha o perfil TOML deste jogo, ou uma pasta de traces APR para criar as regras. Perfis são arquivos TOML compartilhados para cada jogo. + O seu perfil TOML decide o que é empacotado. Pasta de traces (opcional) Pasta com ampr_commands.bin e ampr_emu.index do AMPR Emu de debug, uma execução por subpasta ✓ Regras a partir dos traces: {0} execução(ões) gravada(s). Só os arquivos que o jogo leu pelo AMPR são empacotados; os demais ficam soltos. Nenhum trace nesta pasta: cada execução precisa de ampr_commands.bin e do seu ampr_emu.index lado a lado. + ✗ Escolha um perfil TOML ou uma pasta de traces: sem regras nada é empacotado. Use um arquivo TOML ou uma pasta de traces, não os dois. Empacotar também os arquivos que nenhuma sessão leu, quando um arquivo rastreado tem o mesmo tipo (por exemplo, os .cas restantes). Eles não foram vistos sendo lidos, então teste o jogo com eles. Espaço livre dentro da imagem (opcional, ex. 2GiB): necessário para um teste com o AMPR Emu de debug, que grava o log na pasta do jogo @@ -66,7 +61,7 @@ Ação Deixar soltos os arquivos-fonte ausentes Configuração TOML - Vazio: regras escolhidas pelo jogo (jogos Unity empacotam só StreamingAssets)… + Perfil TOML do jogo… Excluir os arquivos-fonte empacotados (não pode ser desfeito; desmarcado mostra apenas o plano) ✗ O manifesto do pacote é obrigatório. ✗ A pasta do jogo e a pasta de saída são obrigatórias. diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 8df49cd..cdd2c92 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -33,18 +33,13 @@ APR Emu Tratează ca titlu APR Creează jocul gata de rulat (pack-uri + fișiere libere) - ✓ Profil găsit: {0} (identificat după {1}). {2} - Se împachetează doar arhivele din d/; cele de pornire, video și audio rămân separate. - Se împachetează doar StreamingAssets. - Se împachetează toate fișierele, mai puțin executabilele, modulele și fișierele de sistem. - Testat: rulează pe {0}. - Netestat: este posibil ca jocul să nu pornească pe PS5. - Niciun profil găsit pentru acest joc: se vor folosi reguli generice. {0} Nu sunt testate, deci este posibil ca jocul să nu pornească pe PS5; păstrează originalul. - Fișierul tău TOML decide ce se împachetează; nu se folosește niciun profil inclus. + Alege profilul TOML pentru acest joc sau un dosar cu trace-uri APR din care să se creeze regulile. Profilurile sunt fișiere TOML partajate pentru fiecare joc. + Profilul tău TOML decide ce se împachetează. Dosar cu trace-uri (opțional) Dosar cu ampr_commands.bin și ampr_emu.index de la AMPR Emu de depanare, o rulare pe subdosar ✓ Reguli din trace-uri: {0} rulare(i) înregistrată(e). Se împachetează doar fișierele citite de joc prin AMPR; restul rămân separate. Niciun trace în acest dosar: fiecare rulare are nevoie de ampr_commands.bin și de ampr_emu.index alături. + ✗ Alege un profil TOML sau un dosar cu trace-uri: fără reguli nu se împachetează nimic. Folosește fie un fișier TOML, fie un dosar cu trace-uri, nu amândouă. Împachetează și fișierele pe care nicio sesiune nu le-a citit, dacă un fișier urmărit are același tip (de exemplu, restul arhivelor .cas). Nu s-a văzut cum sunt citite, așa că testează jocul cu ele. Spațiu liber în imagine (opțional, ex. 2GiB): necesar pentru un test cu AMPR Emu de depanare, care își scrie jurnalul în dosarul jocului @@ -66,7 +61,7 @@ Acțiune Lasă libere fișierele sursă lipsă Configurație TOML - Gol: reguli alese după joc (jocurile Unity împachetează doar StreamingAssets)… + Profilul TOML al jocului… Șterge fișierele sursă împachetate (ireversibil; nebifat arată doar planul) ✗ Manifestul pachetului este necesar. ✗ Folderul jocului și folderul de ieșire sunt ambele necesare. diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index 9ffc2e3..57943cd 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -34,7 +34,7 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [NotifyPropertyChangedFor(nameof(ShowRoot), nameof(ShowPackPaths), nameof(ShowOutput), nameof(ShowManifest), nameof(ShowConfig))] [NotifyPropertyChangedFor(nameof(ShowPackOptions), nameof(ShowOverwrite), nameof(ShowJson), nameof(ShowConfirm))] [NotifyPropertyChangedFor(nameof(ShowGameOptions), nameof(ShowAllowMissing), nameof(ShowExfatPath), nameof(ShowPackNote))] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] + [NotifyPropertyChangedFor(nameof(ShowRulesMissing), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] public partial Choice Action { get; set; } = ActionChoices[0]; /// --fakelib (game): folder with AMPR Emu and other libraries to add. @@ -60,7 +60,6 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// --root: the game's /app0 folder. [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(RulesFoundText), nameof(RulesNoneText))] public partial string Root { get; set; } = string.Empty; /// --ampr-index; defaults to <root>/ampr_emu.index. @@ -75,14 +74,14 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job [ObservableProperty] public partial string Manifest { get; set; } = string.Empty; - /// --config; empty packs with --preset auto, and runtime-config requires it. + /// --config: the game's TOML profile; game and pack need it or a trace folder, runtime-config needs it. [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] + [NotifyPropertyChangedFor(nameof(ShowRulesMissing), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] public partial string Config { get; set; } = string.Empty; /// --traces (game, pack): folder with APR traces of the debug emulator build. [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowRulesFound), nameof(ShowRulesNone), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] + [NotifyPropertyChangedFor(nameof(ShowRulesMissing), nameof(ShowRulesConfig), nameof(ShowRulesTraces), nameof(ShowNoTraces))] [NotifyPropertyChangedFor(nameof(RulesTracesText))] public partial string Traces { get; set; } = string.Empty; @@ -144,14 +143,9 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// exFAT image path (game with an image). public bool ShowExfatPath => ShowGameOptions && Exfat; - // Built-in rules picked for the game folder (as --preset auto does), or null when the folder does not exist. - private (string Preset, string? Marker)? _detected; - // Trace runs found in the trace folder, or null when the field is empty. private int? _traceRuns; - private bool UsesBuiltInRules => ShowPackOptions && Config.Trim().Length == 0 && Traces.Trim().Length == 0 && _detected is not null; - /// The trace folder has runs: the rules come from the traces. public bool ShowRulesTraces => ShowPackOptions && Config.Trim().Length == 0 && _traceRuns > 0; @@ -161,34 +155,12 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job /// How many trace runs were found. public string RulesTracesText => Localizer.Instance.Format("ap_rules_traces", _traceRuns ?? 0); - /// A built-in profile matches the game folder (game, pack without a TOML). - public bool ShowRulesFound => UsesBuiltInRules && _detected!.Value.Marker is not null; - - /// No profile matches the game folder: the generic, untested rules are used. - public bool ShowRulesNone => UsesBuiltInRules && _detected!.Value.Marker is null; + /// Neither a TOML profile nor a trace folder is chosen yet (game, pack). + public bool ShowRulesMissing => ShowPackOptions && Config.Trim().Length == 0 && Traces.Trim().Length == 0; /// The user's TOML file decides what is packed. public bool ShowRulesConfig => ShowPackOptions && Config.Trim().Length > 0 && Traces.Trim().Length == 0; - /// The matched profile, the file that identified it and the games it ran on. - public string RulesFoundText - { - get - { - if (_detected is not (string preset, string marker)) - { - return string.Empty; - } - - string? testedOn = Build.AMPRPack.AMPRPackConfig.PresetInfo(preset).TestedOn; - return Localizer.Instance.Format("ap_rules_found", preset, marker, Localizer.Instance["ap_rules_packs_" + preset]) - + " " + (testedOn is null ? Localizer.Instance["ap_rules_untested"] : Localizer.Instance.Format("ap_rules_tested", testedOn)); - } - } - - /// The generic rules, which no game has run with yet. - public string RulesNoneText => Localizer.Instance.Format("ap_rules_none", Localizer.Instance["ap_rules_packs_default"]); - /// Overwrite (unpack). public bool ShowOverwrite => Action.Value == "unpack"; @@ -216,6 +188,7 @@ public string RulesFoundText "game" or "pack" when config.Length > 0 && traces.Length > 0 => Localizer.Instance["ap_err_traces_config"], "game" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], "pack" when root.Length == 0 || output.Length == 0 => Localizer.Instance["ap_err_pack"], + "game" or "pack" when config.Length == 0 && traces.Length == 0 => Localizer.Instance["ap_err_rules"], "game" or "pack" when Workers.Trim().Length > 0 && !(int.TryParse(Workers.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n is >= 1 and <= 256) => Localizer.Instance["ap_err_workers"], "verify" or "list" or "inspect" when manifest.Length == 0 => Localizer.Instance["ap_err_manifest"], @@ -235,7 +208,6 @@ public string RulesFoundText case "game": args.AddRange(["--root", root, "--output", output]); AddOption(args, "--fakelib", Libs); - // Without a TOML or traces, ampr game picks the rules from the game. AddOption(args, "--config", config); AddOption(args, "--traces", traces); AddFlag(args, traces.Length > 0 && UntracedTypes, "--pack-untraced-types"); @@ -252,9 +224,9 @@ public string RulesFoundText break; case "pack": args.AddRange(["--root", root, "--ampr-index", AmprIndex.Trim() is { Length: > 0 } index ? index : Path.Combine(root, "ampr_emu.index"), "--output", output]); - // Without a TOML every file would stay loose; the page uses the built-in rules instead. - args.AddRange(config.Length > 0 ? ["--config", config] : traces.Length > 0 ? ["--traces", traces] : ["--preset", "auto"]); - AddFlag(args, config.Length == 0 && traces.Length > 0 && UntracedTypes, "--pack-untraced-types"); + AddOption(args, "--config", config); + AddOption(args, "--traces", traces); + AddFlag(args, traces.Length > 0 && UntracedTypes, "--pack-untraced-types"); AddOption(args, "--workers", Workers); AddFlag(args, SelfContained, "--self-contained"); AddFlag(args, AllowMissing, "--allow-missing"); @@ -286,32 +258,18 @@ public string RulesFoundText return args; } - /// Detect the rules for the chosen game folder (a few folder levels, so it stays quick). - partial void OnRootChanged(string value) => DetectRules(value); - /// Count the trace runs in the chosen folder (each needs ampr_commands.bin and ampr_emu.index). partial void OnTracesChanged(string value) => CountTraceRuns(value); - /// Look at the game and trace folders again and update the rules lines. + /// Look at the trace folder again and update the rules lines. public void RefreshDetection() { - DetectRules(Root); CountTraceRuns(Traces); - OnPropertyChanged(nameof(ShowRulesFound)); - OnPropertyChanged(nameof(ShowRulesNone)); - OnPropertyChanged(nameof(RulesFoundText)); OnPropertyChanged(nameof(ShowRulesTraces)); OnPropertyChanged(nameof(ShowNoTraces)); OnPropertyChanged(nameof(RulesTracesText)); } - // The built-in rules for the game folder (a few folder levels, so it stays quick). - private void DetectRules(string value) - { - string root = value.Trim(); - _detected = root.Length > 0 && Directory.Exists(root) ? Build.AMPRPack.AMPRPackConfig.DetectPreset(root) : null; - } - private void CountTraceRuns(string value) { string folder = value.Trim(); diff --git a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml index 9d29d2f..7fe6d3a 100644 --- a/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/AmprPackForm.axaml @@ -23,14 +23,13 @@ - - - + + diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 16b4df5..40babd6 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -28,8 +28,11 @@ public void Pack_builds_arguments_and_validates() panel.Root = "D:/game"; panel.Output = "D:/out"; Assert.Equal(Path.Combine("D:/out", "ampr_assets.index"), panel.Manifest); + Assert.Null(panel.BuildArguments(out error)); + Assert.Equal("✗ Choose a TOML profile or a trace folder: without rules nothing is packed.", error); + panel.Config = "D:/rules.toml"; Assert.Equal( - ["ampr", "pack", "--root", "D:/game", "--ampr-index", Path.Combine("D:/game", "ampr_emu.index"), "--output", "D:/out", "--preset", "auto"], + ["ampr", "pack", "--root", "D:/game", "--ampr-index", Path.Combine("D:/game", "ampr_emu.index"), "--output", "D:/out", "--config", "D:/rules.toml"], panel.BuildArguments(out _)); panel.AmprIndex = "D:/idx/ampr_emu.index"; @@ -58,10 +61,12 @@ public void Game_is_the_default_action_and_builds_arguments() panel.Root = "D:/game"; panel.Output = "D:/games/out"; Assert.Equal(Path.Combine("D:/games/out", "ampr_assets.index"), panel.Manifest); - Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out"], panel.BuildArguments(out _)); + Assert.Null(panel.BuildArguments(out error)); + Assert.Equal("✗ Choose a TOML profile or a trace folder: without rules nothing is packed.", error); + panel.Config = "D:/rules.toml"; + Assert.Equal(["ampr", "game", "--root", "D:/game", "--output", "D:/games/out", "--config", "D:/rules.toml"], panel.BuildArguments(out _)); panel.Libs = "D:/emu"; - panel.Config = "D:/rules.toml"; panel.Workers = "4"; panel.SelfContained = true; panel.VerifyGame = false; @@ -143,32 +148,23 @@ public void Each_action_shows_only_its_fields( } [Fact] - public void Page_says_which_profile_matches_the_game_folder() + public void Page_asks_for_a_profile_or_traces() { - using TempDir dir = new(); - string game = Directory.CreateDirectory(Path.Combine(dir.Path, "game")).FullName; AmprPackPanelViewModel panel = Panel("game"); - Assert.False(panel.ShowRulesFound || panel.ShowRulesNone || panel.ShowRulesConfig); - - panel.Root = game; - Assert.True(panel.ShowRulesNone); - Assert.False(panel.ShowRulesFound); - Assert.Contains("No profile found", panel.RulesNoneText, StringComparison.Ordinal); - - dir.File("game/Media/globalgamemanagers", "unity"); - panel.Root = game + Path.DirectorySeparatorChar; - Assert.True(panel.ShowRulesFound); - Assert.False(panel.ShowRulesNone); - Assert.Equal( - "✓ Profile found: unity (identified by Media/globalgamemanagers). Only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).", - panel.RulesFoundText); + Assert.True(panel.ShowRulesMissing); + Assert.False(panel.ShowRulesConfig); panel.Config = "rules.toml"; - Assert.False(panel.ShowRulesFound); + Assert.False(panel.ShowRulesMissing); Assert.True(panel.ShowRulesConfig); + panel.Config = string.Empty; + panel.Traces = "traces"; + Assert.False(panel.ShowRulesMissing); + panel.Action = panel.Actions.Single(a => a.Value == "verify"); - Assert.False(panel.ShowRulesFound || panel.ShowRulesNone || panel.ShowRulesConfig); + panel.Traces = string.Empty; + Assert.False(panel.ShowRulesMissing || panel.ShowRulesConfig); } [Fact] @@ -182,7 +178,7 @@ public void Page_takes_rules_from_a_trace_folder() panel.Traces = traces; Assert.True(panel.ShowNoTraces); - Assert.False(panel.ShowRulesTraces || panel.ShowRulesNone || panel.ShowRulesFound); + Assert.False(panel.ShowRulesTraces || panel.ShowRulesMissing); // Runs copied in after the folder was picked are found when the page runs; hidden files count. dir.File("traces/startup/ampr_commands.bin"); @@ -205,7 +201,7 @@ public void Page_takes_rules_from_a_trace_folder() Assert.Contains("--pack-untraced-types", panel.BuildArguments(out _)!); panel.Action = panel.Actions.Single(a => a.Value == "pack"); - Assert.DoesNotContain("--preset", panel.BuildArguments(out _)!); + Assert.Equal(["--traces", traces + Path.DirectorySeparatorChar], panel.BuildArguments(out _)!.SkipWhile(a => a != "--traces").Take(2)); Assert.Contains("--pack-untraced-types", panel.BuildArguments(out _)!); panel.Config = "rules.toml"; @@ -214,8 +210,14 @@ public void Page_takes_rules_from_a_trace_folder() Assert.Equal("Use either a TOML file or a trace folder, not both.", error); } + // Compress every file but executables, modules, system files and the libraries. + private static string CompressAll(TempDir dir) => dir.File( + "compress-all.toml", + "[pack]\ndefault_action = \"compress\"\n[[rule]]\naction = \"loose\"\n" + + "include = [\"eboot.bin\", \"*.prx\", \"*.sprx\", \"sce_sys/*\", \"sce_module/*\", \"fakelib/*\", \"ampr_emu.index\"]\n"); + [AvaloniaFact] - public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_progress() + public async Task Page_packs_with_a_toml_profile_and_logs_cli_progress() { using TempDir dir = new(); string game = BuildPanelTests.Game(dir); @@ -226,6 +228,7 @@ public async Task Page_without_a_toml_packs_with_the_default_rules_and_logs_cli_ AmprPackPanelViewModel panel = Panel("pack"); panel.Root = game; panel.Output = Path.Combine(dir.Path, "packs"); + panel.Config = CompressAll(dir); await panel.RunCommand.ExecuteAsync(null); Assert.Equal("✓ Completed successfully.", panel.Job.Lines[^1].Text); @@ -247,6 +250,7 @@ public async Task Page_builds_a_playable_game_folder() panel.Root = game; panel.Output = Path.Combine(dir.Path, "playable"); panel.Libs = Path.Combine(dir.Path, "emu"); + panel.Config = CompressAll(dir); await panel.RunCommand.ExecuteAsync(null); Assert.True(panel.Job.Lines[^1].Text == "✓ Completed successfully.", string.Join('\n', panel.Job.Lines.Select(l => l.Text))); diff --git a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs index f21fdb4..3327d45 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRGameBuilderTests.cs @@ -45,11 +45,21 @@ private static string Game(TempDir dir, string library = "fakelib") private static AMPRGameOptions Options(TempDir dir, string? image = null) => new() { - Config = AMPRPackConfig.LoadPreset("default"), + Config = CompressAll(), LibsDir = Path.Combine(dir.Path, "libs"), ExfatImage = image, }; + // Compress every file but executables, modules, system files and the libraries. + private static AMPRPackConfig CompressAll() => AMPRProfiler.ToConfig(""" + [pack] + default_action = "compress" + + [[rule]] + action = "loose" + include = ["eboot.bin", "*/eboot.bin", "*.prx", "*.sprx", "sce_sys/*", "sce_module/*", "fakelib/*", "fakelib2/*", "ampr_emu.index"] + """); + private static Dictionary Hashes(string root) => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).ToDictionary( f => Path.GetRelativePath(root, f), f => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(f)))); @@ -148,8 +158,8 @@ public void A_failed_build_leaves_the_output_folder_empty() using TempDir dir = new(); string game = Game(dir); string output = dir.Dir("out"); - AMPRGameOptions options = Options(dir) with { Config = AMPRPackConfig.LoadPreset("default") }; - options.Config.RequiredPacked = ["eboot.bin"]; // the preset keeps it loose, so the packer fails + AMPRGameOptions options = Options(dir) with { Config = CompressAll() }; + options.Config.RequiredPacked = ["eboot.bin"]; // the rules keep it loose, so the packer fails Assert.ThrowsAny(() => AMPRGameBuilder.Build(game, output, options, new ListLog())); @@ -203,22 +213,17 @@ public void Cli_game_builds_the_folder_and_reports_each_step() StringWriter stderr = new() { NewLine = "\n" }; CliContext ctx = new(stdout, stderr, useColor: false, utf8: false, progress: false); - int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out"), "--fakelib", Path.Combine(dir.Path, "libs")], ctx); + string toml = dir.File("rules.toml", "[pack]\ndefault_action = \"compress\"\n[[rule]]\naction = \"loose\"\ninclude = [\"eboot.bin\", \"*.prx\", \"*.sprx\", \"sce_sys/*\"]\n"); + int exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out"), "--fakelib", Path.Combine(dir.Path, "libs"), "--config", toml], ctx); Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); - Assert.StartsWith(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + "\nNo profile found for this game: ", stdout.ToString(), StringComparison.Ordinal); + Assert.StartsWith(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + $"\nRules: TOML profile {toml}\n", stdout.ToString(), StringComparison.Ordinal); foreach (string step in (string[])["[1/5] Libraries: fakelib/", "[2/5] Writing ampr_emu.index", "[3/5] Packing", "[4/5] Copying loose files", "[5/5] Verifying"]) { Assert.Contains(step + "\n", stdout.ToString(), StringComparison.Ordinal); } - string toml = dir.File("rules.toml", "[pack]\ndefault_action = \"loose\"\n"); - stdout.GetStringBuilder().Clear(); - exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out3"), "--fakelib", Path.Combine(dir.Path, "libs"), "--config", toml], ctx); - Assert.True(exit == 0, stdout.ToString() + stderr.ToString()); - Assert.Contains($"\nRules: your TOML file {toml} (no built-in profile)\n", stdout.ToString(), StringComparison.Ordinal); - - exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2")], ctx); + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2"), "--config", toml], ctx); Assert.Equal(2, exit); Assert.EndsWith("error: fakelib/libSceAmpr.sprx cannot read asset packs; use AMPR Emu 0.4.2.1 or newer from https://github.com/drakmor/ampr_emu/releases\n", stderr.ToString(), StringComparison.Ordinal); } diff --git a/tests/MkPFS.Tests/AMPR/AMPRPackCliTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPackCliTests.cs new file mode 100644 index 0000000..2dd91bb --- /dev/null +++ b/tests/MkPFS.Tests/AMPR/AMPRPackCliTests.cs @@ -0,0 +1,133 @@ +using MkPFS.Build; +using MkPFS.Build.AMPRPack; +using MkPFS.Cli; +using MkPFS.Cli.Output; +using MkPFS.Core.AMPR; + +namespace MkPFS.Tests.AMPR; + +/// The rules options of ampr pack and ampr game and their warnings (extensions over ampr_pack.py). +public sealed class AMPRPackCliTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-cli-" + Guid.NewGuid().ToString("N")); + + public AMPRPackCliTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + // Compress every file but the ones remove-sources protects (the generic profile shared outside MkPFS). + private const string CompressAll = """ + [pack] + default_action = "compress" + + [[rule]] + action = "loose" + include = ["eboot.bin", "*.prx", "*.sprx", "sce_sys/*", "fakelib/*", "fakelib2/*", "ampr_emu.index"] + """; + + private string Profile() + { + string path = Path.Combine(_dir, "profile.toml"); + File.WriteAllText(path, CompressAll); + return path; + } + + private string Game(string? emulator = "fakelib") + { + string root = Path.Combine(_dir, "app0"); + List<(string, string)> files = [("eboot.bin", "eboot"), ("sce_sys/param.json", "{}"), ("lib.prx", "prx"), ("data/a.dat", new string('a', 50_000))]; + if (emulator is not null) + { + files.Add(($"{emulator}/libSceAmpr.sprx", "sprx")); + } + + foreach ((string path, string text) in files) + { + string full = Path.Combine(root, path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, text); + } + + AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); + return root; + } + + private static (int Exit, string Out, string Err) Run(params string[] args) + { + StringWriter stdout = new() { NewLine = "\n" }; + StringWriter stderr = new() { NewLine = "\n" }; + int exit = MkPFSCli.Run(["ampr", .. args], new CliContext(stdout, stderr, useColor: false, utf8: false, progress: false)); + return (exit, stdout.ToString(), stderr.ToString()); + } + + [Fact] + public void Cli_profile_packs_and_the_removal_plan_accepts_it() + { + string root = Game(); + string output = Path.Combine(_dir, "out"); + + (int exit, string stdout, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", output, "--config", Profile()); + + Assert.Equal(0, exit); + Assert.Contains("\"files_packed\": 1,", stdout, StringComparison.Ordinal); + Assert.Equal(string.Empty, stderr); + Assert.Equal(["data/a.dat"], AMPRPackMaintenance.RemovalPlan(Path.Combine(output, "ampr_assets.index"), root).Paths); + } + + [Fact] + public void Cli_warns_when_nothing_is_packed_and_rejects_config_with_traces() + { + string root = Game(); + string index = Path.Combine(root, AmprIndex.IndexName); + string output = Path.Combine(_dir, "out"); + + (int exit, string stdout, string stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output); + Assert.Equal(0, exit); + Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); + Assert.Equal(global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", stderr); + + (exit, stdout, stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output, "--config", Profile(), "--traces", _dir); + Assert.Equal((2, string.Empty, "error: --traces cannot be used with --config\n"), (exit, stdout, stderr)); + } + + [Fact] + public void Cli_game_needs_a_profile_or_traces() + { + (int exit, string stdout, string stderr) = Run("game", "--root", Game(), "--output", Path.Combine(_dir, "out")); + + Assert.Equal(2, exit); + Assert.Equal(global::MkPFS.Cli.Commands.AmprCommand.ExperimentalWarning + "\n", stdout); + Assert.EndsWith("error: " + global::MkPFS.Cli.Commands.AmprCommand.NoRulesError + "\n", stderr, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(_dir, "out"))); + } + + [Theory] + [InlineData(null, true)] + [InlineData("fakelib", false)] + [InlineData("fakelib2", false)] + public void Cli_warns_when_packs_are_built_for_a_folder_without_ampr_emu(string? emulator, bool warns) + { + string root = Game(emulator); + + (int exit, _, string stderr) = Run( + "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--config", Profile()); + + Assert.Equal(0, exit); + Assert.Equal(warns ? global::MkPFS.Cli.Commands.AmprCommand.NoEmulatorWarning + "\n" : string.Empty, stderr); + } + + [Fact] + public void Runtime_limits_of_the_default_emulator_build_are_reported() + { + AMPRBuildStats stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles, Chunks = AMPRBuildResult.RuntimeMaxChunks }; + Assert.Empty(new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes).RuntimeLimitWarnings()); + + stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles + 1, Chunks = AMPRBuildResult.RuntimeMaxChunks + 1 }; + IReadOnlyList warnings = new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes + 1).RuntimeLimitWarnings(); + Assert.Equal(3, warnings.Count); + Assert.StartsWith("2000001 files exceed", warnings[0], StringComparison.Ordinal); + Assert.StartsWith("16000001 chunks exceed", warnings[1], StringComparison.Ordinal); + Assert.StartsWith("1025 pack volumes exceed", warnings[2], StringComparison.Ordinal); + } +} diff --git a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs b/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs deleted file mode 100644 index 5991cf2..0000000 --- a/tests/MkPFS.Tests/AMPR/AMPRPresetTests.cs +++ /dev/null @@ -1,240 +0,0 @@ -using MkPFS.Build; -using MkPFS.Build.AMPRPack; -using MkPFS.Cli; -using MkPFS.Cli.Output; -using MkPFS.Core.AMPR; - -namespace MkPFS.Tests.AMPR; - -/// The built-in default preset and the CLI around it (extensions over ampr_pack.py). -public sealed class AMPRPresetTests : IDisposable -{ - private readonly string _dir = Path.Combine(Path.GetTempPath(), "mkpfs-ampr-preset-" + Guid.NewGuid().ToString("N")); - - public AMPRPresetTests() => Directory.CreateDirectory(_dir); - - public void Dispose() => Directory.Delete(_dir, recursive: true); - - [Theory] - [InlineData("data/level.dat", "compress")] - [InlineData("d/0/texture", "compress")] - [InlineData("eboot.bin", "loose")] - [InlineData("bin/eboot.bin", "loose")] - [InlineData("libcohtml.Prospero.prx", "loose")] - [InlineData("fakelib/libSceAmpr.sprx", "loose")] - [InlineData("fakelib2/libSceAmpr.sprx", "loose")] - [InlineData("sce_sys/param.json", "loose")] - [InlineData("sce_module/libc.prx", "loose")] - [InlineData("tools/run.elf", "loose")] - [InlineData("system/x.bin", "loose")] - [InlineData("ampr_emu.index", "loose")] - [InlineData("Media/Metadata/global-metadata.dat", "loose")] - [InlineData("Media/Metadata/other.dat", "compress")] - public void Default_preset_packs_data_and_keeps_protected_files_loose(string relative, string action) - { - AMPRPackConfig preset = AMPRPackConfig.LoadPreset("default"); - Assert.Equal(action, preset.SelectRule(relative).Action); - // Whatever the preset packs, remove-sources must be allowed to remove. - Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); - } - - [Theory] - [InlineData("Media/StreamingAssets/aa/PS5/level.bundle", "compress")] - [InlineData("Media/StreamingAssets/Dialogue.assets.bank", "compress")] - [InlineData("StreamingAssets/movie.mp4", "compress")] - [InlineData("Media/sharedassets3.assets.resS", "loose")] - [InlineData("Media/globalgamemanagers", "loose")] - [InlineData("Media/level158", "loose")] - [InlineData("Media/Metadata/global-metadata.dat", "loose")] - [InlineData("Media/StreamingAssets/Plugins/x.prx", "loose")] - [InlineData("eboot.bin", "loose")] - [InlineData("fakelib/libSceAmpr.sprx", "loose")] - public void Unity_preset_packs_only_streaming_assets(string relative, string action) - { - AMPRPackConfig preset = AMPRPackConfig.LoadPreset("unity"); - Assert.Equal(action, preset.SelectRule(relative).Action); - Assert.False(action == "compress" && AMPRPackMaintenance.IsProtected(relative)); - } - - [Theory] - [InlineData("d/0/texture", "compress")] - [InlineData("d/1/zonelightbin_12", "compress")] - [InlineData("d/0/bootload", "loose")] - [InlineData("d/0/critbootload", "loose")] - [InlineData("d/1/movie_intro", "loose")] - [InlineData("d/0/soundbank", "loose")] - [InlineData("d/1/wem.3", "loose")] - [InlineData("toc", "loose")] - [InlineData("dag", "loose")] - [InlineData("known_islands.dat", "loose")] - [InlineData("libcohtml.Prospero.prx", "loose")] - [InlineData("eboot.bin", "loose")] - [InlineData("fakelib/libSceAmpr.sprx", "loose")] - [InlineData("_DUPLEX_/x", "loose")] - public void Insomniac_preset_packs_only_the_d_archives(string relative, string action) => - Assert.Equal(action, AMPRPackConfig.LoadPreset("insomniac").SelectRule(relative).Action); - - [Fact] - public void Auto_picks_insomniac_rules_from_the_top_level_layout() - { - string root = Path.Combine(_dir, "game"); - Directory.CreateDirectory(Path.Combine(root, "d", "0")); - File.WriteAllText(Path.Combine(root, "toc"), "x"); - Assert.Equal(("default", null), AMPRPackConfig.DetectPreset(root)); - - File.WriteAllText(Path.Combine(root, "dag"), "x"); - Assert.Equal(("insomniac", "toc, dag, d/"), AMPRPackConfig.DetectPreset(root)); - Assert.Equal("compress", AMPRPackConfig.LoadPreset("auto", root).SelectRule("d/0/texture").Action); - } - - [Theory] - [InlineData("Media/globalgamemanagers", "unity")] - [InlineData("Game_Data/data.unity3d", "unity")] - [InlineData("Media/Metadata/global-metadata.dat", "unity")] - [InlineData("data/level.dat", "default")] - public void Auto_picks_unity_rules_only_for_unity_games(string file, string expected) - { - string root = Path.Combine(_dir, "game"); - string full = Path.Combine(root, file); - Directory.CreateDirectory(Path.GetDirectoryName(full)!); - File.WriteAllText(full, "x"); - - (string preset, string? marker) = AMPRPackConfig.DetectPreset(root); - - Assert.Equal(expected, preset); - Assert.Equal(expected == "unity" ? file : null, marker); - AMPRPackConfig auto = AMPRPackConfig.LoadPreset("auto", root); - Assert.Equal(expected == "unity" ? "loose" : "compress", auto.SelectRule("Media/level158").Action); - } - - [Fact] - public void Cli_auto_reports_the_rules_it_picked() - { - string root = Game(); - File.WriteAllText(Path.Combine(root, "data", "globalgamemanagers"), "unity"); - AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); - - (int exit, string stdout, string stderr) = Run( - "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "auto"); - - Assert.Equal(0, exit); - Assert.Equal( - "Profile found: unity (data/globalgamemanagers); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).\n" - + global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", - stderr); - Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); - } - - [Theory] - [InlineData("auto", "No profile found for this game: generic rules (every file but executables, modules and system files is packed). Untested: the game may not start on the PS5; keep the original.")] - [InlineData("default", DefaultRulesLine)] - [InlineData("unity", "Rules: unity (--preset); only StreamingAssets is packed. Tested: runs on God of War Sons of Sparta (PPSA28997).")] - public void Cli_says_when_no_profile_matches_or_the_rules_were_chosen(string preset, string expected) - { - string root = Game(); - AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); - - (int exit, _, string stderr) = Run( - "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", preset); - - Assert.Equal(0, exit); - Assert.StartsWith(expected + "\n", stderr, StringComparison.Ordinal); - } - - [Fact] - public void Unknown_preset_is_rejected() => - Assert.Equal("unknown preset: max", Assert.Throws(() => AMPRPackConfig.LoadPreset("max")).Message); - - private string Game(string? emulator = "fakelib") - { - string root = Path.Combine(_dir, "app0"); - List<(string, string)> files = [("eboot.bin", "eboot"), ("sce_sys/param.json", "{}"), ("lib.prx", "prx"), ("data/a.dat", new string('a', 50_000))]; - if (emulator is not null) - { - files.Add(($"{emulator}/libSceAmpr.sprx", "sprx")); - } - - foreach ((string path, string text) in files) - { - string full = Path.Combine(root, path); - Directory.CreateDirectory(Path.GetDirectoryName(full)!); - File.WriteAllText(full, text); - } - - AmprIndex.Build(root, Path.Combine(root, AmprIndex.IndexName)); - return root; - } - - private static (int Exit, string Out, string Err) Run(params string[] args) - { - StringWriter stdout = new() { NewLine = "\n" }; - StringWriter stderr = new() { NewLine = "\n" }; - int exit = MkPFSCli.Run(["ampr", .. args], new CliContext(stdout, stderr, useColor: false, utf8: false, progress: false)); - return (exit, stdout.ToString(), stderr.ToString()); - } - - private const string DefaultRulesLine = - "Rules: default (--preset); every file but executables, modules and system files is packed. Untested: the game may not start on the PS5; keep the original."; - - [Fact] - public void Cli_preset_packs_and_the_removal_plan_accepts_it() - { - string root = Game(); - string output = Path.Combine(_dir, "out"); - - (int exit, string stdout, string stderr) = Run( - "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", output, "--preset", "default"); - - Assert.Equal(0, exit); - Assert.Contains("\"files_packed\": 1,", stdout, StringComparison.Ordinal); - Assert.Equal(DefaultRulesLine + "\n", stderr); - Assert.Equal(["data/a.dat"], AMPRPackMaintenance.RemovalPlan(Path.Combine(output, "ampr_assets.index"), root).Paths); - } - - [Fact] - public void Cli_warns_when_nothing_is_packed_and_rejects_config_with_preset() - { - string root = Game(); - string index = Path.Combine(root, AmprIndex.IndexName); - string output = Path.Combine(_dir, "out"); - - (int exit, string stdout, string stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output); - Assert.Equal(0, exit); - Assert.Contains("\"files_packed\": 0,", stdout, StringComparison.Ordinal); - Assert.Equal(global::MkPFS.Cli.Commands.AmprCommand.NothingPackedWarning + "\n", stderr); - - string config = Path.Combine(_dir, "c.toml"); - File.WriteAllText(config, "[pack]\n"); - (exit, stdout, stderr) = Run("pack", "--root", root, "--ampr-index", index, "--output", output, "--config", config, "--preset", "default"); - Assert.Equal((2, string.Empty, "error: --config and --preset cannot be used together\n"), (exit, stdout, stderr)); - } - - [Theory] - [InlineData(null, true)] - [InlineData("fakelib", false)] - [InlineData("fakelib2", false)] - public void Cli_warns_when_packs_are_built_for_a_folder_without_ampr_emu(string? emulator, bool warns) - { - string root = Game(emulator); - - (int exit, _, string stderr) = Run( - "pack", "--root", root, "--ampr-index", Path.Combine(root, AmprIndex.IndexName), "--output", Path.Combine(_dir, "out"), "--preset", "default"); - - Assert.Equal(0, exit); - Assert.Equal(DefaultRulesLine + "\n" + (warns ? global::MkPFS.Cli.Commands.AmprCommand.NoEmulatorWarning + "\n" : string.Empty), stderr); - } - - [Fact] - public void Runtime_limits_of_the_default_emulator_build_are_reported() - { - AMPRBuildStats stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles, Chunks = AMPRBuildResult.RuntimeMaxChunks }; - Assert.Empty(new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes).RuntimeLimitWarnings()); - - stats = new() { FilesTotal = AMPRBuildResult.RuntimeMaxFiles + 1, Chunks = AMPRBuildResult.RuntimeMaxChunks + 1 }; - IReadOnlyList warnings = new AMPRBuildResult("i", stats, [], AMPRBuildResult.RuntimeMaxVolumes + 1).RuntimeLimitWarnings(); - Assert.Equal(3, warnings.Count); - Assert.StartsWith("2000001 files exceed", warnings[0], StringComparison.Ordinal); - Assert.StartsWith("16000001 chunks exceed", warnings[1], StringComparison.Ordinal); - Assert.StartsWith("1025 pack volumes exceed", warnings[2], StringComparison.Ordinal); - } -} diff --git a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs index 96c695f..bb2f08c 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRProfilerTests.cs @@ -183,9 +183,9 @@ public void Cli_game_with_traces_packs_the_traced_files_and_says_so() Assert.True(File.Exists(Path.Combine(output, "data", "level1.dat"))); Assert.True(File.Exists(Path.Combine(output, "ampr_assets.index.runtime"))); - exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2"), "--traces", traces, "--preset", "unity"], ctx); + exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out2"), "--traces", traces, "--config", traces], ctx); Assert.Equal(2, exit); - Assert.EndsWith("error: --traces cannot be used with --config or --preset\n", stderr.ToString(), StringComparison.Ordinal); + Assert.EndsWith("error: --traces cannot be used with --config\n", stderr.ToString(), StringComparison.Ordinal); exit = MkPFSCli.Run(["ampr", "game", "--root", game, "--output", Path.Combine(dir.Path, "out3"), "--traces", game], ctx); Assert.Equal(2, exit); From e458d1dfd3c38bbdc1caf0847a6555010ec12c64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:57:40 +0000 Subject: [PATCH 19/28] Drop the tested games table from the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 2044e72..8203583 100644 --- a/README.md +++ b/README.md @@ -331,17 +331,6 @@ What the profiles of the tested games learned: - **Every file but executables, modules and system files**: no game has run with this yet, and both the Unity and the Insomniac game failed with it. -**Tested games.** Results on a PS5 with ShadowMountPlus 1.7 beta 4 and AMPR Emu 0.4.2.1: - -| Game | Version | Engine | Rules | Result | -|---|---|---|---|---| -| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | `StreamingAssets/` only | Runs: menu, saves, gameplay. | -| God of War Sons of Sparta (PPSA28997) | 01.008.001 | Unity (IL2CPP) | every file but executables | Aborts at startup (`SYSTEM_ABNORMAL_TERMINATION_REQUEST`). | -| PPSA03671 | 01.001.005 | Insomniac | `d/` archives but boot, movie and audio | Runs. | -| PPSA03671 | 01.001.005 | Insomniac | every file but executables | Crashes 2 s after start (`SIGSEGV`, null read in `eboot.bin`). | -| EA SPORTS UFC 6 (PPSA23566) | | Frostbite | all 292 `.cas` archives (traces plus untraced `.cas`) | Runs; 166 GB to about 98 GB. | -| EA SPORTS UFC 6 (PPSA23566) | | Frostbite | traces plus untraced types, `.toc`, `initfs_Ps5`, `chunkmanifest` packed | Crashes about 40 s after start (`SIGSEGV` in `Job0`). | - **Recommended steps.** 1. Make sure the unpacked game runs with AMPR Emu 0.4.2.1 in its `fakelib/`. From a230e7acd719c5fade48889c2c6b69dbee890ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:58:31 +0000 Subject: [PATCH 20/28] Drop the per-engine profile notes from the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index 8203583..74729e0 100644 --- a/README.md +++ b/README.md @@ -317,20 +317,6 @@ The build log names the rules used: Rules: TOML profile D:\PS5\profiles\ufc6-PPSA23566.toml ``` -What the profiles of the tested games learned: - -- **Unity games**: pack only `StreamingAssets/` (asset bundles, audio banks, videos). Unity's own data - files (`level*`, `sharedassets*`, `globalgamemanagers`, `.resS`) stay loose, because packing them made a - Unity game abort at startup; `global-metadata.dat` is memory-mapped and must stay loose. -- **Insomniac Games titles** (`toc` and `dag` files and a `d/` folder at the top of the game): pack only the - `d/` archives. The boot (`bootload*`, `critbootload*`), movie, sound bank and streamed audio (`wem*`) - archives stay loose, as do `toc`, `dag` and the other top-level files. Packing every file made such a game - crash at startup. -- **Frostbite games** (EA SPORTS UFC 6): pack the `.cas` archives and keep the small `.toc` files, - `initfs_Ps5` and `chunkmanifest` loose; packing those too crashed the game about 40 seconds in. -- **Every file but executables, modules and system files**: no game has run with this yet, and both the - Unity and the Insomniac game failed with it. - **Recommended steps.** 1. Make sure the unpacked game runs with AMPR Emu 0.4.2.1 in its `fakelib/`. From 42ca45e9cc73b3e01f145af80ae7c51bd9e782a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:03:16 +0000 Subject: [PATCH 21/28] Rewrite the AMPR section of the README to be shorter and game-neutral Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 328 ++++++++++++++++++++++-------------------------------- 1 file changed, 134 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index 74729e0..3779304 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ offline PFSC block repair ported from PS5 Game Compressor. > [!WARNING] > **AMPR packing (`ampr`, the AMPR Packs page) is experimental.** It can shrink a game a lot, but whether > the packed game runs depends on how that game reads its files, and only a test on the PS5 shows that. -> Keep the original game until the packed one has been played. See -> [AMPR packing: what it is and why it is experimental](#ampr-packing-what-it-is-and-why-it-is-experimental). +> Keep the original game until the packed one has been played. See [`ampr`](#ampr-asset-packs). ## Features @@ -29,7 +28,7 @@ offline PFSC block repair ported from PS5 Game Compressor. `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. - **AMPR packs (experimental)**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte like ampr_emu's `ampr_pack.py`, and `ampr game` turns a game folder into a smaller one that runs from - them ([what it is and its limits](#ampr-packing-what-it-is-and-why-it-is-experimental)). `ampr profile` + them ([how it works](#ampr-asset-packs)). `ampr profile` turns traces recorded on the console into pack rules, like ampr_emu's `ampr_pack_profile.py`. - **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. @@ -273,214 +272,157 @@ rebuild a missing index from the remaining files, with the same result. ### `ampr` (asset packs) -#### AMPR packing: what it is and why it is experimental - > [!WARNING] -> Experimental. Only a few games have been tested. Keep the original, unpacked game until the packed one -> has been played on the PS5, and report the games you try (see [Reporting a game](#reporting-a-game)). - -**What it does.** Most of a game's size is data files: levels, textures, audio, video. `ampr` compresses -selected data files with LZ4 into a few large pack files (`ampr_assets-*.pak`) plus a manifest -(`ampr_assets.index`). On the PS5, Drakmor's [AMPR Emu](https://github.com/drakmor/ampr_emu) (the -`libSceAmpr.sprx` in the game's `fakelib/`) sits between the game and the file system: when the game -opens a packed file, AMPR Emu reads the matching blocks from the packs and decompresses them, so the game -sees the original bytes. Nothing else changes: `eboot.bin`, modules and `sce_sys` stay as they are, and -the PS5 kernel never sees LZ4. Packed files are not stored a second time, which is where the space goes. - -**What a game needs.** - -- A jailbroken PS5 running [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus), which mounts - the game's `fakelib/` (or `fakelib2/`) into the running game. -- A game that loads `libSceAmpr` (AMPR/APR titles; these carry `fakelib/libSceAmpr.sprx` once set up for - AMPR Emu). AMPR Emu is only loaded by such games, so packs in any other game are never read. -- AMPR Emu **0.4.2.1 or newer**: the first release that reads packs. `ampr game` checks the library and - refuses older ones. -- The image or folder mounted read-only (ShadowMountPlus `mount_read_only=1`, the default), so AMPR Emu - never rebuilds `ampr_emu.index`. - -**Why it is experimental.** AMPR Emu serves packed files only to the ways of reading files it intercepts: -AMPR reads, `open`/`sceKernelOpen`, `read`/`pread`, `stat`/`fstat`, directory listing, and asynchronous -reads. A game that reads a file any other way, most likely by memory-mapping it (`mmap`), gets nothing -back and usually stops at startup. Which files a game reads which way cannot be seen from the files on a -PC, so the only real check is to start the packed game on the console. `ampr game` verifies on the PC -that every packed byte decodes to the original, but that proves the packs are correct, not that the game -reads them in a supported way. - -**Game profiles.** What to pack is decided per game by a TOML profile (`--config`, the **TOML -Configuration** field), or by the traces of the game recorded on the console (see [Rules from -traces](#rules-from-traces)). MkPFS has no built-in rules: `ampr game` needs one or the other. Profiles are -plain TOML files in ampr_emu's format (see below), one per game, shared and improved by the people -who test them. A profile's header says which game and version it was tested with and whether it runs. -The build log names the rules used: +> Experimental. Keep the original, unpacked game until the packed one has been played on the PS5. -``` -Rules: TOML profile D:\PS5\profiles\ufc6-PPSA23566.toml -``` +#### How it works + +`ampr` compresses a game's data files (levels, textures, audio, video) with LZ4 into a few pack files +(`ampr_assets-*.pak`) and a manifest (`ampr_assets.index`). On the PS5, Drakmor's +[AMPR Emu](https://github.com/drakmor/ampr_emu) (`libSceAmpr.sprx` in the game's `fakelib/`) decompresses +them on the fly, so the game reads the original bytes. Executables, modules and `sce_sys` are not touched. + +AMPR Emu only serves files that the game reads in ways it intercepts (AMPR reads and ordinary +`open`/`read`/`stat` calls). If a game memory-maps a packed file, or reads it some other way, the game +usually crashes. That is why each game needs its own rules, and why only a test on the console proves that +a packed game works. + +#### Requirements + +- A jailbroken PS5 with [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus). +- A game that loads `libSceAmpr` (AMPR/APR titles). +- AMPR Emu **0.4.2.1 or newer**, the first release that reads packs. `ampr game` refuses older builds. +- The game mounted read-only (`mount_read_only=1` in ShadowMountPlus, the default). + +#### Packing a game + +You need the rules for the game: either a **TOML profile** (a file that lists what to pack, shared for +each game) or **traces** you record yourself (see [Making rules from traces](#making-rules-from-traces)). -**Recommended steps.** +1. Check that the unpacked game runs with AMPR Emu in its `fakelib/`. +2. On the **AMPR Packs** page, choose **Build playable game** and fill in: + - the game folder and a new, empty output folder; + - the folder with AMPR Emu's `libSceAmpr.sprx`; + - the game's TOML profile in **TOML Configuration**, or a **Trace folder**; + - optionally, **Also build an exFAT image**. + + On the command line: + + ```bash + mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-emu --config game.toml --exfat . + ``` -1. Make sure the unpacked game runs with AMPR Emu 0.4.2.1 in its `fakelib/`. -2. On the AMPR Packs page, choose **Build playable game** (the default), pick the game folder, a new - output folder, the folder with AMPR Emu's `libSceAmpr.sprx` and the game's TOML profile (no profile yet: - record traces, see below). Optionally tick the exFAT image box. On the command line: - `mkpfs ampr game --root --output --fakelib --config --exfat .` 3. Copy the output folder or the `.exfat` image to the PS5 and start the game. Play past the menu and load - a save or a level: some files are only read later. + a save or a level, since some files are only read later. 4. Delete the original only after that. -##### Rules from traces - -For a game without a profile, the most reliable rules come from -watching the game on the console: the debug build of AMPR Emu (`Prospero_DebugHooksPackedStdio`, for -example the 0.4.2.1 test-debug-pack) records every read the game makes through AMPR into -`/app0/ampr_commands.bin`. MkPFS reads those traces and packs exactly the files the game read that way, -with block sizes fitted to how it read them, like ampr_emu's `ampr_pack_profile.py`. - -1. Put the debug `libSceAmpr.sprx` into the **unpacked** game's `fakelib/` (keep the normal one to put - back), and set `mount_read_only=0` in ShadowMountPlus for this run: the emulator writes its trace into - the game folder. Run from the folder, not an exFAT image. -2. Play a session: start-up to the menu and loading a save, then other sessions for different levels, - cutscenes, fast travel. Quit the game normally from the PS5 menu; a crash can cut the trace short. -3. After each session, copy `ampr_commands.bin` and `ampr_emu.index` (and `ampr_emu.log`) into a - subfolder of one trace folder, for example `traces/startup/`, `traces/level2/`, and delete them from - the game folder. -4. Put the normal emulator back and `mount_read_only=1`. -5. On the AMPR Packs page, pick that trace folder in **Trace folder** (leave the TOML field empty); the - page says how many runs it found. On the command line: `mkpfs ampr game ... --traces traces`. The log - says `Rules: from N trace runs ...; M files the game read through APR are packed`. - -What traces can and cannot show: - -- A file in a trace was read through AMPR, so AMPR Emu can serve it from the packs. -- Files no session touched (other levels, languages, DLC) stay loose: safe, but they save no space. More - sessions cover more. -- Files the game memory-maps (`mmap`) or reads with plain reads never appear in a trace, so they stay loose - too. - -To pack the rest as well, tick **Also pack files no session read…** under the trace folder -(`--pack-untraced-types`, a MkPFS extension). Every untraced file whose type (extension) a traced file has is -packed with the settings most traced files of that type got: with traces of an EA SPORTS UFC 6 session, the -111 `.cas` archives no session reached are packed like the 181 that were, and only executables, modules, -`sce_sys`, `fakelib` and small files of other types stay loose. Those files were not seen being read, so play -the parts of the game they belong to before deleting the original. Files without an extension are never added. - -`mkpfs ampr profile generate --output rules.toml --report rules.md` (or `--trace COMMANDS -INDEX`, repeatable) writes the same rules as a TOML file to review, edit and share as the game's profile, plus a report, a metrics JSON -and a runtime header; `mkpfs ampr profile batch` makes one profile per run of a folder or ZIP bundle. The -output matches `ampr_pack_profile.py` byte for byte. - -##### When a packed game crashes - -The release AMPR Emu logs nothing, so the crash log alone does not say which file failed. Build the packed game -again with **Free space inside the image** (`--exfat-free-space 2GiB`), put the debug `libSceAmpr.sprx` into the -packed game's `fakelib/`, mount the image read-write (`image_rw=` in ShadowMountPlus's -`config.ini`) and start it until it crashes. `ampr_emu.log` in the image then lists every packed file the game -opened (`apr.pack.open`) and every open it refused (`apr.pack.open.fail … reason=…`), which points at the file -to leave loose. - -##### Reporting a game - -Whether it works or not, please report: the game title, ID and version, the profile or traces used, -what happened on the console, and for a game that fails, the ShadowMountPlus log -(`/data/shadowmount/debug.log`) and the console log around the crash. A failing game can often still be -packed with a TOML file that leaves more files loose (see the TOML format below); share the profile that -works so others can use it. - -#### How the packs are made - -`mkpfs ampr` is a port of ampr_emu's `tools/ampr_pack.py` (tool version 4.0): the same options, the same -JSON on standard output, and byte-identical packs. `ampr profile` is a port of -`tools/ampr_pack_profile.py` (4.1, `generate` and `batch`): the same TOML, report, metrics JSON and runtime -header. A pack set is the manifest `ampr_assets.index`, data -volumes `ampr_assets-*.pak`, optional runtime settings `ampr_assets.index.runtime`, and an offline CRC -sidecar `ampr_assets.index.crc`. - -`pack` writes only the pack set (`ampr_assets.index`, `.pak` volumes, `.crc`) into `--output`, exactly -like `ampr_pack.py`. For a folder to copy to the PS5, use [`ampr game`](#ampr-game-a-folder-to-copy-to-the-ps5). - -The default emulator build loads at most 2,000,000 files, 16,000,000 chunks, and 1,024 volumes, and -rejects the whole set beyond that; `pack` warns on standard error when a set exceeds a limit (larger -blocks or more loose files bring it down). Deploy `ampr_emu.index`, the manifest, its `.runtime`, and every -volume from one build together; the `.crc` sidecar is only for `verify` and `unpack`. +`ampr game` copies the libraries, writes `ampr_emu.index`, packs, copies the files that stay loose, and +checks that every packed file decodes to the original. The game folder is only read; if a step fails, the +output folder is emptied. -#### Command reference +#### Making rules from traces -| Subcommand | Required options | Purpose | -|---|---|---| -| `game` | `--root --output ` | Build a folder that runs from packs as is (see below; not in `ampr_pack.py`). | -| `pack` | `--root --ampr-index --output ` | Build the manifest, volumes, CRC sidecar, and optional runtime settings. | -| `verify` | `--index ` | Decode every chunk and check its CRC; `--root ` also compares every byte with the source. | -| `unpack` | `--index --output ` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | -| `list` | `--index ` | One line per file (`--json` for details). | -| `inspect` | `--index ` | Manifest summary, volumes, and runtime settings. | -| `runtime-config` | `--index --config ` | Replace `.runtime` from a `[runtime]` section without repacking. | -| `remove-sources` | `--index --root ` | Show which sources the packs replace; with `--confirm`, verify everything and delete them. Also `remove-packed-sources`. | -| `profile generate` | `[] --output ` | Pack rules from APR traces (`--trace COMMANDS INDEX`, repeatable); `--report`, `--metrics` (JSON, `--full-metrics` for every block-size candidate), `--runtime-header` and every tuning option of `ampr_pack_profile.py generate`. | -| `profile batch` | ` --output-dir ` | One profile per trace run found (TOML, report, metrics JSON, runtime header) and a `summary.json`; a ZIP support bundle is read for its trace files only. Defaults to `--pattern-mode hybrid` without the cache simulation; `--batch-jobs`, `--summary`. | +The debug build of AMPR Emu (for example the 0.4.2.1 test-debug-pack) records every file the game reads +through AMPR. MkPFS turns those recordings into rules. -`pack` options: +1. Put the debug `libSceAmpr.sprx` into the unpacked game's `fakelib/` and set `mount_read_only=0` for this + run, so the emulator can write into the game folder. +2. Play a session, then quit the game from the PS5 menu. +3. Copy `ampr_commands.bin` and `ampr_emu.index` into a subfolder of a trace folder (for example + `traces/session1/`), then delete them from the game folder. Repeat for more sessions: each one covers + more of the game. +4. Put the normal emulator back and set `mount_read_only=1` again. +5. Pick the trace folder in **Trace folder** (command line: `--traces traces`). The page shows how many + sessions it found. -| Option | Default | Meaning | -|---|---|---| -| `--config ` | none | Pack rules (the game's profile). Without a config or `--traces` every file stays loose, and `pack` prints a warning on standard error. | -| `--traces ` | none | Rules from APR traces instead of `--config` (see [Rules from traces](#rules-from-traces)); also for `ampr game`. | -| `--pack-untraced-types` | off | With `--traces`: also pack untraced files of the traced file types, like the traced ones (also for `ampr game` and `profile generate`). | -| `--include `, `--exclude ` | none; repeatable | Narrow the rule selection; `--exclude` forces files loose. | -| `--include-from `, `--exclude-from ` | none | Glob lists, one per line, `#` comments. | -| `--workers ` | config, else min(8, cores) | Compression threads (1 to 256). | -| `--self-contained` | off | Never auto-loose selected files; incompressible blocks are stored uncompressed. | -| `--require-packed ` | none; repeatable | Fail if a matching file would stay loose. | -| `--allow-missing` | off | Leave selected files that are missing from `--root` loose. | -| `--no-progress` | off | Hide progress on standard error. | - -#### `ampr game`: a folder to copy to the PS5 - -`ampr game` turns an unpacked game folder into a new folder that runs from the packs as is. The game -folder is only read, and the output folder must be new or empty: +Only files that a session read are packed; everything else stays loose. To also pack the files no session +reached, tick **Also pack files no session read** under the trace folder (`--pack-untraced-types`). This packs every other file +of the same types (extensions) as the traced files. Test the parts of the game those files belong to before +deleting the original. + +To save the rules as a profile you can edit and share: ```bash -mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-emu-libs --exfat . +mkpfs ampr profile generate --trace traces/session1/ampr_commands.bin traces/session1/ampr_emu.index \ + --trace traces/session2/ampr_commands.bin traces/session2/ampr_emu.index --output game.toml --pack-untraced-types ``` -1. **Libraries.** Copies the game's `fakelib/` into the output, then every file of `--fakelib` over it. - Identical files are left alone, and the log says which ones were added or replaced. If the game has - `fakelib2/`, ShadowMountPlus mounts that one instead of `fakelib/`, so the libraries go there. The - resulting `libSceAmpr.sprx` must be AMPR Emu 0.4.2.1 or newer (the first release that reads packs; its - [release](https://github.com/drakmor/ampr_emu/releases) `libSceAmpr.sprx` does). Older builds are - rejected before anything is written. -2. **Index.** Writes `ampr_emu.index` for the final tree: the game's files plus the new libraries with - their real sizes. This must come before packing, because the manifest addresses files by their row - in this index. -3. **Packs.** Packs from the game folder with the rules of `--config` (the game's TOML profile) or - `--traces`; one of them is required. -4. **Loose files.** Copies every file that stays loose, keeping its modification time, and every folder, - including empty ones. This runs after packing because the packer only then decides which large, - incompressible files to leave loose. -5. **Checks.** Decodes every chunk, compares every packed file with the game folder, and checks that every - loose file is in the output with its indexed size (`--skip-verify` skips this). - -If a step fails, the output folder is emptied so a retry starts clean. `--exfat ` then -builds an exFAT image of the output (64 KiB clusters, `.exfat` in a folder); `--exfat-free-space 2GiB` -leaves room inside it, which a test run with the debug AMPR Emu needs for its log (the image is otherwise -exactly full). Use an exFAT -image or the plain folder, and keep ShadowMountPlus's `mount_read_only=1` (the default) so AMPR Emu -never rebuilds the index. A `.ffpfsc` would put zlib (about 150–250 MB/s on the PS5) on top of the LZ4 -packs. The output also keeps `ampr_assets.index.crc`: the game never reads it, and `verify` and -`unpack` need it. - -The TOML format is ampr_emu's (see its `tools/ampr_pack.example.toml`). A minimal config: +#### When a packed game crashes + +The normal AMPR Emu writes no log. To find the file that fails: + +1. Build again with **Free space inside the image** set to `2GiB` (`--exfat-free-space 2GiB`). +2. Put the debug `libSceAmpr.sprx` into the packed game's `fakelib/`. +3. Mount the image read-write (`image_rw=` in ShadowMountPlus's `config.ini`) and start + the game. +4. Read `ampr_emu.log` in the image: `apr.pack.open` lines show the packed files the game opened, and + `apr.pack.open.fail` lines show the ones it could not open. Keep those files loose in the profile. + +When you report a game, include its title, ID and version, the profile or traces used, what happened, and +for a crash the ShadowMountPlus log (`/data/shadowmount/debug.log`) and the console log. + +#### Profile format + +Profiles use ampr_emu's TOML format (see its `tools/ampr_pack.example.toml`). Rules are checked in order and +the last match wins; `*` also matches `/`. ```toml [pack] -default_action = "compress" # LZ4 HC level 12, 64 KiB blocks +default_action = "loose" # files no rule matches stay as they are + +[[rule]] +action = "compress" # LZ4, 64 KiB blocks by default +include = ["data/*"] -[[rule]] # last match wins: keep modules and system files loose -action = "loose" -include = ["eboot.bin", "**/*.prx", "**/*.sprx", "sce_sys/**", "sce_module/**", "fakelib/**"] +[[rule]] +action = "loose" # always keep executables, modules and system files loose +include = ["eboot.bin", "*.prx", "*.sprx", "sce_sys/*", "sce_module/*", "fakelib/*"] ``` -`ampr_pack.py` has no built-in exclusions, so a config without that last rule packs `eboot.bin` and -modules too; `remove-sources` then refuses to run. Errors print `error: ` and exit with code 2. +#### Command reference + +| Subcommand | Required options | Purpose | +|---|---|---| +| `game` | `--root --output `, and `--config` or `--traces` | Build a folder that runs from packs as is. | +| `pack` | `--root --ampr-index --output ` | Write only the pack set (manifest, volumes, CRC sidecar). | +| `verify` | `--index ` | Decode every chunk; `--root ` also compares with the source. | +| `unpack` | `--index --output ` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | +| `list` | `--index ` | One line per file (`--json` for details). | +| `inspect` | `--index ` | Manifest summary, volumes and runtime settings. | +| `runtime-config` | `--index --config ` | Replace the runtime settings from a `[runtime]` section without repacking. | +| `remove-sources` | `--index --root ` | Show which source files the packs replace; `--confirm` verifies and deletes them. | +| `profile generate` | `[] --output ` | Rules from traces (`--trace COMMANDS INDEX` per session, repeatable); `--report`, `--metrics`, `--runtime-header`. | +| `profile batch` | ` --output-dir ` | One profile per trace session and a `summary.json`. | + +Options of `game` and `pack`: + +| Option | Default | Meaning | +|---|---|---| +| `--config ` | none | The game's profile. | +| `--traces ` | none | Rules from traces instead of a profile. | +| `--pack-untraced-types` | off | With `--traces`: also pack untraced files of the traced types. | +| `--workers ` | min(8, cores) | Compression threads (1 to 256). | +| `--self-contained` | off | Never leave selected files loose; store incompressible blocks uncompressed. | +| `--fakelib ` | none | `game`: AMPR Emu and other libraries to add to `fakelib/`. | +| `--exfat ` | none | `game`: also build an exFAT image (`.exfat` in a folder). | +| `--exfat-free-space ` | 0 | `game`: free space inside the image, for a debug run. | +| `--skip-verify` | off | `game`: skip the final checks. | +| `--include`, `--exclude`, `--include-from`, `--exclude-from` | none | `pack`: narrow the rules; `--exclude` forces files loose. | +| `--require-packed ` | none | `pack`: fail if a matching file would stay loose. | +| `--allow-missing` | off | `pack`: leave selected files missing from `--root` loose. | +| `--no-progress` | off | Hide progress. | + +Notes: + +- `ampr pack` and `ampr profile` are ports of ampr_emu's `tools/ampr_pack.py` (4.0) and + `tools/ampr_pack_profile.py` (4.1), with the same output byte for byte. `game`, `--traces` and + `--pack-untraced-types` are MkPFS additions. +- The release emulator loads at most 2,000,000 files, 16,000,000 chunks and 1,024 volumes; `pack` warns + when a set goes over a limit. +- Deploy `ampr_emu.index`, the manifest, its `.runtime` file and every volume from the same build. The + `.crc` file is only used by `verify` and `unpack`. +- Use an exFAT image or the plain folder, not `.ffpfsc`: zlib on top of LZ4 makes loading slower. +- Errors print `error: ` and exit with code 2. ### Reading and extracting images @@ -547,10 +489,8 @@ dotnet run --project src/MkPFS.Gui -c Release settings for the zlib level, CPU cores, block size, and when to keep blocks uncompressed. - The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its offset, stored size, and largest back-reference distance. -- The AMPR Packs page runs every `ampr` subcommand and shows only the fields the chosen action needs. Its - default action, Build playable game, runs `ampr game` with a library folder and an optional exFAT image. - Its optional trace folder takes the rules from console traces. - Building and packing need the game's TOML profile or a trace folder; the page asks for one until it is set. +- The AMPR Packs page runs every `ampr` subcommand. Its default action, Build playable game, runs + `ampr game` with the game's TOML profile or a trace folder. ## Differences from Python MkPFS From bdf7986390aa3bcefa4bb8840eca105fee81d778 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:04:35 +0000 Subject: [PATCH 22/28] Drop the .cas example from the untraced-files option text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- src/MkPFS.Gui/Localization/Strings.resx | 2 +- src/MkPFS.Gui/Localization/Strings_de.resx | 2 +- src/MkPFS.Gui/Localization/Strings_es.resx | 2 +- src/MkPFS.Gui/Localization/Strings_fr.resx | 2 +- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 2 +- src/MkPFS.Gui/Localization/Strings_ro.resx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index a0d47af..9647441 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -41,7 +41,7 @@ No traces in this folder: each run needs ampr_commands.bin and its ampr_emu.index side by side. ✗ Choose a TOML profile or a trace folder: without rules nothing is packed. Use either a TOML file or a trace folder, not both. - Also pack files no session read, when a traced file has the same type (for example the remaining .cas archives). They were not seen being read, so test the game with them. + Also pack files no session read, when a traced file has the same type. They were not seen being read, so test the game with them. Free space inside the image (optional, e.g. 2GiB): needed for a test run with the debug AMPR Emu, which writes its log into the game folder ⚠ Experimental: whether a packed game runs depends on how it reads its files, and only a test on the PS5 shows that. Keep the original game until the packed one has been played. Builds a new folder you can copy to the PS5 and play: your libraries in fakelib/, ampr_emu.index, the LZ4 packs, and every file that stays loose. The game folder is only read. Needs AMPR Emu 0.4.2.1 or newer and a game that uses AMPR. diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 40ef613..8032cdc 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -41,7 +41,7 @@ Keine Traces in diesem Ordner: jeder Lauf braucht ampr_commands.bin und seine ampr_emu.index nebeneinander. ✗ Wähle ein TOML-Profil oder einen Trace-Ordner: ohne Regeln wird nichts gepackt. Verwende entweder eine TOML-Datei oder einen Trace-Ordner, nicht beides. - Auch Dateien packen, die keine Sitzung gelesen hat, wenn eine aufgezeichnete Datei denselben Typ hat (zum Beispiel die übrigen .cas-Archive). Ihr Lesen wurde nicht beobachtet, also das Spiel damit testen. + Auch Dateien packen, die keine Sitzung gelesen hat, wenn eine aufgezeichnete Datei denselben Typ hat. Ihr Lesen wurde nicht beobachtet, also das Spiel damit testen. Freier Platz im Abbild (optional, z. B. 2GiB): nötig für einen Test mit dem Debug-AMPR-Emu, der sein Protokoll in den Spielordner schreibt ⚠ Experimentell: Ob ein gepacktes Spiel läuft, hängt davon ab, wie es seine Dateien liest, und das zeigt nur ein Test auf der PS5. Behalte das Originalspiel, bis das gepackte gespielt wurde. Erstellt einen neuen Ordner, den du auf die PS5 kopierst und spielst: deine Bibliotheken in fakelib/, ampr_emu.index, die LZ4-Packs und alle Dateien, die lose bleiben. Der Spielordner wird nur gelesen. Benötigt AMPR Emu 0.4.2.1 oder neuer und ein Spiel, das AMPR nutzt. diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 8b469d9..805d907 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -41,7 +41,7 @@ No hay trazas en esta carpeta: cada ejecución necesita ampr_commands.bin y su ampr_emu.index juntos. ✗ Elige un perfil TOML o una carpeta de trazas: sin reglas no se empaqueta nada. Usa un archivo TOML o una carpeta de trazas, no ambos. - Empaquetar también los archivos que ninguna sesión leyó, si un archivo trazado tiene el mismo tipo (por ejemplo, los .cas restantes). No se vio cómo se leían, así que prueba el juego con ellos. + Empaquetar también los archivos que ninguna sesión leyó, si un archivo trazado tiene el mismo tipo. No se vio cómo se leían, así que prueba el juego con ellos. Espacio libre dentro de la imagen (opcional, p. ej. 2GiB): necesario para una prueba con el AMPR Emu de depuración, que escribe su registro en la carpeta del juego ⚠ Experimental: que un juego empaquetado funcione depende de cómo lee sus archivos, y solo una prueba en la PS5 lo muestra. Conserva el juego original hasta haber jugado el empaquetado. Crea una carpeta nueva que puedes copiar a la PS5 y jugar: tus bibliotecas en fakelib/, ampr_emu.index, los packs LZ4 y todos los archivos que quedan sueltos. La carpeta del juego solo se lee. Requiere AMPR Emu 0.4.2.1 o posterior y un juego que use AMPR. diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 99828be..eed27c5 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -41,7 +41,7 @@ Aucune trace dans ce dossier : chaque session a besoin de ampr_commands.bin et de son ampr_emu.index côte à côte. ✗ Choisissez un profil TOML ou un dossier de traces : sans règles, rien n’est empaqueté. Utilisez un fichier TOML ou un dossier de traces, pas les deux. - Empaqueter aussi les fichiers qu’aucune session n’a lus, quand un fichier tracé a le même type (par exemple les archives .cas restantes). Leur lecture n’a pas été observée : testez le jeu avec eux. + Empaqueter aussi les fichiers qu’aucune session n’a lus, quand un fichier tracé a le même type. Leur lecture n’a pas été observée : testez le jeu avec eux. Espace libre dans l’image (facultatif, ex. 2GiB) : nécessaire pour un test avec l’AMPR Emu de débogage, qui écrit son journal dans le dossier du jeu ⚠ Expérimental : qu’un jeu empaqueté fonctionne dépend de la façon dont il lit ses fichiers, et seul un test sur la PS5 le montre. Conservez le jeu d’origine jusqu’à avoir joué à la version empaquetée. Crée un nouveau dossier à copier sur la PS5 pour jouer : vos bibliothèques dans fakelib/, ampr_emu.index, les packs LZ4 et tous les fichiers qui restent libres. Le dossier du jeu est seulement lu. Nécessite AMPR Emu 0.4.2.1 ou plus récent et un jeu qui utilise AMPR. diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 15cf209..ac9e31e 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -41,7 +41,7 @@ Nenhum trace nesta pasta: cada execução precisa de ampr_commands.bin e do seu ampr_emu.index lado a lado. ✗ Escolha um perfil TOML ou uma pasta de traces: sem regras nada é empacotado. Use um arquivo TOML ou uma pasta de traces, não os dois. - Empacotar também os arquivos que nenhuma sessão leu, quando um arquivo rastreado tem o mesmo tipo (por exemplo, os .cas restantes). Eles não foram vistos sendo lidos, então teste o jogo com eles. + Empacotar também os arquivos que nenhuma sessão leu, quando um arquivo rastreado tem o mesmo tipo. Eles não foram vistos sendo lidos, então teste o jogo com eles. Espaço livre dentro da imagem (opcional, ex. 2GiB): necessário para um teste com o AMPR Emu de debug, que grava o log na pasta do jogo ⚠ Experimental: se um jogo empacotado funciona depende de como ele lê seus arquivos, e só um teste no PS5 mostra isso. Mantenha o jogo original até jogar o empacotado. Cria uma nova pasta que você pode copiar para o PS5 e jogar: suas bibliotecas em fakelib/, ampr_emu.index, os packs LZ4 e todos os arquivos que ficam soltos. A pasta do jogo é apenas lida. Requer AMPR Emu 0.4.2.1 ou mais recente e um jogo que use AMPR. diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index cdd2c92..4173c90 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -41,7 +41,7 @@ Niciun trace în acest dosar: fiecare rulare are nevoie de ampr_commands.bin și de ampr_emu.index alături. ✗ Alege un profil TOML sau un dosar cu trace-uri: fără reguli nu se împachetează nimic. Folosește fie un fișier TOML, fie un dosar cu trace-uri, nu amândouă. - Împachetează și fișierele pe care nicio sesiune nu le-a citit, dacă un fișier urmărit are același tip (de exemplu, restul arhivelor .cas). Nu s-a văzut cum sunt citite, așa că testează jocul cu ele. + Împachetează și fișierele pe care nicio sesiune nu le-a citit, dacă un fișier urmărit are același tip. Nu s-a văzut cum sunt citite, așa că testează jocul cu ele. Spațiu liber în imagine (opțional, ex. 2GiB): necesar pentru un test cu AMPR Emu de depanare, care își scrie jurnalul în dosarul jocului ⚠ Experimental: dacă un joc împachetat rulează depinde de cum își citește fișierele, iar doar un test pe PS5 arată asta. Păstrează jocul original până când ai jucat varianta împachetată. Creează un folder nou pe care îl copiezi pe PS5 și joci: bibliotecile tale în fakelib/, ampr_emu.index, pack-urile LZ4 și toate fișierele care rămân libere. Folderul jocului este doar citit. Necesită AMPR Emu 0.4.2.1 sau mai nou și un joc care folosește AMPR. From 2e0d98b650e7438e390e32e20c77817448d266bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:08:49 +0000 Subject: [PATCH 23/28] Count trace runs off the UI thread and keep ZIP members inside the temp folder - AMPR Packs page: the trace folder is searched recursively, and this ran on the UI thread on every keystroke, so a half-typed path such as a drive root froze the window. The count now runs in the background after typing pauses. - profile batch: ZIP member names were split only on '/', so on Windows a part holding "..\" or a drive ("C:") could write outside the extraction folder. The resolved target must now stay inside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- src/MkPFS.Build/AMPRPack/AMPRProfiler.cs | 8 ++- .../ViewModels/AmprPackPanelViewModel.cs | 68 +++++++++++++++++-- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 6 +- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs index 57bef18..48fbd1a 100644 --- a/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs +++ b/src/MkPFS.Build/AMPRPack/AMPRProfiler.cs @@ -1043,7 +1043,13 @@ public static string ExtractTraceArchive(string path, string tempRoot) continue; } - string target = Path.Combine([destination, .. parts]); + // On Windows a part can still hold "..\" or a drive ("C:"); the target must stay inside the folder. + string target = Path.GetFullPath(Path.Combine([destination, .. parts])); + if (!target.StartsWith(Path.GetFullPath(destination) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new AMPRPackException($"unsafe archive member: {PythonText.Repr(name)}"); + } + Directory.CreateDirectory(Path.GetDirectoryName(target)!); member.ExtractToFile(target, overwrite: true); extracted++; diff --git a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs index 57943cd..e9fe91e 100644 --- a/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/AmprPackPanelViewModel.cs @@ -259,33 +259,87 @@ public sealed partial class AmprPackPanelViewModel(Color accent, JobRunner? job } /// Count the trace runs in the chosen folder (each needs ampr_commands.bin and ampr_emu.index). - partial void OnTracesChanged(string value) => CountTraceRuns(value); + /// + /// The folder is searched recursively, so the count runs off the UI thread after typing pauses: a + /// half-typed path such as a drive root would otherwise freeze the window. + /// + partial void OnTracesChanged(string value) + { + _traceCount?.Cancel(); + _traceRuns = null; + TraceCount = CountTraceRunsLaterAsync(value); + } + + /// Wait after the last trace folder change before counting its runs. + public TimeSpan TraceCountDebounce { get; set; } = TimeSpan.FromMilliseconds(250); + + /// The pending background count of the trace folder (tests await it). + internal Task TraceCount { get; private set; } = Task.CompletedTask; + + private CancellationTokenSource? _traceCount; + + private async Task CountTraceRunsLaterAsync(string value) + { + using CancellationTokenSource pending = new(); + _traceCount = pending; + try + { + if (TraceCountDebounce > TimeSpan.Zero) + { + await Task.Delay(TraceCountDebounce, pending.Token).ConfigureAwait(true); + } + + int? runs = await Task.Run(() => TraceRuns(value), pending.Token).ConfigureAwait(true); + if (!pending.IsCancellationRequested) + { + _traceRuns = runs; + NotifyTraceLines(); + } + } + catch (OperationCanceledException) + { + // Superseded by a newer folder or by RefreshDetection. + } + finally + { + if (ReferenceEquals(_traceCount, pending)) + { + _traceCount = null; + } + } + } /// Look at the trace folder again and update the rules lines. public void RefreshDetection() { - CountTraceRuns(Traces); + _traceCount?.Cancel(); + _traceRuns = TraceRuns(Traces); + NotifyTraceLines(); + } + + private void NotifyTraceLines() + { OnPropertyChanged(nameof(ShowRulesTraces)); OnPropertyChanged(nameof(ShowNoTraces)); OnPropertyChanged(nameof(RulesTracesText)); } - private void CountTraceRuns(string value) + // Trace runs in the folder, or null when the field is empty. + private static int? TraceRuns(string value) { string folder = value.Trim(); if (folder.Length == 0) { - _traceRuns = null; - return; + return null; } try { - _traceRuns = Directory.Exists(folder) ? Build.AMPRPack.AMPRProfiler.DiscoverTracePairs(Path.GetFullPath(folder)).Count : 0; + return Directory.Exists(folder) ? Build.AMPRPack.AMPRProfiler.DiscoverTracePairs(Path.GetFullPath(folder)).Count : 0; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Core.AMPR.AMPRPackException) { - _traceRuns = 0; + return 0; } } diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 40babd6..50ed881 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -168,15 +168,18 @@ public void Page_asks_for_a_profile_or_traces() } [Fact] - public void Page_takes_rules_from_a_trace_folder() + public async Task Page_takes_rules_from_a_trace_folder() { using TempDir dir = new(); AmprPackPanelViewModel panel = Panel("game"); + panel.TraceCountDebounce = TimeSpan.Zero; panel.Root = Directory.CreateDirectory(Path.Combine(dir.Path, "game")).FullName; panel.Output = Path.Combine(dir.Path, "out"); string traces = Directory.CreateDirectory(Path.Combine(dir.Path, "traces")).FullName; panel.Traces = traces; + Assert.False(panel.ShowNoTraces || panel.ShowRulesTraces); // counted in the background + await panel.TraceCount; Assert.True(panel.ShowNoTraces); Assert.False(panel.ShowRulesTraces || panel.ShowRulesMissing); @@ -191,6 +194,7 @@ public void Page_takes_rules_from_a_trace_folder() dir.File("traces/level1/ampr_commands.bin"); dir.File("traces/level1/ampr_emu.index"); panel.Traces = traces + Path.DirectorySeparatorChar; + await panel.TraceCount; Assert.True(panel.ShowRulesTraces); Assert.False(panel.ShowNoTraces); Assert.StartsWith("✓ Rules from traces: 2 recorded run(s) found.", panel.RulesTracesText, StringComparison.Ordinal); From 53b198a877d99b95db4980af634e04724343bdeb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:12:24 +0000 Subject: [PATCH 24/28] Reorder the BUILD pages and drop Pack Folder and Batch from the GUI ShadowMountPlus works best with an exFAT image wrapped in FFPFSC, so BUILD now lists Pack exFAT, Pack FFPFSC (formerly Pack File) and AMPR Packs. The Pack Folder and Batch pages are removed from the GUI with their views, view models and strings; `pack folder` and `batch` stay in the CLI. A trace-folder test assertion that raced with the background count is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 19 +- src/MkPFS.Gui/Localization/Strings.resx | 37 +--- src/MkPFS.Gui/Localization/Strings_de.resx | 37 +--- src/MkPFS.Gui/Localization/Strings_es.resx | 37 +--- src/MkPFS.Gui/Localization/Strings_fr.resx | 37 +--- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 37 +--- src/MkPFS.Gui/Localization/Strings_ro.resx | 37 +--- .../ViewModels/BatchQueueViewModel.cs | 134 ------------ src/MkPFS.Gui/ViewModels/BuildPanels.cs | 193 ------------------ src/MkPFS.Gui/ViewModels/Icons.cs | 2 - .../ViewModels/MainWindowViewModel.cs | 4 - src/MkPFS.Gui/Views/BatchQueue.axaml | 38 ---- src/MkPFS.Gui/Views/BatchQueue.axaml.cs | 13 -- src/MkPFS.Gui/Views/PanelView.axaml | 2 - src/MkPFS.Gui/Views/Panels/BatchForm.axaml | 40 ---- src/MkPFS.Gui/Views/Panels/BatchForm.axaml.cs | 13 -- .../Views/Panels/PackFolderForm.axaml | 44 ---- .../Views/Panels/PackFolderForm.axaml.cs | 13 -- tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs | 1 - tests/MkPFS.Gui.Tests/BuildPanelTests.cs | 83 +------- .../CompressionSettingsTests.cs | 12 +- tests/MkPFS.Gui.Tests/LocalizerTests.cs | 2 +- tests/MkPFS.Gui.Tests/MainWindowTests.cs | 28 ++- tests/MkPFS.Gui.Tests/PreviewTests.cs | 38 ---- 24 files changed, 52 insertions(+), 849 deletions(-) delete mode 100644 src/MkPFS.Gui/ViewModels/BatchQueueViewModel.cs delete mode 100644 src/MkPFS.Gui/Views/BatchQueue.axaml delete mode 100644 src/MkPFS.Gui/Views/BatchQueue.axaml.cs delete mode 100644 src/MkPFS.Gui/Views/Panels/BatchForm.axaml delete mode 100644 src/MkPFS.Gui/Views/Panels/BatchForm.axaml.cs delete mode 100644 src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml delete mode 100644 src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml.cs diff --git a/README.md b/README.md index 3779304..d728082 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ offline PFSC block repair ported from PS5 Game Compressor. like ampr_emu's `ampr_pack.py`, and `ampr game` turns a game folder into a smaller one that runs from them ([how it works](#ampr-asset-packs)). `ampr profile` turns traces recorded on the console into pack rules, like ampr_emu's `ampr_pack_profile.py`. -- **GUI**: `mkpfs-gui` with a page per command, cover and metadata preview, batch queue, and a PFSC - block map; English, Português (BR), Español, Română, Deutsch, and Français. +- **GUI**: `mkpfs-gui` with pages to build exFAT, FFPFSC and AMPR packs and to check and read images, + cover and metadata preview, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. ## Download @@ -473,19 +473,20 @@ source checkout: dotnet run --project src/MkPFS.Gui -c Release ``` +- The BUILD section follows the recommended order: **Pack exFAT** turns a game folder into an exFAT + image, **Pack FFPFSC** compresses that image into a `.ffpfsc`, and **AMPR Packs** builds LZ4 asset packs. + `pack folder` and `batch` are available on the command line only. - Pick a game folder or image to see its cover, title, IDs, version, region, and APR Emu marker. -- The Batch page lists every item it will pack before you run it. -- Pack Folder, Pack exFAT, and Batch have an APR Emu section: the libraries folder, download links, - and every [APR Emu option](#apr-emu). -- The packing pages have a collapsed Advanced section with the remaining CLI options: raw PFS, PFS - version, inode size, case sensitivity, encryption and EKPFS key, verification, cluster size, and - verbose output. +- Pack exFAT has an APR Emu section: the libraries folder, download links, and every + [APR Emu option](#apr-emu). +- The packing pages have a collapsed Advanced section with the remaining CLI options, such as the + cluster size, signing, verification, and verbose output. - The check pages cover the CLI options too: Verify takes a source file, the image format, and the game-file checklist; Unpack extracts inside a wrapped exFAT (`--deep`, `--only`); Tree and Unpack take the image format; Inspect, Tree, Verify, and Unpack take newCrypt keys. - Closing the window while a job runs asks first; Stop and Close cancels the job and waits for its cleanup (an in-place repair finishes its rewrite) before the window closes. -- Pack File, Pack Folder, and Batch have compression presets (Fast, Balanced, Max, Low RAM) and +- Pack FFPFSC has compression presets (Fast, Balanced, Max, Low RAM) and settings for the zlib level, CPU cores, block size, and when to keep blocks uncompressed. - The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its offset, stored size, and largest back-reference distance. diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 9647441..32e66ac 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -16,8 +16,6 @@ Keep Output Extension Keep Inner File Name Alternate Key Derivation (newCrypt) - Raw PFS Image (.ffpfs) - Require Game Files Skip Post-Pack Check Use Spool Builder Advanced @@ -89,24 +87,6 @@ Browse File Folder - PFSC Compression - Dry Run - ✗ Output folder is required. - ✗ Source folder is required. - Output Folder - Select output folder… - Overwrite Existing - {0} item(s): {1} folder(s), {2} file(s) - Select a source folder to preview the batch queue. - Unable to scan batch folder: {0} - Select an existing folder to preview the batch queue. - No packable items found. - Scanning batch items... - Batch Queue - Source Folder - Select source folder… - Convert multiple games at once into PFS images - Batch Convert Version Cancel ✗ Cancelled. @@ -159,11 +139,9 @@ Partly written output is removed; an in-place repair finishes its rewrite first Language Import Preview AMPR Packs - Batch Inspect Pack exFAT - Pack File - Pack Folder + Pack FFPFSC Repair Tree Unpack @@ -173,19 +151,8 @@ Partly written output is removed; an in-place repair finishes its rewrite first Options Output Log Paths - PFSC Compression Dry Run - ✗ Output image path is required. - ✗ Source folder is required. - Output Image - Select output path… Signed Image - Source Folder - Select source folder… - Build a PFS image from a source directory - Temp Folder (optional) - Custom temp folder… - Pack Folder Verify After Pack Version PFSC Compression @@ -207,7 +174,7 @@ Partly written output is removed; an in-place repair finishes its rewrite first Wrap a single file into a PFSC compressed image Temp Folder (optional) Custom temp folder… - Pack File + Pack FFPFSC Version Bad Blocks List (optional — Game Compressor TSV) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 8032cdc..7f6ea66 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -16,8 +16,6 @@ Ausgabe-Endung beibehalten Inneren Dateinamen beibehalten Alternative Schlüsselableitung (newCrypt) - Rohes PFS-Image (.ffpfs) - Spieldateien erforderlich Prüfung nach dem Packen überspringen Spool-Builder verwenden Erweitert @@ -89,24 +87,6 @@ Durchsuchen Datei Ordner - PFSC-Komprimierung - Probelauf - ✗ Ausgabeordner ist erforderlich. - ✗ Quellordner ist erforderlich. - Ausgabeordner - Ausgabeordner wählen… - Vorhandene überschreiben - {0} Element(e): {1} Ordner, {2} Datei(en) - Wähle einen Quellordner für die Vorschau der Warteschlange. - Stapelordner kann nicht gelesen werden: {0} - Wähle einen vorhandenen Ordner für die Vorschau der Warteschlange. - Keine packbaren Elemente gefunden. - Elemente werden gesucht... - Stapel-Warteschlange - Quellordner - Quellordner wählen… - Mehrere Spiele auf einmal in PFS-Images umwandeln - Stapelkonvertierung Version Abbrechen ✗ Abgebrochen. @@ -159,11 +139,9 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Sprache Import-Vorschau AMPR-Pakete - Stapel Untersuchen exFAT packen - Datei packen - Ordner packen + FFPFSC packen Reparieren Baum Entpacken @@ -173,19 +151,8 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Optionen Ausgabeprotokoll Pfade - PFSC-Komprimierung Probelauf - ✗ Ausgabe-Image-Pfad ist erforderlich. - ✗ Quellordner ist erforderlich. - Ausgabe-Image - Ausgabepfad wählen… Signiertes Image - Quellordner - Quellordner wählen… - Ein PFS-Image aus einem Quellverzeichnis erstellen - Temp-Ordner (optional) - Eigener Temp-Ordner… - Ordner packen Nach dem Packen prüfen Version PFSC-Komprimierung @@ -207,7 +174,7 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Eine einzelne Datei in ein PFSC-komprimiertes Image packen Temp-Ordner (optional) Eigener Temp-Ordner… - Datei packen + FFPFSC packen Version Liste defekter Blöcke (optional — Game-Compressor-TSV) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index 805d907..e19c3d6 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -16,8 +16,6 @@ Conservar Extensión de Salida Conservar Nombre del Archivo Interno Derivación de Clave Alternativa (newCrypt) - Imagen PFS Sin Envoltura (.ffpfs) - Exigir Archivos del Juego Omitir Comprobación Final Usar Constructor con Spool Avanzado @@ -89,24 +87,6 @@ Buscar Archivo Carpeta - Compresión PFSC - Simulación - ✗ La carpeta de salida es obligatoria. - ✗ La carpeta fuente es obligatoria. - Carpeta de Salida - Seleccionar carpeta de salida… - Sobrescribir Existente - {0} elemento(s): {1} carpeta(s), {2} archivo(s) - Selecciona una carpeta fuente para previsualizar la cola del lote. - No fue posible escanear la carpeta del lote: {0} - Selecciona una carpeta existente para previsualizar la cola del lote. - No se encontraron elementos empaquetables. - Escaneando elementos del lote... - Cola del Lote - Carpeta Fuente - Seleccionar carpeta fuente… - Convertir varios juegos a la vez en imágenes PFS - Convertir en Lote Versión Cancelar ✗ Cancelado. @@ -159,11 +139,9 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Idioma Vista Previa de Importación Paquetes AMPR - Lote Inspeccionar Empaquetar EXFAT - Empaquetar Archivo - Empaquetar Carpeta + Empaquetar FFPFSC Reparar Árbol Desempaquetar @@ -173,19 +151,8 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Opciones Registro de Salida Rutas - Compresión PFSC Simulación (Dry Run) - ✗ La ruta de salida es obligatoria. - ✗ La carpeta fuente es obligatoria. - Imagen de Salida - Seleccionar ruta de salida… Imagen Firmada - Carpeta Fuente - Seleccionar carpeta fuente… - Construir una imagen PFS desde un directorio fuente - Carpeta Temporal (opcional) - Carpeta temp personalizada… - Empaquetar Carpeta Verificar Tras Empaquetar Versión Compresión PFSC @@ -207,7 +174,7 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Envolver un solo archivo en una imagen PFSC comprimida Carpeta Temporal (opcional) Carpeta temp personalizada… - Empaquetar Archivo + Empaquetar FFPFSC Versión Lista de Bloques Dañados (opcional — TSV de Game Compressor) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index eed27c5..077f3e8 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -16,8 +16,6 @@ Conserver l'extension de sortie Conserver le nom du fichier interne Dérivation de clé alternative (newCrypt) - Image PFS brute (.ffpfs) - Exiger les fichiers du jeu Ignorer la vérification après empaquetage Utiliser le générateur avec spool Avancé @@ -89,24 +87,6 @@ Parcourir Fichier Dossier - Compression PFSC - Simulation - ✗ Le dossier de sortie est obligatoire. - ✗ Le dossier source est obligatoire. - Dossier de sortie - Choisir le dossier de sortie… - Écraser les existants - {0} élément(s) : {1} dossier(s), {2} fichier(s) - Choisissez un dossier source pour prévisualiser la file d'attente. - Impossible d'analyser le dossier du lot : {0} - Choisissez un dossier existant pour prévisualiser la file d'attente. - Aucun élément à empaqueter trouvé. - Analyse des éléments... - File d'attente du lot - Dossier source - Choisir le dossier source… - Convertir plusieurs jeux à la fois en images PFS - Conversion par lot Version Annuler ✗ Annulé. @@ -159,11 +139,9 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Langue Aperçu de l'import Paquets AMPR - Lot Inspecter Empaqueter exFAT - Empaqueter fichier - Empaqueter dossier + Empaqueter FFPFSC Réparer Arborescence Extraire @@ -173,19 +151,8 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Options Journal de sortie Chemins - Compression PFSC Simulation - ✗ Le chemin de l'image de sortie est obligatoire. - ✗ Le dossier source est obligatoire. - Image de sortie - Choisir le chemin de sortie… Image signée - Dossier source - Choisir le dossier source… - Créer une image PFS à partir d’un répertoire source - Dossier temporaire (facultatif) - Dossier temporaire personnalisé… - Empaqueter dossier Vérifier après l'empaquetage Version Compression PFSC @@ -207,7 +174,7 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Empaqueter un seul fichier dans une image compressée PFSC Dossier temporaire (facultatif) Dossier temporaire personnalisé… - Empaqueter fichier + Empaqueter FFPFSC Version Liste des blocs défectueux (facultatif — TSV Game Compressor) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index ac9e31e..3eb9ecf 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -16,8 +16,6 @@ Manter Extensão de Saída Manter Nome do Arquivo Interno Derivação de Chave Alternativa (newCrypt) - Imagem PFS Bruta (.ffpfs) - Exigir Arquivos do Jogo Pular Verificação Final Usar Construtor com Spool Avançado @@ -89,24 +87,6 @@ Procurar Arquivo Pasta - Compressão PFSC - Simulação - ✗ A pasta de saída é obrigatória. - ✗ A pasta fonte é obrigatória. - Pasta de Saída - Selecione a pasta de saída… - Sobrescrever Existente - {0} item(ns): {1} pasta(s), {2} arquivo(s) - Selecione uma pasta fonte para pré-visualizar a fila do lote. - Não foi possível escanear a pasta do lote: {0} - Selecione uma pasta existente para pré-visualizar a fila do lote. - Nenhum item empacotável encontrado. - Escaneando itens do lote... - Fila do Lote - Pasta Fonte - Selecione a pasta fonte… - Converter vários jogos de uma vez em imagens PFS - Converter em Lote Versão Cancelar ✗ Cancelado. @@ -159,11 +139,9 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Idioma Prévia da Importação Pacotes AMPR - Lote Inspecionar Empacotar EXFAT - Empacotar Arquivo - Empacotar Pasta + Empacotar FFPFSC Reparar Árvore Desempacotar @@ -173,19 +151,8 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Opções Log de Saída Caminhos - Compressão PFSC Simulação (Dry Run) - ✗ O caminho de saída é obrigatório. - ✗ A pasta fonte é obrigatória. - Imagem de Saída - Selecione o caminho de saída… Imagem Assinada - Pasta Fonte - Selecione a pasta fonte… - Construir uma imagem PFS a partir de um diretório fonte - Pasta Temporária (opcional) - Pasta temp personalizada… - Empacotar Pasta Verificar Após Empacotar Versão Compressão PFSC @@ -207,7 +174,7 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Encapsular um único arquivo em uma imagem PFSC comprimida Pasta Temporária (opcional) Pasta temp personalizada… - Empacotar Arquivo + Empacotar FFPFSC Versão Lista de Blocos Ruins (opcional — TSV do Game Compressor) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 4173c90..0324542 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -16,8 +16,6 @@ Păstrează extensia de ieșire Păstrează numele fișierului intern Derivare alternativă a cheii (newCrypt) - Imagine PFS brută (.ffpfs) - Cere fișierele jocului Omite verificarea după împachetare Folosește constructorul cu spool Avansat @@ -89,24 +87,6 @@ Răsfoiește Fișier Folder - Compresie PFSC - Simulare - ✗ Folderul de ieșire este obligatoriu. - ✗ Folderul sursă este obligatoriu. - Folder de ieșire - Alege folderul de ieșire… - Suprascrie existente - {0} element(e): {1} folder(e), {2} fișier(e) - Alege un folder sursă pentru a previzualiza coada. - Nu se poate scana folderul: {0} - Alege un folder existent pentru a previzualiza coada. - Nu s-au găsit elemente de împachetat. - Se scanează elementele... - Coadă lot - Folder sursă - Alege folderul sursă… - Convertește mai multe jocuri deodată în imagini PFS - Conversie în lot Versiune Anulează ✗ Anulat. @@ -159,11 +139,9 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Limbă Previzualizare import Pachete AMPR - Lot Inspectare Împachetare exFAT - Împachetare fișier - Împachetare folder + Împachetare FFPFSC Reparare Arbore Despachetare @@ -173,19 +151,8 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Opțiuni Jurnal de ieșire Căi - Compresie PFSC Simulare - ✗ Calea imaginii de ieșire este obligatorie. - ✗ Folderul sursă este obligatoriu. - Imagine de ieșire - Alege calea de ieșire… Imagine semnată - Folder sursă - Alege folderul sursă… - Creează o imagine PFS dintr-un director sursă - Folder temporar (opțional) - Folder temporar personalizat… - Împachetare folder Verifică după împachetare Versiune Compresie PFSC @@ -207,7 +174,7 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Împachetează un singur fișier într-o imagine comprimată PFSC Folder temporar (opțional) Folder temporar personalizat… - Împachetare fișier + Împachetare FFPFSC Versiune Listă blocuri defecte (opțional — TSV Game Compressor) bad_blocks.tsv… diff --git a/src/MkPFS.Gui/ViewModels/BatchQueueViewModel.cs b/src/MkPFS.Gui/ViewModels/BatchQueueViewModel.cs deleted file mode 100644 index 5588ae5..0000000 --- a/src/MkPFS.Gui/ViewModels/BatchQueueViewModel.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Collections.ObjectModel; -using Avalonia.Media; -using Avalonia.Media.Imaging; -using CommunityToolkit.Mvvm.ComponentModel; -using MkPFS.Build; -using MkPFS.Build.PFS; -using MkPFS.Core.Diagnostics; -using MkPFS.Core.Metadata; -using MkPFS.Gui.Localization; - -namespace MkPFS.Gui.ViewModels; - -/// One discovered batch item (Python BatchItemRow). -public sealed class BatchQueueRow -{ - internal BatchQueueRow(BatchItem item, GameMetadata metadata, IBrush accent) - { - Accent = accent; - Localizer tr = Localizer.Instance; - string Or(string value) => value.Length > 0 ? value : "-"; - string kind = item.Kind == BatchItemKind.Folder ? "FOLDER" : "FILE"; - Title = metadata.GameTitle.Length > 0 ? metadata.GameTitle : metadata.FileName.Length > 0 ? metadata.FileName : item.Name; - Details = $"{tr["pkf_meta_title_id"]}: {Or(metadata.TitleId)} | {tr["pkf_meta_version"]}: {Or(metadata.Version)}" - + $" | {(metadata.PackageType.Length > 0 ? metadata.PackageType : kind)} | {tr["pkf_meta_size"]}: {metadata.SizeDisplay}" - + $" | {tr["pkf_meta_region"]}: {Or(metadata.Region)}"; - ContentId = $"{tr["pkf_meta_content_id"]}: {Or(metadata.ContentId)}"; - Kind = kind; - AprEmu = metadata.HasAprEmu ? $"{tr["pkf_meta_apr_emu"]}: {tr["pkf_meta_apr_yes"]}" : null; - Cover = MetadataPreviewViewModel.DecodeCover(metadata.IconBytes); - } - - /// Game title, file name, or item name. - public string Title { get; } - - /// Title ID, version, type, size, and region. - public string Details { get; } - - /// Content ID line. - public string ContentId { get; } - - /// FOLDER or FILE. - public string Kind { get; } - - /// "APR-EMU: Yes" when the item carries the APR Emu marker. - public string? AprEmu { get; } - - /// Cover image. - public Bitmap? Cover { get; } - - /// A cover image is shown. - public bool HasCover => Cover is not null; - - /// Panel accent for the details line. - public IBrush Accent { get; } -} - -/// Batch queue preview: every packable item of the source folder (Python BatchQueuePreview). -/// Panel accent for the details lines. -public sealed partial class BatchQueueViewModel(IBrush accent) : ObservableObject -{ - private int _token; - - /// Discovered items. - public ObservableCollection Rows { get; } = []; - - /// Summary next to the section title. - [ObservableProperty] - public partial string Summary { get; private set; } = Localizer.Instance["bt_preview_empty"]; - - /// The list is empty and shows instead. - [ObservableProperty] - public partial bool IsEmpty { get; private set; } = true; - - /// Scan and read every item's metadata; a newer call supersedes it. - /// Batch source folder. - /// Completes when the list is updated or superseded. - public async Task LoadAsync(string? path) - { - int token = ++_token; - Localizer tr = Localizer.Instance; - Rows.Clear(); - IsEmpty = true; - string trimmed = path?.Trim() ?? string.Empty; - if (trimmed.Length == 0) - { - Summary = tr["bt_preview_empty"]; - return; - } - - if (!Directory.Exists(trimmed)) - { - Summary = tr["bt_preview_invalid"]; - return; - } - - Summary = tr["bt_preview_scanning"]; - List rows; - try - { - rows = await Task.Run(() => Batch.Discover(trimmed, NullLog.Instance) - .Select(item => new BatchQueueRow(item, GameMetadataReader.Read(item.Source), accent)) - .ToList()).ConfigureAwait(true); - } - catch (Exception ex) when (ex is BuildException or IOException or UnauthorizedAccessException) - { - if (token == _token) - { - Summary = tr.Format("bt_preview_error", ex.Message); - } - - return; - } - - if (token != _token) - { - return; - } - - if (rows.Count == 0) - { - Summary = tr["bt_preview_none"]; - return; - } - - int folders = rows.Count(r => r.Kind == "FOLDER"); - Summary = tr.Format("bt_preview_count", rows.Count, folders, rows.Count - folders); - foreach (BatchQueueRow row in rows) - { - Rows.Add(row); - } - - IsEmpty = false; - } -} diff --git a/src/MkPFS.Gui/ViewModels/BuildPanels.cs b/src/MkPFS.Gui/ViewModels/BuildPanels.cs index 5c66f21..213551c 100644 --- a/src/MkPFS.Gui/ViewModels/BuildPanels.cs +++ b/src/MkPFS.Gui/ViewModels/BuildPanels.cs @@ -6,107 +6,6 @@ namespace MkPFS.Gui.ViewModels; -/// Pack Folder page (Python PackFolderPanel): mkpfs pack folder. -/// Accent color. -/// Job runner, or for the UI-thread runner. -public sealed partial class PackFolderPanelViewModel(Color accent, JobRunner? job = null) : PanelViewModel("pf_title", "pf_subtitle", accent, job) -{ - /// Cover and details of the source. - public MetadataPreviewViewModel Metadata { get; } = new(); - - /// Compression tuning. - public CompressionSettingsViewModel Compression { get; } = new(allowAutoFit: true, offerSkipExecutables: true); - - /// Source folder. - [ObservableProperty] - public partial string Source { get; set; } = string.Empty; - - /// Output image. - [ObservableProperty] - public partial string Output { get; set; } = string.Empty; - - /// PFSC compression (off adds --no-compress). - [ObservableProperty] - public partial bool Compress { get; set; } = true; - - /// --signed. - [ObservableProperty] - public partial bool Signed { get; set; } - - /// --verify. - [ObservableProperty] - public partial bool VerifyAfter { get; set; } - - /// --dry-run. - [ObservableProperty] - public partial bool DryRun { get; set; } - - /// --temp-folder, optional. - [ObservableProperty] - public partial string TempFolder { get; set; } = string.Empty; - - /// APR Emu libraries and index. - public AmprSettingsViewModel Ampr { get; } = new(); - - /// Advanced PFS options; the inode width applies to images only. - public PFSOptionsViewModel PFS { get; } = new(offerInodeBits: true, offerNewCrypt: false) { InodeBitsEnabled = false }; - - /// --raw. - [ObservableProperty] - public partial bool Raw { get; set; } - - /// --require-game-files. - [ObservableProperty] - public partial bool RequireGameFiles { get; set; } - - /// --skip-verification (not with ). - [ObservableProperty] - public partial bool SkipVerification { get; set; } - - /// --no-adjust-output-file-extension. - [ObservableProperty] - public partial bool KeepExtension { get; set; } - - /// - public override object Form => this; - - /// - protected internal override IReadOnlyList? BuildArguments(out string? error) - { - string source = Source.Trim(); - string output = Output.Trim(); - error = source.Length == 0 ? Localizer.Instance["pf_err_src"] : output.Length == 0 ? Localizer.Instance["pf_err_out"] : null; - if (error is not null) - { - return null; - } - - List args = ["pack", "folder", source, output]; - AddFlag(args, !Compress, "--no-compress"); - AddFlag(args, Signed, "--signed"); - AddFlag(args, VerifyAfter, "--verify"); - AddFlag(args, DryRun, "--dry-run"); - AddOption(args, "--temp-folder", TempFolder); - AddFlag(args, Raw, "--raw"); - AddFlag(args, RequireGameFiles, "--require-game-files"); - AddFlag(args, SkipVerification && !VerifyAfter, "--skip-verification"); - AddFlag(args, KeepExtension, "--no-adjust-output-file-extension"); - PFS.AppendTo(args); - Ampr.AppendTo(args); - return Compression.AppendTo(args, Compress, out error) ? args : null; - } - - partial void OnRawChanged(bool value) => PFS.InodeBitsEnabled = value; - - // Python _on_src_changed: preview the source and suggest /.ffpfsc. - partial void OnSourceChanged(string value) - { - _ = Metadata.LoadAsync(value); - Output = SuggestOutput(value, Output, Directory.Exists, Path.GetFileName, ".ffpfsc"); - Ampr.NoteSource(AmprSettingsViewModel.HasIndex(value)); - } -} - /// Pack exFAT page (Python ExfatPanel): mkpfs pack exfat. /// Accent color. /// Job runner, or for the UI-thread runner. @@ -281,95 +180,3 @@ partial void OnSourceChanged(string value) Output = SuggestOutput(value, Output, File.Exists, Path.GetFileNameWithoutExtension, ".ffpfsc"); } } - -/// Batch page (Python BatchPanel): mkpfs batch. -/// Accent color. -/// Job runner, or for the UI-thread runner. -public sealed partial class BatchPanelViewModel(Color accent, JobRunner? job = null) : PanelViewModel("bt_title", "bt_subtitle", accent, job) -{ - /// Compression tuning (batch takes no "auto-fit" block size). - public CompressionSettingsViewModel Compression { get; } = new(allowAutoFit: false, offerSkipExecutables: true); - - /// Items the batch would convert. - public BatchQueueViewModel Queue { get; } = new(new Avalonia.Media.Immutable.ImmutableSolidColorBrush(accent)); - - /// Source folder. - [ObservableProperty] - public partial string Source { get; set; } = string.Empty; - - /// Output folder. - [ObservableProperty] - public partial string Output { get; set; } = string.Empty; - - /// PFSC compression (off adds --no-compress). - [ObservableProperty] - public partial bool Compress { get; set; } = true; - - /// --overwrite. - [ObservableProperty] - public partial bool Overwrite { get; set; } - - /// --dry-run. - [ObservableProperty] - public partial bool DryRun { get; set; } - - /// --verify. - [ObservableProperty] - public partial bool VerifyAfter { get; set; } - - /// APR Emu libraries and index. - public AmprSettingsViewModel Ampr { get; } = new(); - - /// Advanced PFS options (batch images always use 32-bit inodes). - public PFSOptionsViewModel PFS { get; } = new(offerInodeBits: false, offerNewCrypt: true); - - /// - public override object Form => this; - - /// - protected internal override IReadOnlyList? BuildArguments(out string? error) - { - string source = Source.Trim(); - string output = Output.Trim(); - error = source.Length == 0 ? Localizer.Instance["bt_err_src"] : output.Length == 0 ? Localizer.Instance["bt_err_out"] : null; - if (error is not null) - { - return null; - } - - List args = ["batch", source, output]; - AddFlag(args, !Compress, "--no-compress"); - AddFlag(args, Overwrite, "--overwrite"); - AddFlag(args, DryRun, "--dry-run"); - AddFlag(args, VerifyAfter, "--verify"); - PFS.AppendTo(args); - Ampr.AppendTo(args); - return Compression.AppendTo(args, Compress, out error) ? args : null; - } - - // Python: preview the queue; the output folder defaults to the source folder. - partial void OnSourceChanged(string value) - { - _ = Queue.LoadAsync(value); - if (Output.Trim().Length == 0 && value.Trim() is { Length: > 0 } source && Directory.Exists(source)) - { - Output = source; - } - - Ampr.NoteSource(AnyFolderHasIndex(value)); - } - - // Batch packs the folders directly inside the source. - private static bool AnyFolderHasIndex(string source) - { - try - { - return source.Trim() is { Length: > 0 } path && Directory.Exists(path) && - Directory.EnumerateDirectories(path).Any(AmprSettingsViewModel.HasIndex); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return false; - } - } -} diff --git a/src/MkPFS.Gui/ViewModels/Icons.cs b/src/MkPFS.Gui/ViewModels/Icons.cs index b3402a8..88fbfc4 100644 --- a/src/MkPFS.Gui/ViewModels/Icons.cs +++ b/src/MkPFS.Gui/ViewModels/Icons.cs @@ -3,8 +3,6 @@ namespace MkPFS.Gui.ViewModels; /// Navigation icon paths (Material Design Icons 7.4.47, Apache-2.0, 24×24 viewbox). internal static class Icons { - public const string Batch = "M12 16.54L19.37 10.8L21 12.07L12 19.07L3 12.07L4.62 10.81L12 16.54M12 14L3 7L12 0L21 7L12 14M12 2.53L6.26 7L12 11.47L17.74 7L12 2.53M12 21.47L19.37 15.73L21 17L12 24L3 17L4.62 15.74L12 21.47"; - public const string PackFolder = "M20 6H12L10 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V8C22 6.9 21.1 6 20 6M20 18H16V16H14V18H4V8H14V10H16V8H20V18M16 12V10H18V12H16M14 12H16V14H14V12M18 16H16V14H18V16Z"; public const string PackExfat = "M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M12,4A6,6 0 0,0 6,10C6,13.31 8.69,16 12.1,16L11.22,13.77C10.95,13.29 11.11,12.68 11.59,12.4L12.45,11.9C12.93,11.63 13.54,11.79 13.82,12.27L15.74,14.69C17.12,13.59 18,11.9 18,10A6,6 0 0,0 12,4M12,9A1,1 0 0,1 13,10A1,1 0 0,1 12,11A1,1 0 0,1 11,10A1,1 0 0,1 12,9M7,18A1,1 0 0,0 6,19A1,1 0 0,0 7,20A1,1 0 0,0 8,19A1,1 0 0,0 7,18M12.09,13.27L14.58,19.58L17.17,18.08L12.95,12.77L12.09,13.27Z"; public const string PackFile = "M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z"; public const string Verify = "M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9"; diff --git a/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs b/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs index b94407e..e52a7ac 100644 --- a/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/MainWindowViewModel.cs @@ -57,8 +57,6 @@ public sealed record NavSection(string TitleKey, IReadOnlyList Items) public sealed partial class MainWindowViewModel : ObservableObject { // Python theme.py neon palette; Repair is new and takes the error red, AMPR Packs a new lime. - private static readonly Color Teal = Color.Parse("#00E5A0"); - private static readonly Color Blue = Color.Parse("#00C8FF"); private static readonly Color Orange = Color.Parse("#FF6B3D"); private static readonly Color Cyan = Color.Parse("#00FFD4"); private static readonly Color Green = Color.Parse("#39FF8A"); @@ -75,8 +73,6 @@ public MainWindowViewModel() [ new("section_build", [ - Item("batch", "nav_batch", Icons.Batch, new BatchPanelViewModel(Teal)), - Item("pack_folder", "nav_pack_folder", Icons.PackFolder, new PackFolderPanelViewModel(Blue)), Item("pack_exfat", "nav_pack_exfat", Icons.PackExfat, new PackExfatPanelViewModel(Orange)), Item("pack_file", "nav_pack_file", Icons.PackFile, new PackFilePanelViewModel(Cyan)), Item("ampr", "nav_ampr", Icons.AmprPack, new AmprPackPanelViewModel(Lime)), diff --git a/src/MkPFS.Gui/Views/BatchQueue.axaml b/src/MkPFS.Gui/Views/BatchQueue.axaml deleted file mode 100644 index 7dd2a58..0000000 --- a/src/MkPFS.Gui/Views/BatchQueue.axaml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MkPFS.Gui/Views/BatchQueue.axaml.cs b/src/MkPFS.Gui/Views/BatchQueue.axaml.cs deleted file mode 100644 index 722ad23..0000000 --- a/src/MkPFS.Gui/Views/BatchQueue.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Avalonia.Controls; - -namespace MkPFS.Gui.Views; - -/// Batch queue preview list. -public sealed partial class BatchQueue : UserControl -{ - /// Create the view. - public BatchQueue() - { - InitializeComponent(); - } -} diff --git a/src/MkPFS.Gui/Views/PanelView.axaml b/src/MkPFS.Gui/Views/PanelView.axaml index 5226e92..c67c5f6 100644 --- a/src/MkPFS.Gui/Views/PanelView.axaml +++ b/src/MkPFS.Gui/Views/PanelView.axaml @@ -21,8 +21,6 @@ page template on MainWindow, which also matches it. --> - - diff --git a/src/MkPFS.Gui/Views/Panels/BatchForm.axaml b/src/MkPFS.Gui/Views/Panels/BatchForm.axaml deleted file mode 100644 index a07a32e..0000000 --- a/src/MkPFS.Gui/Views/Panels/BatchForm.axaml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MkPFS.Gui/Views/Panels/BatchForm.axaml.cs b/src/MkPFS.Gui/Views/Panels/BatchForm.axaml.cs deleted file mode 100644 index e570ba2..0000000 --- a/src/MkPFS.Gui/Views/Panels/BatchForm.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Avalonia.Controls; - -namespace MkPFS.Gui.Views.Panels; - -/// Options form of the page with the same name. -public sealed partial class BatchForm : UserControl -{ - /// Create the form. - public BatchForm() - { - InitializeComponent(); - } -} diff --git a/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml b/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml deleted file mode 100644 index b8f5b90..0000000 --- a/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml.cs b/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml.cs deleted file mode 100644 index ca8c2aa..0000000 --- a/src/MkPFS.Gui/Views/Panels/PackFolderForm.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Avalonia.Controls; - -namespace MkPFS.Gui.Views.Panels; - -/// Options form of the page with the same name. -public sealed partial class PackFolderForm : UserControl -{ - /// Create the form. - public PackFolderForm() - { - InitializeComponent(); - } -} diff --git a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs index 50ed881..869fa41 100644 --- a/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/AmprPackPanelTests.cs @@ -178,7 +178,6 @@ public async Task Page_takes_rules_from_a_trace_folder() string traces = Directory.CreateDirectory(Path.Combine(dir.Path, "traces")).FullName; panel.Traces = traces; - Assert.False(panel.ShowNoTraces || panel.ShowRulesTraces); // counted in the background await panel.TraceCount; Assert.True(panel.ShowNoTraces); Assert.False(panel.ShowRulesTraces || panel.ShowRulesMissing); diff --git a/tests/MkPFS.Gui.Tests/BuildPanelTests.cs b/tests/MkPFS.Gui.Tests/BuildPanelTests.cs index 69d6fb5..8337fc7 100644 --- a/tests/MkPFS.Gui.Tests/BuildPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/BuildPanelTests.cs @@ -23,28 +23,6 @@ internal static string Game(TempDir dir, string name = "PPSA01234-app") return Path.Combine(dir.Path, name); } - [Fact] - public void Pack_folder_builds_python_arguments_and_validates() - { - PackFolderPanelViewModel panel = new(Colors.Blue, Sync()); - - Assert.Null(Build(panel, out string? error)); - Assert.Equal("✗ Source folder is required.", error); - panel.Source = "D:/games/x"; - Assert.Null(Build(panel, out error)); - Assert.Equal("✗ Output image path is required.", error); - - panel.Output = " D:/out/x.ffpfsc "; - Assert.Equal(["pack", "folder", "D:/games/x", "D:/out/x.ffpfsc"], Build(panel, out _)); - panel.Compress = false; - panel.Signed = panel.VerifyAfter = panel.DryRun = true; - panel.TempFolder = "T:/tmp"; - panel.Ampr.LibsDir = "L:/ampr"; - Assert.Equal( - ["pack", "folder", "D:/games/x", "D:/out/x.ffpfsc", "--no-compress", "--signed", "--verify", "--dry-run", "--temp-folder", "T:/tmp", "--ampr-libs", "L:/ampr"], - Build(panel, out _)); - } - [Fact] public void Pack_exfat_output_is_optional() { @@ -61,7 +39,7 @@ public void Pack_exfat_output_is_optional() } [Fact] - public void Pack_file_and_batch_build_python_arguments() + public void Pack_file_builds_python_arguments() { PackFilePanelViewModel file = new(Colors.Cyan, Sync()) { Source = "D:/a.exfat" }; Assert.Null(Build(file, out string? error)); @@ -70,36 +48,11 @@ public void Pack_file_and_batch_build_python_arguments() file.Compress = false; file.TempFolder = "T:/"; Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--no-compress", "--temp-folder", "T:/"], Build(file, out _)); - - BatchPanelViewModel batch = new(Colors.Teal, Sync()) { Output = "D:/out" }; - Assert.Null(Build(batch, out error)); - Assert.Equal("✗ Source folder is required.", error); - batch.Source = "D:/in"; - batch.Compress = false; - batch.Overwrite = batch.DryRun = batch.VerifyAfter = true; - batch.Ampr.LibsDir = "L:/ampr"; - Assert.Equal(["batch", "D:/in", "D:/out", "--no-compress", "--overwrite", "--dry-run", "--verify", "--ampr-libs", "L:/ampr"], Build(batch, out _)); } [Fact] public void Advanced_options_map_to_cli_flags() { - PackFolderPanelViewModel folder = new(Colors.Blue, Sync()) { Source = "D:/g", Output = "D:/g.ffpfs" }; - folder.PFS.InodeBits = folder.PFS.InodeWidths[1]; - folder.PFS.Version = folder.PFS.Versions[1]; - folder.PFS.Encrypted = folder.PFS.CaseSensitive = folder.PFS.Verbose = true; - folder.PFS.EkpfsKey = " ab "; - folder.RequireGameFiles = folder.SkipVerification = folder.KeepExtension = true; - Assert.Equal( - ["pack", "folder", "D:/g", "D:/g.ffpfs", "--require-game-files", "--skip-verification", "--no-adjust-output-file-extension", - "--version", "PS4", "--case-sensitive", "--encrypted", "--ekpfs-key", "ab", "--verbose"], - Build(folder, out _)); // 64-bit inodes need --raw - folder.Raw = folder.VerifyAfter = true; - Assert.Equal( - ["pack", "folder", "D:/g", "D:/g.ffpfs", "--verify", "--raw", "--require-game-files", "--no-adjust-output-file-extension", - "--version", "PS4", "--inode-bits", "64", "--case-sensitive", "--encrypted", "--ekpfs-key", "ab", "--verbose"], - Build(folder, out _)); // --skip-verification conflicts with --verify - PackExfatPanelViewModel exfat = new(Colors.Orange, Sync()) { Source = "D:/g", Output = "D:/g.exfat" }; Assert.Equal("auto", exfat.ClusterSize.Value); exfat.ClusterSize = exfat.ClusterSizes.Single(c => c.Label == "32 MiB"); @@ -112,13 +65,6 @@ public void Advanced_options_map_to_cli_flags() Assert.Equal( ["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--signed", "--dry-run", "--use-spool", "--no-rename-inner-image", "--inode-bits", "64"], Build(file, out _)); - - BatchPanelViewModel batch = new(Colors.Teal, Sync()) { Source = "D:/in", Output = "D:/out" }; - batch.PFS.NewCrypt = true; // only with --encrypted - Assert.Equal(["batch", "D:/in", "D:/out"], Build(batch, out _)); - batch.PFS.Encrypted = true; - batch.PFS.InodeBits = batch.PFS.InodeWidths[1]; // not offered by batch - Assert.Equal(["batch", "D:/in", "D:/out", "--encrypted", "--new-crypt"], Build(batch, out _)); } [Fact] @@ -155,16 +101,11 @@ public void A_source_with_an_index_keeps_it_by_default() exfat.Source = plain; // the automatic choice follows the source Assert.False(exfat.Ampr.HasExistingIndex); Assert.False(exfat.Ampr.KeepValidIndex); - - PackFolderPanelViewModel folder = new(Colors.Blue, Sync()); - folder.Ampr.KeepValidIndex = true; // the user's choice stays - folder.Source = indexed; - folder.Source = plain; - Assert.True(folder.Ampr.KeepValidIndex); - - BatchPanelViewModel batch = new(Colors.Teal, Sync()) { Source = dir.Path }; - Assert.True(batch.Ampr.HasExistingIndex); - Assert.True(batch.Ampr.KeepValidIndex); + PackExfatPanelViewModel chosen = new(Colors.Orange, Sync()); + chosen.Ampr.KeepValidIndex = true; // the user's choice stays + chosen.Source = indexed; + chosen.Source = plain; + Assert.True(chosen.Ampr.KeepValidIndex); } [AvaloniaFact] @@ -174,16 +115,14 @@ public void Choosing_a_source_suggests_a_sanitized_output_once() string game = Game(dir, "My Game (EU)!"); string exfat = dir.File("Data [v2].exfat"); - PackFolderPanelViewModel folder = new(Colors.Blue, Sync()) { Source = game }; - Assert.Equal(Path.Combine(dir.Path, "My Game EU.ffpfsc"), folder.Output); - folder.Source = Path.Combine(dir.Path, "other"); - Assert.Equal(Path.Combine(dir.Path, "My Game EU.ffpfsc"), folder.Output); // a filled output is kept + PackExfatPanelViewModel image = new(Colors.Orange, Sync()) { Source = game + Path.DirectorySeparatorChar }; + Assert.Equal(Path.Combine(dir.Path, "My Game EU.exfat"), image.Output); + image.Source = Path.Combine(dir.Path, "other"); + Assert.Equal(Path.Combine(dir.Path, "My Game EU.exfat"), image.Output); // a filled output is kept - Assert.Equal(Path.Combine(dir.Path, "My Game EU.exfat"), new PackExfatPanelViewModel(Colors.Orange, Sync()) { Source = game + Path.DirectorySeparatorChar }.Output); Assert.Equal(Path.Combine(dir.Path, "Data v2.ffpfsc"), new PackFilePanelViewModel(Colors.Cyan, Sync()) { Source = exfat }.Output); Assert.Equal(string.Empty, new PackFilePanelViewModel(Colors.Cyan, Sync()) { Source = game }.Output); // folder: not a file - Assert.Equal(dir.Path, new BatchPanelViewModel(Colors.Teal, Sync()) { Source = dir.Path }.Output); - Assert.Equal(string.Empty, new PackFolderPanelViewModel(Colors.Blue, Sync()) { Source = Path.Combine(dir.Path, "missing") }.Output); + Assert.Equal(string.Empty, new PackExfatPanelViewModel(Colors.Orange, Sync()) { Source = Path.Combine(dir.Path, "missing") }.Output); } [Fact] diff --git a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs index 0406715..d49610c 100644 --- a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs +++ b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs @@ -73,7 +73,7 @@ public void Block_size_and_keep_rules_become_arguments() } [Fact] - public void Batch_offers_no_auto_fit_and_files_no_executables_option() + public void Auto_fit_and_the_executables_option_can_be_left_out() { CompressionSettingsViewModel batch = new(allowAutoFit: false, offerSkipExecutables: true); CompressionSettingsViewModel file = new(allowAutoFit: true, offerSkipExecutables: false) { SkipExecutables = true }; @@ -101,13 +101,11 @@ public void Pages_append_the_tuning_to_their_arguments() file.Compression.Preset = file.Compression.Presets.Single(p => p.Value == "fast"); Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--compression-level", "1"], file.BuildArguments(out _)); - BatchPanelViewModel batch = new(Colors.Teal, sync) { Source = "D:/in", Output = "D:/out" }; - batch.Compression.CpuCount = 2; - Assert.Equal(["batch", "D:/in", "D:/out", "--cpu-count", "2"], batch.BuildArguments(out _)); + file.Compression.CpuCount = 2; + Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--compression-level", "1", "--cpu-count", "2"], file.BuildArguments(out _)); - PackFolderPanelViewModel folder = new(Colors.Blue, sync) { Source = "D:/g", Output = "D:/g.ffpfsc" }; - folder.Compression.MinCompressSize = "x"; - Assert.Null(folder.BuildArguments(out string? error)); + file.Compression.MinCompressSize = "x"; + Assert.Null(file.BuildArguments(out string? error)); Assert.Equal("✗ Store Files Smaller Than must be a whole number of bytes.", error); } } diff --git a/tests/MkPFS.Gui.Tests/LocalizerTests.cs b/tests/MkPFS.Gui.Tests/LocalizerTests.cs index 5d76ae6..5379976 100644 --- a/tests/MkPFS.Gui.Tests/LocalizerTests.cs +++ b/tests/MkPFS.Gui.Tests/LocalizerTests.cs @@ -34,7 +34,7 @@ public void Lookup_falls_back_to_the_key_and_formats_like_python() { Assert.Equal("no_such_key", Localizer.Instance["no_such_key"]); Assert.Equal("✗ Process exited with code 3.", Localizer.Instance.Format("err_process", 3)); - Assert.Equal("3 item(s): 1 folder(s), 2 file(s)", Localizer.Instance.Format("bt_preview_count", 3, 1, 2)); + Assert.StartsWith("✓ Rules from traces: 2 recorded run(s) found.", Localizer.Instance.Format("ap_rules_traces", 2), StringComparison.Ordinal); } [AvaloniaTheory] diff --git a/tests/MkPFS.Gui.Tests/MainWindowTests.cs b/tests/MkPFS.Gui.Tests/MainWindowTests.cs index c3dc616..6d2a3b2 100644 --- a/tests/MkPFS.Gui.Tests/MainWindowTests.cs +++ b/tests/MkPFS.Gui.Tests/MainWindowTests.cs @@ -41,10 +41,11 @@ public void Sidebar_lists_every_operation_grouped_by_section() Assert.Equal(["section_build", "section_check", "section_read"], model.Sections.Select(s => s.TitleKey)); Assert.Equal( - ["batch", "pack_folder", "pack_exfat", "pack_file", "ampr", "verify", "repair", "inspect", "tree", "unpack"], + ["pack_exfat", "pack_file", "ampr", "verify", "repair", "inspect", "tree", "unpack"], NavButtons(window).Select(AutomationProperties.GetName)); - Assert.True(Nav(window, "batch").IsChecked); - Assert.Equal("Batch Convert", PageTitle(window)); + Assert.True(Nav(window, "pack_exfat").IsChecked); + Assert.Equal("Pack exFAT", PageTitle(window)); + Assert.True(Shows(window, "Pack FFPFSC")); Assert.True(Shows(window, "BUILD")); Assert.Equal("MkPFS.CSharp", window.Title); Assert.True(Shows(window, "MkPFS.CSharp")); @@ -56,7 +57,7 @@ public void Packing_pages_link_to_the_apr_emu_downloads() { (MainWindow window, _) = Open(); - foreach (string page in new[] { "batch", "pack_folder", "pack_exfat" }) + foreach (string page in new[] { "pack_exfat" }) { Nav(window, page).IsChecked = true; Dispatcher.UIThread.RunJobs(); @@ -75,7 +76,7 @@ public void Run_sits_between_the_options_and_the_log_and_advanced_starts_collaps { (MainWindow window, _) = Open(); - foreach (string page in new[] { "batch", "pack_folder", "pack_exfat", "pack_file" }) + foreach (string page in new[] { "pack_exfat", "pack_file" }) { Nav(window, page).IsChecked = true; Dispatcher.UIThread.RunJobs(); @@ -163,7 +164,7 @@ public void Clicking_an_entry_shows_its_page() Assert.Equal("Repair", PageTitle(window)); Assert.Same(model.Items.Single(i => i.Key == "repair").Page, model.Current); Assert.Equal(["repair"], model.Items.Where(i => i.IsSelected).Select(i => i.Key)); - Assert.False(Nav(window, "batch").IsChecked); + Assert.False(Nav(window, "pack_exfat").IsChecked); window.Close(); } @@ -177,9 +178,9 @@ public void Language_picker_relabels_the_window() picker.SelectedIndex = 1; Dispatcher.UIThread.RunJobs(); - Assert.Equal("Converter em Lote", PageTitle(window)); + Assert.Equal("Empacotar EXFAT", PageTitle(window)); Assert.True(Shows(window, "CONSTRUIR")); - Assert.True(Shows(window, "Empacotar Pasta")); + Assert.True(Shows(window, "Empacotar FFPFSC")); Assert.True(Shows(window, "Executar")); } finally @@ -195,13 +196,10 @@ public async Task Every_page_renders() using TempDir dir = new(); string game = BuildPanelTests.Game(dir, "games/PPSA01234-app"); (MainWindow window, MainWindowViewModel model) = Open(); - PackFolderPanelViewModel folder = (PackFolderPanelViewModel)model.Items.Single(i => i.Key == "pack_folder").Page; - folder.Metadata.Debounce = TimeSpan.Zero; - folder.Source = game; - await folder.Metadata.LoadAsync(game); - BatchPanelViewModel batch = (BatchPanelViewModel)model.Items.Single(i => i.Key == "batch").Page; - batch.Source = Path.GetDirectoryName(game)!; - await batch.Queue.LoadAsync(batch.Source); + PackExfatPanelViewModel exfat = (PackExfatPanelViewModel)model.Items.Single(i => i.Key == "pack_exfat").Page; + exfat.Metadata.Debounce = TimeSpan.Zero; + exfat.Source = game; + await exfat.Metadata.LoadAsync(game); window.Height = 1500; // whole pages in the snapshots RepairPanelViewModel repair = (RepairPanelViewModel)model.Items.Single(i => i.Key == "repair").Page; repair.Image = RepairPanelTests.RiskyImage(dir); diff --git a/tests/MkPFS.Gui.Tests/PreviewTests.cs b/tests/MkPFS.Gui.Tests/PreviewTests.cs index 2fbc6bb..81b6828 100644 --- a/tests/MkPFS.Gui.Tests/PreviewTests.cs +++ b/tests/MkPFS.Gui.Tests/PreviewTests.cs @@ -44,42 +44,4 @@ public async Task A_newer_path_supersedes_a_pending_one() Assert.Equal("-", preview.TitleId); Assert.Equal("-", preview.Title); } - - [AvaloniaFact] - public async Task Batch_queue_lists_folders_and_images_with_metadata() - { - using TempDir dir = new(); - string games = Path.Combine(dir.Path, "games"); - string game = BuildPanelTests.Game(dir, "games/PPSA01234-app"); - Assert.Equal(0, MkPFSCli.Run(["pack", "exfat", game, Path.Combine(games, "Data.exfat")], new CliContext(TextWriter.Null, TextWriter.Null, false, false, false))); - dir.File("games/readme.txt"); - BatchQueueViewModel queue = new(Brushes.Teal); - - await queue.LoadAsync(games); - - Assert.Equal("2 item(s): 1 folder(s), 1 file(s)", queue.Summary); - Assert.False(queue.IsEmpty); - Assert.Equal(["Astro Test", "Astro Test"], queue.Rows.Select(r => r.Title)); // the exFAT holds the same game - Assert.Equal(["FILE", "FOLDER"], queue.Rows.Select(r => r.Kind)); - BatchQueueRow folder = queue.Rows[1]; - Assert.StartsWith("Title ID: PPSA01234 | Version: 01.004.000 | ", folder.Details, StringComparison.Ordinal); - Assert.Equal("Content ID: UP0001-PPSA01234_00-ASTROBOT00000000", folder.ContentId); - Assert.True(folder.HasCover); - Assert.Null(folder.AprEmu); - } - - [AvaloniaFact] - public async Task Batch_queue_explains_empty_and_invalid_sources() - { - using TempDir dir = new(); - BatchQueueViewModel queue = new(Brushes.Teal); - - await queue.LoadAsync(string.Empty); - Assert.Equal("Select a source folder to preview the batch queue.", queue.Summary); - await queue.LoadAsync(Path.Combine(dir.Path, "missing")); - Assert.Equal("Select an existing folder to preview the batch queue.", queue.Summary); - await queue.LoadAsync(dir.Path); - Assert.Equal("No packable items found.", queue.Summary); - Assert.True(queue.IsEmpty); - } } From f74dd148570815d58cf0dbebf1eb7408662f9de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:26:23 +0000 Subject: [PATCH 25/28] One progress bar for the whole run, a CPU core picker, and option tooltips - Runs with several progress phases (pack file with --verify, verify against a source, repair, ampr game with an exFAT image) now fill one bar across all phases, weighted by how much data each moves, and never move it back; the label names the step, for example "verify (2/3)". - CPU Cores is a dropdown: Auto, which uses every core of the computer, then 1 up to the core count. The CLI default is unchanged. - Every Compression Tuning and Advanced option has a tooltip in all six languages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 6 +- src/MkPFS.Gui/Jobs/JobRunner.cs | 39 ++++++++--- src/MkPFS.Gui/Jobs/ProgressPlan.cs | 69 +++++++++++++++++++ src/MkPFS.Gui/Localization/Strings.resx | 28 +++++++- src/MkPFS.Gui/Localization/Strings_de.resx | 28 +++++++- src/MkPFS.Gui/Localization/Strings_es.resx | 28 +++++++- src/MkPFS.Gui/Localization/Strings_fr.resx | 28 +++++++- src/MkPFS.Gui/Localization/Strings_pt_BR.resx | 28 +++++++- src/MkPFS.Gui/Localization/Strings_ro.resx | 28 +++++++- .../CompressionSettingsViewModel.cs | 45 +++++++++--- .../ViewModels/RepairPanelViewModel.cs | 2 +- src/MkPFS.Gui/Views/CompressionSettings.axaml | 24 ++++--- src/MkPFS.Gui/Views/PFSOptions.axaml | 14 ++-- .../Views/Panels/PackExfatForm.axaml | 4 +- src/MkPFS.Gui/Views/Panels/PackFileForm.axaml | 14 ++-- src/MkPFS.Gui/Views/Panels/TreeForm.axaml | 2 +- src/MkPFS.Gui/Views/Panels/UnpackForm.axaml | 2 +- src/MkPFS.Gui/Views/Panels/VerifyForm.axaml | 4 +- tests/MkPFS.Gui.Tests/BuildPanelTests.cs | 2 +- .../CompressionSettingsTests.cs | 42 ++++++++--- tests/MkPFS.Gui.Tests/JobRunnerTests.cs | 47 +++++++++++++ tests/MkPFS.Gui.Tests/MainWindowTests.cs | 18 +++++ 22 files changed, 433 insertions(+), 69 deletions(-) create mode 100644 src/MkPFS.Gui/Jobs/ProgressPlan.cs diff --git a/README.md b/README.md index d728082..ae6c5b1 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,11 @@ dotnet run --project src/MkPFS.Gui -c Release - Closing the window while a job runs asks first; Stop and Close cancels the job and waits for its cleanup (an in-place repair finishes its rewrite) before the window closes. - Pack FFPFSC has compression presets (Fast, Balanced, Max, Low RAM) and - settings for the zlib level, CPU cores, block size, and when to keep blocks uncompressed. + settings for the zlib level, CPU cores (Auto uses every core of the computer), block size, and when to keep + blocks uncompressed. Hover over a Compression Tuning or Advanced option to see what it does. +- The progress bar covers the whole run: when a run has several steps (pack, then verify and compare), the + bar keeps counting through them instead of starting over, and the label shows the step, for example + `verify (2/3)`. - The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its offset, stored size, and largest back-reference distance. - The AMPR Packs page runs every `ampr` subcommand. Its default action, Build playable game, runs diff --git a/src/MkPFS.Gui/Jobs/JobRunner.cs b/src/MkPFS.Gui/Jobs/JobRunner.cs index 620374b..a28322d 100644 --- a/src/MkPFS.Gui/Jobs/JobRunner.cs +++ b/src/MkPFS.Gui/Jobs/JobRunner.cs @@ -101,6 +101,8 @@ public sealed partial class JobRunner : ObservableObject private string _label = string.Empty; private long _done; private long _total; + private IReadOnlyList? _plan; + private double _overall; /// Create a runner that applies updates on the UI thread. public JobRunner() @@ -122,7 +124,7 @@ public JobRunner(Action post) [ObservableProperty] public partial bool IsRunning { get; private set; } - /// Progress of the current phase, 0 to 1. + /// Progress of the whole run, 0 to 1 (of the current phase for a run with one phase). [ObservableProperty] public partial double Progress { get; private set; } @@ -140,11 +142,13 @@ public JobRunner(Action post) /// Run mkpfs in-process with , echoing the command line first. /// CLI arguments. /// Outcome. - public Task RunCliAsync(IReadOnlyList args) => RunAsync(job => - { - Echo(args); - return MkPFSCli.Run([.. args], job.CreateCliContext()); - }); + public Task RunCliAsync(IReadOnlyList args) => RunAsync( + job => + { + Echo(args); + return MkPFSCli.Run([.. args], job.CreateCliContext()); + }, + ProgressPlan.For(args)); /// Log the equivalent mkpfs command line (callable from the job thread). /// CLI arguments. @@ -152,9 +156,11 @@ public Task RunCliAsync(IReadOnlyList args) => RunAsync(job /// Run on a background thread; its return value is the exit code. /// Job body. + /// The phases the job reports, so the bar covers the whole run (); + /// shows the current phase. /// Outcome, once the final lines are in . /// A job is already running. - public async Task RunAsync(Func work) + public async Task RunAsync(Func work, IReadOnlyList? plan = null) { if (IsRunning) { @@ -165,8 +171,14 @@ public async Task RunAsync(Func work) _cancel = cancel; TaskCompletionSource idle = new(TaskCreationOptions.RunContinuationsAsynchronously); _idle = idle; - _phase = string.Empty; - _label = string.Empty; + lock (_gate) + { + _phase = string.Empty; + _label = string.Empty; + _plan = plan; + _overall = 0; + } + IsRunning = true; IsIndeterminate = true; Progress = 0; @@ -307,7 +319,7 @@ private void Flush() progressDirty = _progressDirty; _progressDirty = false; _flushQueued = false; - ratio = _total > 0 ? Math.Clamp((double)_done / _total, 0, 1) : 0; + ratio = _plan is null ? (_total > 0 ? Math.Clamp((double)_done / _total, 0, 1) : 0) : _overall; label = _label; } @@ -342,6 +354,13 @@ private void OnStep(string phase, long done, long total) _label = phase; _done = done; _total = total; + if (_plan is { } plan && ProgressPlan.Index(plan, phase) is int index and >= 0) + { + // One bar for the whole run: it never moves back when the next phase starts at 0%. + _overall = Math.Max(_overall, ProgressPlan.Overall(plan, phase, total > 0 ? (double)done / total : 0) ?? _overall); + _label = $"{phase} ({index + 1}/{plan.Count})"; + } + _progressDirty = true; QueueFlush(); } diff --git a/src/MkPFS.Gui/Jobs/ProgressPlan.cs b/src/MkPFS.Gui/Jobs/ProgressPlan.cs new file mode 100644 index 0000000..63b4dc0 --- /dev/null +++ b/src/MkPFS.Gui/Jobs/ProgressPlan.cs @@ -0,0 +1,69 @@ +namespace MkPFS.Gui.Jobs; + +/// One phase of a run and its share of the progress bar. +/// Phase name as the command reports it (compress, verify, ...). +/// Relative share of the whole run. +public readonly record struct ProgressPhase(string Name, double Weight); + +/// +/// The phases a command reports, in order, so one bar can show the progress of the whole run instead of starting +/// over at every phase. Weights follow how much data each phase moves. +/// +public static class ProgressPlan +{ + /// The phases mkpfs reports for . + /// CLI arguments. + /// Phases in order, or when the command reports one phase or none. + public static IReadOnlyList? For(IReadOnlyList args) + { + bool Has(string flag) => args.Contains(flag); + string command = args.Count > 0 ? args[0] : string.Empty; + string sub = args.Count > 1 ? args[1] : string.Empty; + List phases = (command, sub) switch + { + // Compressing reads and deflates the image; verifying decodes it, comparing reads both again. + ("pack", "file") when Has("--verify") => [new("compress", 3), new("verify", 1), new("compare", 1)], + ("verify", _) when Has("--source-file") || Has("--source-dir") => [new("verify", 1), new("compare", 1)], + ("repair", _) when !Has("--scan") => [new("scan", 1), new("repair", 1), new("verify", 1)], + // Packing compresses every packed byte with LZ4 HC; the image only copies the output. + ("ampr", "game") when Has("--exfat") => [new("pack", 4), new("exfat", 1)], + _ => [], + }; + return phases.Count > 1 ? phases : null; + } + + /// Overall progress, 0 to 1, when is done. + /// Phases of the run. + /// Current phase. + /// Progress of the current phase, 0 to 1. + /// Overall progress, or for a phase outside the plan. + public static double? Overall(IReadOnlyList plan, string phase, double ratio) + { + int index = Index(plan, phase); + if (index < 0) + { + return null; + } + + double total = plan.Sum(p => p.Weight); + double before = plan.Take(index).Sum(p => p.Weight); + return total > 0 ? Math.Clamp((before + (plan[index].Weight * Math.Clamp(ratio, 0, 1))) / total, 0, 1) : 0; + } + + /// Position of in , or -1. + /// Phases. + /// Phase name. + /// Index. + public static int Index(IReadOnlyList plan, string phase) + { + for (int i = 0; i < plan.Count; i++) + { + if (plan[i].Name == phase) + { + return i; + } + } + + return -1; + } +} diff --git a/src/MkPFS.Gui/Localization/Strings.resx b/src/MkPFS.Gui/Localization/Strings.resx index 32e66ac..0f620db 100644 --- a/src/MkPFS.Gui/Localization/Strings.resx +++ b/src/MkPFS.Gui/Localization/Strings.resx @@ -98,7 +98,8 @@ Partly written output is removed; an in-place repair finishes its rewrite first Block Size (PS5 games use 64 KiB) Auto (64 KiB) Auto-fit (least padding) - CPU Cores (0 = auto: {0}) + CPU Cores + Auto ({0} cores) ✗ Store Files Smaller Than must be a whole number of bytes. Compression Level (0–9) Max Compressed Size (% of original) @@ -253,4 +254,29 @@ Partly written output is removed; an in-place repair finishes its rewrite first Validate image structure, inodes, and payload checksums Verify v{0} + Quick settings for the compression level and CPU cores. Changing either value selects the matching preset, or Custom. + zlib level for each block: 1 is the fastest, 9 gives the smallest image, 0 stores the blocks uncompressed. The default is 7. + How many CPU cores compress blocks at the same time. Auto uses every core of this computer; choose fewer to keep the computer responsive or to use less memory. + Size of the blocks the image is split into and compressed one by one. PS5 games use 64 KiB; change it only if you know the target needs another size. + A compressed block is kept only if it saves at least this percentage; otherwise it is stored uncompressed. 0 keeps every block that gets smaller. + A file stays uncompressed when its compressed size would be above this percentage of the original. 100 compresses every file that gets smaller. + Files smaller than this many bytes are stored uncompressed without trying. Empty uses the block size. + Store executables and modules (eboot.bin, .prx, .sprx) uncompressed. + Build a signed PFS image, using a zero EKPFS key and seed. + Check the source and show what would be written, without writing the image. + After packing, decode the whole image and compare it with the source. Slower, but proves the image is correct. + Skip the quick structure check that runs after packing. + Use the output file name exactly as typed. Otherwise the extension is changed to match the format (.ffpfsc or .ffpfs). + Keep the source file's name inside the image. Otherwise it is renamed to a safe, normalized name. + Build through a temporary staging file instead of writing straight into the image. Slower; use it only if the default way fails. + PFS format profile: PS5 for PS5 games, PS4 for PS4 images. + Width of the inode entries: 32-bit, the default, or 64-bit. + Make file names inside the image case-sensitive. By default they are not. + Write more detail to the log. + Encrypt the image blocks with AES-XTS. + Derive the encryption keys with the alternate newCrypt method. + 64 hexadecimal characters: the EKPFS key for encrypting or signing. Empty uses all zeros. + exFAT cluster size. Auto (64 KiB) works best with ShadowMountPlus; smaller clusters waste less space on small files but read slower. + How to read the image: Auto detects PFS or exFAT; choose one to force it. + Warn when sce_sys/param.json, eboot.bin or pfs-version.dat is missing from the game. diff --git a/src/MkPFS.Gui/Localization/Strings_de.resx b/src/MkPFS.Gui/Localization/Strings_de.resx index 7f6ea66..687b010 100644 --- a/src/MkPFS.Gui/Localization/Strings_de.resx +++ b/src/MkPFS.Gui/Localization/Strings_de.resx @@ -98,7 +98,8 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Blockgröße (PS5-Spiele nutzen 64 KiB) Auto (64 KiB) Auto-Anpassung (wenigste Auffüllung) - CPU-Kerne (0 = auto: {0}) + CPU-Kerne + Auto ({0} Kerne) ✗ „Dateien kleiner als speichern“ muss eine ganze Zahl von Bytes sein. Komprimierungsstufe (0–9) Max. komprimierte Größe (% des Originals) @@ -253,4 +254,29 @@ Teilweise geschriebene Ausgaben werden entfernt; eine Reparatur vor Ort beendet Image-Struktur, Inodes und Prüfsummen der Inhalte validieren Prüfen v{0} + Schnelleinstellungen für Kompressionsstufe und CPU-Kerne. Wenn du einen der Werte änderst, wird die passende Vorgabe gewählt oder Benutzerdefiniert. + zlib-Stufe für jeden Block: 1 ist am schnellsten, 9 ergibt das kleinste Image, 0 speichert die Blöcke unkomprimiert. Standard ist 7. + Wie viele CPU-Kerne gleichzeitig Blöcke komprimieren. Auto nutzt alle Kerne dieses Computers; wähle weniger, damit der Computer reaktionsfähig bleibt oder weniger Speicher braucht. + Größe der Blöcke, in die das Image geteilt und einzeln komprimiert wird. PS5-Spiele nutzen 64 KiB; ändere sie nur, wenn das Ziel eine andere Größe braucht. + Ein komprimierter Block wird nur behalten, wenn er mindestens diesen Prozentsatz spart; sonst wird er unkomprimiert gespeichert. 0 behält jeden Block, der kleiner wird. + Eine Datei bleibt unkomprimiert, wenn ihre komprimierte Größe über diesem Prozentsatz des Originals läge. 100 komprimiert jede Datei, die kleiner wird. + Dateien unter dieser Bytezahl werden ohne Versuch unkomprimiert gespeichert. Leer bedeutet die Blockgröße. + Ausführbare Dateien und Module (eboot.bin, .prx, .sprx) unkomprimiert speichern. + Ein signiertes PFS-Image erstellen, mit EKPFS-Schlüssel und Seed aus Nullen. + Die Quelle prüfen und zeigen, was geschrieben würde, ohne das Image zu schreiben. + Nach dem Packen das ganze Image dekodieren und mit der Quelle vergleichen. Langsamer, beweist aber, dass das Image korrekt ist. + Die schnelle Strukturprüfung nach dem Packen überspringen. + Den Ausgabenamen genau wie eingegeben verwenden. Sonst wird die Endung an das Format angepasst (.ffpfsc oder .ffpfs). + Den Namen der Quelldatei im Image behalten. Sonst wird sie in einen sicheren, normalisierten Namen umbenannt. + Über eine temporäre Zwischendatei bauen statt direkt ins Image zu schreiben. Langsamer; nur verwenden, wenn der Standardweg scheitert. + PFS-Formatprofil: PS5 für PS5-Spiele, PS4 für PS4-Images. + Breite der Inode-Einträge: 32 Bit (Standard) oder 64 Bit. + Dateinamen im Image zwischen Groß- und Kleinschreibung unterscheiden lassen. Standardmäßig nicht. + Mehr Details ins Protokoll schreiben. + Die Image-Blöcke mit AES-XTS verschlüsseln. + Die Verschlüsselungsschlüssel mit der alternativen newCrypt-Methode ableiten. + 64 Hexadezimalzeichen: der EKPFS-Schlüssel zum Verschlüsseln oder Signieren. Leer bedeutet nur Nullen. + exFAT-Clustergröße. Auto (64 KiB) funktioniert am besten mit ShadowMountPlus; kleinere Cluster verschwenden weniger Platz bei kleinen Dateien, lesen aber langsamer. + Wie das Image gelesen wird: Auto erkennt PFS oder exFAT; wähle eines, um es zu erzwingen. + Warnen, wenn sce_sys/param.json, eboot.bin oder pfs-version.dat im Spiel fehlt. diff --git a/src/MkPFS.Gui/Localization/Strings_es.resx b/src/MkPFS.Gui/Localization/Strings_es.resx index e19c3d6..8beadcb 100644 --- a/src/MkPFS.Gui/Localization/Strings_es.resx +++ b/src/MkPFS.Gui/Localization/Strings_es.resx @@ -98,7 +98,8 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Tamaño de Bloque (los juegos de PS5 usan 64 KiB) Automático (64 KiB) Ajuste automático (menos relleno) - Núcleos de CPU (0 = auto: {0}) + Núcleos de CPU + Automático ({0} núcleos) ✗ Almacenar Archivos Menores Que debe ser un número entero de bytes. Nivel de Compresión (0–9) Tamaño Comprimido Máx. (% del original) @@ -253,4 +254,29 @@ La salida escrita a medias se elimina; una reparación in situ termina su reescr Validar estructura de imagen, inodes y checksums de payload Verificar v{0} + Ajustes rápidos del nivel de compresión y de los núcleos de CPU. Al cambiar cualquiera de los dos se elige el ajuste que coincide, o Personalizado. + Nivel zlib de cada bloque: 1 es el más rápido, 9 da la imagen más pequeña y 0 guarda los bloques sin comprimir. El valor por defecto es 7. + Cuántos núcleos de CPU comprimen bloques a la vez. Automático usa todos los núcleos de este equipo; elige menos para que el equipo siga respondiendo o para usar menos memoria. + Tamaño de los bloques en que se divide la imagen y que se comprimen uno a uno. Los juegos de PS5 usan 64 KiB; cámbialo solo si sabes que el destino necesita otro tamaño. + Un bloque comprimido solo se conserva si ahorra al menos este porcentaje; si no, se guarda sin comprimir. 0 conserva todo bloque que se reduzca. + Un archivo queda sin comprimir si su tamaño comprimido superaría este porcentaje del original. 100 comprime todo archivo que se reduzca. + Los archivos con menos bytes que este valor se guardan sin comprimir, sin intentarlo. Vacío usa el tamaño de bloque. + Guardar sin comprimir los ejecutables y módulos (eboot.bin, .prx, .sprx). + Crear una imagen PFS firmada, con clave EKPFS y semilla a cero. + Revisar el origen y mostrar lo que se escribiría, sin escribir la imagen. + Tras empaquetar, decodificar toda la imagen y compararla con el origen. Más lento, pero demuestra que la imagen es correcta. + Omitir la comprobación rápida de estructura que se hace tras empaquetar. + Usar el nombre de salida tal como se escribió. Si no, la extensión se ajusta al formato (.ffpfsc o .ffpfs). + Conservar dentro de la imagen el nombre del archivo de origen. Si no, se cambia por un nombre seguro y normalizado. + Crear a través de un archivo temporal intermedio en lugar de escribir directamente en la imagen. Más lento; úsalo solo si el modo normal falla. + Perfil del formato PFS: PS5 para juegos de PS5, PS4 para imágenes de PS4. + Ancho de las entradas de inodo: 32 bits (por defecto) o 64 bits. + Hacer que los nombres de archivo de la imagen distingan mayúsculas y minúsculas. Por defecto no lo hacen. + Escribir más detalles en el registro. + Cifrar los bloques de la imagen con AES-XTS. + Derivar las claves de cifrado con el método alternativo newCrypt. + 64 caracteres hexadecimales: la clave EKPFS para cifrar o firmar. Vacío usa todo ceros. + Tamaño de clúster exFAT. Automático (64 KiB) funciona mejor con ShadowMountPlus; los clústeres más pequeños desperdician menos espacio en archivos pequeños, pero leen más lento. + Cómo leer la imagen: Automático detecta PFS o exFAT; elige uno para forzarlo. + Avisar si al juego le falta sce_sys/param.json, eboot.bin o pfs-version.dat. diff --git a/src/MkPFS.Gui/Localization/Strings_fr.resx b/src/MkPFS.Gui/Localization/Strings_fr.resx index 077f3e8..c94070e 100644 --- a/src/MkPFS.Gui/Localization/Strings_fr.resx +++ b/src/MkPFS.Gui/Localization/Strings_fr.resx @@ -98,7 +98,8 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Taille de bloc (les jeux PS5 utilisent 64 Kio) Auto (64 Kio) Ajustement auto (moins de remplissage) - Cœurs CPU (0 = auto : {0}) + Cœurs CPU + Auto ({0} cœurs) ✗ « Stocker les fichiers plus petits que » doit être un nombre entier d’octets. Niveau de compression (0–9) Taille compressée max. (% de l'original) @@ -253,4 +254,29 @@ La sortie partiellement écrite est supprimée ; une réparation sur place termi Valider la structure de l'image, les inodes et les sommes de contrôle Vérifier v{0} + Réglages rapides du niveau de compression et des cœurs CPU. Modifier l’une des valeurs sélectionne le préréglage correspondant, ou Personnalisé. + Niveau zlib de chaque bloc : 1 est le plus rapide, 9 donne l’image la plus petite, 0 stocke les blocs sans compression. La valeur par défaut est 7. + Nombre de cœurs CPU qui compressent des blocs en même temps. Auto utilise tous les cœurs de cet ordinateur ; choisissez-en moins pour garder l’ordinateur réactif ou utiliser moins de mémoire. + Taille des blocs en lesquels l’image est découpée et compressée un à un. Les jeux PS5 utilisent 64 Kio ; ne la changez que si la cible exige une autre taille. + Un bloc compressé n’est gardé que s’il économise au moins ce pourcentage ; sinon il est stocké sans compression. 0 garde tout bloc qui devient plus petit. + Un fichier reste non compressé si sa taille compressée dépasserait ce pourcentage de l’original. 100 compresse tout fichier qui devient plus petit. + Les fichiers de moins de ce nombre d’octets sont stockés sans compression, sans essai. Vide utilise la taille de bloc. + Stocker sans compression les exécutables et modules (eboot.bin, .prx, .sprx). + Créer une image PFS signée, avec une clé EKPFS et une graine à zéro. + Vérifier la source et montrer ce qui serait écrit, sans écrire l’image. + Après l’empaquetage, décoder toute l’image et la comparer à la source. Plus lent, mais prouve que l’image est correcte. + Ignorer la vérification rapide de structure effectuée après l’empaquetage. + Utiliser le nom de sortie tel que saisi. Sinon, l’extension est adaptée au format (.ffpfsc ou .ffpfs). + Garder le nom du fichier source dans l’image. Sinon, il est renommé avec un nom sûr et normalisé. + Construire via un fichier temporaire intermédiaire au lieu d’écrire directement dans l’image. Plus lent ; à n’utiliser que si la méthode par défaut échoue. + Profil du format PFS : PS5 pour les jeux PS5, PS4 pour les images PS4. + Largeur des entrées d’inode : 32 bits (par défaut) ou 64 bits. + Rendre les noms de fichiers de l’image sensibles à la casse. Par défaut, ils ne le sont pas. + Écrire plus de détails dans le journal. + Chiffrer les blocs de l’image avec AES-XTS. + Dériver les clés de chiffrement avec la méthode alternative newCrypt. + 64 caractères hexadécimaux : la clé EKPFS pour chiffrer ou signer. Vide utilise uniquement des zéros. + Taille de cluster exFAT. Auto (64 Kio) fonctionne le mieux avec ShadowMountPlus ; des clusters plus petits gaspillent moins de place pour les petits fichiers mais lisent plus lentement. + Comment lire l’image : Auto détecte PFS ou exFAT ; choisissez-en un pour le forcer. + Avertir s’il manque sce_sys/param.json, eboot.bin ou pfs-version.dat dans le jeu. diff --git a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx index 3eb9ecf..aac7a65 100644 --- a/src/MkPFS.Gui/Localization/Strings_pt_BR.resx +++ b/src/MkPFS.Gui/Localization/Strings_pt_BR.resx @@ -98,7 +98,8 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Tamanho do Bloco (jogos de PS5 usam 64 KiB) Automático (64 KiB) Ajuste automático (menos preenchimento) - Núcleos de CPU (0 = auto: {0}) + Núcleos de CPU + Automático ({0} núcleos) ✗ Armazenar Arquivos Menores Que deve ser um número inteiro de bytes. Nível de Compressão (0–9) Tamanho Comprimido Máx. (% do original) @@ -253,4 +254,29 @@ A saída escrita pela metade é removida; um reparo no local termina sua regrava Validar estrutura da imagem, inodes e checksums Verificar v{0} + Ajustes rápidos do nível de compressão e dos núcleos de CPU. Alterar qualquer um dos valores seleciona a predefinição correspondente, ou Personalizado. + Nível zlib de cada bloco: 1 é o mais rápido, 9 gera a menor imagem e 0 guarda os blocos sem compressão. O padrão é 7. + Quantos núcleos de CPU comprimem blocos ao mesmo tempo. Automático usa todos os núcleos deste computador; escolha menos para manter o computador responsivo ou usar menos memória. + Tamanho dos blocos em que a imagem é dividida e comprimida um a um. Jogos de PS5 usam 64 KiB; altere apenas se souber que o destino precisa de outro tamanho. + Um bloco comprimido só é mantido se economizar pelo menos esta porcentagem; caso contrário, é guardado sem compressão. 0 mantém todo bloco que ficar menor. + Um arquivo fica sem compressão quando o tamanho comprimido ultrapassaria esta porcentagem do original. 100 comprime todo arquivo que ficar menor. + Arquivos menores que este número de bytes são guardados sem compressão, sem tentar. Vazio usa o tamanho do bloco. + Guardar sem compressão os executáveis e módulos (eboot.bin, .prx, .sprx). + Criar uma imagem PFS assinada, com chave EKPFS e semente zeradas. + Verificar a origem e mostrar o que seria gravado, sem gravar a imagem. + Depois de empacotar, decodificar a imagem inteira e compará-la com a origem. Mais lento, mas comprova que a imagem está correta. + Pular a verificação rápida de estrutura feita após empacotar. + Usar o nome de saída exatamente como digitado. Caso contrário, a extensão é ajustada ao formato (.ffpfsc ou .ffpfs). + Manter dentro da imagem o nome do arquivo de origem. Caso contrário, ele é renomeado para um nome seguro e normalizado. + Criar por meio de um arquivo temporário intermediário em vez de gravar direto na imagem. Mais lento; use apenas se o modo padrão falhar. + Perfil do formato PFS: PS5 para jogos de PS5, PS4 para imagens de PS4. + Largura das entradas de inode: 32 bits (padrão) ou 64 bits. + Fazer os nomes de arquivo da imagem diferenciarem maiúsculas e minúsculas. Por padrão, não diferenciam. + Gravar mais detalhes no registro. + Criptografar os blocos da imagem com AES-XTS. + Derivar as chaves de criptografia com o método alternativo newCrypt. + 64 caracteres hexadecimais: a chave EKPFS para criptografar ou assinar. Vazio usa somente zeros. + Tamanho do cluster exFAT. Automático (64 KiB) funciona melhor com o ShadowMountPlus; clusters menores desperdiçam menos espaço em arquivos pequenos, mas leem mais devagar. + Como ler a imagem: Automático detecta PFS ou exFAT; escolha um para forçá-lo. + Avisar quando faltar sce_sys/param.json, eboot.bin ou pfs-version.dat no jogo. diff --git a/src/MkPFS.Gui/Localization/Strings_ro.resx b/src/MkPFS.Gui/Localization/Strings_ro.resx index 0324542..d8cbc1d 100644 --- a/src/MkPFS.Gui/Localization/Strings_ro.resx +++ b/src/MkPFS.Gui/Localization/Strings_ro.resx @@ -98,7 +98,8 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Dimensiune bloc (jocurile PS5 folosesc 64 KiB) Auto (64 KiB) Auto-potrivire (umplere minimă) - Nuclee CPU (0 = auto: {0}) + Nuclee CPU + Automat ({0} nuclee) ✗ „Stochează fișierele mai mici de” trebuie să fie un număr întreg de octeți. Nivel de compresie (0–9) Dimensiune comprimată max. (% din original) @@ -253,4 +254,29 @@ Ieșirea scrisă parțial este ștearsă; o reparare pe loc își termină mai Validează structura imaginii, inode-urile și sumele de control Verificare v{0} + Setări rapide pentru nivelul de compresie și nucleele CPU. Dacă schimbi oricare valoare, se alege presetarea potrivită sau Personalizat. + Nivelul zlib pentru fiecare bloc: 1 este cel mai rapid, 9 dă cea mai mică imagine, 0 păstrează blocurile necomprimate. Implicit este 7. + Câte nuclee CPU comprimă blocuri în același timp. Automat folosește toate nucleele acestui calculator; alege mai puține ca să rămână calculatorul responsiv sau să folosească mai puțină memorie. + Dimensiunea blocurilor în care este împărțită imaginea și comprimată bloc cu bloc. Jocurile PS5 folosesc 64 KiB; schimb-o doar dacă știi că ținta are nevoie de altă dimensiune. + Un bloc comprimat este păstrat doar dacă economisește cel puțin acest procent; altfel este stocat necomprimat. 0 păstrează orice bloc care devine mai mic. + Un fișier rămâne necomprimat dacă dimensiunea comprimată ar depăși acest procent din original. 100 comprimă orice fișier care devine mai mic. + Fișierele mai mici decât acest număr de octeți sunt stocate necomprimate, fără încercare. Gol înseamnă dimensiunea blocului. + Stochează necomprimate executabilele și modulele (eboot.bin, .prx, .sprx). + Creează o imagine PFS semnată, cu cheie EKPFS și seed zero. + Verifică sursa și arată ce s-ar scrie, fără să scrie imaginea. + După împachetare, decodează toată imaginea și o compară cu sursa. Mai lent, dar dovedește că imaginea este corectă. + Sari peste verificarea rapidă a structurii de după împachetare. + Folosește numele de ieșire exact cum a fost scris. Altfel, extensia este potrivită formatului (.ffpfsc sau .ffpfs). + Păstrează în imagine numele fișierului sursă. Altfel, este redenumit cu un nume sigur, normalizat. + Construiește printr-un fișier temporar intermediar în loc să scrie direct în imagine. Mai lent; folosește-l doar dacă modul implicit eșuează. + Profilul formatului PFS: PS5 pentru jocuri PS5, PS4 pentru imagini PS4. + Lățimea intrărilor inode: 32 de biți (implicit) sau 64 de biți. + Numele fișierelor din imagine țin cont de majuscule. Implicit nu țin. + Scrie mai multe detalii în jurnal. + Criptează blocurile imaginii cu AES-XTS. + Derivă cheile de criptare cu metoda alternativă newCrypt. + 64 de caractere hexazecimale: cheia EKPFS pentru criptare sau semnare. Gol înseamnă doar zerouri. + Dimensiunea clusterului exFAT. Automat (64 KiB) merge cel mai bine cu ShadowMountPlus; clusterele mai mici irosesc mai puțin spațiu la fișiere mici, dar citesc mai încet. + Cum se citește imaginea: Automat detectează PFS sau exFAT; alege unul ca să-l forțezi. + Avertizează dacă jocului îi lipsește sce_sys/param.json, eboot.bin sau pfs-version.dat. diff --git a/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs b/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs index d2ee724..6bd6bda 100644 --- a/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs @@ -1,6 +1,5 @@ using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; -using MkPFS.Core.PFSC; using MkPFS.Gui.Localization; namespace MkPFS.Gui.ViewModels; @@ -66,11 +65,19 @@ public CompressionSettingsViewModel(bool allowAutoFit, bool offerSkipExecutables } BlockSizes = sizes; + CpuChoices = [AutoCpuChoice(), .. Enumerable.Range(1, MaxCpu).Select(n => new Choice(n.ToString(CultureInfo.InvariantCulture), null, n.ToString(CultureInfo.InvariantCulture)))]; Preset = Presets[1]; BlockSize = BlockSizes[0]; - Localizer.Instance.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CpuLabel)); + Localizer.Instance.PropertyChanged += (_, _) => + { + // The Auto label carries the core count, so it is rebuilt for the new language. + CpuChoices[0] = AutoCpuChoice(); + OnPropertyChanged(nameof(CpuChoice)); + }; } + private Choice AutoCpuChoice() => new("0", null, Localizer.Instance.Format("ct_cpu_auto", MaxCpu)); + /// Presets in picker order. public IReadOnlyList Presets { get; } @@ -80,11 +87,24 @@ public CompressionSettingsViewModel(bool allowAutoFit, bool offerSkipExecutables /// Show the executables option. public bool OfferSkipExecutables { get; } - /// Most CPU cores the picker allows. - public decimal MaxCpu { get; } = Environment.ProcessorCount; + /// CPU cores of this machine: what Auto uses, and the most the picker offers. + public int MaxCpu { get; } = Environment.ProcessorCount; - /// "CPU Cores (0 = auto: N)". - public string CpuLabel => Localizer.Instance.Format("ct_cpu", PFSCEncoder.ResolveWorkerCount(0)); + /// The CPU core picker: Auto (every core of this machine), then 1 to . + public System.Collections.ObjectModel.ObservableCollection CpuChoices { get; } + + /// The selected entry of , kept in step with . + public Choice CpuChoice + { + get => CpuChoices[Math.Clamp(Whole(CpuCount, 0), 0, MaxCpu)]; + set + { + if (value is not null) + { + CpuCount = int.Parse(value.Value, CultureInfo.InvariantCulture); + } + } + } /// Selected preset; "custom" once a value no longer matches one. [ObservableProperty] @@ -94,7 +114,7 @@ public CompressionSettingsViewModel(bool allowAutoFit, bool offerSkipExecutables [ObservableProperty] public partial decimal? Level { get; set; } = DefaultLevel; - /// --cpu-count (0 = auto). + /// --cpu-count; 0 is Auto, which uses every core of this machine. [ObservableProperty] public partial decimal? CpuCount { get; set; } = 0; @@ -136,7 +156,10 @@ public bool AppendTo(List args, bool compress, out string? error) if (compress) { Add(args, "--compression-level", Whole(Level, DefaultLevel), DefaultLevel); - Add(args, "--cpu-count", Whole(CpuCount, 0), 0); + // The CLI's own default leaves a core free and stops at 16; Auto here means every core. + int cpu = Whole(CpuCount, 0); + args.Add("--cpu-count"); + args.Add((cpu > 0 ? cpu : MaxCpu).ToString(CultureInfo.InvariantCulture)); Add(args, "--threshold-gain", Whole(ThresholdGain, 0), 0); Add(args, "--max-compressed-ratio", Whole(MaxRatio, 100), 100); if (MinCompressSize.Trim().Length > 0) @@ -183,7 +206,11 @@ partial void OnPresetChanged(Choice value) partial void OnLevelChanged(decimal? value) => MatchPreset(); - partial void OnCpuCountChanged(decimal? value) => MatchPreset(); + partial void OnCpuCountChanged(decimal? value) + { + OnPropertyChanged(nameof(CpuChoice)); + MatchPreset(); + } // A hand-edited level or core count selects the preset it equals, or Custom. private void MatchPreset() diff --git a/src/MkPFS.Gui/ViewModels/RepairPanelViewModel.cs b/src/MkPFS.Gui/ViewModels/RepairPanelViewModel.cs index ce39d17..1c7cc1f 100644 --- a/src/MkPFS.Gui/ViewModels/RepairPanelViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/RepairPanelViewModel.cs @@ -125,7 +125,7 @@ protected override async Task ExecuteAsync(IReadOnlyList arg PFSCRepairOptions options = RepairCommand.Options(ctx, scan, badBlocks, recompress, mode, reportDir, cleanSlack, cpuCount: 0, progress: true); int code = RepairCommand.Execute(ctx, image, options, job.Token, r => result = r); return scan && code == RepairCommand.ExitRepairNeeded ? 0 : code; // "repair needed" is a scan result, not a failure - }).ConfigureAwait(true); + }, ProgressPlan.For(args)).ConfigureAwait(true); // After a repair the scanned map is out of date; scan again to see the new state. Selection = null; diff --git a/src/MkPFS.Gui/Views/CompressionSettings.axaml b/src/MkPFS.Gui/Views/CompressionSettings.axaml index 3a5c34f..d8672b2 100644 --- a/src/MkPFS.Gui/Views/CompressionSettings.axaml +++ b/src/MkPFS.Gui/Views/CompressionSettings.axaml @@ -6,7 +6,7 @@ x:DataType="vm:CompressionSettingsViewModel"> - + @@ -14,16 +14,20 @@ - + - - - + + + + + + + - + @@ -31,20 +35,20 @@ - + - + - + - diff --git a/src/MkPFS.Gui/Views/PFSOptions.axaml b/src/MkPFS.Gui/Views/PFSOptions.axaml index d8e39d6..5fb5c7c 100644 --- a/src/MkPFS.Gui/Views/PFSOptions.axaml +++ b/src/MkPFS.Gui/Views/PFSOptions.axaml @@ -6,7 +6,7 @@ x:DataType="vm:PFSOptionsViewModel"> - + @@ -14,7 +14,7 @@ - + @@ -24,13 +24,13 @@ - - - - + + + - + diff --git a/src/MkPFS.Gui/Views/Panels/PackExfatForm.axaml b/src/MkPFS.Gui/Views/Panels/PackExfatForm.axaml index 2d57fe4..f5ea36c 100644 --- a/src/MkPFS.Gui/Views/Panels/PackExfatForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/PackExfatForm.axaml @@ -23,7 +23,7 @@ - + @@ -31,7 +31,7 @@ - + diff --git a/src/MkPFS.Gui/Views/Panels/PackFileForm.axaml b/src/MkPFS.Gui/Views/Panels/PackFileForm.axaml index 3600c2a..17938d8 100644 --- a/src/MkPFS.Gui/Views/Panels/PackFileForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/PackFileForm.axaml @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/src/MkPFS.Gui/Views/Panels/TreeForm.axaml b/src/MkPFS.Gui/Views/Panels/TreeForm.axaml index 096ee94..a9ba853 100644 --- a/src/MkPFS.Gui/Views/Panels/TreeForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/TreeForm.axaml @@ -23,7 +23,7 @@ - + diff --git a/src/MkPFS.Gui/Views/Panels/UnpackForm.axaml b/src/MkPFS.Gui/Views/Panels/UnpackForm.axaml index d9ef484..c58d581 100644 --- a/src/MkPFS.Gui/Views/Panels/UnpackForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/UnpackForm.axaml @@ -31,7 +31,7 @@ - + diff --git a/src/MkPFS.Gui/Views/Panels/VerifyForm.axaml b/src/MkPFS.Gui/Views/Panels/VerifyForm.axaml index 2a1ae27..b9f7afa 100644 --- a/src/MkPFS.Gui/Views/Panels/VerifyForm.axaml +++ b/src/MkPFS.Gui/Views/Panels/VerifyForm.axaml @@ -34,7 +34,7 @@ - + @@ -42,7 +42,7 @@ - + diff --git a/tests/MkPFS.Gui.Tests/BuildPanelTests.cs b/tests/MkPFS.Gui.Tests/BuildPanelTests.cs index 8337fc7..4490629 100644 --- a/tests/MkPFS.Gui.Tests/BuildPanelTests.cs +++ b/tests/MkPFS.Gui.Tests/BuildPanelTests.cs @@ -63,7 +63,7 @@ public void Advanced_options_map_to_cli_flags() file.Signed = file.DryRun = file.UseSpool = file.KeepInnerName = true; file.PFS.InodeBits = file.PFS.InodeWidths[1]; Assert.Equal( - ["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--signed", "--dry-run", "--use-spool", "--no-rename-inner-image", "--inode-bits", "64"], + ["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--signed", "--dry-run", "--use-spool", "--no-rename-inner-image", "--inode-bits", "64", .. CompressionSettingsTests.AutoCpu], Build(file, out _)); } diff --git a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs index d49610c..eb5366a 100644 --- a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs +++ b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs @@ -7,6 +7,9 @@ namespace MkPFS.Gui.Tests; [Collection(GuiCollection.Name)] public sealed class CompressionSettingsTests { + // Auto passes every core of this machine (the CLI's own default leaves one free and stops at 16). + internal static readonly string[] AutoCpu = ["--cpu-count", Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture)]; + private static List Args(CompressionSettingsViewModel settings, bool compress = true) { List args = []; @@ -15,26 +18,43 @@ private static List Args(CompressionSettingsViewModel settings, bool com } [Fact] - public void Defaults_add_no_arguments() + public void Defaults_add_only_the_core_count() { CompressionSettingsViewModel settings = new(allowAutoFit: true, offerSkipExecutables: true); Assert.Equal("balanced", settings.Preset.Value); - Assert.Empty(Args(settings)); + Assert.Equal(AutoCpu, Args(settings)); + } + + [Fact] + public void Cpu_picker_offers_auto_then_every_core() + { + CompressionSettingsViewModel settings = new(allowAutoFit: true, offerSkipExecutables: true); + + Assert.Equal(Environment.ProcessorCount + 1, settings.CpuChoices.Count); + Assert.Equal($"Auto ({Environment.ProcessorCount} cores)", settings.CpuChoices[0].Label); + Assert.Equal("1", settings.CpuChoices[1].Label); + Assert.Same(settings.CpuChoices[0], settings.CpuChoice); + + settings.CpuChoice = settings.CpuChoices[1]; + Assert.Equal(1, settings.CpuCount); + Assert.Equal("low_ram", settings.Preset.Value); + settings.Preset = settings.Presets.Single(p => p.Value == "balanced"); + Assert.Same(settings.CpuChoices[0], settings.CpuChoice); } [Theory] - [InlineData("fast", new[] { "--compression-level", "1" })] - [InlineData("balanced", new string[0])] - [InlineData("max", new[] { "--compression-level", "9" })] - [InlineData("low_ram", new[] { "--cpu-count", "1" })] - public void Presets_set_level_and_cores(string preset, string[] expected) + [InlineData("fast", new[] { "--compression-level", "1" }, true)] + [InlineData("balanced", new string[0], true)] + [InlineData("max", new[] { "--compression-level", "9" }, true)] + [InlineData("low_ram", new[] { "--cpu-count", "1" }, false)] + public void Presets_set_level_and_cores(string preset, string[] expected, bool autoCpu) { CompressionSettingsViewModel settings = new(allowAutoFit: true, offerSkipExecutables: true); settings.Preset = settings.Presets.Single(p => p.Value == preset); - Assert.Equal(expected, Args(settings)); + Assert.Equal(autoCpu ? [.. expected, .. AutoCpu] : expected, Args(settings)); } [Fact] @@ -65,7 +85,7 @@ public void Block_size_and_keep_rules_become_arguments() settings.BlockSize = settings.BlockSizes.Single(b => b.Value == "auto-fit"); Assert.Equal( - ["--threshold-gain", "5", "--max-compressed-ratio", "90", "--min-compress-size", "32768", "--skip-executable-compression", "--block-size", "auto-fit"], + [.. AutoCpu, "--threshold-gain", "5", "--max-compressed-ratio", "90", "--min-compress-size", "32768", "--skip-executable-compression", "--block-size", "auto-fit"], Args(settings)); // Without compression only the block size still applies. @@ -81,7 +101,7 @@ public void Auto_fit_and_the_executables_option_can_be_left_out() Assert.DoesNotContain(batch.BlockSizes, b => b.Value == "auto-fit"); Assert.Equal(["auto", "4096", "8192", "16384", "32768", "65536", "131072", "262144", "524288", "1048576", "2097152"], batch.BlockSizes.Select(b => b.Value)); Assert.Equal("64 KiB", batch.BlockSizes.Single(b => b.Value == "65536").Label); - Assert.Empty(Args(file)); + Assert.Equal(AutoCpu, Args(file)); } [Fact] @@ -99,7 +119,7 @@ public void Pages_append_the_tuning_to_their_arguments() JobRunner sync = new(action => action()); PackFilePanelViewModel file = new(Colors.Cyan, sync) { Source = "D:/a.exfat", Output = "D:/a.ffpfsc" }; file.Compression.Preset = file.Compression.Presets.Single(p => p.Value == "fast"); - Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--compression-level", "1"], file.BuildArguments(out _)); + Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--compression-level", "1", .. AutoCpu], file.BuildArguments(out _)); file.Compression.CpuCount = 2; Assert.Equal(["pack", "file", "D:/a.exfat", "D:/a.ffpfsc", "--compression-level", "1", "--cpu-count", "2"], file.BuildArguments(out _)); diff --git a/tests/MkPFS.Gui.Tests/JobRunnerTests.cs b/tests/MkPFS.Gui.Tests/JobRunnerTests.cs index d5454e4..43c8d74 100644 --- a/tests/MkPFS.Gui.Tests/JobRunnerTests.cs +++ b/tests/MkPFS.Gui.Tests/JobRunnerTests.cs @@ -52,6 +52,53 @@ public async Task Phase_changes_log_the_finished_phase_and_the_bar_ends_full() Assert.False(runner.IsIndeterminate); } + [Fact] + public async Task With_a_plan_one_bar_covers_every_phase_and_never_moves_back() + { + JobRunner runner = Runner(); + List seen = []; + runner.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(JobRunner.Progress)) + { + seen.Add(runner.Progress); + } + }; + List labels = []; + IReadOnlyList plan = ProgressPlan.For(["pack", "file", "a.exfat", "a.ffpfsc", "--verify"])!; + + await runner.RunAsync( + job => + { + job.Progress.Report("compress", 30, 60); + labels.Add(runner.PhaseText); + job.Progress.Report("compress", 60, 60); + job.Progress.Report("verify", 0, 10); // a new phase starts at 0%, the bar does not + labels.Add(runner.PhaseText); + job.Progress.Report("verify", 10, 10); + job.Progress.Report("compare", 5, 10); + return 0; + }, + plan); + + // compress weighs 3 of 5, verify and compare 1 each. + Assert.Equal([0.3, 0.6, 0.8, 0.9, 1], seen.Where(p => p > 0).Select(p => Math.Round(p, 3)).Distinct()); + Assert.Equal(["compress (1/3)", "verify (2/3)"], labels); + } + + [Theory] + [InlineData(new[] { "pack", "file", "a", "b", "--verify" }, "compress,verify,compare")] + [InlineData(new[] { "pack", "file", "a", "b" }, "")] + [InlineData(new[] { "pack", "exfat", "a" }, "")] + [InlineData(new[] { "verify", "a.ffpfsc", "--source-file", "a.exfat" }, "verify,compare")] + [InlineData(new[] { "verify", "a.ffpfsc" }, "")] + [InlineData(new[] { "repair", "a.ffpfsc" }, "scan,repair,verify")] + [InlineData(new[] { "repair", "a.ffpfsc", "--scan" }, "")] + [InlineData(new[] { "ampr", "game", "--root", "g", "--output", "o", "--exfat", "." }, "pack,exfat")] + [InlineData(new[] { "ampr", "game", "--root", "g", "--output", "o" }, "")] + public void Plans_list_the_phases_each_command_reports(string[] args, string phases) => + Assert.Equal(phases, string.Join(',', ProgressPlan.For(args)?.Select(p => p.Name) ?? [])); + [Fact] public async Task Cancel_stops_the_job_at_its_next_progress_report() { diff --git a/tests/MkPFS.Gui.Tests/MainWindowTests.cs b/tests/MkPFS.Gui.Tests/MainWindowTests.cs index 6d2a3b2..be9388f 100644 --- a/tests/MkPFS.Gui.Tests/MainWindowTests.cs +++ b/tests/MkPFS.Gui.Tests/MainWindowTests.cs @@ -153,6 +153,24 @@ public void Closing_without_a_job_does_not_ask() Assert.Empty(window.OwnedWindows); } + [AvaloniaFact] + public void Tuning_and_advanced_options_explain_themselves_on_hover() + { + (MainWindow window, _) = Open(); + Nav(window, "pack_file").IsChecked = true; + Dispatcher.UIThread.RunJobs(); + window.GetVisualDescendants().OfType().Single(e => e.Name == "AdvancedExpander").IsExpanded = true; + Dispatcher.UIThread.RunJobs(); + + string[] tips = [.. window.GetVisualDescendants().OfType().Select(ToolTip.GetTip).OfType()]; + + // 8 compression tuning fields, 7 packing options and 7 PFS options. + Assert.Equal(22, tips.Length); + Assert.All(tips, t => Assert.DoesNotContain("tip_", t, StringComparison.Ordinal)); + Assert.Contains(tips, t => t.StartsWith("How many CPU cores compress blocks", StringComparison.Ordinal)); + window.Close(); + } + [AvaloniaFact] public void Clicking_an_entry_shows_its_page() { From ec82edfeb258905c4acd0896358460850a2ce522 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:29:59 +0000 Subject: [PATCH 26/28] Count physical CPU cores, not logical processors, in the CPU picker Environment.ProcessorCount counts logical processors, twice the cores on a CPU with SMT. The picker and its Auto entry now use the physical core count, read from GetLogicalProcessorInformationEx on Windows, the sysfs topology on Linux and hw.physicalcpu on macOS, falling back to the logical count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- README.md | 2 +- src/MkPFS.Core/Util/CpuTopology.cs | 125 ++++++++++++++++++ .../CompressionSettingsViewModel.cs | 4 +- .../CompressionSettingsTests.cs | 6 +- tests/MkPFS.Tests/Util/CpuTopologyTests.cs | 54 ++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 src/MkPFS.Core/Util/CpuTopology.cs create mode 100644 tests/MkPFS.Tests/Util/CpuTopologyTests.cs diff --git a/README.md b/README.md index ae6c5b1..6e6889e 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ dotnet run --project src/MkPFS.Gui -c Release - Closing the window while a job runs asks first; Stop and Close cancels the job and waits for its cleanup (an in-place repair finishes its rewrite) before the window closes. - Pack FFPFSC has compression presets (Fast, Balanced, Max, Low RAM) and - settings for the zlib level, CPU cores (Auto uses every core of the computer), block size, and when to keep + settings for the zlib level, CPU cores (Auto uses every physical core of the computer, not the logical processors), block size, and when to keep blocks uncompressed. Hover over a Compression Tuning or Advanced option to see what it does. - The progress bar covers the whole run: when a run has several steps (pack, then verify and compare), the bar keeps counting through them instead of starting over, and the label shows the step, for example diff --git a/src/MkPFS.Core/Util/CpuTopology.cs b/src/MkPFS.Core/Util/CpuTopology.cs new file mode 100644 index 0000000..17edce2 --- /dev/null +++ b/src/MkPFS.Core/Util/CpuTopology.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.Runtime.InteropServices; + +namespace MkPFS.Core.Util; + +/// +/// Physical CPU cores of this machine. counts logical processors, which is +/// twice the core count on a CPU with SMT (Hyper-Threading); .NET has no API for cores, so each OS is asked directly. +/// +public static partial class CpuTopology +{ + private static readonly Lazy Cores = new(Detect); + + /// Physical cores, at least 1 and at most ; the logical processor + /// count when the OS does not say. + public static int PhysicalCores => Cores.Value; + + private static int Detect() + { + int logical = Environment.ProcessorCount; + int? cores = null; + try + { + cores = OperatingSystem.IsWindows() ? WindowsCores() + : OperatingSystem.IsLinux() ? LinuxCores("/sys/devices/system/cpu") + : OperatingSystem.IsMacOS() ? MacCores() + : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DllNotFoundException + or EntryPointNotFoundException or FormatException) + { + cores = null; + } + + return cores is > 0 ? Math.Min(cores.Value, logical) : logical; + } + + /// + /// Distinct (package, core) pairs of the online CPUs under + /// (/sys/devices/system/cpu), or when the topology is not exposed. + /// + /// sysfs CPU folder. + /// Core count, or . + internal static int? LinuxCores(string cpuRoot) + { + HashSet<(string Package, string Core)> cores = []; + foreach (string cpu in Directory.EnumerateDirectories(cpuRoot, "cpu*")) + { + string name = Path.GetFileName(cpu); + if (!int.TryParse(name.AsSpan(3), NumberStyles.None, CultureInfo.InvariantCulture, out _)) + { + continue; // cpufreq, cpuidle + } + + string online = Path.Combine(cpu, "online"); + if (File.Exists(online) && File.ReadAllText(online).Trim() == "0") + { + continue; + } + + string core = Path.Combine(cpu, "topology", "core_id"); + string package = Path.Combine(cpu, "topology", "physical_package_id"); + if (!File.Exists(core)) + { + return null; + } + + cores.Add((File.Exists(package) ? File.ReadAllText(package).Trim() : "0", File.ReadAllText(core).Trim())); + } + + return cores.Count > 0 ? cores.Count : null; + } + + // One SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX record per core for RelationProcessorCore. + private static int? WindowsCores() + { + const int relationProcessorCore = 0; + uint length = 0; + GetLogicalProcessorInformationEx(relationProcessorCore, null, ref length); + if (length == 0) + { + return null; + } + + byte[] buffer = new byte[length]; + if (!GetLogicalProcessorInformationEx(relationProcessorCore, buffer, ref length)) + { + return null; + } + + int count = 0; + for (int offset = 0; offset + 8 <= length;) + { + int relationship = BitConverter.ToInt32(buffer, offset); + int size = BitConverter.ToInt32(buffer, offset + 4); + if (size <= 0) + { + break; + } + + if (relationship == relationProcessorCore) + { + count++; + } + + offset += size; + } + + return count; + } + + private static int? MacCores() + { + int value = 0; + nint size = sizeof(int); + return sysctlbyname("hw.physicalcpu", ref value, ref size, 0, 0) == 0 ? value : null; + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetLogicalProcessorInformationEx(int relationshipType, byte[]? buffer, ref uint returnedLength); + + [LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] + private static partial int sysctlbyname(string name, ref int oldValue, ref nint oldLength, nint newValue, nint newLength); +} diff --git a/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs b/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs index 6bd6bda..ea7bbc7 100644 --- a/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs +++ b/src/MkPFS.Gui/ViewModels/CompressionSettingsViewModel.cs @@ -87,8 +87,8 @@ public CompressionSettingsViewModel(bool allowAutoFit, bool offerSkipExecutables /// Show the executables option. public bool OfferSkipExecutables { get; } - /// CPU cores of this machine: what Auto uses, and the most the picker offers. - public int MaxCpu { get; } = Environment.ProcessorCount; + /// Physical CPU cores of this machine (not logical processors): what Auto uses, and the most the picker offers. + public int MaxCpu { get; } = MkPFS.Core.Util.CpuTopology.PhysicalCores; /// The CPU core picker: Auto (every core of this machine), then 1 to . public System.Collections.ObjectModel.ObservableCollection CpuChoices { get; } diff --git a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs index eb5366a..a0596ea 100644 --- a/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs +++ b/tests/MkPFS.Gui.Tests/CompressionSettingsTests.cs @@ -8,7 +8,7 @@ namespace MkPFS.Gui.Tests; public sealed class CompressionSettingsTests { // Auto passes every core of this machine (the CLI's own default leaves one free and stops at 16). - internal static readonly string[] AutoCpu = ["--cpu-count", Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture)]; + internal static readonly string[] AutoCpu = ["--cpu-count", MkPFS.Core.Util.CpuTopology.PhysicalCores.ToString(System.Globalization.CultureInfo.InvariantCulture)]; private static List Args(CompressionSettingsViewModel settings, bool compress = true) { @@ -31,8 +31,8 @@ public void Cpu_picker_offers_auto_then_every_core() { CompressionSettingsViewModel settings = new(allowAutoFit: true, offerSkipExecutables: true); - Assert.Equal(Environment.ProcessorCount + 1, settings.CpuChoices.Count); - Assert.Equal($"Auto ({Environment.ProcessorCount} cores)", settings.CpuChoices[0].Label); + Assert.Equal(MkPFS.Core.Util.CpuTopology.PhysicalCores + 1, settings.CpuChoices.Count); + Assert.Equal($"Auto ({MkPFS.Core.Util.CpuTopology.PhysicalCores} cores)", settings.CpuChoices[0].Label); Assert.Equal("1", settings.CpuChoices[1].Label); Assert.Same(settings.CpuChoices[0], settings.CpuChoice); diff --git a/tests/MkPFS.Tests/Util/CpuTopologyTests.cs b/tests/MkPFS.Tests/Util/CpuTopologyTests.cs new file mode 100644 index 0000000..ac997de --- /dev/null +++ b/tests/MkPFS.Tests/Util/CpuTopologyTests.cs @@ -0,0 +1,54 @@ +using MkPFS.Core.Util; + +namespace MkPFS.Tests.Util; + +public sealed class CpuTopologyTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mkpfs-cpu-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private void Cpu(int index, int package, int core, bool online = true) + { + string topology = Directory.CreateDirectory(Path.Combine(_root, $"cpu{index}", "topology")).FullName; + File.WriteAllText(Path.Combine(topology, "core_id"), $"{core}\n"); + File.WriteAllText(Path.Combine(topology, "physical_package_id"), $"{package}\n"); + if (index > 0) + { + File.WriteAllText(Path.Combine(_root, $"cpu{index}", "online"), online ? "1\n" : "0\n"); + } + } + + [Fact] + public void Linux_counts_cores_not_hardware_threads() + { + // 16 cores with two threads each: 32 logical processors. + for (int thread = 0; thread < 32; thread++) + { + Cpu(thread, package: 0, core: thread % 16); + } + + Directory.CreateDirectory(Path.Combine(_root, "cpufreq")); + Assert.Equal(16, CpuTopology.LinuxCores(_root)); + } + + [Fact] + public void Linux_counts_each_package_and_skips_offline_cpus() + { + Cpu(0, package: 0, core: 0); + Cpu(1, package: 1, core: 0); // same core id on the second socket + Cpu(2, package: 1, core: 1, online: false); + + Assert.Equal(2, CpuTopology.LinuxCores(_root)); + } + + [Fact] + public void Physical_cores_stay_within_the_logical_count() => + Assert.InRange(CpuTopology.PhysicalCores, 1, Environment.ProcessorCount); +} From 0ca34197ae0a61531a7f3b3c3e335c1ab003d921 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:36:53 +0000 Subject: [PATCH 27/28] Rewrite the README and the other Markdown files README: a centered header with badges and a screenshot, a contents list, a features table, a quick start with a diagram of the exFAT -> FFPFSC flow, a "which file should I make" table, desktop app screenshots and page table, command and exit-code tables, long option tables folded away, and diagrams for how AMPR packs are read and how a game gets packed. MIGRATION.md gets an at-a-glance table and a table of fixed Python bugs; the oracle README a summary of the three oracles, the profile check and a fixed line-ending note; the native ORIGIN notes a source table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- MIGRATION.md | 109 ++-- README.md | 846 ++++++++++++------------- assets/screenshots/gui-ampr-packs.png | Bin 0 -> 162073 bytes assets/screenshots/gui-pack-exfat.png | Bin 0 -> 108554 bytes assets/screenshots/gui-pack-ffpfsc.png | Bin 0 -> 149981 bytes assets/screenshots/gui-repair.png | Bin 0 -> 134235 bytes native/lz4/ORIGIN.md | 17 +- native/zlib/ORIGIN.md | 17 +- tools/oracle/README.md | 40 +- 9 files changed, 511 insertions(+), 518 deletions(-) create mode 100644 assets/screenshots/gui-ampr-packs.png create mode 100644 assets/screenshots/gui-pack-exfat.png create mode 100644 assets/screenshots/gui-pack-ffpfsc.png create mode 100644 assets/screenshots/gui-repair.png diff --git a/MIGRATION.md b/MIGRATION.md index 369da23..65e6615 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,67 +1,78 @@ # Moving from Python MkPFS -MkPFS.CSharp is a drop-in replacement for the Python [MkPFS](https://github.com/PSBrew/MkPFS) 1.0.0 command -line. Scripts keep working: the program is still `mkpfs`, the commands and every flag have the same -names, and the console output has the same layout. +MkPFS.CSharp is a **drop-in replacement** for the Python [MkPFS](https://github.com/PSBrew/MkPFS) 1.0.0 +command line. Scripts keep working: the program is still `mkpfs`, every command and flag has the same name +and default, and the console output has the same layout. -## Install +## At a glance -| Python MkPFS | MkPFS.CSharp | -|---|---| -| `pip install mkpfs` or `uv tool install mkpfs` | Unpack `mkpfs--` from the [releases page](https://github.com/danthespal/MkPFS.CSharp/releases) | -| Needs Python 3.9+ with `cryptography` and, on x64 and ARM, `isal` | Single native executable plus `mkpfs_zlib`; no runtime to install | -| `mkpfs-gui` (customtkinter) | `mkpfs-gui--`; on macOS `MkPFS.CSharp.app` | +| | Python MkPFS | MkPFS.CSharp | +|---|---|---| +| **Install** | `pip install mkpfs` or `uv tool install mkpfs` | Unpack `mkpfs--` from the [releases page](https://github.com/danthespal/MkPFS.CSharp/releases) | +| **Needs** | Python 3.9+ with `cryptography`, and `isal` on x64 and ARM | Nothing: one native executable plus `mkpfs_zlib` | +| **Desktop app** | `mkpfs-gui` (customtkinter) | `mkpfs-gui--`; on macOS `MkPFS.CSharp.app` | +| **Compression** | ISA-L by default | Always zlib 1.3.1 | +| **Images** | — | Byte-identical to Python with `--compression-backend zlib` | ## Commands and flags -Every Python command and flag exists with the same name and default: - | Command | Change | |---|---| -| `pack folder`, `pack file`, `pack exfat` | Same flags. `--compression-backend` is accepted but ignored (see below). | +| `pack folder`, `pack file`, `pack exfat` | Same flags. `--compression-backend` is accepted but ignored ([why](#compression-backend)). | | `batch` | Same flags. | | `verify`, `inspect`, `tree`, `unpack` | Same flags. | -| `repair` | New: finds and fixes PFSC blocks the PS5 may decode wrongly. | +| `repair` | **New:** finds and fixes PFSC blocks the PS5 may decode wrongly. | +| `ampr` | **New (experimental):** AMPR Emu LZ4 asset packs. | +| `selftest` | **New:** checks that the bundled native libraries load. | -Exit codes match Python: 0 on success, 1 on errors, 2 on usage errors (missing arguments, unknown -or conflicting options). `repair --scan` adds 3 for "blocks need repair". Usage error messages -are worded differently from Python's argparse. +**Exit codes** match Python: `0` success, `1` error, `2` usage error (missing arguments, unknown or +conflicting options). `repair --scan` adds `3` for "blocks need repair". Usage error messages are worded +differently from Python's argparse. ## Behavior that differs -**Compression backend.** Python's default `auto` backend picks ISA-L, which Python MkPFS installs -on x64 and ARM. ISA-L streams use back-references the PS5 decodes wrongly (corrupted game files -after mounting). MkPFS.CSharp always compresses with zlib 1.3.1 at the same level (7 by default): -`auto` and `zlib` are accepted silently, `isal` and `zlib-ng` print a warning and use zlib. - -**Same images.** With the same source and flags, MkPFS.CSharp writes the same bytes as Python MkPFS -compressing with zlib, except for the build time stored in the image. In Python that takes -`--compression-backend zlib`, plus `--cpu-count 1` for large inputs, because its worker processes -ignore the backend option. Set `SOURCE_DATE_EPOCH` (seconds since 1970) to pin the build time for -reproducible builds. - -**Fixed Python bugs.** These Python results change (details in -[tools/oracle/README.md](tools/oracle/README.md), "Findings"): - -- `--skip-verification` works on its own; Python always rejected it. -- `verify` of signed images larger than 12 blocks no longer crashes. -- `verify --source-dir` reports each missing or extra path once, not twice, and compares an - exFAT-wrapped image with the files inside its exFAT instead of reporting every file missing. -- `unpack` refuses entry names that would escape the output folder. -- exFAT images use the exFAT up-case table for name hashes, so names such as `Straße` stay readable - on the console; `pack exfat` fails if a source file changes size while it is packed. -- `batch` prints the real PS4/PS5 profile, and `batch --verify` checks a folder item against the - files inside its image instead of failing every time. -- Game titles use the `param.json` default language instead of the first language in the file - (often Arabic). - -**Name.** The header line reads `MkPFS.CSharp - https://github.com/danthespal/MkPFS.CSharp`. -Tools that parse the first lines of the output should accept both names. +### Compression backend + +Python's default `auto` backend picks ISA-L, which Python MkPFS installs on x64 and ARM. ISA-L streams use +back-references the PS5 decodes wrongly, which corrupts game files after mounting. MkPFS.CSharp always +compresses with zlib 1.3.1 at the same level (7 by default): `auto` and `zlib` are accepted silently, `isal` +and `zlib-ng` print a warning and use zlib. + +### Same images + +With the same source and flags, MkPFS.CSharp writes the same bytes as Python MkPFS compressing with zlib, +except for the build time stored in the image. In Python that takes `--compression-backend zlib`, plus +`--cpu-count 1` for large inputs, because its worker processes ignore the backend option. Set +`SOURCE_DATE_EPOCH` (seconds since 1970) to pin the build time for reproducible builds. + +### Fixed Python bugs + +These Python results change (details in [tools/oracle/README.md](tools/oracle/README.md), "Findings"): + +| Area | Fix | +|---|---| +| `--skip-verification` | Works on its own; Python always rejected it. | +| `verify` | Signed images larger than 12 blocks no longer crash. | +| `verify --source-dir` | Reports each missing or extra path once, and compares an exFAT-wrapped image with the files inside its exFAT. | +| `unpack` | Refuses entry names that would escape the output folder. | +| exFAT images | Use the exFAT up-case table for name hashes, so names such as `Straße` stay readable on the console; `pack exfat` fails if a source file changes size while it is packed. | +| `batch` | Prints the real PS4/PS5 profile; `batch --verify` checks a folder item against the files inside its image. | +| Game titles | Use the `param.json` default language instead of the first language in the file (often Arabic). | +| `ampr_emu.index` | Hashed the way the console looks paths up, so files with non-ASCII names are found. | + +### Name + +The header line reads `MkPFS.CSharp - https://github.com/danthespal/MkPFS.CSharp`. Tools that parse +the first lines of the output should accept both names. ## New in MkPFS.CSharp -- `mkpfs repair` for single-file `.ffpfsc` images, with `--scan`, `--bad-blocks` (PS5 Game Compressor - `bad_blocks.tsv`), `--recompress`, `--mode`, and `--report-dir`. -- `verify` warns when compressed blocks use back-references the PS5 may decode wrongly and points to - `repair`. -- The GUI has a Repair page with a block map of the image. +- **`mkpfs repair`** for single-file `.ffpfsc` images, with `--scan`, `--bad-blocks` (PS5 Game Compressor + `bad_blocks.tsv`), `--recompress`, `--mode` and `--report-dir`. +- **`verify` warnings** when compressed blocks use back-references the PS5 may decode wrongly, pointing to `repair`. +- **APR Emu:** `pack exfat` and `batch` also write `ampr_emu.index`, and `--ampr-libs` copies AMPR Emu into `fakelib/`. +- **`mkpfs ampr`** (experimental): LZ4 asset packs that AMPR Emu reads on the fly, with rules from a TOML + profile or from traces recorded on the console. +- **Desktop app:** a Repair page with a block map, an AMPR Packs page, tooltips on every tuning option, and a + CPU core picker. The desktop app builds exFAT and FFPFSC images; `pack folder` and `batch` stay on the + command line. diff --git a/README.md b/README.md index 6e6889e..1081d0b 100644 --- a/README.md +++ b/README.md @@ -1,326 +1,402 @@ +
+ # MkPFS.CSharp +**Build, check and repair PS4/PS5 game images from the command line or a desktop app.** + [![CI](https://github.com/danthespal/MkPFS.CSharp/actions/workflows/ci.yml/badge.svg)](https://github.com/danthespal/MkPFS.CSharp/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/danthespal/MkPFS.CSharp?label=release)](https://github.com/danthespal/MkPFS.CSharp/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE.md) +![.NET 10](https://img.shields.io/badge/.NET-10-512BD4) +![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux%20%7C%20macOS-555) -Build, check, and repair PS4/PS5 PFS game images (`.ffpfs`, `.ffpfsc`, `.exfat`) from the command line -or a desktop app. +The MkPFS.CSharp desktop app packing an exFAT image into a .ffpfsc -MkPFS.CSharp is a .NET 10 port of the Python [MkPFS](https://github.com/PSBrew/MkPFS) by PSBrew. It ships -as a native executable (no Python needed), writes the same images as the original, and adds an -offline PFSC block repair ported from PS5 Game Compressor. +
+ +MkPFS.CSharp is a .NET 10 port of the Python [MkPFS](https://github.com/PSBrew/MkPFS) by PSBrew. It is a +single native program (no Python needed), writes **the same images** as the original, and adds an offline +PFSC block repair ported from PS5 Game Compressor and LZ4 asset packs for AMPR Emu. > [!WARNING] > **AMPR packing (`ampr`, the AMPR Packs page) is experimental.** It can shrink a game a lot, but whether > the packed game runs depends on how that game reads its files, and only a test on the PS5 shows that. -> Keep the original game until the packed one has been played. See [`ampr`](#ampr-asset-packs). - -## Features +> Keep the original game until the packed one has been played. See [AMPR asset packs](#ampr-asset-packs). -- **Pack**: a game folder, an exFAT image, or any single file into a compressed `.ffpfsc` (PFSC, zlib), - or a folder into a plain PFS image (`--raw`, with signed, encrypted, 64-bit inode, and PS4 options). -- **exFAT**: build exFAT images from game folders (deterministic, 64 KiB clusters by default). -- **Batch**: pack every game folder and image in a folder in one run. -- **Check**: `verify`, `inspect`, `tree`, and `unpack` for PFS, PFSC, and exFAT images, including - encrypted ones (`--ekpfs-key`). -- **Repair**: find and fix compressed blocks the PS5 may decode wrongly (images made with ISA-L). -- **APR Emu**: copies your AMPR Emu libraries into `fakelib/` of APR titles (`--ampr-libs`) and builds - `ampr_emu.index` when `pack folder`, `pack exfat`, or `batch` packs a game that has them. -- **AMPR packs (experimental)**: `ampr` builds, checks, and extracts AMPR Emu seekable LZ4 asset packs, byte for byte - like ampr_emu's `ampr_pack.py`, and `ampr game` turns a game folder into a smaller one that runs from - them ([how it works](#ampr-asset-packs)). `ampr profile` - turns traces recorded on the console into pack rules, like ampr_emu's `ampr_pack_profile.py`. -- **GUI**: `mkpfs-gui` with pages to build exFAT, FFPFSC and AMPR packs and to check and read images, - cover and metadata preview, and a PFSC block map; English, Português (BR), Español, Română, Deutsch, and Français. +## Contents -## Download +- [Features](#features) +- [Quick start](#quick-start) +- [Download](#download) +- [Desktop app](#desktop-app) +- [Command line](#command-line) +- [APR Emu](#apr-emu) +- [AMPR asset packs](#ampr-asset-packs) +- [Differences from Python MkPFS](#differences-from-python-mkpfs) +- [Build from source](#build-from-source) +- [Credits and license](#credits-and-license) -Get the archive for your system from the -[releases page](https://github.com/danthespal/MkPFS.CSharp/releases) and unpack it anywhere: +## Features -| System | Command line | Desktop app | +| | Feature | What it does | |---|---|---| -| Windows x64 | `mkpfs--win-x64.zip` | `mkpfs-gui--win-x64.zip` | -| Linux x64 | `mkpfs--linux-x64.tar.gz` | `mkpfs-gui--linux-x64.tar.gz` | -| macOS Apple silicon | `mkpfs--osx-arm64.tar.gz` | `mkpfs-gui--osx-arm64.tar.gz` | - -- Keep each program in its folder with the libraries next to it (`mkpfs_zlib`, plus Skia and - HarfBuzz for the desktop app). -- macOS: the desktop app is `MkPFS.CSharp.app`. It is not notarized, so open it the first time with - right-click > Open. -- Linux: the desktop app needs X11 and fontconfig, which desktop distributions include. -- `SHA256SUMS.txt` on the release page lists the archive checksums. -- Check the command line with `mkpfs selftest`. - -## Usage +| 💿 | **exFAT images** | Turns a game folder into an `.exfat` image (deterministic, 64 KiB clusters). | +| 🗜️ | **FFPFSC images** | Compresses an exFAT image, a game folder or any single file into a `.ffpfsc` (PFSC, zlib). | +| 📁 | **Plain PFS** | Packs a folder as a `.ffpfs` (`--raw`), signed, encrypted, 64-bit inodes or PS4. | +| 📚 | **Batch** | Packs every game folder and image in a folder in one run. | +| 🔍 | **Check** | `verify`, `inspect`, `tree` and `unpack` for PFS, PFSC and exFAT, encrypted ones too. | +| 🩹 | **Repair** | Finds and fixes compressed blocks the PS5 may decode wrongly (images made with ISA-L). | +| 🎮 | **APR Emu** | Copies AMPR Emu into a game's `fakelib/` and writes its `ampr_emu.index`. | +| 📦 | **AMPR packs** *(experimental)* | Packs game data into LZ4 asset packs that AMPR Emu reads on the fly. | +| 🖥️ | **Desktop app** | Every command in a window, in English, Português (BR), Español, Română, Deutsch and Français. | + +## Quick start + +ShadowMountPlus works best with an **exFAT image wrapped in a compressed `.ffpfsc`**. The desktop app +follows the same two steps. + +```mermaid +flowchart LR + A["📁 Game folder
PPSA12345-app"] -->|"1. Pack exFAT"| B["💿 PPSA12345.exfat"] + B -->|"2. Pack FFPFSC"| C["🗜️ PPSA12345.ffpfsc"] + C -->|"copy"| D["🎮 PS5
ShadowMountPlus"] +``` -All paths may be absolute or relative to the current directory. Replace values in angle brackets -with your own paths; square brackets indicate an optional argument. Run `mkpfs --help` -for the parser's built-in help. +```bash +mkpfs pack exfat PPSA12345-app PPSA12345.exfat # 1. build the exFAT image +mkpfs pack file PPSA12345.exfat PPSA12345.ffpfsc # 2. compress it +mkpfs verify PPSA12345.ffpfsc --source-file PPSA12345.exfat # optional: check it +``` -### Commands at a glance +**Which file should I make?** -| Command | Arguments | Default result | Purpose | +| Output | Size | Made with | Use it for | |---|---|---|---| -| `pack folder ` | game/homebrew folder, output path | exFAT wrapped in a compressed `.ffpfsc` | Package a game folder. | -| `pack file ` | input file, output path | compressed `.ffpfsc` | Package one file in a PFS container. | -| `pack exfat [output]` | game/homebrew folder, optional output path | `.exfat` beside the source | Build an uncompressed exFAT image. | -| `batch ` | directory of folders/images, destination directory | one `.ffpfsc` per discovered item | Package many inputs; existing outputs are skipped. | -| `verify ` | image path | — | Validate an image, optionally against its source. | -| `inspect ` | image path | text report | Show image metadata and integrity information. | -| `tree ` | folder or image path | outer tree | List files and directories. | -| `unpack ` | image path, destination directory | — | Extract an image. | -| `repair ` | single-file `.ffpfsc` path | repairs risky blocks | Repair PFSC blocks that a PS5 may decode incorrectly. | -| `ampr ` | see [`ampr`](#ampr-asset-packs) | JSON on standard output | Build and manage AMPR Emu LZ4 asset packs. | +| `.exfat` | Same as the game | `pack exfat` | The input of `pack file`, or a mount without compression. | +| `.ffpfsc` | Smaller (zlib) | `pack file`, `pack folder` | The usual image for ShadowMountPlus. | +| `.ffpfs` | Same as the game | `pack folder --raw` | Signed, encrypted or PS4 images. | +| AMPR packs | Much smaller (LZ4) | `ampr game` | Experimental: AMPR titles with a tested profile. | -### Common examples - -Pack a game folder into a `.ffpfsc` (wrapped in exFAT and compressed in one pass): - -```bash -mkpfs pack folder PPSA12345-app PPSA12345.ffpfsc -``` +## Download -Compress an existing exFAT image: +Get the archive for your system from the [releases page](https://github.com/danthespal/MkPFS.CSharp/releases) +and unpack it anywhere. -```bash -mkpfs pack file PPSA12345.exfat PPSA12345.ffpfsc -``` +| System | Command line | Desktop app | +|---|---|---| +| Windows x64 | `mkpfs--win-x64.zip` | `mkpfs-gui--win-x64.zip` | +| Linux x64 | `mkpfs--linux-x64.tar.gz` | `mkpfs-gui--linux-x64.tar.gz` | +| macOS Apple silicon | `mkpfs--osx-arm64.tar.gz` | `mkpfs-gui--osx-arm64.tar.gz` | -Pack a folder directly as PFS (`--signed`, `--encrypted`, `--inode-bits 64`, and `--version PS4` apply -here): +> [!TIP] +> - Keep each program in its folder with the libraries next to it (`mkpfs_zlib`, plus Skia and HarfBuzz for +> the desktop app). +> - **macOS:** the desktop app is `MkPFS.CSharp.app`. It is not notarized, so open it the first time with +> right-click > Open. +> - **Linux:** the desktop app needs X11 and fontconfig, which desktop distributions include. +> - `SHA256SUMS.txt` on the release page lists the checksums. `mkpfs selftest` checks the command line. + +## Desktop app + +`mkpfs-gui` runs the same commands from a window and shows their output and progress. + + + + + + + + + + +
Pack exFAT page
Pack exFAT: game preview and APR Emu
Pack FFPFSC page
Pack FFPFSC: compression tuning
AMPR Packs page
AMPR Packs: build a playable packed game
Repair page with a block map
Repair: block map of risky blocks
+ +| Section | Page | What it does | +|---|---|---| +| **Build** | Pack exFAT | Game folder → `.exfat`, with the APR Emu libraries and index. | +| | Pack FFPFSC | `.exfat` (or any file) → compressed `.ffpfsc`. | +| | AMPR Packs | Every `ampr` command; *Build playable game* is the default. | +| **Check** | Verify | Checks an image, optionally against its source. | +| | Repair | Scans an image, draws a block map and repairs risky blocks. | +| **Read** | Inspect, Tree, Unpack | Shows details, lists files and extracts an image. | + +- **Game preview:** pick a game folder or image to see its cover, title, IDs, version, region and APR Emu marker. +- **Compression tuning:** presets (Fast, Balanced, Max, Low RAM), the zlib level, block size and keep rules. + **CPU Cores** offers *Auto*, which uses every physical core (not the logical processors), or any lower count. +- **Tooltips:** hover over any Compression Tuning or Advanced option to see what it does. +- **One progress bar per run:** a run with several steps (pack, then verify and compare) fills one bar, + and the label names the step, for example `verify (2/3)`. +- **Safe closing:** closing the window during a job asks first; *Stop and Close* waits for the job to clean up. +- `pack folder` and `batch` are available on the command line only. + +From a source checkout: `dotnet run --project src/MkPFS.Gui -c Release`. + +## Command line + +Paths may be absolute or relative. `<...>` is your value, `[...]` is optional, and +`mkpfs --help` shows every option. + +| Command | Result | Purpose | +|---|---|---| +| `pack exfat [output]` | `.exfat` | Build an uncompressed exFAT image. | +| `pack file ` | `.ffpfsc` | Compress one file (usually an `.exfat`) into a PFS image. | +| `pack folder ` | `.ffpfsc` | Wrap a folder in exFAT and compress it in one pass. | +| `batch ` | one `.ffpfsc` per item | Pack many folders and images; existing outputs are skipped. | +| `verify ` | report | Validate an image, optionally against its source. | +| `inspect ` | report | Show metadata and integrity information. | +| `tree ` | file list | List files and folders. | +| `unpack ` | files | Extract an image. | +| `repair ` | repaired image | Fix PFSC blocks the PS5 may decode wrongly. | +| `ampr ` | JSON | Build and manage AMPR Emu LZ4 asset packs ([details](#ampr-asset-packs)). | +| `selftest` | — | Check that the bundled native libraries load. | + +### Examples ```bash -mkpfs pack folder PPSA12345-app PPSA12345.ffpfs --raw +mkpfs pack exfat PPSA12345-app PPSA12345.exfat # game folder -> exFAT +mkpfs pack file PPSA12345.exfat PPSA12345.ffpfsc # exFAT -> compressed image +mkpfs pack folder PPSA12345-app PPSA12345.ffpfsc # both steps in one pass +mkpfs pack folder PPSA12345-app PPSA12345.ffpfs --raw # plain PFS (signed, encrypted, PS4...) +mkpfs batch ./games ./output # every game in a folder +mkpfs verify PPSA12345.ffpfsc --source-file PPSA12345.exfat # check against the source +mkpfs inspect PPSA12345.ffpfsc # details (--format json for scripts) +mkpfs tree PPSA12345.ffpfsc --deep # list files inside the wrapped exFAT +mkpfs unpack PPSA12345.ffpfsc out --deep # extract the game files +mkpfs repair PPSA12345.ffpfsc # fix risky blocks ``` -Build an exFAT image from a game folder: +### Exit codes -```bash -mkpfs pack exfat PPSA12345-app PPSA12345.exfat -``` +| Code | Meaning | +|---|---| +| `0` | Success. | +| `1` | The operation failed. | +| `2` | Invalid command line (missing argument, unknown or conflicting options); `ampr` errors also use 2. | +| `3` | `repair --scan` found blocks that need repair. | -Pack every game folder and image file in a folder (existing outputs are skipped): +### Options of `pack folder` and `pack file` -```bash -mkpfs batch ./games ./output -``` +The defaults are PS5, 32-bit inodes, case-insensitive names, zlib level 7, 64 KiB blocks and compression on. +The output extension is changed to `.ffpfsc` when needed. Single-file and `--raw` builds run a quick +structure check afterwards; the default exFAT-wrapped `pack folder` checks only with `--verify`. -Verify an image against its source: +For the default exFAT-wrapped `pack folder`, compression stays on with 64 KiB blocks. Use `--raw` to make +`--no-compress`, `--block-size`, `--inode-bits`, `--max-compressed-ratio`, `--min-compress-size` and +`--skip-executable-compression` take effect. -```bash -mkpfs verify PPSA12345.ffpfsc --source-file PPSA12345.exfat -``` +
+Compression options -Show image details (`--format json` for scripts): +| Option | Default | Meaning | +|---|---|---| +| `--compress` / `--no-compress` | on | Enable or disable PFSC block compression. | +| `--compression-level <0-9>` | `7` | zlib compression level. | +| `--cpu-count ` | `0` (auto) | Compression workers. Auto uses up to 16 and leaves one logical CPU free. | +| `--block-size ` | `auto` (65536) | Power of two from 4096 to 2097152. `auto-fit` reduces padding (folder packing and the spool builder). | +| `--threshold-gain <0-100>` | `0` | Keep a compressed block only when it saves at least this percentage. | +| `--max-compressed-ratio <0-100>` | `100` | Store a file raw when PFSC would exceed this percentage of its size. | +| `--min-compress-size ` | block size | Store smaller files raw without trying. | +| `--skip-executable-compression` | off | Do not compress important executables. | +| `--compression-backend <...>` | `auto` | Accepted for compatibility; this port always uses zlib 1.3.1. | -```bash -mkpfs inspect PPSA12345.ffpfsc -``` +
-List the files, including inside a wrapped exFAT: +
+Image layout and security options -```bash -mkpfs tree PPSA12345.ffpfsc --deep -``` +| Option | Default | Meaning | +|---|---|---| +| `--version ` | `PS5` | PFS profile version. | +| `--inode-bits <32\|64>` | `32` | PFS inode width. | +| `--case-sensitive` / `--case-insensitive` | insensitive | Name comparison mode. | +| `--signed` | off | Signed PFS with a zero EKPFS key and seed (not with the exFAT-wrapped `pack folder`). | +| `--encrypted` | off | Encrypt blocks with AES-XTS. | +| `--ekpfs-key <64-hex>` | all zeros | EKPFS key; needs `--encrypted`. | -Extract the game files: +
-```bash -mkpfs unpack PPSA12345.ffpfsc out --deep -``` +
+Output, checks and other options -Repair blocks the PS5 may decode wrongly in a single-file `.ffpfsc`: +| Option | Default | Meaning | +|---|---|---| +| `--adjust-output-file-extension` / `--no-adjust-output-file-extension` | adjust | Change the extension to match the pack mode, or keep the name as typed. | +| `--temp-folder ` | system temp | Where staged files are written. | +| `--verify` | off | Full verification after packing instead of the quick structure check. | +| `--verify-structure` / `--no-verify-structure` | on | Turn the quick check on or off. | +| `--skip-verification` | off | Skip every post-pack check; not with `--verify`. | +| `--dry-run` | off | Scan and report the layout without writing. | +| `--verbose` | off | Print per-file decisions. | +| `--raw` | off | `pack folder`: write a plain `.ffpfs` instead of the exFAT-wrapped `.ffpfsc`. | +| `--require-game-files` | off | `pack folder`: refuse to pack without `sce_sys/param.json` and `eboot.bin`. | +| `--use-spool` | off | `pack file`: use the older staged builder instead of writing straight into the image. | +| `--rename-inner-image` / `--no-rename-inner-image` | rename | `pack file`: normalize the file name stored inside, or keep it. | -```bash -mkpfs repair PPSA12345.ffpfsc -``` +`pack folder` also takes the [APR Emu options](#apr-emu). -### `pack folder` and `pack file` +
-Both commands require a source path and an output image path. By default, the output extension is -changed to `.ffpfsc` when necessary. The default build is PS5, 32-bit inodes, case-insensitive, -zlib level 7, 64 KiB blocks, and compression enabled. Direct-PFS folder builds and single-file -builds also run a quick structure verification by default. The default exFAT-wrapped folder flow -runs a post-pack check only when `--verify` is supplied. +### Options of `pack exfat` -For the default exFAT-wrapped `pack folder` flow, compression remains enabled and the PFS block -size remains 64 KiB. Use `--raw` to make options that control direct PFS layout or compression -(`--no-compress`, `--block-size`, `--inode-bits`, `--max-compressed-ratio`, -`--min-compress-size`, and `--skip-executable-compression`) take effect. +If `output` is omitted, the image is `.exfat` next to the source folder; an existing folder as +`output` receives that name. | Option | Default | Meaning | |---|---|---| -| `--adjust-output-file-extension` | on | Change the requested output extension to match the selected pack mode. | -| `--no-adjust-output-file-extension` | off | Keep the output filename exactly as supplied. Cannot be combined with `--adjust-output-file-extension`. | -| `--compress` / `--no-compress` | compression on | Enable or disable PFSC block compression. The two flags are mutually exclusive. | -| `--threshold-gain <0-100>` | `0` | Keep a compressed block only when it saves at least this percentage. | -| `--block-size ` | `auto` (`65536`) | PFS block size; it must be a power of two from 4096 through 2097152. `auto-fit` is supported by folder packing and the spool path to reduce file-data padding. | -| `--temp-folder ` | system temporary directory | Where staged pack artifacts are written. | -| `--version ` | `PS5` | PFS profile version. | -| `--inode-bits <32\|64>` | `32` | PFS inode-width mode. | -| `--case-sensitive` / `--case-insensitive` | case-insensitive | Select the PFS name-comparison mode. The two flags are mutually exclusive. | -| `--cpu-count ` | `0` (automatic) | PFSC compression workers. Automatic mode uses up to 16 workers and leaves one logical CPU free; a nonzero value is clamped to at least one. | -| `--compression-level <0-9>` | `7` | zlib compression level. | -| `--compression-backend ` | `auto` | Compatibility option. This port always uses zlib 1.3.1; unsupported values produce a warning. | -| `--max-compressed-ratio <0-100>` | `100` | Do not use PFSC when its stored size exceeds this percentage of the raw file size. | -| `--min-compress-size ` | resolved block size | Store smaller files raw without attempting PFSC compression. | -| `--skip-executable-compression` | off | Do not compress important executable files. | -| `--signed` | off | Build a signed PFS using a zero EKPFS/seed. It is not supported by the default exFAT-wrapped folder mode. | -| `--encrypted` | off | Encrypt filesystem blocks with AES-XTS. | -| `--ekpfs-key <64-hex>` | all-zero key | EKPFS key for an encrypted image; it requires `--encrypted`. | -| `--verbose` | off | Print per-file decisions. | -| `--dry-run` | off | Scan and report the planned layout without writing an image. | -| `--verify` | off | Run full post-pack verification instead of the default structure-only check. | -| `--verify-structure` / `--no-verify-structure` | structure check on | Explicitly enable or disable the default quick post-pack check. The two flags are mutually exclusive. | -| `--skip-verification` | off | Skip all post-pack verification. It cannot be combined with `--verify`. | +| `--cluster-size ` | `auto` (65536) | exFAT cluster size; 64 KiB suits ShadowMountPlus. | +| `--free-space ` | `0` | Free space inside the image, for example `2GiB`. Without it nothing can write to the mounted image. | +| `--overwrite` | off | Replace an existing image. | +| `--verbose` / `--no-progress` | off | More output / no progress bar. | -`pack folder` also accepts the [APR Emu options](#apr-emu) and the following: +It also takes the [APR Emu options](#apr-emu). -| Option | Default | Meaning | -|---|---|---| -| `--raw` | off | Package the source directly as a PFS `.ffpfs`, rather than making the default exFAT-wrapped `.ffpfsc`. Use this mode for `--signed`, `--inode-bits 64`, and other direct-PFS settings. | -| `--require-game-files` | off | Refuse to pack unless `sce_sys/param.json` and `eboot.bin` are present. | +### Options of `batch` -`pack file` also accepts: +`batch` finds packable folders and image files in `source_dir` and writes one `.ffpfsc` each into +`output_dir`, skipping existing ones. Its compression, profile, naming and encryption options are the same as +`pack` (`auto-fit` block sizes are not accepted). | Option | Default | Meaning | |---|---|---| -| `--use-spool` | off | Force the legacy staged/spool builder instead of direct-to-image streaming. | -| `--rename-inner-image` / `--no-rename-inner-image` | rename on | Normalize the filename stored inside the image (the first flag explicitly selects the default), or preserve the source filename. | +| `--overwrite` | off | Replace existing images. | +| `--dry-run` | off | Report the conversions without writing. | +| `--verify` | off | Fully verify each image. | -### `pack exfat` +Folder items also take the [APR Emu options](#apr-emu). -`mkpfs pack exfat [output]` creates an uncompressed exFAT image. If `output` is omitted, -the program derives `.exfat` beside the source directory; an output directory is also -accepted and receives that derived filename. +### Reading and extracting -| Option | Default | Meaning | -|---|---|---| -| `--cluster-size ` | `auto` (`65536`) | exFAT cluster size. The automatic 64 KiB value is optimized for SMP/LVD. | -| `--free-space ` | `0` | Free space to leave inside the image, for example `2GiB`. Images are otherwise exactly full, so nothing can write to them even on a read-write mount (AMPR Emu's debug log and traces, a game writing to `/app0`). | -| `--overwrite` | off | Replace an existing output image. | -| `--verbose` | off | Print detailed packing output. | -| `--no-progress` | off | Hide the progress bar written to standard error. | +Encrypted images take `--ekpfs-key <64-hex>` (default: all zeros) and `--new-crypt` (alternate key +derivation). `verify`, `tree` and `unpack` take `--format `. -It also accepts the [APR Emu options](#apr-emu). +| Command | Option | Meaning | +|---|---|---| +| `inspect` | `--format ` | Human-readable or JSON report. | +| `tree` | `--deep` | List the files inside a wrapped exFAT. | +| `unpack` | `--deep` | Extract the files inside a wrapped exFAT. | +| | `--only ` | With `--deep`, extract only this file or folder (repeatable). | +| | `--overwrite`, `--no-progress` | Replace existing output; hide progress. | +| `verify` | `--source-dir ` / `--source-file ` | Compare with the source folder or file. | +| | `--expect-crc32 ` | Require this payload CRC32. | +| | `--expect-manifest-sha256 <64-hex>` | Require this manifest SHA-256. | +| | `--require-game-files` | Warn when `sce_sys/param.json`, `eboot.bin` or `pfs-version.dat` is missing. | -### `batch` +### Repair -`mkpfs batch ` discovers packable folders and image files in `source_dir` -and writes `.ffpfsc` images into `output_dir`. It skips existing outputs by default. Its compression, -PFS-profile, naming, and encryption options have the same meanings and defaults as the corresponding -`pack` options: `--compress`/`--no-compress`, `--threshold-gain`, `--block-size` (`auto` = 65536; -`auto-fit` is not accepted), `--version`, `--inode-bits`, `--case-sensitive`/`--case-insensitive`, -`--cpu-count`, `--compression-level`, `--compression-backend`, `--max-compressed-ratio`, -`--min-compress-size`, `--skip-executable-compression`, `--encrypted`, `--ekpfs-key`, and `--verbose`. +`repair` works on an unsigned, unencrypted, single-file `.ffpfsc`. It scans for risky compressed blocks, +stores replacements raw, checks their content and cleans unused bytes in the outer PFS. | Option | Default | Meaning | |---|---|---| -| `--overwrite` | off | Replace images that already exist in the output directory. | -| `--dry-run` | off | Report the conversions without writing images. | -| `--verify` | off | Run full verification for each successful image. | -| `--compress` / `--no-compress` | compression on | Enable or disable compression; these flags are mutually exclusive. | - -Folder items get the [APR Emu options](#apr-emu) too. +| `--scan` | off | Report only; exit code 3 when blocks need repair. | +| `--bad-blocks ` | none | Repair the blocks listed in a PS5 Game Compressor `bad_blocks.tsv`. | +| `--recompress` | off | Re-encode repaired blocks with zlib level 7 instead of storing them raw. | +| `--mode ` | `auto` | `auto` copies when there is 1.2× the image size free, else rewrites in place. An interrupted `in-place` repair can leave the image corrupt. | +| `--report-dir ` | none | Write `summary.json` and `bad_blocks.tsv`. | +| `--no-slack-cleanup` | off | Leave unused outer bytes unchanged. | +| `--cpu-count ` | `0` (all cores) | Workers for scanning and repair. | -### APR Emu +## APR Emu -Some PS5 titles use PlayGo/APR and need Drakmor's APR Emu to run from a mounted image: the emulator -libraries in the game's `fakelib/` folder plus an `ampr_emu.index` that lists every file. MkPFS does -not ship or download the libraries. Download them into one folder and pass it with `--ampr-libs`: +Some PS5 titles use PlayGo/APR and need Drakmor's APR Emu to run from a mounted image: its libraries in the +game's `fakelib/` and an `ampr_emu.index` listing every file. MkPFS does not ship the libraries; download +them into one folder and pass it with `--ampr-libs`. | Library | Download | Required | |---|---|---| -| `libSceAmpr.sprx` | [drakmor/ampr_emu releases](https://github.com/drakmor/ampr_emu/releases) | yes | -| `libScePlayGo.sprx` | [drakmor/pgo_stub releases](https://github.com/drakmor/pgo_stub/releases) | copied when present | +| `libSceAmpr.sprx` | [drakmor/ampr_emu](https://github.com/drakmor/ampr_emu/releases) | yes | +| `libScePlayGo.sprx` | [drakmor/pgo_stub](https://github.com/drakmor/pgo_stub/releases) | copied when present | -Before packing a game folder, `pack folder`, `pack exfat`, and `batch` (folder items): +Before packing a folder, `pack folder`, `pack exfat` and `batch`: 1. With `--ampr-libs`, copy the libraries into `/fakelib/` when the game is an APR title - (`sce_sys/playgo-chunk.dat` exists) or `--ampr-title` is given. Identical files are left alone; - changed ones are replaced. -2. When `fakelib/libSceAmpr.sprx` or `fakelib2/libSceAmpr.sprx` exists, write `ampr_emu.index` into the - game folder. Paths are hashed and sorted the way the emulator looks them up (only ASCII letters fold - case), so names with accents resolve on the console. - -ShadowMountPlus mounts `fakelib2/` instead of `fakelib/` when both exist, so `--ampr-libs` warns and leaves -a `fakelib2/` folder for you to update. - -Both steps change the source folder, so the image includes them. An APR title without -`fakelib/libSceAmpr.sprx` and without `--ampr-libs` gets a warning and no index. - -A folder that already has `ampr_emu.index` gets it rebuilt by default. To keep an index made by other -tools, pass `--ampr-skip-regen-if-exists`: the index is kept while it lists exactly the folder's files -and sizes (paths compared case-insensitively, modification times ignored) and rebuilt otherwise. The GUI -turns this on when the chosen folder already has an index. `--no-ampr-index` packs the existing index -untouched, even when it no longer matches. - -A folder that also holds AMPR packs (`ampr_assets.index`, see [`ampr`](#ampr-asset-packs)) always keeps its -`ampr_emu.index`, with a warning: the packs address files by their row in that index, and a rebuilt index -renumbers the rows so the emulator fails every packed read. `--ampr-force-regen` still rebuilds it. Keep -ShadowMountPlus's `mount_read_only=1` (its default) for such images: on a writable mount the emulator can -rebuild a missing index from the remaining files, with the same result. + (`sce_sys/playgo-chunk.dat` exists) or `--ampr-title` is given. Identical files are left alone. +2. When `fakelib/libSceAmpr.sprx` or `fakelib2/libSceAmpr.sprx` exists, write `ampr_emu.index`, hashed and + sorted the way the emulator looks paths up, so names with accents resolve on the console. + +Both steps change the source folder, so the image includes them. | Option | Default | Meaning | |---|---|---| -| `--ampr-libs ` | none | Folder holding `libSceAmpr.sprx` (required) and `libScePlayGo.sprx` (optional) to copy into `fakelib/`. | -| `--ampr-title` | off | With `--ampr-libs`, add the libraries even without `sce_sys/playgo-chunk.dat`. | -| `--no-ampr-index` | off | Do not create `ampr_emu.index` when `fakelib/libSceAmpr.sprx` is present. | -| `--ampr-skip-regen-if-exists` | off | Keep an existing index while it lists exactly the folder's files and sizes; rebuild it otherwise. | -| `--ampr-force-regen` | off | Regenerate an existing AMPR index. | +| `--ampr-libs ` | none | Folder with `libSceAmpr.sprx` (and optionally `libScePlayGo.sprx`). | +| `--ampr-title` | off | With `--ampr-libs`, add the libraries even without `playgo-chunk.dat`. | +| `--no-ampr-index` | off | Do not write `ampr_emu.index`. | +| `--ampr-skip-regen-if-exists` | off | Keep an existing index while it lists exactly the folder's files and sizes. | +| `--ampr-force-regen` | off | Always rebuild the index. | -### `ampr` (asset packs) +> [!NOTE] +> - ShadowMountPlus mounts `fakelib2/` instead of `fakelib/` when both exist, so `--ampr-libs` warns and +> leaves `fakelib2/` for you to update. +> - A folder that also holds AMPR packs (`ampr_assets.index`) keeps its `ampr_emu.index`: the packs address +> files by their row in it. `--ampr-force-regen` still rebuilds it, which breaks the packs. +> - The desktop app keeps an existing index by default. + +## AMPR asset packs > [!WARNING] > Experimental. Keep the original, unpacked game until the packed one has been played on the PS5. -#### How it works +### How it works `ampr` compresses a game's data files (levels, textures, audio, video) with LZ4 into a few pack files (`ampr_assets-*.pak`) and a manifest (`ampr_assets.index`). On the PS5, Drakmor's -[AMPR Emu](https://github.com/drakmor/ampr_emu) (`libSceAmpr.sprx` in the game's `fakelib/`) decompresses -them on the fly, so the game reads the original bytes. Executables, modules and `sce_sys` are not touched. +[AMPR Emu](https://github.com/drakmor/ampr_emu) decompresses them on the fly, so the game reads the original +bytes. Executables, modules and `sce_sys` are never packed. + +```mermaid +flowchart LR + G["🎮 Game"] -->|"opens data/level1.pak"| E["AMPR Emu
libSceAmpr.sprx"] + E -->|"reads blocks"| P["📦 ampr_assets-*.pak"] + P -->|"LZ4 decode"| E + E -->|"original bytes"| G +``` AMPR Emu only serves files that the game reads in ways it intercepts (AMPR reads and ordinary -`open`/`read`/`stat` calls). If a game memory-maps a packed file, or reads it some other way, the game -usually crashes. That is why each game needs its own rules, and why only a test on the console proves that -a packed game works. - -#### Requirements - -- A jailbroken PS5 with [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus). -- A game that loads `libSceAmpr` (AMPR/APR titles). -- AMPR Emu **0.4.2.1 or newer**, the first release that reads packs. `ampr game` refuses older builds. -- The game mounted read-only (`mount_read_only=1` in ShadowMountPlus, the default). +`open`/`read`/`stat` calls). If a game memory-maps a packed file, or reads it another way, the game usually +crashes. That is why each game needs its own rules, and why only a test on the console proves that a packed +game works. -#### Packing a game +### Requirements -You need the rules for the game: either a **TOML profile** (a file that lists what to pack, shared for -each game) or **traces** you record yourself (see [Making rules from traces](#making-rules-from-traces)). +| Need | Details | +|---|---| +| Console | A jailbroken PS5 with [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus). | +| Game | An AMPR/APR title (loads `libSceAmpr`). | +| Emulator | AMPR Emu **0.4.2.1 or newer**, the first release that reads packs; older builds are refused. | +| Mount | Read-only (`mount_read_only=1` in ShadowMountPlus, the default). | +| Rules | A **TOML profile** for the game, or **traces** you record yourself. | + +### Packing a game + +```mermaid +flowchart LR + A["📁 Game folder"] --> B{"Profile
for the game?"} + B -->|"yes"| D["ampr game
--config game.toml"] + B -->|"no"| C["Record traces
with debug AMPR Emu"] + C --> D2["ampr game
--traces traces"] + D --> E["📦 Packed folder
or .exfat"] + D2 --> E + E --> F["🎮 Test on the PS5"] +``` 1. Check that the unpacked game runs with AMPR Emu in its `fakelib/`. -2. On the **AMPR Packs** page, choose **Build playable game** and fill in: - - the game folder and a new, empty output folder; - - the folder with AMPR Emu's `libSceAmpr.sprx`; - - the game's TOML profile in **TOML Configuration**, or a **Trace folder**; - - optionally, **Also build an exFAT image**. - - On the command line: +2. On the **AMPR Packs** page, choose **Build playable game** and fill in the game folder, a new output folder, + the folder with AMPR Emu's `libSceAmpr.sprx`, and the game's profile in **TOML Configuration** (or a + **Trace folder**). Optionally tick **Also build an exFAT image**. On the command line: ```bash mkpfs ampr game --root PPSA12345-app --output PPSA12345-packed --fakelib ampr-emu --config game.toml --exfat . ``` -3. Copy the output folder or the `.exfat` image to the PS5 and start the game. Play past the menu and load - a save or a level, since some files are only read later. +3. Copy the output folder or the `.exfat` image to the PS5. Play past the menu and load a save or a level, + since some files are only read later. 4. Delete the original only after that. -`ampr game` copies the libraries, writes `ampr_emu.index`, packs, copies the files that stay loose, and -checks that every packed file decodes to the original. The game folder is only read; if a step fails, the -output folder is emptied. +`ampr game` copies the libraries, writes `ampr_emu.index`, packs, copies the files that stay loose, and checks +that every packed file decodes to the original. The game folder is only read; if a step fails, the output +folder is emptied. -#### Making rules from traces +### Making rules from traces The debug build of AMPR Emu (for example the 0.4.2.1 test-debug-pack) records every file the game reads through AMPR. MkPFS turns those recordings into rules. @@ -329,42 +405,39 @@ through AMPR. MkPFS turns those recordings into rules. run, so the emulator can write into the game folder. 2. Play a session, then quit the game from the PS5 menu. 3. Copy `ampr_commands.bin` and `ampr_emu.index` into a subfolder of a trace folder (for example - `traces/session1/`), then delete them from the game folder. Repeat for more sessions: each one covers - more of the game. + `traces/session1/`) and delete them from the game folder. More sessions cover more of the game. 4. Put the normal emulator back and set `mount_read_only=1` again. -5. Pick the trace folder in **Trace folder** (command line: `--traces traces`). The page shows how many - sessions it found. +5. Pick the trace folder in **Trace folder** (`--traces traces`); the page shows how many sessions it found. -Only files that a session read are packed; everything else stays loose. To also pack the files no session -reached, tick **Also pack files no session read** under the trace folder (`--pack-untraced-types`). This packs every other file -of the same types (extensions) as the traced files. Test the parts of the game those files belong to before -deleting the original. +Only files a session read are packed. To also pack the other files of the same types (extensions), tick +**Also pack files no session read** (`--pack-untraced-types`), then test the parts of the game they belong to. To save the rules as a profile you can edit and share: ```bash -mkpfs ampr profile generate --trace traces/session1/ampr_commands.bin traces/session1/ampr_emu.index \ - --trace traces/session2/ampr_commands.bin traces/session2/ampr_emu.index --output game.toml --pack-untraced-types +mkpfs ampr profile generate \ + --trace traces/session1/ampr_commands.bin traces/session1/ampr_emu.index \ + --trace traces/session2/ampr_commands.bin traces/session2/ampr_emu.index \ + --output game.toml --pack-untraced-types ``` -#### When a packed game crashes +### When a packed game crashes The normal AMPR Emu writes no log. To find the file that fails: 1. Build again with **Free space inside the image** set to `2GiB` (`--exfat-free-space 2GiB`). 2. Put the debug `libSceAmpr.sprx` into the packed game's `fakelib/`. -3. Mount the image read-write (`image_rw=` in ShadowMountPlus's `config.ini`) and start - the game. -4. Read `ampr_emu.log` in the image: `apr.pack.open` lines show the packed files the game opened, and - `apr.pack.open.fail` lines show the ones it could not open. Keep those files loose in the profile. +3. Mount the image read-write (`image_rw=` in ShadowMountPlus's `config.ini`) and start the game. +4. Read `ampr_emu.log` in the image: `apr.pack.open` lines are the packed files the game opened, + `apr.pack.open.fail` lines the ones it could not open. Keep those loose in the profile. -When you report a game, include its title, ID and version, the profile or traces used, what happened, and -for a crash the ShadowMountPlus log (`/data/shadowmount/debug.log`) and the console log. +When you report a game, include its title, ID and version, the profile or traces used, what happened, and for +a crash the ShadowMountPlus log (`/data/shadowmount/debug.log`) and the console log. -#### Profile format +### Profile format Profiles use ampr_emu's TOML format (see its `tools/ampr_pack.example.toml`). Rules are checked in order and -the last match wins; `*` also matches `/`. +**the last match wins**; `*` also matches `/`. ```toml [pack] @@ -379,22 +452,23 @@ action = "loose" # always keep executables, modules and system fi include = ["eboot.bin", "*.prx", "*.sprx", "sce_sys/*", "sce_module/*", "fakelib/*"] ``` -#### Command reference +### `ampr` reference -| Subcommand | Required options | Purpose | +| Subcommand | Required | Purpose | |---|---|---| -| `game` | `--root --output `, and `--config` or `--traces` | Build a folder that runs from packs as is. | -| `pack` | `--root --ampr-index --output ` | Write only the pack set (manifest, volumes, CRC sidecar). | -| `verify` | `--index ` | Decode every chunk; `--root ` also compares with the source. | -| `unpack` | `--index --output ` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | -| `list` | `--index ` | One line per file (`--json` for details). | -| `inspect` | `--index ` | Manifest summary, volumes and runtime settings. | -| `runtime-config` | `--index --config ` | Replace the runtime settings from a `[runtime]` section without repacking. | -| `remove-sources` | `--index --root ` | Show which source files the packs replace; `--confirm` verifies and deletes them. | -| `profile generate` | `[] --output ` | Rules from traces (`--trace COMMANDS INDEX` per session, repeatable); `--report`, `--metrics`, `--runtime-header`. | -| `profile batch` | ` --output-dir ` | One profile per trace session and a `summary.json`. | - -Options of `game` and `pack`: +| `game` | `--root --output`, and `--config` or `--traces` | Build a folder that runs from packs as is. | +| `pack` | `--root --ampr-index --output` | Write only the pack set (manifest, volumes, CRC file). | +| `verify` | `--index` | Decode every chunk; `--root` also compares with the source. | +| `unpack` | `--index --output` | Extract packed files (`--file `, `--overwrite`, `--no-preserve-mtime`). | +| `list` | `--index` | One line per file (`--json` for details). | +| `inspect` | `--index` | Manifest summary, volumes and runtime settings. | +| `runtime-config` | `--index --config` | Replace the runtime settings from a `[runtime]` section without repacking. | +| `remove-sources` | `--index --root` | Show the source files the packs replace; `--confirm` verifies and deletes them. | +| `profile generate` | `--output`, and a session folder or `--trace` | Rules from traces; `--report`, `--metrics`, `--runtime-header`. | +| `profile batch` | ` --output-dir` | One profile per trace session and a `summary.json`. | + +
+Options of ampr game and ampr pack | Option | Default | Meaning | |---|---|---| @@ -404,7 +478,7 @@ Options of `game` and `pack`: | `--workers ` | min(8, cores) | Compression threads (1 to 256). | | `--self-contained` | off | Never leave selected files loose; store incompressible blocks uncompressed. | | `--fakelib ` | none | `game`: AMPR Emu and other libraries to add to `fakelib/`. | -| `--exfat ` | none | `game`: also build an exFAT image (`.exfat` in a folder). | +| `--exfat ` | none | `game`: also build an exFAT image. | | `--exfat-free-space ` | 0 | `game`: free space inside the image, for a debug run. | | `--skip-verify` | off | `game`: skip the final checks. | | `--include`, `--exclude`, `--include-from`, `--exclude-from` | none | `pack`: narrow the rules; `--exclude` forces files loose. | @@ -412,215 +486,93 @@ Options of `game` and `pack`: | `--allow-missing` | off | `pack`: leave selected files missing from `--root` loose. | | `--no-progress` | off | Hide progress. | -Notes: - -- `ampr pack` and `ampr profile` are ports of ampr_emu's `tools/ampr_pack.py` (4.0) and - `tools/ampr_pack_profile.py` (4.1), with the same output byte for byte. `game`, `--traces` and - `--pack-untraced-types` are MkPFS additions. -- The release emulator loads at most 2,000,000 files, 16,000,000 chunks and 1,024 volumes; `pack` warns - when a set goes over a limit. -- Deploy `ampr_emu.index`, the manifest, its `.runtime` file and every volume from the same build. The - `.crc` file is only used by `verify` and `unpack`. -- Use an exFAT image or the plain folder, not `.ffpfsc`: zlib on top of LZ4 makes loading slower. -- Errors print `error: ` and exit with code 2. +
-### Reading and extracting images - -Encrypted read commands accept `--ekpfs-key <64-hex>` (default: all-zero key) and `--new-crypt` -(default: off) to select the alternate EKPFS derivation. `verify`, `tree`, and `unpack` also take -`--format `; the default `auto` detects the format from the input. - -| Command | Options | Default | Meaning | -|---|---|---|---| -| `inspect ` | `--format ` | `text` | Select a human-readable or JSON metadata report. | -| `tree ` | `--deep` | off | For a PFS that wraps one exFAT image, list the files inside that exFAT. | -| `unpack ` | `--overwrite` | off | Replace an existing output path. | -| | `--deep` | off | Extract files from an inner exFAT image instead of only the outer PFS contents. | -| | `--only ` | none; repeatable | With `--deep`, extract only the named inner exFAT file or directory. | -| | `--no-progress` | off | Hide extraction progress on standard error. | -| `verify ` | `--source-dir ` | none | Compare hierarchy and payloads against a source folder. Cannot be combined with `--source-file`. | -| | `--source-file ` | none | Compare a single-file image to the source file; not supported for exFAT input. | -| | `--expect-crc32 ` | none | Require this cumulative payload CRC32. | -| | `--expect-manifest-sha256 <64-hex>` | none | Require this manifest SHA256 digest. | -| | `--require-game-files` | off | Warn when `sce_sys/param.json`, `eboot.bin`, or `pfs-version.dat` is missing. | - -### `repair` - -`repair` operates on an unsigned, unencrypted, single-file `.ffpfsc`. By default it scans for risky -compressed blocks, stores replacements raw, verifies their decoded content, and cleans unused bytes -in the outer PFS wrapper. - -| Option | Default | Meaning | -|---|---|---| -| `--scan` | off | Report only; do not modify the image. Returns exit code 3 if blocks need repair. | -| `--bad-blocks ` | none | Repair the block numbers in a PS5 Game Compressor `bad_blocks.tsv` instead of the scan's risky-block selection. | -| `--recompress` | off | Re-encode repaired blocks with zlib level 7 instead of storing them raw. | -| `--mode ` | `auto` | `auto` copy-replaces when free space is at least 1.2× the image, otherwise rewrites in place. `in-place` can leave an interrupted image corrupt; `copy` always uses a replacement copy. | -| `--report-dir ` | none | Write `summary.json` and `bad_blocks.tsv` to this directory. | -| `--no-slack-cleanup` | off | Leave unused bytes in the outer PFS wrapper unchanged. | -| `--cpu-count ` | `0` (all cores) | Worker count for scanning and repair. | -| `--no-progress` | off | Hide progress output. | - -Exit code `0` means success, `1` means an operation failed, `2` means invalid command-line usage, -and `repair --scan` uses `3` when it finds blocks that need repair. - -### GUI - -`mkpfs-gui` runs the same commands from a window and shows their output and progress. From a -source checkout: - -```bash -dotnet run --project src/MkPFS.Gui -c Release -``` - -- The BUILD section follows the recommended order: **Pack exFAT** turns a game folder into an exFAT - image, **Pack FFPFSC** compresses that image into a `.ffpfsc`, and **AMPR Packs** builds LZ4 asset packs. - `pack folder` and `batch` are available on the command line only. -- Pick a game folder or image to see its cover, title, IDs, version, region, and APR Emu marker. -- Pack exFAT has an APR Emu section: the libraries folder, download links, and every - [APR Emu option](#apr-emu). -- The packing pages have a collapsed Advanced section with the remaining CLI options, such as the - cluster size, signing, verification, and verbose output. -- The check pages cover the CLI options too: Verify takes a source file, the image format, and the - game-file checklist; Unpack extracts inside a wrapped exFAT (`--deep`, `--only`); Tree and Unpack - take the image format; Inspect, Tree, Verify, and Unpack take newCrypt keys. -- Closing the window while a job runs asks first; Stop and Close cancels the job and waits for its - cleanup (an in-place repair finishes its rewrite) before the window closes. -- Pack FFPFSC has compression presets (Fast, Balanced, Max, Low RAM) and - settings for the zlib level, CPU cores (Auto uses every physical core of the computer, not the logical processors), block size, and when to keep - blocks uncompressed. Hover over a Compression Tuning or Advanced option to see what it does. -- The progress bar covers the whole run: when a run has several steps (pack, then verify and compare), the - bar keeps counting through them instead of starting over, and the label shows the step, for example - `verify (2/3)`. -- The Repair page scans an image and draws a block map (zlib, raw, risky); click a cell for its - offset, stored size, and largest back-reference distance. -- The AMPR Packs page runs every `ampr` subcommand. Its default action, Build playable game, runs - `ampr game` with the game's TOML profile or a trace folder. +> [!NOTE] +> - `ampr pack` and `ampr profile` are ports of ampr_emu's `tools/ampr_pack.py` (4.0) and +> `tools/ampr_pack_profile.py` (4.1), with the same output byte for byte. `game`, `--traces` and +> `--pack-untraced-types` are MkPFS additions. +> - The release emulator loads at most 2,000,000 files, 16,000,000 chunks and 1,024 volumes; `pack` warns +> when a set goes over a limit. +> - Deploy `ampr_emu.index`, the manifest, its `.runtime` file and every volume from the same build. The +> `.crc` file is only used by `verify` and `unpack`. +> - Use an exFAT image or the plain folder, not `.ffpfsc`: zlib on top of LZ4 makes loading slower. ## Differences from Python MkPFS -- Same inputs give the same images as Python MkPFS 1.0.0 run with its zlib backend. Set - `SOURCE_DATE_EPOCH` for reproducible timestamps. -- Compression always uses zlib 1.3.1 at level 7. `--compression-backend` is accepted but ignored: - ISA-L output uses back-references the PS5 decodes wrongly. -- `repair` is new. -- `pack exfat` and `batch` also build `ampr_emu.index` (Python only does it in `pack folder`), and - `--ampr-libs`/`--ampr-title` are new. `--ampr-skip-regen-if-exists` checks every path and size, not - just the file count. -- `ampr_emu.index` matches ampr_emu's `build_ampr_index.py` and the console lookup: Python MkPFS folds - every letter and hashes code points, so on the console it cannot find files with non-ASCII names. The - index also skips the emulator's `ampr_commands.bin` and `apr_emu.log`, and `fakelib2/libSceAmpr.sprx` - also triggers it. ASCII-only folders give the same index as before. -- Bugs found in the Python version while porting are listed in - [tools/oracle/README.md](tools/oracle/README.md); some are fixed here. -- Switching from Python MkPFS: see [MIGRATION.md](MIGRATION.md). - -## Build from source - -Requirements: - -- .NET SDK 10.0.401 or a newer 10.0.4xx (see `global.json`). -- Windows: Visual Studio 2026 with "Desktop development with C++" (native zlib and Native AOT). -- Linux and macOS: CMake and a C compiler; Linux Native AOT also needs `clang` and `zlib1g-dev`. - -Build and test: - -```bash -dotnet build MkPFS.slnx -``` - -```bash -dotnet test --solution MkPFS.slnx -``` +| Area | MkPFS.CSharp | +|---|---| +| Output | The same images as Python MkPFS 1.0.0 with its zlib backend. Set `SOURCE_DATE_EPOCH` for reproducible timestamps. | +| Compression | Always zlib 1.3.1 (level 7 by default). `--compression-backend` is ignored: ISA-L output uses back-references the PS5 decodes wrongly. | +| New commands | `repair` and `ampr`. | +| APR Emu | `pack exfat` and `batch` also build `ampr_emu.index`; `--ampr-libs` and `--ampr-title` are new; `--ampr-skip-regen-if-exists` checks every path and size. | +| `ampr_emu.index` | Matches ampr_emu and the console lookup, so files with non-ASCII names are found; skips the emulator's own trace and log files; `fakelib2/` also counts. | +| Fixed bugs | Listed in [tools/oracle/README.md](tools/oracle/README.md). | -The first build compiles the bundled zlib in `native/`. Parity tests skip when the oracle corpus -(`tests/fixtures/generated`) is missing. +Switching from Python MkPFS? See [MIGRATION.md](MIGRATION.md). -Publish native executables: +## Build from source -```bash -dotnet publish src/MkPFS.Cli -r win-x64 -c Release -``` +| Requirement | Details | +|---|---| +| .NET SDK | 10.0.401 or a newer 10.0.4xx (see `global.json`). | +| Windows | Visual Studio 2026 with *Desktop development with C++* (native zlib and Native AOT). | +| Linux, macOS | CMake and a C compiler; Linux Native AOT also needs `clang` and `zlib1g-dev`. | ```bash -dotnet publish src/MkPFS.Gui -r win-x64 -c Release +dotnet build MkPFS.slnx # the first build compiles zlib and lz4 in native/ +dotnet test --solution MkPFS.slnx # parity tests skip without the oracle corpus +dotnet publish src/MkPFS.Cli -r win-x64 -c Release # native command line +dotnet publish src/MkPFS.Gui -r win-x64 -c Release # native desktop app ``` -Use `linux-x64` or `osx-arm64` on those systems. On Windows, Native AOT linking needs the Visual -Studio Installer folder (`C:\Program Files (x86)\Microsoft Visual Studio\Installer`) on `PATH`. +Use `linux-x64` or `osx-arm64` on those systems. On Windows, Native AOT linking needs the Visual Studio +Installer folder (`C:\Program Files (x86)\Microsoft Visual Studio\Installer`) on `PATH`. ### Project layout | Path | Content | |---|---| -| `src/MkPFS.Core` | Formats and codecs: PFS, PFSC, exFAT, AMPR, crypto, readers, validators | -| `src/MkPFS.Build` | Image builders, compression planner, batch | -| `src/MkPFS.Repair` | PFSC repair (Game Compressor port) | -| `src/MkPFS.Cli` | `mkpfs` command line | -| `src/MkPFS.Gui` | `mkpfs-gui` desktop app (Avalonia) | -| `native/` | zlib 1.3.1, lz4 1.9.4, and the `mkpfs_zlib` shim | -| `tests/MkPFS.Tests` | Unit tests | -| `tests/MkPFS.Parity` | Byte-for-byte tests against the Python oracle corpus | -| `tests/MkPFS.Gui.Tests` | GUI view model and headless UI tests | -| `tools/oracle` | Python oracle scripts ([README](tools/oracle/README.md)) | +| `src/MkPFS.Core` | Formats and codecs: PFS, PFSC, exFAT, AMPR, crypto, readers, validators. | +| `src/MkPFS.Build` | Image builders, compression planner, batch, AMPR packer. | +| `src/MkPFS.Repair` | PFSC repair (Game Compressor port). | +| `src/MkPFS.Cli` | The `mkpfs` command line. | +| `src/MkPFS.Gui` | The `mkpfs-gui` desktop app (Avalonia). | +| `native/` | zlib 1.3.1, lz4 1.9.4 and the `mkpfs_zlib` shim. | +| `tests/` | Unit tests, byte-for-byte parity tests and headless GUI tests. | +| `tools/oracle` | Python oracle scripts ([README](tools/oracle/README.md)). | ### Oracle corpus -The parity tests compare against images and logs made by Python MkPFS. With Python MkPFS checked -out at `../MkPFS` and `uv` installed: - -```bash -uv run --project ../MkPFS python tools/oracle/build_goldens.py --check -``` - -The `ampr` tests use a second corpus made by ampr_emu's `ampr_pack.py` (checked out at `../ampr_emu`; -needs python-lz4 4.4.5): - -```bash -python tools/oracle/build_ampr_goldens.py --check -``` - -`ampr profile` is checked against `ampr_pack_profile.py` on synthetic traces (every output file, -byte for byte; build MkPFS first): +The parity tests compare against images and logs made by the Python tools. See +[tools/oracle/README.md](tools/oracle/README.md) for setup. ```bash +uv run --project ../MkPFS python tools/oracle/build_goldens.py --check # Python MkPFS +python tools/oracle/build_ampr_goldens.py --check # ampr_emu ampr_pack.py uv run --no-project --python 3.12 --with lz4==4.4.5 python tools/oracle/check_ampr_profile.py ``` ### Releases -Push a version tag to publish a release. The workflow reruns CI, then uploads the archives, -`SHA256SUMS.txt`, and release notes, and marks the release as latest. With a `VT_API_KEY` repository -secret (a VirusTotal API key), it also scans every archive and links the reports in the notes. - -```bash -git tag v2.0.0 -``` +Push a version tag (`git tag v2.0.0` then `git push origin v2.0.0`). The release workflow reruns CI, uploads +the archives, `SHA256SUMS.txt` and release notes, and marks the release as latest. With a `VT_API_KEY` +repository secret (a VirusTotal API key) it also scans every archive and links the reports. -```bash -git push origin v2.0.0 -``` +## Credits and license -## Credits - -- [MkPFS](https://github.com/PSBrew/MkPFS) by PSBrew: the Python original this port follows. -- PS5 Game Compressor by Juma Sayeh: the PFSC repair logic. -- Drakmor's [APR Emu](https://github.com/drakmor/ampr_emu): the `ampr_emu.index` format and the asset-pack - format and tools that `ampr` ports. -- Drakmor's [PlayGo stub](https://github.com/drakmor/pgo_stub): `libScePlayGo.sprx`. - -Third-party components: - -- [zlib](https://zlib.net) 1.3.1 (zlib license) -- [LZ4](https://github.com/lz4/lz4) 1.9.4 (BSD-2-Clause) -- [Tomlyn](https://github.com/xoofx/Tomlyn) (BSD-2-Clause) -- [Avalonia](https://avaloniaui.net), [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet), - [System.CommandLine](https://github.com/dotnet/command-line-api), and - [Spectre.Console](https://spectreconsole.net) (MIT) -- [Material Design Icons](https://pictogrammers.com/library/mdi/) (Apache-2.0) - -## License - -GPL-3.0-only, same as MkPFS. See [LICENSE.md](LICENSE.md). - -This project is not affiliated with Sony Interactive Entertainment. +| Project | Used for | +|---|---| +| [MkPFS](https://github.com/PSBrew/MkPFS) by PSBrew | The Python original this port follows. | +| PS5 Game Compressor by Juma Sayeh | The PFSC repair logic. | +| [APR Emu](https://github.com/drakmor/ampr_emu) by Drakmor | `ampr_emu.index` and the asset-pack format and tools that `ampr` ports. | +| [PlayGo stub](https://github.com/drakmor/pgo_stub) by Drakmor | `libScePlayGo.sprx`. | + +Third-party components: [zlib](https://zlib.net) 1.3.1 (zlib license), [LZ4](https://github.com/lz4/lz4) +1.9.4 and [Tomlyn](https://github.com/xoofx/Tomlyn) (BSD-2-Clause), [Avalonia](https://avaloniaui.net), +[CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet), +[System.CommandLine](https://github.com/dotnet/command-line-api) and [Spectre.Console](https://spectreconsole.net) +(MIT), and [Material Design Icons](https://pictogrammers.com/library/mdi/) (Apache-2.0). + +Licensed under **GPL-3.0-only**, like MkPFS; see [LICENSE.md](LICENSE.md). This project is not affiliated with +Sony Interactive Entertainment. diff --git a/assets/screenshots/gui-ampr-packs.png b/assets/screenshots/gui-ampr-packs.png new file mode 100644 index 0000000000000000000000000000000000000000..4c8ea8c3381d40cac9bfd24b3ec79d252ccb7b6f GIT binary patch literal 162073 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

qn!5}MMK0aP6izu^?XlR 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 z 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 GPyP!pUj}^u literal 0 HcmV?d00001 diff --git a/assets/screenshots/gui-pack-exfat.png b/assets/screenshots/gui-pack-exfat.png new file mode 100644 index 0000000000000000000000000000000000000000..f18c7d2b830ae25fabcd758cecbe16176f3491c4 GIT binary patch literal 108554 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%0DGF6LgA34E1=rB27x+DX5vG7 zZbsV0L3m9jj^pJx7nW<<4fAB)Da2-q^c@MY)xFa;^b>z1=s?ERKQ7KE*czj}`mI~y z*}6IMzc(LNs#8Dnp@Mr?j&7-Me^OA1eJ*Al|q* zu=`9~FES!s&|L){$wbC@ocWUis6lO%L-KJ7&Fj~76?EF{i`I~?Y&2R-wN-p@OL_)| zX!2h2rGH)we%PEv?w0ILqYDX|wzK2wJ3!dpxq9({d!ES1FXB2gYgcN75;Pm6&%G1} zIhe(bQ3qZGnOM!dLeneXdEs@odx3e8xW6XW9^Zt=D5r>-=iQ}t9q26wYL_qIy0UYL z2|fl_=U{FvS}$rY)u5>xCB8F5xn$>-qH`|`PtKSuXGgJ?2VAv=1cx#PxE+hh|AA%s zj;~Xd^049w7<|+ZLqrZ%ag0u|tL-&)J@eEHzrwN29@A2=42~mW{kV2n9xqaEQYaE0 zZ`6r`txJt>7`C)kUF_G+|Lu|{j4;(!$h(M!%Q32!R z5wbNikIKg>mTZO}J_Qza6Hy{Hil7E$-eVvaCJq(naJqIpy7ZqN1C< zMtNtapD8=!qjp81Y()jsKX5A^G8D6W=`x!Xnowa=c(fjGJ`g`%)gexXexj>|9Z&FC zaflYP7u}49_GCIu*eMsvnVR!qT%#5C#oF5WG;zdS-Rm~ZPTkuwAe zT_eWcif}07z|^sSbj{nJN%qjIl3sF;iIrzymWgChnrzx6CR`gU;5ep*WVC-$?elbL z>FSHdzNbmCq7-SHi|PxehoTn-1v_xNe8dlo3hEu-al#h$J&q0NtlCURpog^WG25sH zXf_zUjR`2Lm!TswQrYY-Pta2F-!9j@TIBWAooTsY&(8Dn?lB5}Nk}Z;&y|vrStJ8v z-OWy>t8;=E^$^GKA6V%=^MiigNEiz=1HH=6zZ#qZv9&%c9c6SLP#qDkfYl8C{CZHC zC^`ZFs5|uBR0iR^^BL78w{S!DxA&(tPy*pL0w>MmFL$2}L zg8P)O;~9O&bejt@<3eAwnZxV3Um6<^z59D2nsWJ7Rqm1jJ2w&Dc~>OQ@WgQDdC0Kqv^*D^rgpgVG)UC78j}G6Cszd=@oMu6`KF~wj|ae?&9T3{(M~xtuOmPp-fZy9 zJ~Y*F!42bItn*7w3o(T$$zBB4m0VpN;4f)Nm}EZF{|%NpD*U7%zIAACcA{&Xfs`(^ znp|Rm{~KpbrFHlYO$vD`F>${E8FJ(*m(7_-edaAId%MPwu38e(?w6t3YgB7y^d4RQ z%kZ|qm8Xm)P%&D2;kjk?aSWx%-I0pp@_PG;yFgt^!deo?lY1Rg_R+(7#6U`&Ev`Uv ziM#YI^+blL_&xmVxLY3AQ0K;nujG8%H

mqjM%k%J0)tLq{NN zoCBo46h7u{aLVDTm}@jd|4f}0wP@(O2h_H!_A{VpET7z+?w=cLRADh% z^z7Jiv}00{C&j@0LW*?%(NP6ffHH3WO9(-_&ohO_k512m@-NOp>uRK^Z+ST8!C0Z$ zRTvf%H$?bJuYhDG^04Mb`P(__v(IcYT6sds^t-t4x>Xk$74js5&^Ku$TwTN)_zYG_ zjw&Su@}S#~+Aats&Q!>q-k)bF3Kq|)gI(Z>Lwn~=-wM&*Z$90GJmL#zMWj>3*pfAn zhFxuBlLTcL8c*|j5-LBYga|MmhKmY(rl0@C06Y(ZKzcuU=7r{PJyZ|L;W;|G)zxw= z7V9y>;=1_jOqVt~>tA=m9wW|rIN`Em0{c~nG>wBNCJS<&9|_-)sbC$1{33eM5^ybt zsaNg?4dj$8&^^YHa_|^DO`LslNw~=R49UyCFQze159!Nla9x{wexH2$P5WjiTGYVE z+dKHwOstMSpwU%~NxxK$C-VCc5FVB|t?(SBKvdOHQ`NCv3!jsXsW5jny!0e$7q(dA zFw`+2w@R{50KOSGFP~5nyW)YuGiO{f9kxiOhimUrm?(DI3!mIGxs%T6%r%~f~= zb9S0%0k2w1wews^B#2MNC9rke>?9%V`8VI+wY2a-y&r?**g51bo`FY0!RvaogG|yc_N(ro&O1zZ^ zy@co4v#t)!FN}+8Lh2)gj6MJ}dOj?(T8?T$xhy%Ha5}ntIX9R+#$nps9!E#v^1;GT*hMi?fK-dpsc4~Un@z8ryZ8p zLrV!XFf&rmlblHqy z1KRq!b0~$)#A5?Fp;l%9a{*!)fytm0 z3Eb@ruEx`r>Ds(VBL`;ovUppxPlsm2fpfRlsRLwZFP(>;&cYwTj}KcJE;7{_r|Y}$ zr2TaZw-3prKn3lAwl$Br|K%P==y@6K)m;Z4Kc6HlJcg!1dLTG|pXHEM`jFmD)o)WY zPqAW25r9P6SR+MP7r9O3*w^_9JF1KuI)K;bq|7BLp+i(GxH^If zg@=)wPk3?tyZusIc3r-=S9V#JujJcxmx4G+)vCGYs+QaiM=8_dh*YU*_RvcUE z!)aqZnPleR_M42V+`b&@&D!`iv>)P~u}XbQE9)LhVZpsjj^e*}q!L@hOt~L)Ed*%g zB|IRYRW^Yp8D6i3_$p-?o(Z=#WC5)!>DKOENMr-XbheMSTG?q%<;b`~E&IMLd9Lc} zhG1a>;wqy2HSt48_`mIMpP~?Qdg?or6g-f+hG51_peZ`MO%XKG@)VS#&d-lrkmPec z2e~}Z+6QY_TQ+b4f;>&;oD~umYIy8x4x(Mr{Eor6n;ut#VpCeaK?#?wUj*}h2KJ`+ zw;xq4eTyaLgdba`NvV<9s50lBjRuqO9PnA|Xpz|fsx@r)&VMzE#lIm>f4V&kXaRvl zGc?p=UF?Q>3bRG?B)VkdI6n5zd0XTwt!wi+O?xy-e-pls-2Ng*U0hYRJy~nu8`3QU z0)1qC3|n?`eW)VejNf~2U5^>Q5+F#baI&Xt{gnG5@5{;Gh2S_^8le-2dKO_P6IE6? zJ2(1kE-Dk}79&B>Rw=eR)+AqHUY7d@+l@K)!^3E)qOt7Z(VAht0*>}zVZARoiQrC0 z4U-@~gEDOZO9aM2ze@bBd-b0CJFBN~gpJ@6;Cc5U3NM*=^hd(w{RSEk$bye!jDy3& z(MQav78ug55UH!q8YHf&9i6B+nTi)6`#3fcpC)MJN67w4GU4;Lh|M|YdZsfcy~h00 z!+VW03zw0Fa&$6qzmZ@P41D>UlyzqvEUsU`lay1a`vxP7 zIOi9aB?k~ao+^a^MlP2lr|~Z;IS-BJYc#>|&Hb$St|P}=1HENNq-zG~7l;P5cM z>Vv*FG{Ehy9H$h=?jqX%l4?y{(ct?W2xoTbrk-E-uowH!P!g;CW=bn=b;!Z3rE1y- z{l`QzSo=bqtAZ#fu1i3}ArLCQy-z~IX)h}5zOvc4)eq_)J2MG9?G~um5metO2H5W{ zew9th@{C=9Y;6a(5!O;oZJ)sUW3gekQ@`vTZGx#>2A2GF-1d}1bf=n9%z?mDAaJPr zqmeJBXIY+F5hcodUhv`<#oiG)jxEJ+zVhHpsxXg4R4#Ae+~i;ZYDbNH2(vsSm_D8#L4 z-mP>LMDhNqUgTu7wY^OfJHUbTS6dFoTR~g846o_{KWBQqaPT8IuKs4dyd z-WmbDMH#aGJwfnX{n6C@Rg2oxtZP8Xfy~qm@@*vbq2Tb3B<8M*rn|=~@KWra5NN(8 zRv;2`SP{1^E1Avk$u2e5tNJJO6XVH`S>+%JlD34KWIa`4>Iqep38&VHnZWpqJzuPNMGFh2o)F? z;Jp0jA+2Eu^(!Clj+>JaZQn(20FB#V&wRR=2$#p+h>oBOf|9XaBQXVQ!rJV417Eoq zM2*4BFH{#%xWI!ZPeAeZVQrGCRMtaVsYXiz0a!^Bt(Lpp>A`z*A_fpsl5+jVkpo2(f+|YE1WdD?i#r zRCxE+1=wIWS!U%xk~4A~B}@8{Kx1>Ce#6`dY>pwXOH&Np+aQxB`mDj$NSvpKoZ zb?;B!Sg*nS^RB0N2V#6K$lejMYfV?`X)u;gJDerd?csPOvz{89sC*rg!d;n# z?JT!_L?bm{vu$(4^{DB_rAy3{IrR1Z#%t|}abhz&v0klMCC*RlcLPa)$W|}Tz(U); z{ws+)bg)N>qJ<$!#4vBBF<1JhaVhEE*4F3{bDbYHWXW`nLqX}l#vahTxbBp3z^>62 zL(=^{eD1M~)5{ezb~+JL!P;s+a=JVPUdu2q&=Z>3e|WX5U8oQ_JK?kp9o zTm~-`YsH0U3O!ihy?k?Wm5rkAMIc447~8&Bsk%$DK2R`5aP+8my@Q5?>@+_Dkk=V~)Wu!2J!!j-rzK-)fLo|L z880wUjqUXF<&@}UkS>;~8;jIxY?I*v+JEr#wQda&sFY_$@bOc|5Iwd=Lce9N@JHrQ zYfs&DQA!i;QHK8j2{i(-jE5vlx%jh{t37JIQ3b_wbG9qD)?knd*hlJ$%i0Ft52f*2m)P=u`H<5da`8?) z@gaLKmcY2qmQPy-l-W%??jqXheaEbY34=~`ig2+{{azjOyZN1y-5)p7r`!q^ZtHw- z)Cc+6#PB^ha9p^36sUujH@DHQR{8-N?#Be#`WL`^AXo1zXx!UIEy}>X65FwK3;nFs z%yxGKw>dvb@3ySCy1Wb`yV@NU!eq%pM;J(3ucfyuCtIwpaaO&5Msii~bQa)epkzO& zICyn?W%=6gOecgYsf>yBqR!5pbq9<1oG|6+!o{k$eWNd1iPosrMA2xiE#Z?&a>d>s zhr?}=J?b!dnm%bb74gc_FQ>EHp#>FAv!AQyO9FClgF-OJvOF#gk_|{3^p93DOT5?c z+6{{&toVk5dCkAoL;w%89zYng{Y2goROn=%#OuyOv3%AKxZQ6n{S>30jHq@#8>)_# ziL@Q?i`@3{VmJ?`a2eg`)=WsMA9_pq*L=9Ep@U3DKkdiy+V211?lZBILA)CD#zvln zyUXc}7AMV{F}A}`N`D4Qs&A%g^OAjM(sS8Od+=bTrfA@gFDQ!MKoqvUpHc$;iPV+5 z3nJq5tQbf5<|;VZBwX&T(x6>(H!$j2jFRfrI(+z(^~j z?$cK5MmAgu1nR|hs~*Y3UqiT2CUR#qF4>z^R}@oRd$ z+N(DQyBy6n0dg?t{XVPq0*W(Rjo#a*#E-It>KJbj1~@gL?Hw=Dw$gklwu$rs<;C)w zdsh*-0OG4F%OAA;w*@Iafj%-)Qm+GZxx-^kt*ruJXM6(;zq_yRJ0KH9Mzk0fVQR-9 zWk)Oh8`At~jrw&TaYtPLx2jle+Px18g7FWz8qei0u4weM7ycEZ{tPyy{L&i1@NdL> zr|(dbf6)K2h}ME~fV0`azhm?Cl`I6ofiFa z$KAWDzEjd&LcoU>Y;VSQSI6j6xj2Y$tM&rk-$|UmiFys14x5qKB(;-zw2WN&m!r8} zeB~j3PT_1<hiU8i%$0mv-3Wyem*7ZqzTP@mi+Ee zr%~O~x^^EUogC5XCw1(MrBdXgvAwf3zc8uj!<*1$iDfEu3ZRUUx+>>CCX&9A+2H30 zIQV=pvBOUO<#!h;`8!mRLul~dX*?9iqplJ=6iC4a+LyAJI#&`|%Eg{y?!+ZLjBIp_+VnZ9fLeJ~Rpk^_(GwG+6gl>L@vhhHeU*Chl5welnNFEi4Q;b0mPvpB>uNn0Lg(e zL#XRbdy=xIFTTIy9xF>4CIDa-P&_{cR&m?!H~03*)K|^JmN%lFLYKp6`ew;yI`{3} z-=4{8qF&LAIALY>=58e)opM4JmIBBLWQhv|wd>9Kc9c$oK*kYiFL3QC6ik2$pYzFbA;*8pV+W`ZI)s?y=Mr0l zyh&9oPeBzjYN%Q<7WgB;ET0zIT^O#a4&5WATPaa*SX2N5r8*$Wyj#QNF9V&wiQCZ3 zrPJ3yUYPBtzIskAp~{Ic9*7)N@^DX9dA@5ix47}w%Bp=QQK8yksJJK1)_dv?!;FU8 zbfkn8`Qf}bX1JT#Atuk03EzKr%Nvm6SCB6s8M0&|$d3Y-qbh`L68#+wiL^@D6Osg- z^gM?JzY>4t0zVwEFit6DIT)O%=uGHiy^7{t+?EPSX0%cQ&5eux>+@4=``MWLzeEWV zANT*e#(w#2wOfViO`lH8Lw(&p8xi7#k#qCfS=v@sIvNA-TZ_xhn78&q^m|$9Lkd-~ zOOF6qlLl3-hYsAfDyL%LznXr`H~ivAAukiaH%742n2tHk+k`Pn(kNt-&vHt3FNEh8 z12%)U16hY|ln+mq4sp=`YLbJ2)vgf2b`Ud!bgY)l5aR`09BaM`!7i9EFO4lyDW5T{ z(1=$jsHxY$*{t(GT5~p`^vWxnm42Qa@KfY{TZhAhb8Qq9d}wqv9B+X3+Qo14XvZfB z517vB$~}N;Pw9 zD55&bR)ej>tIL~XIHLj~ty-KDRiP*f8*=z%f(J)zlPM=y-;AOJ*8Ghn;wpzHk&SY1 zSL=5tK5W({M6Lq@^8&u_yP+V`C+Yd$hY9jE*oY0 zzHEQ@1ri%32K!F_P!1U%FBb&} zU0&5SPNgh(nW`|JxG}Gi*6lvs=$wdreD$Z!^W6ZErrV#|W&c(1Q=3-|@eCk; zvGUa}I37I3zPG6T+QjGV5I^2S4 z+4JTdLZH`)KWC+wb>L?Aou=A$iaF$VZ*k~=m-|h!py=Pk4;iq_Di~eD@5n;~ykuUe%i)6Qe%n2ki<$T?Uv%SjR;KM#~*;`(1n8ol+Ps zxQI!$?_hVIQLDJ8{_J*s2;R|YteZI#PJ;GVTklpA78Tf-(fVyns2;zTPnVW!KiO>| z-5Ufm6Mc3}L;_zHlDa<&J51h5K(49$E7g#gXrJlQT0V}1vo}O9etP*p1)f7qAiOJG$1Dhc8Iqgm#BSbySZK~1dlN#@mpKzQ*rLIB+gq`i14VGI3+Z%TQNNC1!LENv- z4HtjtV>(feDv?=F%XK&bumRNifvPSC+}zJBZ|C^isIY3BPdB+w@)~vngIRkLI7hnQ z#i1I8%#=tVDENsyV)?UO-@ZmGyS5RDe%X#NpvSaQK<`C47eiI?N796kz$6h_JWuqn zKjeUS%2=i9h&oYwBlfTwgPdVX(xSu>J5zP5JPNyy15Ho)F>KRD%*pf|$S>beVQkdB zQ(k1Yv~VuTD)aB2hzwH`*z|bdkLrBR>RrbZyZ;DGksJ7v6WaJbQu3_XDNL!^3p2KWy3 z1=QiyY*J@-aFv>)F>v+#M>g{9N{gvD9TV2uF3}$*rDo~aTVqTkwcqc^PN{DucvhWV zm_T&_H$g9y`UVrtf!3gxXAB{tO#9%6S1|_>+0s*eh3zZ#Z~9-c^p)CurSv@leo)wD ze)jj&l82(ku8?a5{V!^ifQK5HujVg=e%?(Y0n3Gwbx$OGYD2dZ>OV8G4D9WlFB&rM z1is?Ff_F1fu|q0$5@;uEKuqM+&QZfJHoP^%vT>AngDzq$585Wp0$<%s@RF1c)7L&m z%MyS5P?s||qa-&TR<+dV~iFkcqg5cNXcYrxU&!_o0l(A#kOQc z@bu5bYp*;@VN!j#a0F^RjldD)X#jfN*r9hzz_WOw?C1i`Mc*^?cT?U|`_2um=6#4~ z*mi;AH|Togb$|Q+?D|$NSK24+!oWA#=TU@Nk)SKc!=q{~@R8Ld-<(zVhPM>|rCu}X zU-=?Afa$YtsM7Inz0Jjmjbyv~ZFE@kiCM?*q@@Mf+2whIOHrDZN z^{WyI@;Nk?&t9hjCq2t88o&$m%J`fiIu4Q0>ob8nc-BL^|9ExR(+0)JXcDz2*@)r$ z>N_}Hh_1(O82i716QtR+vFDkbF!s2)*vWY&65`opS&ELhj#_@UC&3V z|0S@IX0z;#M^k7yrveKjj!hHs>jxPu#LVd$Pam>5;X_PJzaD(X&Kc#B>LNXM6>jsS zXaw&|t{(p6&i+mtTU@+(z}&K|cWUA<(vuY!sfptCkP&n-eZWcSH1U(1uDD9jRg~41 z6ujB4Kimm2kOnH|HF9Q$g;uYSW#^igfAH0Wo`6}+OSeu;@pHg+#{ zQy}D7#u>MlwOdiq77s&egr=$EpFU@h#oI-h*3|Uj|Qg$WhEV%I>^26v3vOn4S zz1FDN@XY+6_kYe)TKMou_b~B;5Sr}_b8=FU5VMvMG?E()4XZF(%i&%ZaM^G>>pVZC z!DYS_X4z2$O}zlR`sc1+ss`QqK*fo99vnp>wtc*)U=EUkz_(|{-H_hmcvFNo-|L1r zLJ~x8aV2`7#l?pQ7D=GJlU;jfEf%@-M; zY)k36XtL#TG-whJ`vrxbZ@k(dZEWidB&j+RjVML=3c8)sMNffe~ zxaLL3{&Y|Heb>DF+cd%%&)aY3a#Y-1w&$bV*<~=!5Ou$=2r12X36JlxH}460Bcd(2 z6>)hU+LEMv*p&|$Z>}x^!Q*^KRF^l>OC?OAiQxMJ9LM2xZz3I2frH;e;U{V74M$IG zpUeZ-;9w8=8ZqgF#gV>$o1dQyV`k5=0Q?)R0$h=`%)wzl)LHMRk^TFT{YjblUjXD~ z2O<2}Ozs&Mnzo|L8kq^rSf!#L-?XZgGNLX7zPn%enC8*F1@f4?Cyy z17)Ee>47?OUTKo9qm%@QB4>;#KcEO(7KVvrblB3?gNRQvzPw47Wr(cjg6xh3lDv9oW!@PuN)Hr6418;}E7sjG2;3RZ8JHN6}GJ*#1 zG+VOF9wy9W#@wE(bRVio`}`+n@?{GV&QTyNqR0P`PU4b%BQ*bZaX>{mv(j>C?@y07 zQqu3X6_@ij3JZ)oyFPrE2%L63{Kk1f@~(aQ9F=F=%`uBQ%$}=Ax_+^Gh=q3hx$(2x zQ|_xLXRacvmO$;CsZOXLP7iI0=DC$`{xrGOHClxsE zJ3EV)8jQ`~WiU$IpL-pcNM^o3?rKLPBOHj~t8fwkk*n@&3G#PFe1u4xhB01xCZvao zq7idB8=fzJ&#NgQ`Wq}e7_4F{m5Obuyh(`}agg0#8eGo!cr(dX03>)G?+}WJn+|`Tlnwo3*5y!6DCzWyd zoX2jn>aT3>cFeuhrKPU4Z`zt?FH$e^%fb`Z?!71D7%!XMNIAyZQyAINlLl7iz8Wr&Km> zWVD?EfBPY8-Lli45JS`Uqe)mSH%s@FVt=Sk)@R11+rB9x4v&PXO*zCENbw_8+1wyT!NNXgj}!sJsU)cZwAb*Gcb?UX*xq3$&2c}|}X?40FblFe3HKZ;|D{h}`? zPdUVYbbud!92#_WI~$@5H2KzFSj{njS*VJR&GE&~X(1dlJissPoS5t4;?)aWT}Iu8 zkIF6LnJYsK8r*Ol+okT7{B}&0!g+h;4)Nc}L+a%&q7iw9$-31RZM3B;{1@uTssnNG zl~`dczVEB2m{*M?6E*wY+sRdGa>|LeMK`Fjnml>B*JBT@2vk_@S7x6U$E6@`De4VQ zZD$vfes>s|HmVOU7UsCjtIM%5F#{-dwFWb-+EYq_)12*s-yl1h*f;E(S981$Du+VsN^O_Q!5>dpX=2{vH6`$v{wtHx#G~uw6ki2`8 zwv9S~Sg#`U4rY2LX{A#G^)p}iPSF<)@s#SH<*^D{&4~dwic*Ecgt>@|C-Xv`qpoBB#Bu8W$IRw&3@0BJ~)OClZ~p<)8V9tanpn zwfP@_o!~E84vnujAMY=NS$BBLEDxw5lP9?Kii7U4+R`9UaudKE==vDK*a7v+rMoD| zs#hGvsxIV-5Ca8nv9iXnH~lrerYW1g)9Un88OdGve6yF$5zU6<>h*iX6as6RJG&fw z|3led21T}PVY_JI(6}`2?$EfqySp{+6zo_xe5ktc@?jUnYgB)%5*b*1w8O3{9IyH4MV^U-C2UHh92A>P6h~=Bx z3$D#oyfk~v(vKp{w8m5$7<9mg@Z9%1H$XYhI8P!320H&Ms8%-$Y9NVecW zhl+mQ7{5l(^)=69Qbh;9*YC1lsTUSg%X+Ip%>2xBxxFWF4QX zSJ|R0dskHBo))O{qDuSW`Qa=(w+5sD-IZyis-o}HAi=(n(BwRip;Dt_q;%66GMNy`8#)|4HPD#SkYHAH3%Gvi!uA*W7 zwbJ?P%z-8_FaFog`4AcROKuZ9z#*^4$GACo%@M329BRD4<5}DLF$D(Cbn|ZgPlS=@ zCT|Kv!p-Kse`FFNRvm5X6me6I6MA#9&@q!*kn1sYz?i?hsb{yxx>1MTykfzY1E&g; zwUcNXdan8X&v5gTv5wQ=LMD!8g;ysVm7IuJk<|M!9jOc*jUmcP;xL)z$(<$Ils0pV z>Y}7^Z|+Ln*-lJl&)CinP>efy@psxoDC4E?ysO_XZEH-*B*oT0vy)4Mo&X!~D>{Ec z&GwVCb2A&QzxBW>f9I(o&Jj(t{=-B{UdQXBq1ruxssmV21O89zA2q>gTRr|Uk)wFe zUV2v$dV`9FKD;(pKd`@{^KA#oKkK?%C@G1_ow1S?4sfPU>CDIx{ev_blk`|gJBlZ=_~n%4cIHUmW1!Cu6^BTvuUf_rYkRIk>dYH_vJ+vBIpNI^@mdt zDJMJGlKWLqKL*EuX0-=_~h(yR~SZebzr-I4bKOetcR~@sAss4&r%$IT(@V3ClsUMMxveU?}Y8Ef|f% zEn+5Q>-`}s&QlhGiu%Dx{KX?3%*^OP zr%Uj`>;Z_5!QOcKo{_rZUDL5-U<=JclymsZN41&GUgGsk7e3P?>{!!@1yj<|*7hd% z@epnElLrkJ59l2p~!T5Je%+mTe6RW?PIGsk@^>U!h@6=dbG~6o9HISw8^fqLW75 zO}Nxgata+5h6@IUYs*u$q0Z>BS|Gg1=IJK7J^IysW6fc%>bKV-S;gf&E~q!4>%IjM zLna92nZ$`gRz*-0E;Cyr7xGw}rDDf4|F%I<&fo#3hNVbCQ$qPtfIq{;Nlm9uMl<;% z;ACnlzorYSc8{i3XXU43&%bTKLO!yJfPOQs{l;t?&&@J>1Y!$Cbm+sCnq&kVkCn7H zo3bPZh{>Xn1}dS*gptPGU7@m1BJ1N?36q0QwiYCD9cF#2W`LW}T&6cNTes^p`sWvd zM&=cZBn}chy2(UR=5L0uzDIuypQ26MTF%^^4~ICebg)!}#E%Ek;PSR%QR1>CG~~d3 zxKLcNoh(NMI2a4Ll~85MvPdhlB{Dbi`S|~~Nc3-uYr~Z`+*PdQO=XyTJSvu9LAFN=~VyT zS|Oky**_yGGHreq)_}%qtphbLb5Xy%;SCHUUkHG->*mFu6V` zh+i%XC{7}`!$7jIKmD}c z(|-mQ_QLSO15qWEO=b9Qa{#7d?Y=RUDSL7yyY?}`Cp3kF?;*9mmk$kM>f-5qb;pgs zp|Znk-^czyvvnvt0ONiPD)B8EOYimd^|X`mr`b`dv?HqMTiZ*w>%EM)^yJ68c52qD zw0>x;m-WstiQk6LAEnC{4SkSRY%qUzl=puBi~Xpb>7wg=a$p)PJL-s7c(@d0Gg|?r z2)RCG**2Lqu$C+VSAbNWYDu@>D>xP-5&Bmt8FV#d3^p}HAu)c^59zF7qq=b%2dkc( zzhD3F;dJjS%Ktv7>iTf1|9Shl>iY2LuiO4zm%zshDLRvn-9BTa)_!f9w*;q};O}q7 zThq{3Sw9E~K|!P&2G z)sw9v4Zy|QY4wcjbJgUv$=ARqA6(#r|20^W@9U$|F;T?j{1R;o9D$P=ar5@5kbJPm zaa))SWx*IZDXR30R0fZVv80+)vxt*>)IxSok8pPnAb^!&Eao_ntiXIF@Egh?cVSeZ z6fuiM5}iso3N7m-jzf9O*0#Q5Y?t(Zd+MIXOA$w}`*5T#J=qU;(-*|cC49c0sQZ|T zkSk!>B8Z>gB}aG+0jTzpoH#BtG&`%D-(gm%Ig)mqL58|xYsF8MK{7Qx>o}t+vF-Ew z(0l97sQI{BX+GrHmxTbdp7=Z*4iRSaIz0g~OdEY-*!W!HmRh4?Z^ur-xR?$O{j+mx zVxEJC6cI#5O7J+7X(~G0s&7_Xvl*yadi0A#YspLw3l{Zp0HCTL;9w<`A{}g2MfNBo zd(~p3AztKD_ELj6QZK%)8@*uQ!0M(jvnzTbV~IrQbQI`8uKXls1ycoBGqli17VTPC zhYq@=e-|(C9^VN9bg5(SyYs=?J_m=oM8o$FO+&B%_ay-`qFfeFFQ0)`MdUF+n*g`3 zo8Ht71n-iC`urm{%2zzpKaUmYFF$|f#CEcos7&4R{@wH}xOrHdb~Fv0`{^f2YP)H^ zl8GR_+XwBrfdAGeS2XvYcN9n0$6&T-brPn+MCu7kPO+pgQd5{9Jttni(u^slvW}~k z7-9~ulu7K!Ua!#j9Ad04$&F1G70Apy3RfZqT}X@&?4+b%`lT1HxNiKbO?yha$j0|J zs3`>NW#^*GVj?YB(`D?mV|ex5Sx62WZgJe`M?T+c(a~3wnT`aJ{%`&*SH- zk?+Pp*7!!BXo&>m&J|7G=I$;`n~cD_sRa%|kGs+0oaS-lmmm6p0@1ntpZks)nVJT- zO#8wFe|DLT>IwH(o(4BP@f$p5uamwFys7i0SmcP;`^%mA^9X%9KYuNOKbI9@IEg!k z25j5L&&9G>lfIh3Ej4)tz}@+G2*w2$12NfYZkPV#{M~fdez7n#${W&zR5W(d`8XR3 zX4~nAc=8~NAdT>%;$_E$%QXVwS*V1ascx>4te}eMSfTkh9uzn^4f9>O17eIM)?Q&6 zGKAn!78sipOG%!IqU|0k^Oq)}Y~)*AXm9w4iyrk~wAl28;Jm3fN92H24I|T}g8${jgeT%l*1=xv0~7Z53Z9m&>(=xT?{pJP zzc^eXLes`>ZS|xMPjQgWV^*}OI>JOXC@%K%MfVJ;6b^w5z5nvYC8omC4`m z?fubgvORhE^-S_LRYDsPMuy{O(>)omJ~wG47vGm+{l`rdH#(N74GdMy+UKB$pc;Y4 zZ8(AwA4oLn>fl)#)ma|JKH7WYTSzb9UFZPEd@C+9W;19N5!^1B3l_uQ9wlAIrY?w-DQ z?x&WB36xp5r!@vNYuOL#fs7p)OT^nZ&8HvDy2i`nt^0(7oMCQyJMvWJC-$~sO#7n{Ex{m1kk0QKO|oZR zeE_gU|6)0=sPPiq-%nN2iJyA&k}=zH+a%a(Js|72$h!8|+ud8^C;Qt-S_@n_%^b&J zvDMtYOf-Cxy87C?1UV&%bE4ZapWQ^lVIk0RZYNqh#p0l3a7w$j;O=;!9n8p0Mg{xk z(bj$N)LNc|=lI#uW}v!}=yJ3MHY}UpV(?Dff%hg?R~0%+Dg)DsW=!k-quNJO=?9Bi z>w9bTMI;$kXFA#$wfnM8(r@q{g8ufk=@KHZyn?-DVUUq;es-wD;&<0&a$R`^pgMVd zW)lH-;zMMH_WV`aktZ80p4BA2YBC*z=;F|Yhhfn9tkoepJsJ9R`l!X~R<(qR!63fT z#*wb|6%@)d&ZL8nXt@_n`dw+AmNfsco;Avws#pC5ZTNa}D@Z8lTUD)r^zux{>fH@F z901v(vxsw#HMQ~zDTu*-bGhck^RZH*u7j>nrK0I|vNo%_34Vmi@}7DfI}~DC!%*F6 z4AXXx6NXM2b_da0#R+uQgkEUtK004?l7j=lK+TAVCZv)khV6;-&Bya573r`mq2FJ< zrXa9O1$Q6K0T{yiTMwp%lDbh3x3ZX_NrRlJQxJk3l@zeef`d@lfnsH=)+wdo?sa1e z>yK{={#bWnxVa>ALEaD@R8er?O1c^KyDEvQ9pLXly)REM9<_L&z$S}sqT*bLy0rFs zur*^YS>f$z`+Gb)x3`VMIb8+UA73*WBM$``OD#4dpjH{f-{hug9Mn!fk>Mxh;an6# zBbq6X+k3>H042Q^Uf<{;J`yJJ&;rY`@`!7?LX*RE&MIXQ*@M=^!Z-X@Iu#9jm^ghx zw6}pM$MEpwY$T&+=U9ktc%lJLZMDT8IOvV%f8P2Qr{5#{$czIJg?eo2{VEZ^R`THs zd`KA!X|d(`;^Ms?%vymJg9;kS9eUb!_Rh(T&jefTI^j{Jfr5hEohX3hi1 zeXjkUQqVh_7lz5`lr=76NQnJdI1T)idW&Gl=qwr2Js#9V?{Kk^0{iGV znR@5v{B+WCj$jX+50G}w|zQXc~p5uy-(Qz`~X+aSr7h07QO zdnu2|hEtY(W{fA8rj%kO{~;ftPajviNg!t$*`r7wPhhcTk)@1mt8kFQ95KyLNS85} zu9G?m!D9E6PDnrDoE^<=$h7W6JqZzO5Jy9HEfNTA7dt;zlx6JEZlkg=)S2ugu=n?Y zqG7(8Q5JY`P&9QI8QU=YJMFOC7oW0tV4Xa;3XgR%?(0QE)Le8GM`aG}c3N4}E!5hF z^aQOCdGFxK$77}!S5IzppL*&%MxVc{(m1yx^2RV3Va$-HmSUiRWTT{}C3&&I)NLJ7 zpD#^O#0^vvIXO83Bpq6u5)GjJ_2Zvx2A}fc2rE#2CNhCbWc&|Hr9_P`coEIgthd}4QJ&sfIgwqp4 zv+PGbaL$y7ta=`fCBNb&HXx$O#T6C&j;~R-JKCS65)`L|YaNw8s^Lq)};?6(isJ@=u>Bb(iM z73|TnpAjlIXzON#W>wGieuFn;YJ+!?jsJx3vJW=iM|e1GBJ%lkO^MRB{IJ8(3oHPWN3J2I+el^XdH;oUDM#cf@G z_!8b--&mizj1?kBAE~+e^s|#FN`q~FgK9XGW|{6jJXAQ ziO>b8c?vSv@ZV2Qk}DeCcw~<%$*9c1fU6a>NInhrJ#GNRH7vP_4GvbT(d9Uy&+aRt zpRu_p=aoiNWQ`!AaBY<^zWn#ttO+A z?W^+KC_OVo9K1|sR8Wod0bJGV9c-E)86##Xo$K*d;md}wAn;yYbj`lqhF{T?&ONY`HVYP8V**lgba z>{$5TqhBJA7t-`f!0v9Qv=BcFsX>|{S1=!`ByEROF;9%j9w3^;m4Mlu@3oR(Z!KB6 zHaV#uR0K!9u%+v4nHL2_>g}dL8Ejp>2}A3}+0RQ-reN$B#%7y>L7@#&o#dN^O#c>o z6$%Db@uYj<-b`@z`|b#Q#b7ffS$2$?5|IJn-#==m#Z}`n5P;KV3SA8<0e-s}ht)c3 za;m7`J-GER!*GweDP#s=;u@5r_()onU@zM!6^NF=EUPJSU-T5;-*=K=`~LbVb>#Kh z3bBP;-lwCslG95LvAJ=U2KDsJq}}Fl9xwNW;x(t}vR_}#xG zS1i?Ry|Os0#StOH<9L=?JIra|99>}Dq#61;ZXY{KOnr_~2?%O0T<7j%(u`IA79={s zTfci^cq?MFT~878wi>7F^!ut!=LUYO!+kFI_krEB36(pN%sBD4FqhO9$j2lF6sdlL zrKe>ey`1eiHA({{%4?s!7 z#s&-pu4mF=ZU&nXF-9X(f1cJQfxOV4str2HGl0Ef1q4?j#iy;)eP#CE16`nEnY&(N zVOM$_WtYbvA8;y!>sF7Q>E@8p{u3SEH=ozUa@FixSz2Ia7vkj2$*&=YV=A-L6j5gl z#Vfjv+Oq7A4NO*IS}>>|-qKXC36C&_>mSjeH4}bDZ#5&W zsgbH4NuKposHGfOnnGg%KGQ24K~0L5kH+34p74iyMpRm2$pKC*UV46-G38`63L-cN zY`(8c0eU-Y#F|s9cptS8u$_l6O3w(565s^<(kl#x1QJ6^%UfUrNoFn>XFWQ4W;>eq zMa*s`s3&EAs)H)2v9P_lSV?EYQ(pS5Ii!5nip#QS`5(4ueX(pUgUIz+g zjhAhMM==r#XW21|5_L=bK7imR%c# z-(BAKXY1d|Bm`Ub|Archsa?J} zEd`25b|;3@T<52pK&&m%m@xS?_&chJui3?~lCs{1XL^NZzKRdZ%gTJI=N@BW4F#IgYyilI2=njA@X^*Fvi9YE;Qck4^HKUP_G_K4XNr4+%olNXvK~)}C8G15& z9QY`YTkY@P!dm{^_dhSdpV#5-nZAjJ^oeFYx0kqePHJ1JejvPE%lDd2j|<(`2~8>} zv2g?db|PNQ5>K^;U#hN0)o!#guiVNY;+mmMM0hUxBWos}zYyRn#(Z zNfl*ZV%Htn^ssCej$v@fcgyE$B8DZ6-wZA%r==@den7v`@%a`_Tg;L<1J#Vz>(Sy0 z9T{6p*b4y*I++Q-kHV0U<+ijLEkm2nkE>dE3VA<)_PaZFRY>uP>2`k_j?F(8TeYIw z5J3`%{=XwfTnitZ%%0QH37%IzZ0D-GxgD996l-;|V#-hI?CAdb^1s`|e$}0?po%c~ zIji5+Ms9LnZq{22nn6o(IUS*YPuXEVT8}uX&*Z3D(~yv2s`=Jx!NdK6daKnM*Zoe~ zmY8(i?j+D99SS$~>DmG8!Z!&Rcsz;XDtgDI+AQhed&QU5ool0GmD+1Gp6(g<9Pvq` z6z-hlw5_kiR^Z_fAhyB18|BRC{EOH*{Tn4~>NvJZovho4TuQpEc^&;2i%rr(5TkaA zzEff3NL}07kyERlT*|%#mh(s!Yk=YBZ9ZnM!G8eQKgbItS-N00Kf~QNetJN4-+cN# zBDE}oxK_(QDjJ9&gb{!R14U@!^HLF%i#01D3(VG06D75zX;c!(gOFR=(LmWyIYiDX zTgnfm0UCDtK9di$~g$h3{D8DEEMa8Dv10nAT(*2WNFdz%DWK# z+5a?t;(xFd?H*+{!ueRG>H=yQBq-HFd`$~WaV){06lOPygH5K6Zzw0PB8oql6QZbM zE-TB<4vi-`l)*ux%KA3igAqs`q>KbDf?XH^YfK!W4*?kER*i8|pr*<~2f-9m6-44v z$jSV1>5Fpdp#RFW{6etgfkGu@)d&+W1UN}wVTv>If1nAgV8pk{vkgI&hf$-GGlUg2 zh|lsigUFL3geESvvJA-Ha8epBw4--zZWLjcZQxGsEJAIHL>=N9Q5bYFnkPdxnQv#w zB0cMtUU1RvP&g9z-^&R?{tMqErY>}&39Kf*H_~%8no1pCmy0FlX|I*REIcKMkuwO1 zZSb2Wmy?Tt(g_x}Xm&1NybNL*Ld0NZ;9$61vY#Ze9%U5TLI{|>G9*7xjJqX61EyD~ zY?fczPDYl73o1Vt{wMjrplHia@}K?{4qz+W)RKXa*ALth*28S1nFbTqEfQv zAp#1uALeiJrL&@oktpnSViZh`f(hhS6mo>%Z~;3LfJxI{3Q7o!3qMJj5;BwgctR%Q zJTxvcG16ev-te}H6z)J3X6cDZ2y_~^|49B9&i_zSHlCf)>Hd0!SOTyNo>?}uWFc7P z^VTxnx;<1gmf(<51AB7i4(tWyIXTI$dgFzGrqq|=Jmo7YV{%77av7RQ4C3nN~nW2#cV9FHa%#fGk zf0IaIixy+k7enlHOBjhWD}g7U#j*jL{zU0aY9&v{b_hFE**#p@wPoTc%2lSV5aNEkowaPk zgkoSuVM$U+^$eCI>PS{43lIAc679RS4goBTnbA55jZ@|~l&S9ckDWpN{{dROe{d?| zM(EkLZ{13YWyZ%FP?OE#;zA8`eDXDgtP7`LZ%s|K| zp^wh6{n|VBD)Hbxx|fOICxivV?#}CvOWQfAdO7&^oAMj!xj`^)p{^c-25C8)3IiR& ziIjB19Q^<$SX$&pT4aafc+Y`Nf5*O~wyYl7?{2 z)S+1l-ttx#7IcSp;}8Hb${K?NHjd#b@RC9{|DhA@3O5_gW&yUlpUs<6F@$Fp^<3v) z2XHQ@bermRaNu(PPan`WGf!QVWst&sI6?J0%|698Z_0D_Rsa&jZHKfy%n5qBww+jeybDcT|KSK^>vJY zgBO&FMnMpv5Y*Y$vb; zN2q(Zc;MGoim6^SxK&1p)vzp_R3i%4>WuclwC1tC(#z$m*cc~9O7ysn3QGt-qBC)>qh(#9@8;))dTGSxbIDue$! z;q(-%=;8lKe6ts;V6D8ltB}Qf|SP1QnYxxS&2&_*XyLe^I-SCzm>Ug3{Fuhn# z>^uosY(FgATJ+0%I$pPDcG1*F`wMu6T<@gAZFfGDTkAft-uh-TVD{9Oy@+D7Q_*6} zFP3*YuQQ1gxW2spxzG2z-Sl&np}yf@@N&Fp*PiTgjNH`1h{;5R1DRrQP(kh?HXaOK zm3@?qOD{{3M-D5MJ`)tskr1(M09Qoon;d~44Z`AgS-_VsKv3O~klyvv8Rx_GpDO*k zW8d(vQ9M?wYk4Z>NTm!m2%}Dd>Y;|5tLb1&y*qaMpLbVV=DQw)w!u0#{!A-r`fmGJ z#dM$o=-+pUj(yAaRF?eSz4?p=qsJ_!a>gr?g}vl)zs@2aah!ys08$lw{OZaHi!@_< z3SS=2XMT=yo-J&q9;huWxO*f#bf0F$Bt-ODKMb=X^JDw8lokbjFUnNP{@mxit=#ta z=cj#>X01a{7=+@I<(SZ|Ys4KMMM9X84$t5t%I$#QTRWLvF1qeXneFOqBCY}~XPY6d zd=5U*)I)I6GeI;>X109=q`aGf(Vy$74s4&(se<=8tBCX!1R6gI*}jlX|5tRG+J z#2avS6Su`=9wjT+f8hcc%k{jtwq6Rj;aH|aOj^kaa}!BB{H#248h(hR0QBfKT&OqEa~n3K8RdR5v? zw&y8Mf@paYQ=eC$r=Bv{@6&<01J(0EBo!q?<2WyqEG7ZPq+P?D)^B4Zbg38+VV(jk zr96VoB(x_7oDT03J~o%Pi=iS0F*sJB_uGVLMHgh888ZClU13^LEghC=VKfOy7BeIx zl>!fhwz_Da4xtkrJAnm`5i<0fPRkDJ9~n5y2dQ2Bk|RuUA!I@t?7=pLPH#tcazDnn z)Adj&cBx-LK5^B!Y9SyQ#6gH}nDm?vdV7(1aQ?ZZOW5>`=>4awtd9I~?oU?G=G;dg zVT_AE@RD$T3Y1zk=MqhFYnk|29&H6eZm}B@Fm^(FUvK+balg(gXe>Z98T924T6O|o zrVl+=(l^z+{r7?m3<$D|MCaA?TSUB(ZyDuTWYOK#pbp*bE3silXWmBq2|~re85} zR}ZI-oj+|1b?2sg`Q)n|-ldy9Nm6;w0vxs)v_20^Mt_*czykLiHd|ch@@czJ;YQ43 zm4|ZHRcxesZCL}lHH}4-v($mN$?o`@{b5Rx^&U$Jl}(elS-1qYH2e)D&iWehtRRVA zYHGXg9O@x2Y|j!PC53QH$29)CnI#b>`(41ngxU)Tz&Gt#q7BgnPN za95=ph$QN=ZBT~Ht$%0ppsi5oT`z*lS+G!)w*kcCNdsn-cxl zXqXAPwHO@*6-0O1AAnyt$oXJ^;cp8;Jk$0)*b6_6j?P+ISi=7UY?L(IydcOd3L3s| zWc1J-Kj&Hq+YCmIh{>8Z%Yy@!I%3bL!Z0tj3tAxhi_SL|G#htu;lIQ(lS`REASNyJK;@2q_mjUeMsZ6_NPCO z*Pi8I$UY5vrFd?2+GD!YJm+=C^S6Wb#;%ohw&^)EJHC+z!MzZ7PKzXN(cR6-<*v(2 zhFqWD#>zX?yHD_{5>M&`GKw&6zFC^qh_T)StUU9-AB4gw?$Xu##eRuFa>KJ~H2=|> za#zS~xTB0hID(p2J8XIvGM5Y~IO={KDAjK5m?lJtpBF>@bva&f6D#KRM~pneK4z`*%<&U;z|2$`H{wvH>`3q}D`1L3gJ_5h!D)y`Zm)X=TAS zk<8h~jn$#N_d)7#R!$6yE3giu48{=e?}pP{#c>?u<|0900Si00KomlkwNEellf*r5 zr#R9dT{ppyg$=8!Xh~zcFhYq~Ma*x+x@72jBmj3K{_1+?(;Z^t257NgqQh>f05h+_ z^pBJl*kEYTJ=$fBhS9$l=w5hGbkfyZNv^N)wa_>Rm2sTM{FHKt^tu=Ctc>MEj!9)@qwteb4-1;Ug_T~T8*@xNAD<)w; z4a9C9CdPH0w$t4W0MNtEUV1#K)>-8Dc-*j7?aSB>PK{WJ98%bq%F)dhoSj79nGua#P+E zlwLRWWs5*rX^`Ds%8sR`{;2Sqpw%3vC@8bKV99=z=k_HWch-acIW7;=GB=N;h%4oNf(6pJMP4@Pc&*h=fA&_RJK0Mxrum zi~*?OY}qt+8#tnChV19&rWJVL=kbwQ?fejFxR-8gjBRc7Jhr)+1p~z23V2>xC_6Gq zRx3sXWzfZfJn9l=_5LP5K7yg4{N|KD6=<*`>etHaf- ziR|dKc}P);twtLY8`Y>DlJEUxhIC_fg0t*MD(d5&BR`sIWl%|0v5u6^&P)Rrpf$v} zQpa-ge=>{=`ubvbh0qbY8ZhYB5X)kn zOqD@f6>!HX8`P?i@a>P^&rFOwby{8HEYDCrO)Jcbi@!vR9nlZS)0FS7S6LdE-ey-W zZ#hCn2^5}mHZ;6?G+33+sqMX`h*8#;Ty!GzHSwMYK9@f(<2J(_F-4gDV?@%Nbm)Ks@-{#7Lm4nJCR8#nkn|zg%43 z=*}^&k*0egBxIEEfXdEo%AECeAl(j${fvPw)bJ>gY(9P@9!x_m_00NJqegxqdO$}5 zo`Ax^#ERXB0|szqh>d5>u#sZ=n<=xaVwBToP!51I@KqtwKkUE;>mQOLX>~ENw3Ye$ ztgra`KQOB$JhB;;y|4~!ST@`8O40jVh#i!Cu0DCK{gWRwzLOKI;1shfef_TGq{bW} zKxn765TPe2w0EoMY0%(0mQO2asC%3*&-u*wmuKNPG$Jb{j%JKhmzNBbDb#prmiGkN z55}nI0`oNg1^*Kp{lb;yO54F2aqd=ZZlCKS+1G zu;Gy;p73crB|>^u$eJmBz|DMAyPqPpp4C`*AfT>VUc9}J-taV`n(K2t9MrA&6Du#f5Uh^Nv%3$w)NI8 z90c~|oXspqgr%AuFOeCk7=mnmR#Zh5#Wue`ui%0@D!O=!wU;+&5}|E3Tz;>kW&Ji^ zsh=@H$E@=IUIco}9`bVHjQ(}W>&IdK{=CMT8{udE0mCHu%|*!6OHtvgWj8$nbgd-w79?{Q<>pxg1Wltr(mM3c%L_~b48`{$SD>@ zRJ4L#6D{MiwjVS9JEeR5ASiD0>UbW(B4W&ax0Tb9+GAy+*A^5UUD8hSt$)@j0zLf~ zq4+Ilim%O;x&oI!mz7CK3ei+F>BDa%J*71SQ21GChqW_4Yn7#J1(b{*Bcv*=W~R9ljW@&HXKZy=^;XH9UW*VRG}OpY(oh zYeVcRv%4$;WBu$}A@eQ&d;k?hSodeUVBbId{Lo_3n|$`Cdt9=FVW|ClIDzzEr(XX< zeoG&)UE7uQ_17YRbJ3u~Nr3aVVtrSE*!1OcCukw^1Y}-SBzmDGyH$}j_@gqk}b^)Vasq1tveYx%@w7UD|fuWZW;w zk2@g!OZ+;e8Pk5c+#b`8`Z+3e@lv>#9O+i)?p8&Y36%`lrHQ(g?~K@5hvb1NepX|#bti8^SdGA0~qi{Yu84#dVb|OO=~RjbAQe|;P|d~ z5b^VvdfYzi*?K7*-WTxJGBw~f`Mi~6cI|6F|Lo=4{L$rhigllHbL$`bdtQX`*;4=4 zs(BBG+xD39q8E{m1&S1eEJx)SAKZvT_t9Yq-@az8$$|xxrpT{P#?H0PMf+t97$V*2 z{wE8f`sAXTHMf@MP#l_*RCr2u^EbEiZ)xc7NZbec z*N}h!5Y(GYS}($G$wv4O=Nr%cm-A)YIF{BvPVsDwP8*wN&#H!@fh=L>wCNNha`NWV zD;yvT;7=KYRY#_MF7|Yq`-FMfq#%P)Xl0I4ta!O3FVqupif|P4d^8d?<@O%jA8OXP zIrOamlDy15G(4vFu2m%N3K8ZL6!M<1cHlpcX5~p@fDL6{(fgOtz@B~jo#Pa7glAP3HSt@UgEYtxO_UR zid(X4^8bSx|V0$=zj2ug0><_p=&}{ z?b89MQS*N>!sEBSfhe5g>lRf#GvD;P4HQuwzq z`>vF(ZN7jXS#{E?k}+2T>P85^_lS51Ki7{ek4-g<)P9%k-R``QC^m2PV(>Dy$l8s}q@Y)=r_pzi=HT+eYR39$?h4Q@WsiTp2_5ZRe+Mh2hxtJn zbD`_+@=kQa1TM56UbUvsqIiMJ=KNCMm+k*~)W=Wt_3M09`-1EBQ(1;i-oxyOU2eAR zV?FA+C(ra{si%3iFnyW8s?47-8oZM(nKLOGwjG%g(qA3(xD*t?{%|o8XAy4DUK6KY zE)tAj)?RFCDC#?kR(WCskucUlJrLDjuZlONY==)wLz7W4OJIyh5-oQFKMXJ@aMXMO zX+e)UcdaJDD4=Eahrb3-J@PE)@xA|;%g2rWnn~^7+njPn-oMu^QZ;a7uLc4yWu&-B zhxoDH+cR(}Kjch&-OMg(Z9WOV)n(EF!zw&(zvo%`={GBnbm2nf)qb2L*KJrCe$`Wk zc@f|^T#BX1ytjCKt1Sj6j}~_9`q0lZo&{bX#Y9yl2h1OiNGA?LKGeQ=RCrCTAX10g z#pSyDifczgX>3R;m&3xRkjWv#xG%Dc&NS{-z0y z4cbB&oZynm)3HKJRW708d>B?8gAHL8%*qT+8>Fm29R@kb70(R|wXZWoMnMs)6pn(V zAY7V*Im})NT&Ry>9pu_ASBFp)kFT@^G9bn}t{B;sWQ12#?CD8Y-$v2@54c{sI2A7Q z@G|jUc+q1v7sLdBnFO`>y!8Ls#Q7nzP1bJqv2k>zo9KG>Iz*Zid7n4(|5BM(p8PXw0<;&=gita>(TmZ3YbDB#_l8No zos`ULwPx4hVpG?r@ zbuSPnkl^><$Hg-ynoj8ts)v)af}WqW`CroZOfZw!T$-6;|CeVgrTtotYU(a8PR-jh5PVZEe(ewLUGEE$D-8FkmRQxd?Olum zaa1|h%>&PkqhpLVM)TB}dmHS;UN=ec0xwF(OOjp9{}*R(0aQou^ox>^gkZsfYjAgW zIl(2kdvJG`-~>NdaDsEN;LZu|?oMzgxWirY|GrmWy<7LztG88DEqiu)W=Fbz-P6+@ zo&Izl3VWwX@B0`cz-J=ba4&j$b3AMzA@^s}Oo&hT^%{oYR&qQM^2z+PcGU$U!9=IZ zNdx^9a0c>&eSj(82zt$Di7vg~Gb#MLk(Lcd{SK`d3y6SAYBHrkpBILuX<8+t$eMat zH>ATz+59yb@cUWX1>@s$tR9Isj&E67J9BV)61hDOeg)iust%20lXO|tBcU!Sc{SOp z2(e#NtbS6BOleWpiz^|-2K|?ROu&7{BBmttaZmN15n?OL&CaN2Y+4+=`u7$p=1bP2 z`Sc*NELZ61K7~3N45s`<=p`Z*rC{GRHUgxDbaFM*-^U^nf@t$G_1*5D=Z<_*j>m?c zPF$D|p62c>39js1**)zV$i?(#r9DU7I&FxjAV4o`g6)Y`TNP&|YkRMV9pCnj++1^E z4f*jDC`^|t2)1nuAHF@x`#j1!QM#%q103;lN}&EMty3*KwiR1y*2YpE;-*rTbI3{w zOcO@Uaha+^Td}T>ryGv_QI=Vx$qTP_)dER?z5=KnIQ$i^_sn(JXU7uM^)VzAswWmw2ywoU?gZ?2GD&i>@A^ZmEgJ zcIVT45a*1$t>k7hzbv43z)(W;?dnobceW}AJId(of0nYCuSS&7tGrg#>m8R_WnU{F z-pUBauiGVp`Mvn74|~&qM3rw07PjwgBvcPk>q0lHQZnhHST2l4M<8_|brR=^qaiP! z^Po#Y0@O8e5s~+*J4Oj~@@_%WYYEhZvF`z2GQL{${xv=`W&;DuACjiB%l4t*3s&-i z-spx?(d}D}|Mu%L<7IFu8yNvoj|l+i^7drm4k>m5&|oQB+X!a?!H>hca?-auFy@R+ z0^;D#CXhV`Qi!Jq_NRZ5Cm+F~Vl@e}qTG0s;Dm}=a1$+d=}yx1rUQQR5SL%)(Anip zd0ywt=TAE72U;`~7Z9=`BS%=C z3*&3J$@|}y8F5IqsHw7!EXHpWu;i5T;#fth%fDm@*N>&NU+n(bpyC&-*B~Mkr0jb- z+Sws|rp9d)(|5m*+T3mT<&2orQ%pa7YBAv}NsRm39|11>Eb$g@o_-5A90R*%0hO24 z;}V=#DdLO8O_+bnr#7lpdrv9WniG#rsSy4i{%>u&MTKB|?N(VZ{)c+*Mjq2YfR_13 zL=7=r_49n1dY)}x^VU7W8#jWiPvI!n>b&NC$g_5HP$7q>4w@a5r%_g!Hf}J^rbnNz z`QC0q*(bWTl`As$r*|4>w{f7=Kt!Ufv^|fXlY2#sybTsx>%+HhZkMmWWmE9BcgvGB zDC~yOiNz1`qQt+WZq}OA;Fy)On}%3^6!6Toc1kY28DyzNm8{pW?`KS=x9V}elW{9? zYK&?4`1~$;=p!QEFjVByE4EhgGoHnMUn`MVGWoRd>(XMk&t?V{MFwr(1O#++*Ot9v zFYkgOi)s>%s-`2l@Wh`PJ;;C@7i0&^jWiLQTV!8*N=m@@G-9KaqtVFbefn9tD_0*` z=#rRztZ6@OO(gIDfg&r#1)!UO7F_tLDg0z}DNy6ffK6P)m1UFbn6&#I$XpWT|G}N1 z>GGxbg75qspSsR|q6t;%wmfY$)BvifhkbJ)Wc*4u5sujcTA!k}t z@gF13XVk=T3{|H^eZSC&0zm-_+cR0<@dcP2zAq~xtHI-&s8Ee;gWn2z9f^?=K;0~T z8K>k1A{RAzIfZ^C{Y74-o#LCa+naFIe2-Gg2^jkiFp*-{7R>CRiHNwbDGsg2VT~9^ zI*u4ja3)J^)#a0SB&Svs*auV>S4XO<#q}r$tA&2py(#OSFnZsfL=1hreOl(C76!3L z61S&0U;o|b@b-MZ%f)2><#%G6R3dR6?Ps>fX1gY=Pi$8_uBWS+>eg>)mtktESW?=fY`0o1P$zQX)tj*rhZ#wD&rfjqv9UtHD7_vGyy2d3~(xX`=VPtEDyodsx~w!@L&qR};99ojj0S zX3Xe{g_ZfRLY7{xoP9$!J2yX>;KQC`pEVwc_5ZN{f1%x60h|r7ur3{h+W+~9=5z0r ztXl*Ja{_k<`fzw+=LHirdo*q4$TT%o1dVBV4TnTHUs#x;ThZ$H=QTe6lC$IB9$l%_ zcfvh%!JGDMz2)XFvr#3h*qID`TS2`{No@i?^fm3if6V7^tD1HBbsgOA_dCDE@zDG< z92Y*-ZEF@LF%LlncHrfy8d!g$@=cU)k=%qAJNj=v1n(hnkHN4voHVWAwbU*K&W7U# zHS6LE!vLW%9DqUuw4098}0|6Wo0&x zZK83*&(Gy9J}+47Bnhw`@XqG_tlIC0@_$0{P@kdB|DLxP=(}xYl3XqdI2}1B%r>TD zP|@L;qb&Y*056^`%@Kb*+*pJ@C&u?aIWA0h<9%d1{G%A%lI{t!JMA{b z_Q-g`qcme$$Z;P3?N!m8sol3GC|kwD^r=WY%kPjY!JuD7FmwZhoW&L%?u9q91Cr0N z8YIc>_ACpiFg=xM(x#DsC+f{7o9*XR7Q7JM@r$7Tu@$8DkHGDcQS7XsE zbvsf6_?yc=2iMfEGs|GQKt3@PlAwE;OTUA-$txRW0t%XFbQmFA?ta@!_TLe(1PM+Z z+|*P-fld^zp!btju_|eH*Glawg?78*u1jQLU79CN2M3NQE{WQY(AMDdWF~!|+cQ;| zG#{dylgv7-7CPR*I~g-Vly|q9dmeDGElv0LlQN-9gTF2i51byGTY%&BFpb+6_88#Jh~@2Y^bUr7W-Cj8Z5QoXBwt} za~IFmZaWT&YWKvyp`E{nNIHL=yeF-CU-UjCTSpO5PQ@179A}c6v^~5J(bTk1pKK~y zI8z3KZX6A?mInjD^U2>M^pTR>vzB%u4Ix!OJpI5WFc3}>K@vJyJZLk`+~ME36*Qjn z=GpNc4Xl^6JZVgN3KMh2yq}U zBar&}#s2Fsi#oLS7*xb^?qNAa4z*}hte3{u&!3nyFvl&!^RKu7{}P55#y&hkc51c5T}XDzH!2^c-E z79R}xo?(isWNy;kQ**(2%8rMV&c(IJKUX~6MAn#25#Oqaox&O?uZ{ z{h27O7#gFkqpb7ic&EKNZPon1l#xNML>(13_7#nE1;uvFGx#?-X|U^CI1k^ar~%eY zy3vVtk36A57Pbvn#~m+39Mt#=)lW>dBBEa{dBKtbp2ju4^}(r!9E9yrw8G)A{|xYDxxOj#DT;m`aLmw)`A| zWHUpE78oXC9JzlypKcdV7*&dbt3=$?Fn+>N3CAK?tU|qs5Zol6A3JOQKWR+EX!0+uDD3jRF~5ePj}BlXJZ| zd=Hg+%FQI$Yo4E1#0-SADlw|}ZOP?%&kih&cKaS0EH1Z*EMPxXG+asha_q%aafVj> za{c96>Bw$HJA#gmM$_dY3M*q~i;c}k^LhO)a^RsFH$42cj9oKl{53^w6hb*oeZB^f z9cl~>S{=UmKJH%d19}y%phMo>;}BK4Y%%SDHCIzZhQ@@jyzQuV$tu$&ktld#Xfh*f zVkCOS?+@GZ+qUps71QFNqus}ryRt=oE-K4DnU>{<``7&JMp8%l>HNhR3e3qONw$IIEbr zud zovreZCb`RER^4b*=`l#3e*|(7c@BQ9+P=?kH5q2rbQ(M`P1(-2Q=Lvh+<6Lv8kB0< zk{dE_NBzYoW^PL8w^pM5$QrCenOw5(Q$puIn27LJ3l0BhHDje7M9dE)Ej6 z&J1LMPs_7O-Avx(m`6%8FtqM4_}lPdBg5k|cq=1Gk3=;tBsh1OfN1J)e>Ay@Tt2?; z!RycFSh3ZU-RD?2F{8s6lvEDBU5E3{4jqMcg<0E^z3py;+04oJE3T# zOusuR8Rd#eB-AD7l(v(XFwa6BhsQgT`k4)IG#zYun)$d<(ZVMp-eNI!dUMN?g~SDx z?R?4j{DC|XSCv4Wiwc`81T9`*$mBVRM;b5p%~@?A zHEPgK9~E{R^txNI1=6M8HaXp3eQgHaid(EvWzss}MTSAaD42Uy6#h{b1C|FZ7^%6z}!PNl0D>p~VUHI)&XiIn$SJs)QrOOoKeK;UG|c*qgF1H7p?MXhfsH zmQM14-2L=hU51RG&r#>6=WFrJmNLF9DtMujhY^$&!P#?QbvqAqtT)%@w!$(DY*-G; zuY%t;BBiYLnb35JicBxBi%!RA>zXWZ)D=Qk8_>Rs8`?PNy&Y}7{0bzm1g{RuapJPl z+fRK_O*!d`d@%BPa+xWRgxz{(<=+>}nY6R5DVQD&ZEG-@v8>Az`J(FSF{_U6i?)Bf zIm*z?B)4!jb$B`H>y6l12lM#d(Bfb|*c8TdV~uxRPfcN_s*bCRuBKG@`gNrIvEJaU zZl>fOM{lyOpXuN3tHOzVEoj1&isLzl-iW%a7GS)LFKI7`GyIT_bA`Wo$gy-@hjY z?SA0Tlu_m|EVgyLNB74HVa-k1N#J_Kwe6Q*{CLFHRvI$Gv^=@lQas*j!XG;B>7lfE zC}}k(@$(C{2@+}WE0zT5kp1UG=@2C;Bnq<0-|jeH)YkY-&cHzurC{x9yH45d(M?!d&=Keo!dNnBw)^1|KC7<@gSyL6GPnLZAN}BSWUQ?)FSIhvXu8lzi^I(qWhA3u zQAz7$O+Zp&PLO8(!kX|7SX6>r$c8^K0{iFGQ`aecgqQ!KLRqBq!ID1{`V*x;%P{ce zi>jV!m#Y*FfQppKrtQ`5Q(W?NuXP^PvhGP*Ak)Z zVsyG0nzNLAM%2^sOOgOXQ1K$JMItV)3s$8LZ~p?N3_0`b!Afl|p#{^0i%6%)R%3!i zrShcX6QtkPl1m~4TiO>-%MtUtPI;1CK$bnKw?^u37 zS|sC1ShP?Mi%!bFfRz#vi$i0kjHPXQ+M2Wye;zR~cYgpVz2kkEDhuJ#X^+v)3i$_L zO$l=m52CeNZPExW3WcTfm5Rwjl$;VR3HE%c@;JOf5L{=ZeeyM;;nK+`4S)d5J4S#4 z-hweb;czJs;;O|}6}M{!Ser{Nw}nCOSbEAWhnjL*;kcS^X_r@PFuX2}w6M8y z)tTxhV;O{TsjTs;d`L~#qAGWyE0W-= z#8@v**`&XG32V{n)%cuZ+H3d)Gi@^o8`Bw<1v3fHqJc3=ga7o#Jv_QsP)Rk5l+5{w zX;>mig=$kD{c3kbeCCOfUrA+Uq=GsLL(xgoM-dkdEdt2YC0iPz;81B2E?r@=y-CIw z&{mK^cxk5^B4K8lF{1nPqW>lwO@xS&W6y6zKCzyF9dCx!_H7DQfF+xo}~w3Y3Hw7hwE>VIt!^jai6GaEx> zn|!Hciao9LUD;?vCME)S2Kc6$X>OAPLTve0@2TuY@TCK~6gsepzvy50tfUPRFjXM< z9a(eT0ny0)Lkkw+(c{f*L_q-q@0M2+==nvlJ)P~#GM+{G07-Ned07j_+3^vCz7cQ8w3W-8mv3k>)OBdA;&X@}Iz(d`rgMEB@-Lkpj zMD!!JGcZ2%V|dsPHB&K4r0Xnl8GhZ8I}Rz;fvi1TM^;wWQwMz9O&el{ouIlz^fSqf3uHM@^gC@sN}KyH#XAjH)w;mo#}u{KRi zW(T=v^j;}wev$|X6^*y3$>f{eW^i$?1D5@@Q(;{rT&@pqo^g&Y9M=)RPXe7sORX@_ z<3>_k2*rzf6&5=>#kF+CNg#BL9FAA(SEbmnrZG>hs8dO5DSV#5q{U6)*)nrOka+E?<-v+gAvFNWvNn+5g-T2g^UpE+i@q2xaO=ov&2uvi|^=@$;tc;Ne4 z79$}fv?66sXL_2{nH)cI3|^o&uNXXiCn z?|(@5L)B$IE~DUN%kZWb6;#s(3R=!Qnm}01iS)BR<3)NOG8_!DsZ1_>zwMrgizd3> zuV&{m;EmGU1?9y`rM*#Y?Dtcv-<0^Drj|rq=L2_SK&eHf9SB^qu{kj5bWg<9bYwje zzxZdeX&!&NE(^sX@^P9N;A(63e$Gy;ucc94zSo5K`W+k!Je4}kh=7v$_FodJ-06&7 ziJ)_-=?s*cJN;r7dJ=wmtWV7MBN`*;8=l^SgScGfm6kH_YHLs3W$z9Q-_WIU4X;;C za`E0Gyvb%-h74rKJVsyWBz|jBOZ=tieCufCo<&mKJ`hH(KbmQ~rCR?-2|=D%<<0mP~pc4qG@;3RL>S zyRA9yXJh-xJ*rFZW28Z#{AMUGpVdyO*%QwZRNxR`Z7@X9nG}*Ruu2D?1sCk@&7ryS zD^~`<`Ko(Dmm0bRJ?=Sf{Bt|PfifFhjBSmmu&R2Fiet;-HzT})MBJy1L{KR4%tKxAT}b6+mYx`DH>oH75dqqgVD0>qR&4RG zs6-W)_lM?eaH>m&x{0|emjY8(O-eFh2?Qd~$AC7gI+Moh8e4vux^#5>pW2R#b+YLWM%|YKo5|L$xw8UB zS73|CvCSh4ng*?h4^71ncF9kk96<28Mq9df-?^o7<3E~mTsvGOK4#T@mfeJF-j2=t zNq$m4%IWP{qFxU)%YVg4W5M&v4e77DgFT@GlZ+6QoOm5mOU=Hy*{x#hKrh<}haXcF z7cg&_3;g-zo%;niBxblVu`GlLl!o!y+u|BRK2XCLPqNWB{7xv0@3#d(nyLl<+UMeq zd!bo`791~l59b$v)`W9&T_jLiX<_4LQnQ{a2F4uAh$7fu=craP{mz%Bb!%EE0)HJ zKJ|iFOsOY;cVS}SB465Kby%eOEhxG-df}twu9j+un%zB=HgM)&W4?WOLB{nER-~!N zXkHJQ{}hBJMkj}<-HtH3=lhq&de=-N@ZSwgcE~@F1*F`N%Yq00F({wber4-7QhVUZH=Ru-pKS-0K_q?X)1qwF2i-fd=`#a^||6KUm1dt3ifa|0b; zRu7NvmRfSlsvmzE?f5Nke{wVRWHRA3O<*r;)`=lNLgvufLCfXny8dnG$v({*971}n zFVXqbg1#M)U_|q84bPv>BROP~MhT&(r=M?M7B&ow-{)4j*mPe6J4Q=tZ9=9Sio&gI zC6cm7=*7ab%+B!nFOB1!zn5L*OJ87T(NB6J$cqd-ooxWpDbuJbXk<4Ei`K@Mn-9st{7Io7TeDmr3wKWnZ zHp7`H+piK*mBdydF=56Qru3zKVy^$`LfvuodlD9R`sBI2DD}ahLc}KbaQ+Q(`L32f zBAM6(Q}qfc4O63e zqdMz)%$S~&X@t$~^w{zz1T+#c6Rkx)*yP$v)5Upt-nzqKm26O-2E+YRF$rZzzlnQSOVMs43*Uz#N5b-CsPWQx)`vobP0+`=iN({H zNg4e)_BziKuHMP;KF+0+x2sY~J1A4AXya=n`qhvN{Jv-+HY8Zsf}e3ZGfS)Akf>$F z$UgDl%?^jMqWO~a*d~R9z{$4b10fYB&C{qkkgDrc%0O3*rBg*^ImGRoaZ4nfZAQgj zdv1rmW_4b;Mc@7j^*g+RPbHe)fARqJ;=K3d3n0gqL~^H38*Vo}=SMKhux3QJG?M^* z-85Q=I);oECdA+p;|vY8(Q~D3?kuId9kv7^5gy9*3x4fx{i@S-o1@xVl)mg~^MP^R;!1~JB8xzWSE znT$30+ErY^czh!KU$rYA0<)6aR}G|cGbCm7CGD`!o_W@J0d;)OOZVgp*pp0QdzTo8 zcV<@Uhs!M^T3l&W&bPyskW0^~zy~$a=%Hyp(IBXmlf2UF?~4MNmb8en?lX^s-M`zn zG-v-dpR&CuyML(Dp?0VZ%98%?@K2-cVHx2#HI zw&AY-#KxlQQhKqkd4 z63iZN&$?B#uIx)H9rjA4&aUTW%}~}h1`HZ1%SjvQ)#nC$`}`?cHF1@Lv(mspHd1aV zQTlC!KoKO_r*_}LcbAS+zAzMe=l*|_@F0z%llFU##{lwcWC|JgD^{&)SQ2s zKx4^$D+MZ{f&YH>m(Z{)nfng|N5TQ@D*vu2K%)M;Df%SxuVR=lVgdheaRgX+{zC!} z5MBQ+4!{=j@3Q>g3E^J3!N8y-1|EbxefUS4D7$pc)o&RO9-UwB?lkmYLQ*21A4V@g z7s$x<|46<6TQ#0VM)kj^ECoj?olz_F82M;Qm-UpD$m93PIR8=c50w@o19Y$zfp(i0-Q832vy;Pcw?A*Q&|C4)zvPRgBD)2nz3}QM7U6yU45- zn2>qYqPH&UZ$)$4cg}C&V););I;&Qo=bZ++zizY+ZM`=j8*y~v`=;ai<+nmSC))V? zt8BoQ0s?IT5+)iGEt!j*-|;waJ|uCqmvT?wGkW^7`@M&c8^XWV_&Kyn$G&l#eTvQJ1L4f5BJY0G;o2oH4-hGc^-@k$k&cxc;D&Ne zp;HYVqq?9lYUiuwxR6;UDV;|+?KHgabWV&!0*UEVRHX5m5wk&az{Ye$Fmirzp+c4d zCR%a9T-jfS2aDcs((BQ>O)S5jv{HX2LrW+Mr4uv%_3ZDBB6au)DaE7LnJyZJTCF-O ztgbb&W@4aAgkawsHg;BWQdZ~p`htTcSJsEoL+kJAtUzC<)-|h`K2V&iBI~~PQI0-( z6s+%eom$dFi^wcp!BZz8Q#@RWF#>EAIx@3iVMUE>2Z$&X2RO6qp0|8GgpYgn5SYo^ zf89c4+)6v~tE?$V#CfRy-Z5U7!?k8<=-A&si_&7raA&4j%avedOmm|(GPsc@7}SMC zL~NdlaqVL%k^*A*eouU6iCR%mpwSEL~c@DVC(n(DXLT3hWh>7)=U9?3x)(G(B;;;NRxwueyo0 z)F{S36jN)%&+(*TEPSv6sFxSK_o2sm#%H z8sj|;_HgvH=kD-;A(F6@9Y9ykqqWCNtlV5;W%b1t{pl$m-l28YC!cf~BR5z4O0vvd zcRd@R6bG`#e_BD*Yg$0-cLrAM%N2wDX#LSkTBW(HVADYC(#-|4WWY(nURH%iDNfdN z$irP>d!gToF8-Q80gRl1zBNoq%T)SFnTt3%I(4zBE>93EFhkblGM@wVp`0^>f{W2~ zC!E}fv^=oYf{6(2Uls2JAIT*bRPKBBKS_{kjyJM-Y!Y$K$8RQY#9Nm3Fs9?K4H`Sm znPztOw6Y65JOz-JpA69mGFsOSuVn{sWTYMI$dTlvLf$#zaSV(E^t;-p)vyn#LQ(#+ zP+;Zk3g`BmfBj4ibDe9xwk#(^lR_WSMb_p0#QJx?P^u6rq(O|yY5Qm5JsJ9p3RYfk zZ+<;Hy{t4BZoRulUh&Tc%AKi|hS#`g(>na#GoHsc*sGb+!5Ei1qN(mf?KFi0xl=rh_WI zCcJ+-lQxsC%iyS?>!eLF^G_q^@lNOqE`q6<{vR7I`z2`PiAfH5?$|;^llH0}wyVpC z*@HizqcudGimhtkn#KSo!Q_b=NF{XRWmfu2_&?2(SSXiC7@?NYj;e0Sz{G@g1R<1= zC8n`ry2`h_#ei#_l~QgNnRYsabk3g>8@az!I>vNo|3+%dK+J`>P(0c1QBvrYU1k_Tqg<%C%qT+bgm!jahP3;)>w|lMyD2r@{Wbgx5n4dkaAabxyIn;Sl8lABYFv!Uei=Hxnb<7>GZA;Ny^!&Ly14zw z=Pwa2^Gny|7dUSilOn*0MlLP9C#_QGl6cc-Wb@aQ2U@7HF!@OlT)2i5h@6TAEfj|! z=fo)pO`czY>YSSDJL%TKz}?YlU)&4HC+S#{c4LTcoNZv##;}0g*Lir3lJ6glTZZZ* zzq-|r4v1Uy6+^;A-fawPqAC1noJ$?4iu3NDwOV>9J(m2U9E)+-lB=gyB4amLnMfGr z{|MtALPNQ8xDAE74k-C!(70~USW^`jwt2bUhK&1C>;e(cLcgyM2HlCGbvm^i#YvW+ zQEHj(Qxqa}8<8DwQii8;i0M?`OEX0>gS8?u~?OF1mp;f|q z2e!(Ef6bo-JDw#%j8m@=0LKU$%hHlkYFUVl<;-BS9Fw|pkv0#zqB#Y7g#_**sVLAXp;8>eEeTZvN%1$J#)TMmejEbgc ztT&JaZ$!#UXxjPdvxlB0&FDb);>1cszkS_C_kM3vup7!c)yBJcLH{0;{8sAHN>0oIkXqMHF&RcM(50@6_KbBl(1; zpry#BRU06&-6jiUSubO&Z(!A$Pr)`FNF${i#vwkYSXAoUE#s%{>>d;~mUen+dNitK z-rDX+^hLjnMH7Te`7o8D7XEsGFmw^cwHB~!mQ2ovS@2Bt_=D!5tBfU-q@-Ne(BL_4 zPbcYeYHd6gZtL=x17<7O&&q$6k?4g31j!Y6v5fCFH(4fgs2q^-X&om=GQKmQJG4mf z&QSRP8lsF!nlpX(Gl!quc5t5j45ey*7lj$HD<>Xx%F*KVcV`HJ6f(@8ApvS}Xp7#0 zf0iFf!U(TI(P5fjKN*ygv*t@1p)i_Ucn_c=nOlqA@pfM0hSjCjRo!%43~h2!^o(d3 zyxJ*%AQz<53%+_phiNx!{-0vwr`W0$2mM>%!GJ-bV4Pm>~4O@=p4&3kyfrX zl;9b72uE_l^mJ@gZD16QYF&+6l1%Zwo0oCoVC;{3ct@}f1ic?P4 zYHwbeLvfoH8%Is1zJjmm3<#6(niPUrr1#544hdhnL@R^6nw4Tl%C3#CcuUf!kxWr9 zO;D>s#PM_zH&E6o)5h3nE}^1PQO>0k_Jjgbez+n53k=5JxdV{^l^?`@B+xD0$*lT~ zXB8+TU(%PE^4lZW>9UoGl1UB;Jm*?CcMbAs_#JfEURj5l{PRmu$7)9sS~61BYL?uP z2U=2aze(3NYIs=K9D%W=zLJO%4fw6LRtb>n7cuQ?qgfljdn4c0DXmNIn9`nn2X_BM zS!uX*SQveotd|4R%d8kH>$Qr7c}hJr9oA!>U7YR?>NnJWEC0bUK{Zhg%zds_thv@A zOodn%Zf1}N)f7=HNDo7R9MB5{h^H_&CS{JsxXsQR??*Y$|FszPTGad{7vibl<(q&D z4_2njp#1#q=4_6jF7c$uf{vko!oveq%xbrK22pu+Hz7zZ{5ujB3F@oZ?h}d(; zw>R)a67R&V>6(9%zS3trn9x^vX>;qRWR8+Hmp&78t58GLtww~DnsXU`fu$%o2O>59 z7*vONa=Lq4p?Cfql7Ht6S))pAmfvvX|6?s{pW+Ae0#cQ%yxd?-&0sONw_AGI04q~8 z%i>b{Y|JYXk$~Rl;IfjUpOAkhNB`br$uA}rY?_UuYHrqpOIe6=#PpRaSNF$=XHC=P-<7+nGga#>!k~#{|cKCer^jTK=pzzxFyBLXk}H=|HGYBY{3W3 zhxE~Rq_n7CP1UH-<1R^dsAFbgM;sL=ka69sDl3Q5A4}VF1M6`ZLR^S^BGmuZx^Oq{FVXV~nHl6d0Ih)YTG9(}uWa%>uC*^0a!`wYi2D67k zzrT-}H&Zecx8d;m9_G46JPzFw`wC3wNj!2nJ=c3dU`q)#J-aE07s!G&71VM9P+UDm@aVNW@JBDOFi}8o>g^bZ3#Oq66 z`l(ibb-&UI6%+J)K1wMMxXF;gf>z&C-ZFY_q+x!5D34drKQ&jg-!58xwt?0CHpqBsmRv_&>%QlrS`sFa_j zZR7`sm7Q+LW^EsjdMP~5vKF(*bC!%3QS^?73*Y!jG2I~>CqLTxAuIk}6l!JRnrlyz zGxBwDTq?gj$sEe}7f5bHMAMNpg$rp&bG+eVMCg&t#$d}@NGNSua^9*G!bXw5vA;m1 z(!S*M9uE0THrGg{m(jKt05Q>Puf>pXvz=Mu^1dH_PBWJQfs904PKPm0pud!-S?|{7 zV|)fib{t{7ONZuE^wY7bk0Ou2JB4@Lsg)FoxTk=Rscobss@vhY7pHh|10V48@}X)| z->t1iKu;9(bbkF{MqmVm=!QLCs%bb1tzZ1*~^0rH<)1lySUufT%oVVY^;m$I2F-hiQwYM!2+IY{6 z%akXz+fF+4XsShCe&iB~n~%&u8YV-M^6s{=P%DXVoBUj~z!#61K2p+$&(ZHRo~vW` zy1x9@_tCmHZFE=&eAmN0Z6PJ5XghH%XXU0p>-}_m?MqjOpy>0|Xu*=EH}4rI`N-5+ z`?b!@zd8|@Q<-Qcia;Tx~i*PqTRF4P7Bfx?etYTMx6IIevPwNM0Bu zZmBt?jk|`x>Icsdq(b-Ma-~XEwK>Np!GolRHn$mr&4Q1o(tvM{g4SctxPp99nN7AOe0qwHTJ0qm{S$fWWNC52F~fU3RbHm=>(w&$b*i+; z0y6J8^pi;ddrQgB<@s8h>o}4}`RJpd5NoUyXp@!G)8U7%SVuddzHi%x2G|{Sv^B3VWBY8*P?g)OeDP7CdZ~uYqsz-~dV+bfS;(88ZJpSvtyR38Er-Kq$XoQ? z4v{X~fkk1q`l?(X<+Xpl@pPfJ9?GeSI@O8Ld;wS5=WlJ+Ql9CQe3mCe3*)vT^0p#k zSvr+Zv(~PE*HMJVQ;~YkFY-4>^?XgP_)fjkeye7cr%2A$wY7X5FR((trlI2s!9W|o z5YVqk?Hh^qFzX)DOmkhyi&dZTJExBbODfmCs&U$T<}7=kb$M*&F#U=xmwo=r-__+Z zAf2=`j;a~tEjp>$Xg!s^OO}~{kXenz_;4=Eq5>r%fmo94ECCAswrpbKX=>NOX6^dB zs#EVKZ7#Q+voLaS)rc z_9dJy+X2MM7?;zv1KSFr@gO?ZYU{Y;*o+^!?#lV;`rf(z(}?Mw_P9c`jB@mo*KkOM zA06vyjMkGLH=3?g8LH+7B7yH==8%Y171~;R+#M+pCB`y+@!7Ey>B&}vnY+?HQ<^%o z@^}#gR|lG+92OQ*p;2!d692cS?OLru|N3YPueqP@`F8!U?*o!2M4e2syycG@q@tdu z!TjbO>B6d~MlqyvZV_@159a0)ymF*KQ=Dkrlp^avuFW4b*{-9UijB`?1Y@Wc7G}!N zHf3K7{Mq=7PO8|kH<@WWZrr1lA*gP{Vp@fre=O)jmljsU`^P!`WIwo_s8T9Lth09^ zLfQ35s*Hq`c95GOZ9Es{^}E!+#m5Do;T`%xpEz}*voAWZsp9h!ys2*IVzkP>SF}QP z1X|+nZItc~-@b|n*z4_`oTk)BF{Ygw#KxKBP$*xjX$W#D16-phkHi;y4XrSuge~wHV9&v(raUL|A6>K zDA>C6&=`z>O`IgEyKyacXpZWM8Q+wqHCUePnfq!wIB0r`E|niqY5RiHXXQ?}miex1 z_|dw_>F+t+#o03=Kjn-USd)GuDa)Ug{+8fgFtU7OjBxL*8&*bs?{|MfC8-P*dhGlN zh(D=4yG6kVlFu&O1_#jXd#o(?kvp_aCdhL?m7wX?l!o)JA3o|{=2ocRd3a9?T3A*=0j8Tbf1>H?{m&| zozo*>c1wS%(f29D@m`AHn4}<1FMz{Ri!x(XKV_refm{NAC6}3Kw2W}P z7|NbnLN}^NniH*Ug7G2IA#WS4D8h>EQCuiAe%fr;mpB#TTF@TqbZ<)Zd((m}3$MT< zY)0=*=Bx)0dZ>gh+9uV;!bzU7{WEG=R)$I4Dls}XH8CYJnkmeTa9lA)jJ*W?*tBbK zBqYx@FO$P&FSZzSY`?x^B1l}*;`8#zE!s02J3u7#}1mG78Zj+HOwglBcR7k~+ z!u6G39*3hnh)~DHqbXuVqGpqShOx)Z1RQLmujQJ*h5 zgu{6kFix_9d>*99LEPs;Bfhjcb`!RG)#z>))KKVCD>4npq^7gOY-a)k9KLz6Hk#B$ zt}k*qr;InKdC?8NHPYe}kcX&3ChI<|uUSlz&K0dCh2B@02IN)*=U<=X%BjOZ zZ@ybrJiM(DMI+>xf~we`$bjmt`KV^Vr(|xP2{s3omNU_~Wg92(uE~Xz+VkO!<&3I~ z{3<|epS*g|QSR^Y;Yy9rVEz}g3 zM=U(W%3a+1ZE!fVejB@bg?pW7`?MfiPIVpXa$1vb|X-ey5CEH*;x>*KvCpFc=E0 z{K#bYqrhu+tYm@#J?G>G66)WO?9R8b$a*-ep-r6CETJ2DQ>oM`+1PlQ ze9&<~39^kl8L!dq!_cmG94*^}>f!s<;`-rf3~CRJOnfxYJ~_8&2NO|3c~i*3U2i*M zIza+LHl0|7REv8)4{Glul@T6B15PG;12LLBM<-RE>5=N_f`&4=xYW<*NIvtPmkas# zswGHXWl^4-;~hle%1@dp5zj9y-;E6XI)K%7g_1dK`rJ(xFGvn}7|86?EA0-TsfqJ& z0eJcljL^=i&BLy>`=L$p!Jln2%P_!2*;6>)rK$Aen9*gB7+y7pFe0l%tT$66G=X$g zmzFUo5Xd_fGJ7f4-52{Y*vaW6{Pvts zFIbzcpstEebVwKT9`q6Nd8)J|G@?(+98A~fef1t<`P3!hF*P@lzE&8t^G(7uFaJZv z+z4IyG4W`#Yi*hr^ZMs{)hZtH9m;v{r-ORMp?V(sRWVPWigQ(*rJ8L>rt-Mj5FQ=GZ+D()oeLWZeq#uag(uk%35CC;3T`dbA6rq zOfX5Rf#~j_Zd|uo2%bZ%K~!ht>FIU_0i`Bz+QB&8?7Qa5pugLH?AwEvzByTq{V1n+ z_42H3{ZIw^wvN)e^uv}%%?Ij!NGq)CW9lF9oqyJmQ(*T5wPZz$kt@7{_NW5pcF3q! zo{6<#F0)_yE_6I%$~^cwarEx}ywc>A0`p#0=JvahB&@7V(ebQty)f=sJ~C|JmKT!s$G_d{~+SXl_k(meuIc6asE$~rUYz&fH#>M{^2VSe8O9B3eeD++j2g}ZDSaaQ{ z?Hg#i#ZX&56x?gpHNto5?{}u+0oc?d!N+XD{g?&9Uz43JFMT`KeTmkLxUD7>i|sZB z<2m}efAp`$eGax+jmWDPu^X_tos0L8sx>cMG#PFD+zu{Q#>IxL>BdZP*`HT_Q^#c* zZ8Inx{5_3ssEQ-I5FceL|G>q3#l=-R&H0+&&Pa+-7^*P&0VI#_M3mzR@u$uW!$IXmLZ20JAzXR>~nZi+Z4{XBZQR}#_RVycRs0{(t85|#P9xAXX0Px z{3vm+Guk`#zkeFeTzs{@2@CZAPHJS*?LoMVl8E2URZuejWM=>cHr_m?>-=CcS_EwK zNL2JP1#<7Ud*eWl?~96YmRM`u-%Rz?Fv4xiMlK_7^Lyd-oSB-QJmn=Ptuia6zSt+( zmixXY2PWOBlIT8FeN%b~2J%?WY=#zWutlskO zjO$+OK~jGbAw~YgL~pJZVy#fr6+b4mJp&jyo5~_SosuqRkwZERvN{FDvH^|SOi1kM zQ1xZa>%UNTFIWq*%?NW_{_L{kNN;jtw#HdPLu|E^||>Kc`@0=;}p-?OLsWe9r{NwC%fgOI;``ypTCB2V~Dt8Mq4U_%uwV_4$O$oZn zQ(-QES7yVVv+r2tU1EihgLSXNo{QhxwIj8(JNX;dYWu7%1zL@zW&N%1O1|6tn5c|Yn^P}h3E3=<;Ja-d z4s(!|0Q*`khO2x_zTX)*gE%_x?XOH*y+BP6E60MVr&WMK^@sa*%xjkJda_+eZGbi| zMQM_UuYe5Zd4JL_1`3$21h4M2%Y!}7oFc~XjkJ3|C6CP$i@Ba=$EqUDlOQ(TH!=u8 z!0E8xUrPdcBYV7mH@Tq2y0RA^*gq9 z49nZSM^1s**z4qVqpKzlDhb~45~F?Qc(JG>vfQm%c+MbZH7LWT#9jF~;M(1z#fTsM z^>7I)R;U)3LaVMLvQDsY#B?2^%*8b+(g@Y<6w9}S8HrO^&Y3^3uf&2WT z*T+aIM2qwaO@gk75pi;}AXD;2cH1ejX4ou{+L!6(JDmhw0WGsMmrEnMoe(M4f6*`c z{^N_Sm%Z=2=TWbe@Kh(_VI*wU0i3B~YcTil#`4K@elLqlH|E-0j|?`lGOv$?sIy0fNDd3Qd6nnNb$f#kFln}W=ito~5S zz)HEB;@gW(gAtp#ef&O{eIR(rKU2o9elqTqHtA+id9rn zsAeP^s{5a?f@sfzDUa|*+wHJ+S<(^@{w<9ki?0!7KjG#mTy@93U`{ZQ&-?hMsi_Hp z{-+gtd4P%EQ$gmc&%Fr{$nYoTO0*VF$nGP^`b7$2&y9$xMLip?=UlTQ~EaRHzsSP+jD(YIS(5kk0W5jO$-caj|YJ z(99Do?9s6H?~8?(ng77(4xLS6*jX?gtn?o#goi_)Fjf2If z>@N84chU9?fa+-f2Q2K|$o|SqmEkqYBS#{Qj0ICwL?r2>tR@Wg8TTWEAPoQ;uLkS? z%m0rbYoytVm=t^NuW&R$Gw9zpoQ;$0AeZ7 z$a54QleVQ;1Nyhg24Cr>x$1aRX#H9ZKfTAI>bTEUpljFl|1va$(0P9GVX{PhfVI{{*>pF@8uR{_*`E?+Y181vI%A z{cKVaJPapvMZBz`o(3jcGK?k9s?xrdefp;GM)u8eSnH2DbGtt%T^(09de5KcW_aZ_ zreKj_b#C0$IhNz69sqb2>p!CZ5#oQV&@*qs@Ht{MU*D~Eq1w7>U+Ps_4a;=#4}OZG z=^0bXV!XFmozS27Vx)3Notd6L9g(TYjLzO=q;u!l28A8UWOwJH)3~SqlDL{O)t~yq zmn{q04KP1|*zSB6Wiv<<-hE*nA*s6!9_|5WN<;PEvYYUP#PPaxJgW-3L&R{Lo%KCR zn^xk$^T7Z!jETSnvo6r<#o$w_(4)-h#yAqA$SWvx?q^(PRu$;G_iUY>zx#RHnUyj0 z9?T2is4Nx>ofEW)h-|JCUsnHhb0^Sp$L{Aof*SoF0+ta0!4*Ha74bX#esylJhj+k* zi5Ma$e|-ewU{5ovbClZ;{y6-bJkxQq*5B>6kJe zer^gHmni~ni0}H2C0#BlpkT8w zsk??`B6ASNq}5bn{xG z^jbGrk=-n+92+?QHKaaY!6oTI7YW8d*KI`v*Xn&T8~~+;zd{|vmm8hdOB}=r$7$^u zwmN6m4&|f9P3ZbdJtf3PvHPdp++Zi3?b#SjG#oxgJ@^kPQ^@t&cT?I{?mv+4)h8Fo zoC*FdipM~fp;UYN5AE{`6rh(N5YD_}P(!{_7wq_TsD&0)Q#!xxTKSO!cH)J;gOTkM zTbB5~4sM-JiV!G=?#PG&EJ({)nS%L{66!+DWC&yFpN>B+ZvYqh&c)$g34*wtD)f_- zuBNjh#4~z!k^+{V_xq#GZgYqo%Vm!EF*|g_x&r3qR~3RM{|gRB8}k>{jHF#hS9g0e&Msx3lLE)2YBe+3hZ{2))i8@h{;T#s2NLoKW+@@2vU(Q5`TmNp z<+agL(5%&^wVZgOD(0#*$q@yL9vR3su}ugAh}zh~s8(nS{-A@A~W>X8;9r;H@*_N~ z9pcBuoH_l277^v$*b;+h8oscpgdA@?o=AM&Kixw|_+!?u8mX0;Dbch0BY}O$ZYwus zujZ{Oo4@tuOZz{8ND|QNq)*(|;i;$GqO{a9zW8Fw>b9Va&lR1_3!en~dZkva8SVW6 zmW8YPIYug$(p8?do+I)48xA~u#kxxbk8E-QI&%uV$BZ(gJP><}?RG(hMMFa|p^dhR zg`CS`Ewz1r7qJH_ZWC|D}O+U?B6$z*9UO0FBBBb9cJki#|MT9^1Z+Y1dxTy2F zKD_*3)$58sp=UXuCCE-z#`Y!k;{xC3bv}!jTM2J=&-^OQG&kjJ#Y|<8vAuHxZnx$c zmo=oveh0OL&^LA(#PVe#fyZ%(Mi`9 z2ecs2hz*eY9b91x?n+8y&Bj7x)AD zIPwIXbJ@Z7=G-ILr=R!zJ+#lxh-dy`O5*mK7k}izxJrh;%#sIkkX!EbS4A(VIQ4yC zGJAqubdEmawNbDDdJy+(3tX_vbfEqsI;+KxnqB)o{t4ABaSsQ8x;^ki|^^}`3g zRqM<&SF0|I!;(kJ5H7#zU=VfOLGsM85 z;~qT0z}h_-WEFnro06_r1V@fdWrzN|b@z=;&BHu*j7_69i7=bx2TUw~!<=!|iOi~= z_T_T?HGh(8yJr5y|&1uI?F}O)o0ga}5vvq* z&ZOH7b3-LvF;5UR{dGsSUMDB2OTqcjHyN5ueN)}t*F>%!lfk!bI9rI{67l|>Aw`4~ zMP+BH6UVnuX(fBXDPJz85k|d|% z4N|DFu5(V{PWO|+?1zE+V+$0Y@@$o0nkiWI~1N&ecIAR!|OIat!Dd?sLE zb)EeEQ5u&w0m~Jl2yt(+$q~|)*}n1tMn9>mY_>f2&(_gGM=`|Nx^?VtL?!Mle+s8g z2NC@RF}%K?5Vz9Y5exH7U+cpDd-TVL2!PQL5tKS7#n36^?lkx^e7Lq|lGweeitwSu zT*3+WAluBdTL&ZY`YPt#In?fTk?MYCCr=fSziSG~F2w6Z;(RKisgwf>dJ z);O2qy#PKitlBZ*7med!i%TkTt-$LF$gNI(xcz8S%|?@{TI%gebjAQKrWWU{lI_ar zgj1}vo6{f?l&K7vI&4+g%JC8&MLoh5U^prvWfx$pE&3~JXzt;DS}^J^XAY&BNOT@lJ^rDpEwklvQi`td^i&(27?rK( z{x;jYOK&e7>p5T06b8=8%Wm15Nq1LYp<-0#y|cHPqK36DYG$lglgt6Wp*f?Y_Z2Lv zGa7cPZB;IYue31|VL3upLdw4r_u3YdJdJuf&_@znUMfrb?M;sXFvK4C9vmYD@I%7W zE<3gc*AK!cl5lbEqCKWvLVOFGQt*6!wr_o{N!DXd;^|&c2_#=K=m63-$b`lKQMK?o zRPYODH6k+>Nm#I0Ix8q&`|Fc)xBdU9G`YwR@5 zeujRHeL+eEnt9WZz^#RC?w=2=$;uIsP*xZ?Ue22JtTlXOfofbhDeF0rGD)p? zG4k$c?>!%=spJ?J0ip|V9n=$R$<+P{93CC_1F#<)Kb)Dr~u-o2v=_Z0cN4xK$tQl&f!*>Hkgoay| zx?7eUI$H@y+xMg=%~QjcMxSDeo>M(s6xHOyaC*Vk>dkiTD)r@OMZYS*THz`;46~oY z;Q)ql>v^RjGB7L?BYlFb-9T|^Zd&)7RP;9<-fl06i@C3}*@8aEJP=V?C*utz`0{F1 zO{^HCxGnxB75NbHCVr-vq=+2Wusui6${uL>lVslWqpJRHowc~xRovg4ww1POloB@O zfWq!S=h2;nb80@ql;Keh4C7T~B&Yr6Um0ZuZu2KtV-1k*i!|J4{XDs3Bgw<%t?DsY zg^1&oj;(u%$=jG#!ivj65NDmznVQ=0TwyJ=qR2qZHcD2YMSJSzYC)=3C?ct&*m8cfRIyu zi=A+J2+4p_5pArb-N=gXkL+`NSJ=q_ zL|#Iel30$(djiV1@F4!v((icMT&~4-R-QSo&}W|b z&7=~?5i*wIhO=Kwh&YF%bOQ~txnRvzM}J5_*Qd9;EIvhc(l0D{)MV^!#;>OR?TR%0 z2UwI|264~?JDZLF%c!6lFZM^eNT-S;{8WjI0|M>NZxb{mqvCPg(kx1`F-SK8WoQ=Mm9M_8_5L$b1zDJB{P^8z832&`H1mh0`<@DW~M6s3hcs9h^wgz3vYBye)O+UW@7z%Uf2+M}=dkU5hI*Nd{;~KROw~*B-pFNBA*MX2NR$CMf2F;xA#Wz5qN^DJj1+m(fuB|e(&?r~U zc3$)Rn=$a_Tp8kXW4pXm2Z7wSwS=9bmUux(U{x&I+NElRV(;imEtGj{Q_jNB-B|-q zc4BYLZV3uqCFLCnkqc!)x+K79=#73YrO6Ale%!isuPB!xg;V=^y|7Ll#wEdc(VdF} z-|Wd>Rp*z7G@f@B#&Z#XbwfRY-El@IuIu@?i7GbY?kLHto%CrW&5~7N9mO7jaIPTp z+@!h@x@X_qVvaBr+NiR1@2tlUsoWjc5n;gdp)G2iJf-BOZDPcsM22&$1#XK`8Yc#c zQj$NHa?AL8-XS@Hjv<3#4taz0g@bfQwKOkX$Eb3H`jUdC^7s%RA2|@7^+oYC8-ETg zst8`A<4WM+ghRWSc^*dZon|-*g6^mw;qbNy)BE7OA|gaLWk#y9OIwI)wRJ}B-3K=L z$v-uGckXjaw?=v|u*3Vm>Y3BBt?mGf9(ZcR#8psynfS`)`94|MNQwQ! ztPOGdU!uo11hjN}rboYKo8xD^y{dX#f35cM zae>+5;U_yw3z3EcNu-so6c_!N<;iLVJtfF!MLWYCxxJ#XYHeBBnp$*Cd&wk!xlHk% z+uXC+#i2ZEx)L+9BM-(StD~BBfDPEyy0L=V@%WswUK zmWXRGu21x-+8|EN|1k=KQGcI(Ig+erMY)>PT$A>wHHuuB*OxS%Qey5GuI~EB;x}xJ zPVqsZB5ch!N3$ll_(e1C%aV=TcxB9VF2_ zIEwg?%Yo|0rS7%G3V1k*Bybx&m{oIJI}N9ob$$JS;wGIcm^)ajM#Q2T)Sm7iI9qVJ zOVcfWW80PJ#?7u^@Xg`2;}x4{bBP29q%32`2huG+?fVU1-IwLtsbDtbInES>JgkY| z2I~*ZqH$y2*!@V1j zv63%)w)V6&DYrIpX98bD7>HztS~yqe&m^ui{$||k93VbmGnxB)M%1F{p9k76+|s5z z-{;%$_>A%@4L9+Nn;!W_Pj|IP)sA4L#PHi9;wAfZu41M~C+(>xKZR7byIS2gDY&Ywcu1sp!xqG=E`vJx zYGW?EN@C&SW^mO6vHHo^1tsKzT=IsYR1J z*ax?iV;ppPdz72mp4VR@OuIf;Ib9@#LK5DUGMD-8@jK!S2Op!2RGoWXname007lPH zxR_U=gM-L#hEILdPN4H4UxO02DXgtOBs7>cqK*TYmz^;i^1+pNe_9>dq6V=n;!Zz*T^04O z{-wESR^QaLvsgf@r>Sv=_xqB37qIuBtJkgZ5b1ptPNR&>J2>r+b5}_*sbS0vEaIa4 z4_CRU^Z8#BoDWkWpz|UvPP2@{=#?o|`VdC@@rbzrcmXw9+)6t2s)k7or*H;Yl>pzyWRn5cHFY+BI@M0adYnZCdB_D>Na z>1=M^{hl}Y9fvZN`{YlGK4;rH^}=WNa5#;@ga1#{^SCu^p7rZsYvLQ%p$U&pU7;9k zY^nL%_DX?_DZT@9Wc7M3J0afbxGNVnEt~uYLkz+iu0NygdIRfZlP;T87UJv5xV>F= z1^v%u(g4GJC{m%%_olI;)veUjLZ_gSD!Fkb)@tH4ah>&wB=bgWM^lAfPLqhi@|NC6 zFwDl8eP~Q0+KD~@jw2svikgsE5EI)bhGnEydp{If2stF%Fz4tp%W0@XQB+vPrMmYd zM|h^)Y?DZ6k!^jlzpP!nm>}aWb)6Y)n&o@eF!j{_kJ_d_UESTJ)^sSfC)$YyNvUyb zn*#$*;0pr8o+<_GfgY6cJlB^0)Os{xHR-bKi=`FRerXiEuxfs3UA<9A<~(5VMy%i$ z=xF)%$BYp2U%onl_ zU@m8FN$W(w8*{WNl6`Da;ZRPR>|s3@$K~u2)sb{ulVFi=gTiXdq`5W3!jw-(-atX@ zN}>11TFpZ-B`9F=5RXTF!p=$~((?7gO?mvoC6~nQf>KdQ2?OHlVky}M8ddK!%IWH~ zpmNZjDkBHPw9o(`yPZfvYEe!}!F?Nr%UAlaLpIe1nl2iZeHF5kWh+#g%68 z#hI~H)A#MTzrRO<ZkenF%B;cx%J~6B{NAc z#E*Gy@3j8vi6%(AkjbvOk}bl?uIlVkCDh3~NTYtpFKT!*dlr&3yu=zJUQG7;`t-R` z_Pz|R7d%hPwg$cGQ8HJO<`~|@dZMuEsVkAj+nkzW#T!A{OlKF7skqB|VcjLt+%0_G zn6fA`TUvat+^+(%vSO+W-As>8z!VUbf#S=TN#Cwj$BHy}-KT|O)q>nbf8m*ugQ#PM zO?Qq`^}jkA{hmlhqGh17YwiYB#VM$#V0#=-s#ig?SagR`z)R5#1`XsN>j`_tvk5jR zq%~vqDZo=O2`?WsAX+@RGzr&f8(RORdNMU4r6#nbCfL8&R*!@EMoxeAS3u5d3c4++ z^VF3hWou5#Th{YhHx_o$?WiclL_(r#R}diMPzfzr;D^&*4k)Hm6e*a9-UzwCd$e?AG(uc&!au)yM4wEkEk!oJdi{#KKZ+^-3u4> z*bREwTu&7%y@$}Cn_#g^<(=CXR9J6HkCMFg@$qA0l_us+3<%_H{fos6M-El#AZ^beQ~=x44+aLeYuaOfH2(q3X#7Ms8NC=vU^r`UG)B z#hi$8Q^2DO$axI_B#Q6e8ERV8;A5rX+nc>KvC_GXA#Wa;B{u*I_E`R|CPnnPx@!pE zm!Ce0hy*rlo`58D_A?8g9d|>5xYmx8e{D#dsfF27U8Y1!&N%#gyJL9*s(XV}d)~3!gtS9;y}1sP3Kri1j&D50sPi)@ zAXi%I$oH)iK$!=Em60V6P-Kl0!Y>qKw!@zs#H`t?GH3uifwNpJR#~?rYZK#Dx^nx@ z+^8Sr{ zy=$7hDZ0(lXeSVg&`) z$A=H34X@>>!y6NUi4b0<1D<9kh1i1pH=JSqEQxsz@uJgR-y#dd>FbB$OpN<8D7Wr6!Tqr`-r5qg%?dvx0n+YZKV9nnezE1>k{h)^N z%Z}N@FrAw@z~jJVIAEf1u#Z($KDy$-i*`*b z8XUz77qv}4;LOX> z`&m>?PwTDT^JH$`P1l~ePQV@!uh^1WxDdXSBPFL9wLKW4-Jm3^R)z0Ov42 zXn_p9zPGDo%kg}Tbjvg>*-2GEE!6&BbZfB0(z8{g=9&+5Z9D74=ZGOkd0C!v86g=>Ldz^nYJj#<)4T`nzxX zqQP2o$|FYhiw+IuETCH`!jDg9X= z-5=C9S&8WnM|si6|G|r}_^aYijIsM`DwKIVQMQN@CcWs$gy3q{iaP(^!azyvlBhS~ zJ<$a9)fd-hZ;#D9Ln6&hKYo=OpD*6#bnWT$MX_|zTt!4gM>M;LK8`32QwhKdb+%ah zqTzY`%0p_DCVlU)!wdTVI9j2~o-IIX$*9ENn@>Dr7WwAWc7G8d2+C;CLD-#1D@o!w zZca!>mbrOX;}){q&Ls=K0x;_d?^n+!YIbDY8+511ZjFCLOnlk)S;euU;&^aK6TDBY+3oh(z>GG!c$+MZQqoQ>6h32Atq@;FCBhK zL0wJ%J|`XCb0>0O1lj1!A2+-mz|U30&NsV!Z9c1&nlk!kW{6mM_zP4mApp2UqX3#^ zu2)q5B{869$2VI?nGfj5s~Pay-ANSJ{(_5&3G6JGX$wdIMY-7#B}#0G6Zwj~AMmKE zF|y&Jx_UHhv3+qjW4sC6aBsW{MjH7Dex2kl|F`C~V(3)RW zn9#QOAEclGfVg9zw>7+3V$NHdg^;@5j2Gl$z?+bjI0J8Pp63y3eKP+G5MENKb%7no z!5+EeHJ`BB{*8O7SVxp`d6r{#hD1r<__pWi)PLs!w9jP-o-mF9%UZDAa)85815bpD z`WHzD-MI2asnb*HCKT?Gez`}Hq_)VIN$5KE>l0dn7k0c760VIUP%%rPh5*ZhDW);T@gO4iZy~uy)gum%I^Y=+7=O!Qpu2GQRXGLI?^C zPN#K3I_WwibXCNiT0Kib%#@q*)Q)BC$LV69-s56=Clb|BwUoX+%2~hZXwEgzF4m83 zT94hQQ=?-P6tJ-GnU`_hV|wiS3qJV=0a#^;LSu=-Z5Z0GS93bT$>>gNiU&P~%@=l# zc5akNZz4S5wkboIC|B#*G} zuhkYNHO|Ty{CmC*#l8Wa+_GxYx0m(WtH@!$P|^ckTq7gDKfp~Uo`GLP9@)OmOThF! zEc4kyfg1Vw1e#W$qTM}?XaCN^;x9ivpa~XMR`=ibl+g(ZI_yKGPKJ+~)04qBFDN&) zr%*!q_eDKhnTShIcUh)kj154{#3_$6slB11=x7 zsgfcdY?5T*X=_!;Q#aQQD9&XUT&HQiGgDW=n){Pa! zmTFJ?DYzkPaVEH{=!8VygS0Lt8NX?xZOx2o&Rk$7a>4_5I`yNsUP6O@u(}Rfs`1a zXFw1zQ-N7z!k-w81?=~={OlhoAX}k#z0YHQt+FN=dRDgofE#Wdmh^SU8=?KJqcgTdOP@|*Gwc%{#CvKrTN*{leG=S`JW zDq&VQseQ>lU*%Sp#sqVsCmUkQrHW7H%xS3at%<{DbNI9eD{$I(li0XES>F+)sQ!sL zn5e5dPhb1ov#Ydu+^yv*-OHI98+P>##s*@2_1>HgPoj?`_Y=J948mzHW+}IMC#AdJ zZ}>nN7)G3t^o@s4h=(t5l9SJL^K0bK!Fk)9u)9=DzxDENkBtx)>8RrqH|>uOd8-X; zD^I=x7sUvaT=zY2Q%<(Q*5B4eZb5s8=Naw}zyAGU=a}Q7||Yvm|gB@ri?*`tawh`Xzc zI%M(sS`DhP5HH=nB<%D$EV_1ijVJ-Q#r6Ah@nu{f>Ly!e9md$t)i~7H;__gVAW=!x)gc){n!2&csn0J9 zfB4234J9)nsnU$}a~&HZQF&=s;WO)rlVe_)To(wxzdX#e5eh|Vas%y#1ZS=u=bCXk z@C&5*lLtE6q~8n}VH;2-V>Ln0yJOu zx>&%Q3muJK^a*f7=tq$F98v05PKpI5stnnadCB%IWha9-!~fXXjU1~@JKp<@Txi5) zne0=UF|2RQ2Xc2mtRBDT3=v>=f@4-tT6FdcGSZh!8vpQlRq=_rTvRL4&!ugMWbN1c znXRh3$Q%j~D8S{RBFx@5GB*0t`cC`J$${B>0)j#C1tiSn>e9byC#F%?Mf>H|&y2*a z^2BjL|DP%hY#>{dxkkWQG z@rbecDw@@vGLk=qluQ+E1Mn2_Yved_xozTxbjb^scnRB;3w&(sd>rhHT^`rH6~A<+ zMY3{IN=nm7ZGDY!QM1FmtCgZkG%H`~hsa*GQWvVOUSBGP`Z2r!ZcZ2YSB!bZ*8|dq z7@!ed_35r@jdJ?SDuPtsj(6eli4U=U4~^x$4=kw8&uDo`SulTd8#NsP+pHyH?1t!* z|M2tHb0`@g>d*#YWz;kB-@1|$gkY#aYBkfRm$NNV6ygYRWS~%jo0So&Y6_p27hFUE zRw4&9l0>YYyZ z$EDZF3Nts+?ZHWL`F*8b!(0XSod5L0+VWz23bV{TrY2 z@^sz2?hMpAa;3lDY+7b`lfR#|l|j`^U|0JS?yUQYg+m=Txqv8fT&?K(ejl!~zT($q z$t_u`gB=p^tL>zAW~94|FL}$Z^2RAJe-b>HM=+j14{la+rfwk(t^C@@0S&XC zA1-PR5VkIZKE7DR^y)-nvhJU;NqT6=iRkU<#CVRqP)Jr^{n9r+DUO|v#9)k_ zJZE0#898e;<5xvEswZ_hu5UDGZz-&uAWBG(2$SLmQHh$#bxg2c_@Q)hQn$U-@dId- zsyY%e^|1e<#72Mz>julHr{sP8Hf)&J_o9VwPL+(J5}TifL`LY0Vr8tu)>HRh7RspZ9tJ; zGB9}4{YJNxyg5|)q8U1yGIlXCejkUCJW}SwComD zx?UOzL&c1|m0=2ni;oEU!zt6j=>B>0z-Nhj+YwA@{__x1Wn8y49crR#)fsMWnS zZ~;!3?If%TydTovR|0Ce_)YrNW=~qO?Cvn0-FDiRQ`#}X3Y4+j%Ysu9dIA0Exjfix zXSiWRAL$RLxv$vslcx+h2}$9rzhT7Jx|PzIz2p%ZSY>s;xQh=Rbq91~UaLn(y9XcY zruP5pXjh7S*q^#w>Lg0}{+rtJpQc|d7YCIxeGwB=s28gohCDZ`&fRX;;VaY(`|jTS zkK5VT{)A|~e*6G?ICi$V)ucHcxpZ{EyXHF-$+?I49s{E}m1N>VMO;i+Gv3ETT-itf z`~S-WfbiW(s1i(0Pm^<;9mPCPN+#*)scqt<-7Fv4oFfSY?UjqmlS}yCK!0g$QvOj^udg)pqC17@kA&W>RxkkQ0PB8J8u*x= z?*F-|^1ov!{cFyo$R^-aeV^#Eg-r(ti^4?kSc8^Ei z3vvHz9(jI$`RY1Tal%v*WD^}%8UNpSd#C8S!tP&q#cFKZYGb=G8aruY+i7guYHX)* z(%81`G`7Fp_x=83oOABZ&AHlZjJ?)ebInI{{-(4{A|GZ?T-Md81eA{IXDtq-du;Xa6^Fd}tu6bVClXXmNOQ=`UpWNx6L0714Kl~~@IcQj-TO7`iq1gyM zc8&;5_<8&_OLi#>exLtQ+=TpfIkK3IzGYJ279LXu;qbV3a1762D8B6tY!Yg;%e^S* zKsi3HK(eqC1M&76C0O{Z%cB$MvI^;U-3|!O8YD; z$jPnHyRiB-B{cNp0zRm}v}rUvBm1Y#30`p3Kv8f7Sec5xvT{FtTNXtkn^Pg>s7Z3| zkrS4>7{%M|gM~aYbM>jp`|G!w2BPCq^*E<96PJhvvEdC6#RnU`k>8ND6%*erZCwKn z@@|`D*cEOmMjGbhO53t<^z;^YU8OSk#}>oC*5QWJm5;E>JQ=UM{k~1|tgeFjW_0A1 zj>s~z@C{TXWe=Mw2@aA`;d6BBV$C*Ie8p_qz~|xGV9`r;B{* z!Y&*uwxJ0j^@v;@=2BGEM3ZF4h_>6X1#P0I6Jz|CON;j=P64piK<=q1N@+=HM(_Bh zF;-dONmIx+l5Law1op+6Y;!F~jtKe_6TyxX&1~&Sx7))>1P~zfOo_=Wx}JX077VQw z&feqs=Mg`GS?>@d0X)K(ePLtzOninN4t0KST~azW7G894^qrE~{uQ_LuWK8bjQ*eN z3HLlwv7WV2Q%JdRX$$#?aR+pw{JE6&p*Bcs%%SCf-_^YyGGx{LlWIEf z->N0n`5NU{G@h3ZHsJE<{CDx?93L^B66-FJRxUNs>&vFZodSn8`(kdD+IW+4-Ay=U zl-x*JFrJksMCFdsc7pRFdG_6;_y929%DaZmK!~asK|&{dxi@afHCXTAz;h`^^z?L- z_b;&~G}kt_d-SzwhK9vxuxpSGX0R=48Wy_vGg5>@Bii-N5 zx*avgHh$HNjFXv1Or#vv-{4g`E6&Yq$E?BUuyy=x?SOEe; z!rYDg!D5>R{vu|dK8j?BSs5Cru*Yhr6wT4GMoUJWX5>NZ$j;s>CE-G8;wP$Ad~{re zI~);}?9^Ds`1hp5>gkqtUGlHN(ZxYYs1Z`}Wjp?vkctp-;HzEt#>!rZh0l21XO>M* z?MN}mz4P5k{gcWpI0e;38tO?YUE9+1L#N@A!s8o1YqE%Be(#@W#bbyw>KQnZ9KtX=_(=crG%N?~iR#JB{v7eEVgP>xj#aTV4 zPvZOX7>mtV*=)_!teSNh-ImdQsiu2s?R?3By!XMAv=nQpCnCO54sFzrJ_F>8v1aG? zlILmuCbqD?o;~dkI&L>ET#QY+>V%Kix(ZXg#mtGFh4}az6x)!4u^NKTR23b<;Bsu8 z78TRaPsVS&Eq7MRjloL!C2XfZ-G4XF_HuPi8OFA31N+ZyuGiN#h2h$v7=NK-TxO{) zY$~cd5>kBVpQ>(?<_ZtLmi2CKVWzp5$g4p!%mEWT-GX;)S;Dpe3tzo{(Ac?$!SD%s!7)L3PG{X| zu4?!_bJEV)ZXOkb?l|qFgnr@5wm)eBxwe7bxRgT`lgchUJfmPTohx6htv4|=a!lN9 z>dYXxHi#o3!@2N|`PV02P#Q45^?Y*rLJAFN*i=J&GCqyn&6kMBrr2dLn~^HDKA2fb zU9%3)w3F?#Rad1YLTk72&cD5wnp?jZV zQpT7!(_SId6TD#l$)08ES8F)9H@{+WacxJmPq*sqeGU6R9_JH_?XPXwr$mW=*bqH9rST2E4&#yd5kl`AUsxiW zej|ZZF264YnT@@?NMS*4@2NE~f5l?EZz3%ivFAhA*eUE3482?%20bxV+}a*!r&nKC zTbYpCh!^&Ct-X0>WJ0EtPD^j0u?TPEE^nv3)$>o}o)<}zmq*5Fc|<%~z3uwx*9m4? zk4xR}7O9bqY>SHK6{u@iL%b@zLso$yJ7+B7gX$(P^O(m0zebg;v640>!qGVzeEjEr zS9n2}nwYMSS>>D>(@UzlL`Of1Jrh}RmN7p*NUyC&8*Y9?yUiIgeH1=kPU=0KQb(rn zjz@)Jq^X%8Wo=$K9FTtyCa#@xCcVZaA~C;f`@`L>vbVNlAK*WLy+R^oD+ zg9_g;gQJr;RIwVpa4fZsLg8&(AO9+u1~MQ#t#nQi6QKA?Et&ahKioDcFS6LHSbc&`tMopyafd#p8GcLKSBCVuS_)8)X?r?kl zq6V^dh&Zb(jge756O1&IDP{7fBR2#oaP=v)eZxdwe+s_$5=Bj9ayw4AWpkTO;KV09 zx8i#cv@=rkXPj3Ny($bZGjrc@Ullb9jKq>zSUaH&=l>zv6^`^%%0ChCX&9$h#(ovm zvo~-(8~(%TLD^~y^_*F5_)&##cw)a#gIUKEL%nl#=6XxVrX}o(P7_Z7LFA`|uz=-9 zCS}6e5}*)g;dFgNoWt2tTJW|08iBBwKIEJwH$6v?Fn#i4emjA-9vgG7brLTF9L#XG zcz_B(q1nBQ_%v(nV`~Eude$t)kaw%%IbQn*ITrpc@3sB49{ZFlWxR51it^rxn+NCo zq%elWyL*gpIBCT&MMpr3TRj)W<7MyU6-ug#8INvUfR4Q5yYO?DzV?eIi1?jF^;6~` z-;}e2T72WFv?&cH9k;MsU2Pcz#?6f>v+5FFiKV!tz(u zz0mA@`IjH<-c^0xuWL~FQ>|ih(8nBgfDk3MOgUb;{BvQw13b1l3{qfK4LeW&Q^H|} zCpbV+o|dHlx1y6|IE$V=$I2xJC-9&l+jA5iAi*j>oE*wI6~O0V8&R=-c*-|nE*#Q} zXXSqzm9YlGXrY4Qqm_LGHyM=@53rt=U7Wf{wqS*JmTY7rSq!5kWczXpDu&~e1D~^NuCqVKa zu99&i3?<#19CBVvePVsKyTy%ZqOC@%C0CId7^4Q#a+FJMG6lUCj+4x=_A!%LR52mI zK%$_wcRu`B`83?Rj8H_Gp6=0(@>Ic3Y`BDStp?2bdVoJN@YFn9{t)n`z(0_>zM)x2x1!lf(>GgsIr zCmCcP)J79?3uSr8h7k?x_9J`_sst}<2x8tFy371IARcgjsBB=Mu6p*pih=4Df8{Uy zRQd{kSMf#Lsqe=3vldje3mGD6G2^iOkyXjl(ylsGQ~#n>*=?dOEg_v&kyTSZ z3?&pwbJ-%QkA+i*UBwJ~h10e}5&$TnE4Lsi-vNWrsmUA(a%nuRn|tZ7n5DCPDGGOP zYkt12zuE@q=4qw2+_Mim_y%E`vpeZc#lOdXIMTradwc8l! zoc%Q_hPTEBP(?$kj!BTvwWuE*?^N=IIOa<48V}%f^N!K@erN7so^h9*X0r2 zj+d=Hp80-><+o^Ad5mF!v3-J8r8v>agg=xZ3B>MEC=*&|nJ{&+comQBI&C5vvU_bC zEQ4msXJ;8D2eVMM$I{_rF$u%Mgez#Skou0gn1G`o;zGix&lT@np5qgJ7##o*nz>h? zS3V904o7?YdApPPs$stS-g%Yn>@n%4oQq8nY4G#zVS?_3KYs&1x^qJwdUINf{h6T!>a!B0;QBZ9)~AP+An2|lQFLep{_Fv z!NoJ=L+%+kP`@v$gHdbp{Cv{j_k{2|gk11r!zIA9HeD;9zXUVaBp6j{S?V5vsyqmx zCUelu_%jb0RjDbH++9{>8wMp$c=yp+wE96D^3~(v#!_U}cg1V;QmC&S*NG0luO)1! zf1pl4Ow-#npqs}sac3Qw@vwH)`3Wgp7^4>)^?y;1sTTYzETSCE_f1(Cc%R2Qif|sCs*SDXK0q?HVn{Dfyy6G6mzT3Ld3MVG zJ$s*lo4Z`o?G=t}m?-J5@EKZn{KKq+N5EV_wS$xpi2-r?;7>w(ePzt+`R36gHcZbS z-Kdt!sV#3Q1}7jP(Y*HBY1eUcB?KD;sGsI$VnFO7exw+C^2Z7*0g)^M^<297@u6^} zfKU@GN#ibB+939ZSl9Ste*vuT?(+M|Ld1L}35BuyF%*KstB++3I3sk7>!XiXtt^=| zqek=l30j5@fI@P6LZR-ej0_0*L|`81dl_6>Anv&d4hDX}UXoy-b#tK7$^ct8kw4>u zFD!Y!$}w{k#J&nCC`iaD*!_*#WOqXyc3W2wdE^M6E*9ppvgWA3`7BFYWq$QF?w9dTQ!lu)Z{=Uvb#2Wl<=LV4&%t zYaocUnDC#urKFa?T-|1wCILy36FA&gKtN&dh~`?bG_;L`y1(nO=MW}m76P?+B|?Lh zJb4L4dYvs7WD=Y~XAei#=nC(#5M!L7#JHdDpWq2pMo^08)8n8PB8gf2=kU_0e4Umr z4)B2|%Y?~O`EI+1PAf=3E1k!vC-`O|O{h{azl-GyhL&zIGf$f+=_DaFLby$t7+%#F z`2y+Kb=&nshND%QJEp~zZTh%0a7plydvURGGueyH>+n8QQ zrLOue69(|L*GSc=j1PoWhJ{><)R&DAO~C_~mi3fyR{4rAiRRuCapm7}FJw3wDg#?w ze}isH4a8+DHnVkHWi4Fgl!RqskUm@znwB&D{-m$jeOcE{|4E4f0*5_6h(dxMhUcSm zrkira>NshloP2i;F)PnU(ly(l{~kjM(YCL(KefwD(U& zCf=m<*j@xrE$*f+`2uR=P#SG**@u0d3T(4l@aIebD3Au%3qcO;GYDvJYJA=WONMfg zf6tNvtzNeQK_*2>8Yo}r??W7$%3NOCf+8ufViuZ6*t z5=tRk>YO^fKj&h+?ROU8T&zc@6>f9Y|6aC&BB8kX^cCSXhxil%h|Bz>uQp|nHi=EE zgJI^}A|>zWs^e1H^FBN@%HR5#UfrNnz~Z(UA~u!B_V`HVf;4FBF0|KGTu3s4Hk4f~ zUXq5?%?$u*BZ=&gZBaxKHpd^018Sw++Tsv=tm00Q0lSn|>!1}#!pew?FKHn60agfN z5p`kHT@;Zqc8dd&V*-*KLsd5G;3W0w&6GnB;7^DEuk+1y6=}qiNcdQf>oSZuP{+hhRn<>g!jtT|S0sIbNLVH(@tC0R^zDcM?oD-JQ*5A&=q{9fo0AFk}tCNDa6PBUkevK%2Fp$`&lO z()Lt=P7yOQ;-8;rXkS!gv;m}AQRN7sr2K>GIqzpRdL8U?SWfN@^!d+t!E~`2}pZs{P&`vFjCQeVtuBx|H)g2@k5=GkSyIff_sN|8SLK!L; z4x$aPZI4X^6(=0Vx$r?jfw%vuc>k9`Q6g%db>Zr)>4C23)EJ2sV#|5SX!g#bjk58b zaASF%F-Jgw5)~m-EJ;9O#n=H_h=vS|Yhn@(RQt!t>+yZ#agY*^r{f!A0vzOsn^)(| z!pWBJw&+z#X0ouW*+p-Bt@z>>{lX}IK0qK$Fno$A~Ylz zGks6E7L-B;1{Ur4=haC8&c8T0&@bWV$>~n&L5e)cemf6obpj2MH+*WfgzN;x4Yy(8#iHUcA8kWYhULzw*%e z($$$S!{e~mupU}A4l5srWYFC?pE z%l4%S5{#y~g8?2pqmj&A%*&d4@jT*ZRy|w%q-ZCkMzInMz+mB&=u@C0=p6Q6>5#@B z+ba~v62b?^4pl4G|4NQ5_R~Xw*Rr*6sX7%>KVu9jPJ}DEF*$oG&(1m(_)_dj&nkPZ zH~!dV)@)lY99D<|?QSgYE17#!au-9?dq;U63E?0CF7M4A_RkjhPLQ$0LQjmTDMN#v7imWB7=9H;}AH|qP*6upoE~K+htpBf4qlkQ^ai~WoXuC~! zcXaGGFsAMHkiT#0gn&nutUU6_$kXHgJrbVpAgm$(h2^L&Vvm9rCG@!YHPU?9Kec>t zk@%h@xAbdT{8$4a@%7~B>1S7?{e8*mKDLvb-*cMvhedt5*A}prhLJZ(*Ip@>@z;l& z99iG98j&&(tFo&z?~zQ^IxoYm?S2L=x*wZ~Iw)G4&&bk32~7IVnp6qQgFLz$#?Q^F z`W7@3r0y}jugjFDLtrC%GFjw*+|GO2#ClE3BO3Xpe9Edy-L%f@WNEuwt=3Z}n}Mzw zip9y%$r$Q4rjV!l-s?wI=sbd+_Lm5Z`b=%bz4h1_2`E*Aa)5HmXu#A>` z-Y-AI431A!@?Ku2UQDgm3HzUDpBpuDbckghNfvaDi90PF-mDQ|+US3^9aJW35cR^b z>6XqK?|?KZ@8j+G9i?MNp8sw5g>m2*OUcssVF6r>c(a0crd)$mAxk zVh5)Fm8rOYja9z?=(wVi^yXl*2qvcE_v+uU&li$L+2>4>Szb9JV=b){948~&(SC6$qo@e1g7X_FAI&L%v?CSEK63Q9 zL1o_ANxVE$bge;@RiOCIp`7QUa|gUf(e>9fkBb1mw9B@gB$Pn)G6(US$${aT@xyV}OJO5EhK@rly`RPPXIS(OYtQe&DU zdnj-?F$^(ii9YM{r)Zx^#f2e{NKXn5{pY94_Q4esbt7%uTy;`PlS$40-LT z8%T#&?<-ohwZcu!@mcg35%j=nlUyaN9?Mtlubt>f^uNDvme`~u%x)3#n#vW&gZ-`76!08uCv6rCrd1qFyq9qC=Budu~aM6;YRd%#{tvzjGU~zZH@?m++ zi-vPb(4P9;JdJ*A)Z9%!c1`>N04}eG)ip41xu-qz?3vl2PLO!ct$Bk^aQWncQJz^I3%QOQadBz5lZ~J*m(J4aM36tO-Ml=6M`nv zFJr&iH`i<+0&R#uw=)0d)*j{qQCK;K!@?#5PbFITqURlK^puQJgGi2-BCw=vzkm1!7U9XA zw3o!mngzwzs^bX9fwI)o-?i?JyW?&U)_rl9efBkxji|#1brHe(9FC}GJph~>dpf9_$o z41M^?MG;`(iwZC`i-cwk9%0SyY0J3Mw4Wzz91V?`S)JsAZpT1$o$*&jRp zRv@XrJ3(d~QaCzf0Keep1CJ zKq5P?q<~Ap(lJ0Pk2$L-&UbV{>A{}47ebe( z^1o8v7~JBte2pEg5z`o_o17 zn3*0LoFtS!BTM1;Pj`c(^34ze;aZ(G5^`Ni^yf@nXGX?>Y2>YFfzD|ciR`=B3Fn4f zPdlS#mz6Vjdomi0W}+^`vRA6f@_!SM96+T$Z7&OWBiE__$Wm&be~S za1ihj;M0p9o1~p{pCA@Iy_IH*xQ3TWD+~84&z04d>6h~Mo)(%XrAGihqj(G{F{n-E z#0f5XH9AOsL@OC<6znq3&2!fN`gX`H>EJc}&HP>w7i1aFz`U=l5L@%`N;%Ley1rMZ zkZ5Xu7F4%vTRH{+d;jF$SvcHZ-Q3P$z)EKiCMOAM*zP>QfRZ_illp)LJR~|31-@H7&n-a$;KuFVaX(tmF2=2Mv!+X0CyA|Z zM&_VCV%0O3bUm*xqV&RSH2nk)WjnqAfQmz1Ls{)ffj$p?v0a?YmXO3{QZ!!H&g<|E zC<#HokR~)ECx_mg*XK4qB>@Eb?oYPZof9B{p7XynGE{}3IXIluE30A0DgPLQb-Zm# zDYN!ziSCZdN9K=ptTUkoy`7zhTk~Z73I2$PT&9CMgWv)8_L!Y2H9O~T$xAuwiqToT zrsi?y=se8xYa&}NjlhK2am?}KK+(-e3Szi{=4om87k$XEe@j_on^n?G`qY+p_4EoF z{J+T9kRp97T$;4Du67PN$|aJMy7T4i+!y|{?K>??o*=ybt=@+oio3DG($0G4lynVH zM%n*~?M26B&ff+_7(-OF^v*F#4d;RZiSUR^e-#f?;c5;hIJ8BSxHLOge-=g|USZ2cXFU7u*u=+p)j>(XIJUP<@XaXkjWHFx2o#U0O6jQ+5*qT7_tuF3taeJu>rw2GZ&f10qe(`tzJfNgsl6^D3bw-9cRLv5Nk@o>{^=F+mxn$eZ&II<5Aer@|H+FB;t4 z75(F}9;qS=0BLa)Cu~tf3SSF~_pmII9Ma*bRzXl1u=Nnk^w>RkLxKVB`wvkWIe=|zwFp&&#%j_j_f&H+`xZv051!YqEAv3i70%0fUkx) zax*s|k|ymkA2|^&;v0{9j_joywL)(`D&YkfBe z!GN%r`O2G+Ct;r-x>o>@fFf8Uj0N-=E}&h%=+!I|nf=A>H2vaKddWwP5gegS(H1RF zxo}9Dv`vGwhjyF_a!R$*(628p3(JmjES*M8i{fP;S6ZDzq0p$bj9v#$Ae@n#*oNBu ztTuLSuI*t3eLAczIe|gQ6s+C?b)1x_qiMZMJvpsDb~OfU-^|{Q%5+lp7ot0q|J_{s zD>~rOzQHg|$DoV?5E%~oGI?q7$iYGdillQ1idGVkEBqg}TWAtq+WH1J4-7adn0NA_ zkdWy~5p`5caeMtJRgM62HWgH$c;zGxAqm`93SjVw8!WCQy^46fn~f?-K1T{cQbesl zCWL8p=rDuKv~)LIR!~r}2nANsUtS&mpr6Z+op)<5;r5DxmIO`J!|M@ITx34-u6_p1 zP`ca3#r#@%7X#c+Hk*T5lg%ZWjrNJ#%JA!Hp)%o44}n5E{+66fp|5` z$)oscmgK8mjwC5*FSVo8u{A=<)=R~|t>-kDUlKNIt3lsJ^>AHXM~LeLiA`&geeoOa zvdp10+`{s-N9PRtz?sM7|E7=csvv%(KK!pLs^z-W6!t#Wrf|Td`IkmrqTQCC3a;1% z6P*gA2Rgc@_`HN9pGz>-bx~W59y>?brJinVGvsUJwL`|C4TE&M?y{|pG-5Ryg8v=k zO)ClFdi(XsC!=sTCL2i*5LHC={c#?Q7dqF@uEqeTfn(e`tJeAfa65$NN7oP^R+f?W$N3^r)uDRP}vnKd{ z%rU?f&K?wdSWakW0aL23w6$j5dAnu`eSY3v;^jZ}?$2C@#4<-sSX?5JEF}mxEmyL9 zOUtElRNFaZ_P(eRHR#;fkpEk1IKuu^u5~xQMOfAY6{7=_Aock;xQ$1fSyoc13Cqu7 z*jlRSM20aTEAHqhFYRl5GP41JXedbjq)wtXm+@@SsxD4??w)) zX?XuVCh`STbot*4K$kR-5EP)Ff7kHcAY%W$76GXV|M?p>7v)pz|HZ9S7>NU&KmK}l z{+zpDc0?pmfshEV2&Yd`^3-^wLlb_6&cD zu-}?)w|rHrwQZ{XSRUu3RcI1;eej#&3^%J|^_^nO+k-OEO;ad7pb0**E~Z2N=IMg4 zbgB>oqG9Nl&ez6z-m;FKrr^YC#dAVaPsd@?r7ewa-m@}ia!^@m!wvWXIEvLt72X*e zzqLP;rj4zQ*=X&K${OyoJ>tZzO6ze6pRbZO$V#4=$tG-^pSu@-)KXmK%M9HQkAR7o zIe^4j)a-KoMfA1Vve5d$M!PpXM#i;WF4d1&Yg@l=!FAH+`O?|e6+*k9$fNRag(tTI zr#*xpR_oKuE0jl%6Of<3BrBhhHzC06V?%7FW{02c!0gVqWfS|iw0WWd8nb-=3c`!TD-d~P@ZA#+kuP0E)>lz9N?L8dD~4lYo<2CuiL%~!nKqKF!#WPVdQ z0(>dn-_=u9g9I=UnW&c?bO^MN0N`P5bZ9*O8s6ZA$3>T@wwkoz=RU7j#%v$8^}D9W zpKz?(^gd5lw0fe4dykxrJa_zwUi{0e*9rqo*Dz85cKie*|ab%E%8W;3Cmo2AiF;F_F zu5z>HJgIy3b0`EXZc@g-?~`osYyZUYT{~L*G6&NJ=IeRO!kbJ(coru(kz|z6z!m_RU_KU7Thf6$-Z2Q5pjMGL+_w$e7r+O{X#tOMNTOL zzK(#@h3-gm(j7io^1GXF!*PjMlEB*NoIK$(``ZSpFW*P`MQh3KRm>aBoa|6>`wQ_l zoe$0G@`aC86wJwP6TcW_(~cQxBLQymq#34pDvOO}OEJwjGfdv1be3DxhO6Be)vN#E z6kZ7*)91u5bm?54sWX)XzPsV$+JV3?_mP(qa;s6jCsy97+=z-JsDtUk4I`-L3Ki$K zSmx-Zg`4X)%JVE4km`WaQlWAu>~T1n&w(*$>*lxx#&7Qwvd3wAvu{ce?9#$Lky3g; z>i2n3a+3>9qiNZ2%DyX>agcJ=hvHK<89b=%WI1k!KSVIwV#LF%q{=$dZd2Qr5%^zw zP!~moV_Q{8Oj`d7yjIfl%y zVx2eNFJ<1C`JglzrxP>mk}hC))G&K}-FYoTN|7;Hw1bJ#m{qHK{7*=d)G4CcTpduRJ6?${~+FEIJy;cycnr`QTaZ zKhb$nTGK&$CR10iP*kv#5WR&p;QhoO_4*1-k?<%o&tbke_Lf)D8`7}eZJS(QHX$Wdav zOSA=O?rm3({P2!t7SRa1wKmZ$aT>+--fIjFQFC*ReDUtDVy({t1{mx9eQ$7}^M4pE zjBz8pj$9D-evLl-9r%^L9YA-?iaaRjqfd@$g zvW=&vmAs+)I>LO$zU^G>_h`?#*qV`F9@oXYSWbIo(I~A@KakNxdSkKgXI?g-0fQ6; zgQeZh!6Y?p&^80BVGAt_5qL4ex>ylu0E93r)3-eZc7T5no2hlYxf^KvK0a} zd8Wg1zG23l4^_Qxc8;6xs2@%fBkEVZ3b4wM3N!@0mcRpIyfb~C#6N<65^=9u1^hDL zt)#uJqCbFsAOzIT9jzHKRL&DG2wKU%BxYZ~bZWvsAH|d)(*Z)a1dJOmnGf&9B_}Ch zfN$L2O^UK3V%a$Q8JMvPWMXLZfB~<(L(^|B$ZdO`A-8+<@>pjbE1Tb&2ml;k z3t7R7AdC>Yb|{d9jNAn+cy?6^VttvS9e+n45n}+b0%du@9N|ZDsbw2-9oQ#DzAMC1 z0;$t~PV6u0IR?5U_1~oW@OlJ22fmc^zx(;%xrbippDJonGFwwwfc*;JNEx)D$9;aP zu|{-IheK8miXYLv887@<3QzyT*sB%YzPeVQDA%K^vth#= zxQi98417|?U*LJq);|m(gdl29&rU6=;>vkMj<5p3K$ALD3!0d*grqgAT_6euMI`7b ztv@_oqPdPclPQW(`;bYSZnHk@*>^i06MXbav4e*4Criz+wkNVRp~K|H$Rv14a969r zfGV+1Ga;~*A8u z)K5H=v+fvKRGZH^0>T+GYx_C&10Hd;;am+bCzllJywhm!Z-G*`8l<+N0kd)OWgBGN z_B}1_-@bbb+ANktl+}()?{-P)m%zUrd*{^fvE1HluS)N5w)Zfkb(dkhPoc3^gONSj zwO920PNS0NqhDn=K#}=W$VoqrEZ^#Jd3|mjSGZO=4FiDkj?}h;-bvxA?oKgPzPc%l z>it+qdSZhYgzAPMC7gj1*XT(qUZ(}b&5&&KR;dA=U+~q~Ee$VVz}iBb;ea@e^bN$g z4d4DUta^DeJuSggpO$;4w7zf#IIy{9`ZpvS?7J8fz4OlE?Ga4u4982h&grPi3oQ>< zHKBPLm>uMIV$GM(HZyi)n4lJXTtf1V7yY>V{I!>qs9YrYn-{p4H?#I|3^Fb)N=D7y z6;FQ-j?l6gbYcR6SEooS1{PwRR_pF;)52i>i58EtYt83;tY$2~F^Y*r`MKbas?TAK zNuRQoZ66zx?e?`hpv=9w2~UnsqIP@p<(f6|UucW><{57#4uh?Gtvh(yf@A#jR=2$2 z);?7$0YXAKeNQW4-OtNzT6Y;tM zi5m`DVyJnwRF7Y|#$U!WBlQxlC|EBYoAHOL$+Ov|!zJy(wCD2uLm@8`+5p~VlIMmt z`mem7sKC!!+j!hN84L`>t;7ztn9glD*x+B(pDQiDC|bi;$oGc&1?IWH98aUD;96erx^NR1->gKX)l2qk{L z61%{4eh_R!8(0CPxSEmJnqyxL zfdF0i@%(vixq~h1?;EjwPgbZ0pK(g1-5!hQ96u=5A1mf64bR~OZ%7n&S$fMEreC^g z*~B{w;H^9+tiKzzJ;)5j`&ldKYDY6Sp#Rzm4;S?rS+Gk#qQ^e=>h!Q!9PJsP`|ihC zVBagip*;B)Lp03trLz_-EFKY+=Jjg;^7qPpwgwu59DLx98}#Zr2|SjSFcH10lFt*W zqAu9EH_hw|xtHLQ!s}8YUg8(je6(2^m;+mwzZtJl%9aVp3FqNe~ zmY0**cXOD1B|Y}N-hq$2@dnpn<|R#wYY{gM^%vopPo4fznJm&E2NT68s=*}IbK3qj z^#*pS^pa}s>0@ffC4tQOriX^En&jngSoVcxkc=E6*#nq?A4K&V*F1!&iFR+9UeH&X-uDccB%Ru{jxbovS&A>mU z2J`#wS2G-!1#y$!6`>Wh^Du{)3EHPEcEI=Lp+$fFP`|}*ax9<$bFGM9kN?{MJgM(x z$F4z{-PY4$OWuyqxt7hTq6q|$Ng`N6?IZ(}y*_b>C3;|C(`$24UVVcyxUM{OzYt*& zVl{KkVP2F*x%dt(t{PU{o~b~8pniUzSXMd`5cm8`?~;I}wW$gKnoS?NWU61VtC47K zz#~$gjY%5jLeyv7bKJ5<8IcmLOcyX64&bky0vmp9VNUXUOb@4&_OAB7Sbkp+zj*79 zTZrhn!t?UGV)BmGZKsHB0U4zWGGFyu`o)zzTzXf0iy}8`CY8>KtVUJG_c~=$;FiC#0>`c8lF|^;)Ho|j$W6;GBs^2fq!9;W$XS=y(b>tL&xsF9r zd)(UVM;{xg;0LC*(IPfEp||!-`8{8220a}T2H(D?L+PY*`U?IRGJ=hBf3oCxPnfSO?kEu)0?MTsW=JS?4lcQ`P(NJig4y5e z3_x#6vc3O=980DSIv5yXLzZ%BLuQMv$N=eXb5EV`|F3bJ8R8uj4%aIL>?imSAsRCP zH7B!M3S>S;gOfaUCmB-!mn)yL6~O>=K1S&`iN$J;>n*gjcPj70h$$jelwV1i91ws% zFJ>%-{mQ3iH-845(>KA734W<=-z=+Fe6KCC>IHD%cHvu98pOxDr#!saBKLBR%N5A9 z#Cpm1^qQF5|G0qyvCr&7*}1D;b;lcMy-7OuCFQ+yQ-&B+Cv*=VY=N@XA^W^_R$}1m z2uD|oVAv$zcE<(xBm~Ld)4Y?aPpuk39gUU6tly6hQ?W*2Mp0x#BCwUID zqx`{m-LDAvZRLfMyUj)SKiPM0f>B%pu;AgBITq#sW!Lxy0=cP+CXm16+_rFjzsoO9xCS^YShdY!k5B->)jRD~ zD=tKbX=vl6B82b6rc(7|VELgZ@!EiOf$tq0%-_8vg`M|%y`u<4dNe4Ky(HXBtgT0w zz9w~~*GpnA0=qS$S~1@uuqykegZZCe`u z3A&^qw8mjN*K+)~aLBII;~mY~2F&81mFPi2r3TMibDZPAGuJ{P}mJk@#maXFBJ zecevo^fbuwE&1VH!a(EHQqByuH<&Tfo!WT@O;WNmt^m@@rS5SMNi^zjlq)#TTi^aw zGIfpX>}=;@^B~>RJ~8+)H+n+Jro{N#&gZ!y*Yj=1Ls{8$bcvrwFzuXDy7$~({AjwXyQ;eORPFujr}hpKj9T&7@M)%5!f})zE+xSkdHc%UiQaHgI>qKC zP5)?sNW$;y?Xyfq3PS)es#~RbC1;&8>+;`CilkBP-Y{K z&%~7Izf1Rm1@@19Q>T-L_EFkebrgd6R}k0v2ALKgC8J`6{o9@Ra8;p>`wgtCKa!+7 zIIsI0Rm9;)BHv_M-#cy>aPEly$3-#B)&Cr$wC(?v!vgbmQwDzN~%2Pk2j$QCBHGTzTT1c7_i8QHYr}%5xsM9LC>32LuRGFKKh#S@+4@n)_5` zyY{*-@)@Z4=-{aO`XnMVjYrZ-wrT%viC!5)Lmz&- zEvtX2mkhbvI1)Nc{>Atls{c$T(!BK|U-k6S zHaOk`ma+RA2c$oEfV6Fgb`x80IiPmsfK7KWfAP9l69ReYFcShz(|bJxM|phJm_40% zU7|VSadNP`IP%Vt@3b6N(2yCogof_rS!<9nww|x+QM!xNm{#@Y)1v$IdeeyeInn(C zvlV-&)MpNSfk#FDtLc_m0Y+MMRGUf3h)e!LDzoQszLp9G zi&VnTqJlOMbG&?Au~DMINu#n4gAuvyE#Gj0TzYcSj8)^BfV@GV?xoZ76ylO29&Rg| z{$mnKn@w1%Hm4+`FT85#HPL-NfaIuOeGS}0m9-T%L1DjN5!ww-)#8#w0O`hMZ>;-J zOi(jLH-v!`&T9yf567DBqwdmMNGN#?H-aFz^0L^zeVT6jUV`Qo2%lQ_(dqHuVRtMa ze)M!bE(&NgDE%U0Ra^Z~PQM})*j}er$k6v0%eMfwv0f)R2=lkycxX*kk58aHoed#< zaM2Sb?RRlrLt|}jV;)4KnAJD z6|L>BG~~|dP@oK0?Rvj7mG>=I13mful->&Z`sMtduVGu8Wm2_QO>(toLk$qX8*`tO z8vHQ$0(0ntG{Ak^fA{cRB%3ic3FR{EFkQBztYU*rQC-$zkHbhTF46gLVX5hSv}Ln; zZpy={@y{u^>3$}aqG1-duRL&Xslh;u(4{xgx5?9B=vkCyi%`k%&%;2w#MOvJ$>Zr&?roVWf3@N>>%)PD3*8}qt~Ae+E%4F9JTGMrD|XNJ^)alZ z>AVhnrx?dr7YqVz-*3afP1k3+dhRO|ZCgE&FQ-D#T%Y~!Gsm}Jzb3*4yr=9NPh36{ z4~?+;%*$j{S30H{cK?pqbW+WH^M`;kvodl29%7EIfU6fhs3dS0ej0pd@?|g;Xv;+ts?CL*e_0uVnO2j=+y|!7XWBs)J~Z2l)byi|y8vvAZU5 ze}G|?p;>d7^q4`;S2k`pD)t4^L4g@U`pjIfvamjG^&>Mm5DM`xm=nW4MN9z~?d`N( zW9cfIRqYCEM+DrdI+7FebYfl>yjw`_TfCm!xg6JE>i{u;t_hjDE@t1oCV^kC9`i%L zy{-2IZLTB!o3v^jz$g2|!@Y zH>#`bOy|g0IXt&v+eC!j@vaMb5BGO4a z(6EL@Mcg2R?De(I-SU<|+C*(*lXWO>`b`@W#wNyt`7vQu1Ts>Zu%cP;lu)&^!eWoz z7Wp}^xC>I?4ti+Z*&R<47r*1+(N)xHpm`!3-e_GbfDx{akAJ_gU`VJfp7VIwYs{t; zgB|e-2ym2*wEY5PeHE5yjj)Qd_6fF~=cUD7omE1QyS!iekQa>aamDwwsjlv(FC2pl z{oU6sQv%Fj%9R7@^isV{~rSoCYuo8&xZKHe;dMxv2eafX1 zdmzT}ULFj`6Nq>fsaB*;mbdgcJ7+@QMi?tpqsxBVAD1D2tjX}X{pcSV0;T1><2Up4 zt^Sd_q}TPwz>oYjyxzK%C{Bmnbes3{MFBIMpTFdsV_U#!bUUr)k9F(f#oEF4<+sx~ zi*Kwc?o&al<9jkCw;jkedt0WVH~~BbLmH!a zMRbXIzdP(#`+53c41=IwjCwcAyZc5Q!a($Wa$7Ob*Afdmnmti4yh63Ax(u(FL1p;V zZ~o$}yk2gibD8FA8w_u6g1v}YT_t|nxX=mFJH0k@En%nex>dgSVb=+z-f9>b|h5Ux->O0KYG|h!Uy({e5}{2 z_0F2lqHDDD{08Xr#gV>KBaXYr@9&@x2QTflTitYB`;PUO-}tK@ zyMrc{Zl_3#pF<#hrH?!#B)ux(r97S|S1`)Jdd{^7AyP*KkiF+k%}I+fSg;{v|7r12 z^ZorNP;3~!B8kt%w0K|c4~M62ZqkP-tl(wg4@R>S6%x5k{bM@g0!n;MRLV>C%5Q8Y zI-H)I@1^Cq<|k%c4MZYExZVMZSk8-Bp@$ReirgTS$p&uNMfQr)goikE{>T&7etpL! z)Pm&*a8!mFcach1`n>KDLDMH`O|2x9e#2^hY!7g!oSs3kH`;4Qiv%-UKc6%=T9h>5 z|A*EB4A3hN#_>b1T1SAK^E70B0Nd$5{FgUB0AIs@&i$8x2`O889XVQEMlljJq*P&F zR{l>9gpiT4kzt}}q;+jMxOF<0^Ca@O-0b~$(pugK|DqePpWH$|uJ4-YyP5em27Z$M zR|{k!V%X$CSwySy7E4_=QB0K`;AB85Jf~D zH6O(X1hikn<02!9ubxx$;Xl=xxO@G{JHt3C1@+A{U}iIaZ-(3zdiI&}>C+AmblNdU z%K|0;^BnfF3z6QxQIhVu@$dl&&D4CtdZOIKCLMFC(=}zCAGU@-(?U;1o+}Q0sC5(e z;axSo{ZWViW=~?_DMo%%zfLAEb>;Z){XL8G^0LSbt$lE~MV=~MnDy1gkx#^n$Wu;}y zhpZgC)v_lpt{g7-kmoO*!2QpmtuokD{o|65+t%v(=5DMnATJRt0u#B+JT!dxtYwpG zUW!6KJC&+wL_&=jV<;xO>mikA88uzV(V8iL6moYzUvBO#6+0#-B6PkCDqKm|`)@8l zJHvLpkNwlPrS12;?Xq}0L;GVouVMT6c*Pt{3O|fWdt9ENk4h=fLIKRM4w>|!1fFSw z)%_YpryhRS+Ltm(X(bcDeq7$RI;C|DHd~I}<#xDWZe@91;}{{)i`>j)Rnnr^u}s$j zd;jbi`wyB*{U7KQl|m{ngI)1IiFisE2bkSwbrz>8uqz$ZIhUy_C}yQU`*yH^S^-)v zEGPtWX4t5<5SzPL*WuY2ru1-ZhMBx!93GwQWo{M5m)m+1LrJ%-U&X9f2x9}8R}wWl zWH(L#(Zn}B>s+L&cCwNaG+IhPYx`#Tbw(HB%sylfkA_EsZ5h?04?ay$$4Zn?gas%A zU4f+!sKt*ebGe#2EdFzQKbXu3VUwDUkRwU!^E|e1!tzJ8j(M^{^lhVd1`&4QfmckH zKP=1WlBBrMN4@Piel!Wy3i~0wHT#CPG_-d1Jb#eqLe)%d_nbp}y=Z0+G{QdeqlU{$lE%8viJrrGz?R-cqz*F z&3cNTftHB!Fe>#w*N&Z^#Q zTT7{`B{-d=krK(6QI!mRozjXSa&;KvA&1{dzGh}A0nc7e6~zUb6vG|UoOCRkcOQwl zOb=pz&2ZlG5}D^WC8fO~gv;3qV96zxJL>$(4XqKBq_!xguffByS?n|@d5Qdq zBVZCE2dQ0kOLwS;5esLixz|%O+$r9M;UE{;nUaCFzglmV2jEI- z6cCB}IG1O7$LVn6L@GYcZbN9H>r_Q15jwJA2Zb8;u3=)e5ny8NyxnG{j2L%d)V5_5 z?Z%eQXNGU>c06hi{P8F}3EA@pKDs{df~|M%;IV_Wa=cB$Uv)A5#!z46pHOvGiT0I* z?GFvA#g|C@*lOz=M(emJ8aixa@Dgt-@XzVa$k~tSegyD}F1_DHnUbL$Q+6Lu+Zi$q zO(2<)tMhw2gr`lfWFI77xFES=+ZZT`x*c-0;Q*uG>}d zOtc4jir(OO<>#TuVGVr(E2g+T>~>g~(NcYlW&K;mMm<_u!M>@WBu-LB0c;hkmX1wj z{_coxok$lv#><7GotmCiB7~|*gy{->+zg08WiU?2y@Y>XgQw9`*D`2TZiI_PC##GV zLM{v0utwa^Xg?-cmeu5JEJ$Z)m|?CmSweVvZ1x zV_4kh#8G&S@xf2hd!S_zM|?*O2DvR3$63R^&wryCF5hPOteUDOyA?rMIrRTXkAx1Z zx1ZYU+#to)8oPw*nA_waJGTd+oaFUa#6r0vj>%mWL`TVdXY00pHHrkt=`Ipr_PJQU|c9R*&HNJH9<~@6_JyOaLLH%g0m1(CTx7bGmvlKsQexjI)&gI3(8~VY4 zqwXK(!M5VH#rI~#At7B9($WUVu@OMx7WC1yi<=sypfuwP+T2DS&9NGAtdrH9n&}}9 zmIlLBRg&}7>SEDjlC+qH*;+riVdG@P8G!?5xpVUmQ2y#pSb^Zha(In zP925Y$u80Ynwn4u9&m|Ui-ce=)Tdm?5@BJv6dqS(7p@hwd6ARXYFu+=?g%0A^Ld?e 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/gui-pack-ffpfsc.png b/assets/screenshots/gui-pack-ffpfsc.png new file mode 100644 index 0000000000000000000000000000000000000000..b90097ae702a9e1f7b848b709d94daee3aa7a387 GIT binary patch literal 149981 zcmce7RZtvV&?r$t2=1Ps!QDM9?(XjHE}Mh^iwAdicemgKcU|0Fc9BId zNtF*DJ}j-vuff56hLZ#ct9YayEqm*zm_EXvoq|V`ei$|)alXRBL`D08CW9v9$WBdP z#pW6_JEOzCRJ}N2!?{FX7wR@9HB_6CY-d!GzaNObBK!#e5E8O;A~3);GdO|V`L7Fn zUi+#iaH=ml{&I3|(rZ~p-Xfp7@_fIpvJq5lKE_w0#n+%0s{A#glR#(r9Y@>Q5-{$!Tp{O%U@n_`tB{i`a0J& z{B9F}snvY6onEh{%@>d4!TU+|ZDkojtmPGC2fALra-$V;FD$uxtWy?PP>jB%k9Opn z;a-&K+7DfJ4HZFJuAS%pgIgoUYaBK@qp>xG@YZb%@>AZTVIa5pQ7~ z^G8x^X1g(mxW+DFb}EI7CAw>GbrI)wOXnIj=R3d9iA_by5d69oRQE?tm|xo!<;QyfpucF&UI4Y+Fns!-&f9AbDQ*iL1fAHDeT$0kr1Grm+l{W{vN{5 zX4Cj1uUFISF!O*E?d&|o64lZ3=L$8@Im?`0@a{Oaf1Y_?8RNVGy?2Lfd`wWKYEbrHa{Fj76 zk_Y&1%MA?myIb>TXIqPOdGr`i8_t3LQ&ptTR12*x`+Uz`$0(s3P?^A0bTJo8GZ|J;imV$TPy5a?>FaawZKrMc;Abh{D9 zRqw6u;`?g<%%GXbkKZdSnGM7v($=%)YDN|{O??w83g67V!nBz^$7cMx&JLZ-@}@3t zctQ6VhfY$~cmqihtr?YZmlTUp`CiioCA4*`fGI(e;K!RzrT#0*A=ax~AuJWJkZ*u&3tF+QMyM(a3?w zZWD#-mUVmYoVVy)S@6zW{0AD-)_b~@CSQK!cq1geGCba%(6SG=t;Zq(UczL zX3B)hih~t&>sUJLL@Rytaju$i=SuHX79P(yVl=FLHcVB+?5B3~J&+xuOY$YARb; zn%40|2fXpw(f(7*d4ps5x_oRFHFQ-cYS|iE36c6KM8xq2% znL<2vI!oBQZroJ4a3*0m2LVefHd;=NoU30E>#WGBQ&dAwQQp;w?7Aty|>EuZ>dd2tBbRH)cXTbywMweKxJG`dt@G zD`AFZ0hn(5ip^>_0^OSUb8CHW>AN}3h|_InN%Ro5J7-SmtlNWmQthR&M(3QFjnO-e zGW%M)5qGx|l3i_3eev5n?P??wM-{-%>Z)dpNQx4f-sDKgn-ZG z3wV%L;)H>RbOVbVqIIIDdI~{2IfAon)n3Bbot{BOQ_OvY04)f4Fl}QdzEJ*5yJTq4 z^}~nohx&&M*0A?s}C+UK@(-ieA4t( zIrhhxjt8J*@oZ?!Q?K?lH_-RvjH@!mI4ampXO9Nxt8do!!ZB!gT=o31Eb6FM89D}Y zbbgAtoM`ph+bC<40~Q-+mIj|C_GM9VA{SWZe7#-bP7Aer*M@vj;VxA1hZl){TT1Rj zWk{~!?8lwm?d8#ATH6%U&aE{Omuz_R%xyvq8|Tm8xDKfwzUn{wZlhJpZ(?OOgf77a zJ=PtkDpl!AUd^z0UJlC|D@_+p>0nT@x=>wz$;fH@)G?w3x0LAh_FcFq(&QP(tLXRy z$5txUaQ}QO!A3eCr~%~t566Bn*%nFtws8TT$qRX(K0dF?2G4iTmGzc%p<)KX&vkHU~IMP#mQSFm;*?{HIIJbGwA-K38_j*dq>#_=S}2Qt3v z8KjeL9%CN@HG9mC>S$}red(B;-Ork7Va`V3Yi|)i0~~`lXUdCsF$Bp8qKSn3;ecf^ z%^(ko!@m=)pCos&t+J2#+uz9ToXDD8p^E_0S&>-NmHKP*g~q)^QC&a&u1)3$*VZY* zDoIk>3Xs=27$Y;1XMY zIB{xA-S@_`>X_ zCNq|1y^O(B7jD}eS#bkRc+Mw^bItW#gv8d?9=CJ{`nN`J_=}*_&3zT+r}}_+XquE`|4-aE1~DO~iJi zgo9Q$U)<6g^~iN>4Z{%iH|(y?SM$`k*Lq-(i{2AQDdJ3zZ_#dKY|o)P)Uj{v$9r$8 zm^a4feamd)_k(+0a*3mHHc3dMXV)(vc?{*A`NkMsrA@887*QdxFSoqa7 zF`;}TA^t<})(E1nW&KddI;>U#r5P|@~Z?-Z|)EiGY?q2b{r z1XljV3panE&@nq$R zO~&p~QS>WE6S^w8A~s`AAXv-K2CrBjqbns6;PyrnP{F6(d_X5>hFsjpg035*PjB14;U~FXjwSY`o&}26^o}5p z!QvF#bGJ}7WScaLfff&;S}6C@1j^E4|9r$MZKcW&?dhF;e&nn`SG#yA&EmxBq!C4q z4h(CY^0_j^)}->)@G$s>1Gk>KVRakvzzuN^BF4Gm;5diPS!mYV?XPI{G5*M<*dmB(pZTg|xq6$=XZkRl8tl*?GBI6+t zr}Z(S-45nZddwXk%uT4`nD;jqOM1xQVv63#v_6lEjwIGL;3OX>A0Jz;0T4rju$QS{sc< zocY64vvLyny;V;9A?4V%c`k%ylB>hSk#$hkFDqX&@_@2I6RfHM=Fv&L;eIm6&0=@X zY%zOK?-P%JTobRS(rq*%vCBoUMWD?Rmxy-D51ZNkc3=ZjK8@TUd zaza=Jr{uLOx zw-qJgYzEuyjDZr7&X#J@akpcTxYf&_3fZ0FTK%zWY<_myU2f6z@Y1^VZgyTbD5rhB zR<7*Xq^7Y1<2X|Z(t@MR&Qq-NCP+Jpo+sA85&OONSaYeXrre{=qkDR4g2|FQ>qHxJ zL)8FDXxgVCZ7x(EPU`^RUKbj!tN3@cOz7D>4^eYEjBQPISy|ELyvbX?7k>QKS8MF) z?DT3Mg>3Tw9O74bg~d&cXdqFdAMsqaVTJYV6Won zE7@mHfo31y5oIh-)BYC`tyP+PCI}9$6oE{_!MJU7)k{ttL%EbDKsItYR(%L zb3rHIUqPX#g|C#V{>*>v@M~(^D$Wl=sjCb3`J?X1Otw6p5b@>(g+KJu4eS!(F!O6w ztGBQ3;DaK46kMx09YryO2Y#a=U)1I_U55Kid^WGh>H7@}>(^u&b6y(0rs?b%;~%ajXiMAk30Lgn%PQTe^4Z-m@(eE;C%R-Z%EZ>=}v zZYtyc-AACXMKMoFEn&wW5$|uq5TY{#b*w*=R-dPHYovH+elePvVZZ!{5mkO}W9UM_c3@ zb=6!-bPoF|oq_w~*Jx;C3%$S%xVx^d=IrW2!*(S}+kl@InM-i0meoIH)I(x19)4yQ zo6H~$Mtj9|0s0n8W3b9?^XIou_)??+uYc(FFg+QF-UeK~YaC(SB4_CCwxNxgWE^UY z1{so(5sVR+v7$!hyy4uJa(WlsAAyUl6X#TKY_U#!p6UG-EsEv(dU`a{u*#JfyDOeN z&y*IlyTd^`DFW-hoc%0K#rXFcm%|h!o1{anOf5D2o-8VlwJsFDD>gUdQF4w@7b_o& zr%tA7^X3yCWm%I`W2WK|#yb=|yp`YgB|kxggfLi#UaQsbPiY)Bv9`jC>(fKu_Ros+ zMiVO3j8k6{AaHAH``X{hFMJ)+8S!eRL}&7p>ul#%9CUO815ti2hT@WviiRg1U>;of z;B)I}II3;#QAO!8Frh`Y@>pl2URGkYgtQSqBV=N#vxoZU!x0G-?|`Dp@J5Gp(=~p; z$>h(uL#&F9JR>VA-hooZ&?aGzin=(U&eYWOJ!vtc+J^^??1)!NE!1vrc-arP>FV60 z`@A^p3#-tgi|de-9~OpoZ3B!@w9hj;MY`G4BeAE8=~9c-CO#9hIWUn`P%C~NbRn9h zmVD1G4b~yuNM7X}#8X05lr>jLEdrfvk^7mKk+X(>+aF~p*(UT+cn0z@p~NQ)NAq(}*t!oRyeU$=C;L;b&(4&Cc}o(z1h(4;aCh6@!SQ}y;OoXwL~e2 z>(%1~ahpVgIUDCHVHj?Bp8&-?tD25L)V%KNk103PEvnOEnj<6dN8>dhU+AzZ+{<}8 zH?YmV!&y@v0JxWQo*Tx~N}0s>kl0jjMb&HGZ2DFDc)U+!IG&sICH4ZAoHpY{nvCyw z7`ex{eQesvonb%k7TR^Y4rxzi2j6SyGSZI8@-#n51f+Wy>jRI`PU|VQ3pjn8z6K50 zN=zR~qO~hkAr&1ChEBCu{KFj$5G_!ttxyvl;fqCO!zd=}+){W?CZqE_;OXl*@MfC^iC6Wn zt;PwUVTs!c{=pKO8uSQ6dvvtLMQl(e1mfDP!NCN1B$6GG1m|GT0M$TAmGJ%^KR^ac zij-7aP#>P=k-njWcTsYAV&+M}D?A#&JR76$ahlvh zld%OepCUaBbP~lB6)CJuwB4r7)z7J_VNE-$Myd%zHeGFEZ>4QK`reCP#^qLvkro#Y za=s7qv-+SPp9YoQ$I|ambbJ#YUMD)^gpTS;Hx91NK5!}?f9gcl|7RA?RnlDjPE}R) z1$rS!c-YKpJ*bMCbz8pM;q=}!A%`$Ke-c80u5@-Q)r)9a^%~ju(OQa(M!mAqws_Jh zG{qBFQ)uNm1h+SC8j1+B%>E%lCK4Em%MjImGg*X@SUrtcJv`axnC|x&n<4_R^IN&! zU@eMZ_P+-d0PV>)P|lqTs(bkOn|j;tO7%OVUhgt@t{`XMKeazbm|Ydrh<^luT#zm< zQdh71ysIq9JMDXE&nJg7Owu=f@*+5Hi|>`a1qE~`=V5EFbnC{`eb=(_lz+Ao+CZ%| z|DM8Mlkn$QarVlw*ulJwt$@}!8{J$%A6Fmn4NHCebSKbOz>+4g|a_N5! zL9hl_*_kW#8-MU8H5(@0+&%d-N4*W(4{{?MI%aaWw*eE3JqLDLah{B)H+I6CWgwU> zZXT0Gk$l(~Yo=ob?>!}Vs@hh~hAxQ^>(Y$opurCfVEV;XUApn1P~~fT9<|K1a9>}| z+E|I^qcTT;ir-e<2vzZ4!=qTu$UqOtfZUh_6Us+-h^b~lANAL()3KlhG_-w!LzAXm zl;c%pFaLY;kb_d&{_1bLuwPyiN$5kS;J_Qn%O+lAQxkNwzs}WTTP7*?rxZD6vLsRo zZhlSF6-B%kKb1U%)!#&3{Wz~u{s!MNuiy)t$>qNrGqD1JPFbD$?h$^hAd{vw7jr%A zHHg>H6v9FV1xTGkEt<01>7nT)1jmPV;)B)0WV{@@@feCzs#-p|fBa-JTl|UsA}S#t zuf!LSTRBJlf=LAY_iZfq&Y7Hhk@+d~94$l%j2gAe>7_d#bgb%G-0qeR(TcNJ3{K8F z=u>wHKmT7YGv3?3ViuwsDIe4I^cdOi6AW_U$!q0;>_y~gj;toSSbYcG1eVpzniYKG<&{_NG4& zLlbxO4DWh3&v?4v8rZa+th0snCvT}E4{t<|K&nTntTh@ zo}%~3-jIaE%j`b2o8O&a;=$gJfHEF-&D-~V^oW{*0GL!Z-iO6X=$H-Ass#vSWkJPb zr)=z)=XnQ^g!v=`dW1eka_Es|RB}Vidj*xvv(AS_PU*M-0P<>Al*lAjZ#|v!zBF^Y z+X9pTEm@z)%r9NJt;p-xf|NbtpQU*c=lr$pI2?MrVC@&V{QACWMVPliGQr4$1Fe}` zD9KgODxuz}v?m$H8a%%3q(;~nt)R~GJ$)m5IGx=5RiA3z^)HyT@#8&;spsHhMT3s= zT~80Aw@qK}(Q*H<`7R5oQc~jCLjQKxcoD>WEIsGu=FnGm!LX;hn;*D%J~Ik&^&&mb zEui<4)zz_2uR2%e54rYfS})cizYI-cv$OH)ViuWGMIUxMGJLWf> zb}-ZelS#fjVpCp$xqn1MLuh-?&m?H~IJtu|D99mT2fAoTJ0+nj$V7nbMxwEgecRhK zeXc7R$7$18oHF0|;@lTToZFo2HC9mA4jhF>WQ`5cQ=w#h@gemaKt<6sv~#tz<6h+* z2nT`v`9zy1!GzA5x_%q!WIKC>Nd7F4uJj)3h{rE_^h+-45q8ZQdS!|`I`htF9|a9` z8l5@QDyP4(!n-ri#F^!mK*RAhZv!3(c*Vs5fOmqY+%b9ub}Cplyy`+H0+uBFzSmTLZZ?P7ZK!G!G(d7cQ*A z@9+1C%m<0ObNpLiN%@MZ>9tQ59IN(6;{+~38%H>T)Z=2Bzi01X-3eUHCOk1cU3n0Eh+=9m}9eGEVH zxfqqji3Qy6?I~W(Mc7F(6pj}nR$l{Cv}nUq*_t1AbC|xeMpEF!WPWizt>;VX%9N3B zRT#cTws2m@e5yB=&r-^YqBvg7D7wNUptfCbHXBQN7M6n(>w^ui8u!Y%ggo;h%=f;c zqO+sA2>qXyb-C>fCtlr#-l6-ADHhR6DRWTlK$$$v#^hr_plON#^LAOrnD_6R(a_$> zAvw|s)_I#}>10J^a5wXcu3ThYpTB6yJw#ghsv3!;hC7=TTQ0e$mS@Qr&H76puXv+C z(kvl-5>6q>Lh32pw7K`iNa+^FvloJ)-3Pb5jb+;h?{T6`;z{lWYH8CjNf*dnFV(R>BkYx^WpLnYTq8F_A3lT~|)%IF65tNl1xLi_=!hWu1xU{-g~o^H%~pXt`A*G+gxR7pXjlwfmjXwp-*f2=@QUS)-^ z-CW@S#AxNI@Omb`L5`AcAYQSs20khbB%vF(>N8&6k*_l}r>!TI>~wX6B`vnAJ32hd zlR;H-ay97a=KfjpP{_^L(38j$y4AhEVEIv`Zv=!0amEgPEBgem6yJoQIxm%X-*+(? zKO~~2P)(tHkWSXlhwLGz$L^PTv|~d~Es+f>H;lqM#tSsNFUy zdm5#HROfcyb8SW4Gzz~%@V-iCOi(AVm;^WMKrbS*g6J{HfiSJ~)%ov2Pnp!A1*2c( z@4X)OFVf4?_T~-Lb!}Q}A;U=rk;6RN^sPJxC9!(b@g-HATeqLzPw38J3@o(B82Vk;i z16S?ZeXA^W%Dnm0%ge0V2v2gkm15*n1YVjxwwU&-dT8sL*3t{(p!V$22(bU_Qs_`{ zyQ2JbbGyDO!&E3(+k7?nVfjcxPY0ZZJ+`F1mpN8&;IHSz^6 zUcbkXv@CWoo}jIsuk`fj^i2-gN0ns;i-`p>Br-g1jk?FFL&ZFss=j5cJF>n3#D{DX z^QoF)aO}c92$IAzR=JZ9pk3dFt)&y^`cE1M&wc&O;rE=5N$PiZzDnZR9-|f06lfqQ ziQ)V`?+X%f?r=s)FNqEu2`1V2Y)B*)nSybwni3s5;W+*0bBS6=YxhrIjrJPnwd@6B z`rZKFb*!qjOFYF1|6Du1_5n2>w@KqH z4Sa#s&C8(_<#iOj0=#XdHcsm$Rpa3?TA5TdBK1|83~26*!4|z~tol~*ecrrF$D|-4 z?bMg5jcPc9UxLmsZ-UkAEkfv6YP9Cp>Ft+AiwR49@UpqFFs}kpLGcfEhwV#C)ZCr7 zRdOk}A>yN9-hWor_Kxt{+4?$4=-hl2^cbkFqau;fLp6eagFGXVt(%H#B68=N#YdAk z2ZXzL!qQl%S}9`dPyShd{(Dk*cYayB_ORBxa0*ZQhUlhu+dttLb^Y8DF!H03kIyrR zc!3Ma=7oB0$Jepe#@$Ot^z!^=1guY&;)J$PRah`@gNvM@dk%Io)_v-hfcIo-!p8HB z%j%0TB5ygDDF4ORzIt8pWHr0J_b`3sOV{e3ko{g};oH%nZA3X>lO(^f+161)eu#vw zwlDqWZjP@XQ~AZfD5I+~NocUHfUwL+YV)Hn(u;^(KNn=XU{)m)) z19OpZ*XIplM*HKH(^N7EEb3ICm>B2oEODVi&el`mXu>xQT{W80dWy3pBRGxiZehaoEC}_CSksso6P`oisw3=RzxdaxP$#YhHWQ`Z+osVk$Y&&-1 z8*h%{cKKXOtZ%>HFDGqm&X8EGF>L>P=pxc)DO&v5NGFHM^76-zf_yEv!`^#(J{E0V z6#Zxu#2G4%xjwl{hMcvkQ=JhI3?h!^CiJpZln!y%(hGC#u6r`dkIxNB`N=m?5*5sG zy1BPMhyNPj`v2BNE|kOF_8BXj)g8q=EQglE$~-7m1RO`SaZVb8O5gTAKEQ-Kp1h|f z(t|V{I2}WMuQfYrBLx>V=T`Zf$hd~JZ=F2$=NL~qoaX{w!To7Ncuts{82mVw7YldA z2-R>LA87Ra;GerdQoCQ8Tat4hOha-Q`#K_llEiLrn5t4(5rb231a8a8hOc`jp%hX1Xw9u< zs$l*lrS-@6)AQT^9sj$+<^`m5f0Y`+JGaZXCq#0ruWwtPmDcm&>*x=M;NJWXc6j=O z$@>rbU)E8(`LnIUxlb0#w9id}i?v?%5*Xa46Y_E%(EtfXod=CqF64oz03 zva+h3^!%i+$r}rpzVWJ$3B`S*q-|MT^?l*Y_msS;VplC!V~92ZuQ}uD+2ABynV4s2 z1nBuKL=g=BqV;k!+b}Wz@{YTg>K(Y*IR*VPONGWy9~5TPhfL?}dcXY6eRYB+XD7m~ zZTlBrI&aURgM(l3jX=tlXueQD1{k~LIUpzkI z+~~Rl`2NleQ`u4cBM3)5e6fpB{yNS8B8D1x4@S${2RrTl$~g^Ne{n;=K~ ztaU!#4TdPqm1_H+EJGrwGVbknw$XF^?f>kF!_R*#onipz;LgsPRFCTa<1IK(M(j$cZ`_~R|V$Wl{NyOSaO0GW5hJH?N zBu+Yc=nClBOfKkkYy<$_x7DK{LCBS~Nu!7G?TUd8k*xTiyommF7ks}<)+^w^y_~f= z-g!+P;ol<(02Bvmk+OWHISB7@zlb|)B7n>MjhZ26V=Cm46pq`u8aJgie zoEPT&#S#5@@{d1I!Km=Iy5#1+>=N&7MT}==!m_Q{(V}*~jf$@P>C_V;_K^}=6zjcg zwWh9nKXke91A~Nc9$I~_bK{cw$GTyo`)56ke49Uh-P zw#WNU(#RqqFk0&qf7Jqaz$1K}lohT`@1ppNhBpdCsM)bEHmXpj7AIp90WrkSeJP)+|%&O1I z7(EN_8nj<#za|R%{0fYn6JJP!Zbc(78-9?lQ05_-I#qj_Eu}3xud>|&WYJvzlPBYm z8lw#+!bEyu54e-*=i6&`<1Y6LiKX}koyiLtRq`tK4|oyHN{x3ISN2H&pjBz&%DhgX z2+hu<Z}+2lANTijech4V|hAedwZPs4~xr=gLIOKi{B=rmr7>e3j#p{ zC65lTqh_x*3+5YI_ND?%c3h~-KG z1&bMb*%k7*9vO$vlMb>A2nu`b79p>DGw|hQ?Q_vvlEx63;$L!^*YM9=LRAdDiNiuJ zunOifJZ^8{nBRTc7t|~I z0NK#F?RzX@#Jf=H?a0+U7HUCZi35BidH6^`$|69VOR=HP|F>hY9BUH?Xn%aBnr}Gy zZ6+Z~#WhtO04VR1QTyNHb5NJkxSQ^1P)2Ik6@k=YdO{kkN_| z-vWoW2pP7~qQ)n<%JY85bGOEM^MwN&zsu($;@f9_SmshEL-W2zJUJ&^x$9sBcy%z} z2Ggs4oTm`yR&U|5P!|)m==V$pc{C+>B=3Za{yh?$ln`cf%dfX_ zcg@JsFXTQ&axUG$jIBZlaaKa3(zay1UF)RxryNIe($2h?HXitAOux6_zb5}qT+NO| zL7JUaFGi{~#NC^h%0ueiXJRihEwoAaT%az ze1i6!UA#DBcvX*gb3A*6rF(RECM~xLin_H@D$>y(a~{%*33o2hyyoDi(b01sN7Zkp zYT?QiLMf2RyZAi#GQMMsND1@feb z=Q_HQ7n{0KxfiF>ajA7th(XR)kx*I^i6hy3&LabRC@4v3?6d++N-(0=4kYcYioV;p ze#>rtP506__WkbTGiTB#VX_Ipr!g-PO4pvuvzu$%SXRcW<%xccEa2fb$8V0H+AE+rOpAruPpR-qR-yaQnd1Yuz zg>Ua1mMHuI>YOQzz2%B`Deo`*ai4pfz0(4=RtH9}6+3OTNj@vnBwcO*>6 z(*{*D?9I#iJZn`YHC*9nHgWMFsp+9HT1*~@GmH2l*nA4yO5?MIQS zJj8V&OYnzdMRw|u|Ip0jEXDpzFK7=7POQkAIRy?B;UL9jz-)#Du!7U>jSKpF1v)F9 zob(TfFi6o9*RvV~TBa=z$3bverTKC^Mzu@r##kyoYvJxFY{v*Gj|YTv)AAjU`V+^e zwEwsO4RL*)s~ndcpX7@EifS@|8=M-wt`b+iRIIBwyLBWvi0tL0Fb-FY;$x^-mo9n#&j#W21KtvKPYb-@`n@H_HQiBZ+Q7z>eJrK;~x@cUcWHbsDrH^51vPf+*9unf^pbvcPy5U-oQ5p zM}X#kPqg3OLlTB288AP-=|gF2s`;-D=g}w)E%ygU*^k+*!{qUd;-hz|@yvM=>h;&m z(er;fIe3I}heuminib26xx8;Q6b!%?BvNfsTWCb=3Ky714*NvjItrz0zC!}k83 z-E=~-*6Q7<99SK8#M)gD^NDIl*^;`!XHrwgi_z(*l5gU$^gI1_O7o@lVfFM>Jo~Ri zsKaBA@?8rYZ6ug?w=GpWb8{GTzKK>22t@3e48GFLXu5pbOcV?*xn~EWu8dB}(0(2+vRmtYc`rIExY)rxl?}?yvJ}i_LYWMS00) zdaen58>0yEipr!Ego0E!c4H?l+`{dhHv5@d{*!BkzkaUD5QMxZqDova+->a|>;&XZ__m2Khj1!nKkmyH#66S>Lpz@?)k$5*c67>|ss z6>@oOH;*uwXG9Mb3+g!t>J>UtNE?D__sS0~YxgScn8h-c=T7hxXv^GATJ!|>1r6ai z?YOYAW&QK1T|Lb{}|PXp-C@(Wcvta4!^-x5kTluD= zu(R(xUcsc z1xItAYB0&DKbA~Ky}9Ruy~|4zp?i{K?O((uzLkgVB`I%oU#!RAf z?@l*`X+oteew{YuLs$Rd<8WOm7ad618S(Q0g?n3YhjiU}2MPOxM&-11)&nwvUu+%Q z^(w0yzM4;*>dBL}zm^h-k=Hj{0vDCny}6++vF?(H#}O90ZcC#^IDff}F!TEvvrje0 zV7tes(KtjA^GEJs_s>HZ(%`PH{0=5tuIJQI!5AL>C~xeUb>}%zM;x!5hm)DQ=b&e| z_#HxpxoHEDQSsnlNfiqIsV~I*zZT|(gc-5lA*x>X+5%_`tUBI-EhdE{I8EWtin|l} zUSdv;&YumwmE-I*!Jq)`>qnt zy9#cczvB}S=`qzXlr_zo_#XMfz1t+LQ>HSepSk1sWV%Vm#>c#LoVN^<>pKMSjaZGZ zXBS4)^9sM|OvAr#E8ll!-D6jZ%&NZN%h6j}eQ9!pt3;-KE1|!|ao;<(gT`p6{w_rI zmeHxK5*OhQjR)V4nv%%u9WRu@Z|bzXQc%-+Pa_da5&WXBILDOeULV!Yc~15aq78KRB2}Dwgu-JIquue7K?_OUmd)u zwyajmd3cgQ>5kV%fxW$er!GDV52+FO#zD+!=++Z490*&glLs_b<~I2mt>Ux*`RHTb z2mJ~oCJ|Uvx0>_6eR&mpl~(lo?=3$ij&f(%jLg!o@Ss{{^@=8kp6(*&zPn0* zikIVZJlS0Eo>X7evbMdYpnX!%iax7gSHLq>v-BF(Ey*BIxvmWIgY^Hc1>if=1zvx8 zSrxcf=wN-^**+7tD|f~dcFLG2w8bk*p^F4IBrI6ON~K%x)v`XZ+-YTqWp8WSP9zVOm;*dREI_}0HouOuUzMi7i z@9NW`x%$+41+UKg^K<|p7I@3*>|!GP+>U6Q!k2mJ!^WcN*LM<5+mK35>gA<9S^R-F z`xYvKbKE8F)jVI7e0vYg01o4yc^p~jYpa2u!*^l)f#*~7W2YveGH4Fl1@@>_I6KifGm9NMs3RZkh zjxq9jq~8A;_VIHB9knFHS4hWKpo>h)D(>c2RrL4xs@(&4= zPmzg`qvR)@2?_o;R1Eqxc3Le;Zr$JZ`seiS>6+3tcvnGG>H^!&rbC0~4Z13oZs|Wz z81*4<(a>NWjmI^rtm3y)2lD0G#82P|+T>S16d|kg^^P2r&}VlJ%=T8o0mzIOo~Uo` z`uwdXY&wazOAp&Y?wQm!HZX9RjkgPyRx@U^a<9B@qv&Zz6VTBXkV2DI{7x@p4h6X5 zoz_e&eW&j3dm0*2{|{ep6;{{MM2k)qp5X2*+$|w^@Swrn9YV0+?gV#tcPF?z1W#~x zcXzpK|9hYF-TQLir*(IAb=4T7YJPn=?Q=v%M`u9BpKGY2KKeaE=>6IC{>3o_R#cQZ z#$MIc;V|ufreEo^201;~?c|d{Vg9MBm062l@G;2fZMw!;6q<53TQraD&R|jPtu_6| z{mL8B#-Ug~%iiv8(evN~x#4H=fq=#%{a2PMYwna5s%TEKVXoYQ6{eXsc zdhg3IScbzjZIX{}$yIfZ)Wp1C)9!aX&xi`=xpJx#i1=W~8jGiNb5-(sic%Zbs+gfl zd8KhKa^74w4IzB=7}YW|!w|fNmFUpiYY(NBFtt+bZ3?o>>j=ujXp_?ogG`yh>y)_r zr}PGQ?R+xPprxcly2Fs;;NXO0vp>XMx2QpM_i=g+gDwp?as01~Phm7DS9NjKR&u{( z2_)htFI5>BD7ZI#9mhIo*;xs6ZH4`GV%EhM6Sn_uKqlf5O&ox~)qTDs3NQp*%bzR71n zuld>vl)PAw=NA6%+3ag>uvYg8X2<0W8dzi9yRV5q3LS!`6vdExJ8$Tg<-J)qEmc~YbIgn`)b4z68EpyrtYO|~pQcy9 zf|4L5lG{VxLlI=|{VMWIlQI1Ec$rF^X|Wg7B$21;^%gQQzS26u5cqle6iR}Q`$KcqbmFD(mY5-dY-0M6cfS{q%l6x>NaCuZ>O9?ZPe6C1PZ7Di{Cq>9Dk1`*J_t z>|5>KuC0Q$#p|2B?9HZdqe2&KIEWfV&=)`@wj@fWU{HxffPwS?cZ2fHG=7;O^`XVs z&$zPZNNgx0`zmZWT&$C9^V8ol%wS3FTnVCR1Vi<3D9%3;Tayt|LUY`m0^bh~3m34G zTqd6SrOOVLGkA2^+a7zb__Bh!^;z+d#BdEjA>I0XE~C4Fi9SAvY~52^eX&HVi}KEg z(~%KFOQ}G%{!q{o-)acr?M;|w^N?{R9evdWN;|F$nWCE8Fkbx!2BC%r-A>D*5v+#i z9s6vyPv&;jdh)_A|<8nMb6!L7U>X9*r(Lp^6+E|)h`-M$g1`_ zu2eu*VjVJt8jbAv6;WKBmxzdrt8EFl;jxZ*|F{RcS&r)%uPwEAXR-5~IFt=r+R=Ez z$-*z+5(+qf$RJMf+4a|d2ercutl^3T!e^8*@5hvQJj*0aBst%m&Ji+S|6alW{^*W! zbb7bhopUWTEbU@4l-DBhzq|471W~MM8q6vStGa4|0E{I3-RTQeO zS0v{<-hoWjzn449(SSlg-|E}BA|emS#oWOGT(Z#F8(bM{{F|MyBsaS$R| z`P5x^vb)cb!E>Mxf_c4L09KBZ6BJtRZJMDiE}H7;z6&T9hzG%D1Cg9Rop3DIRH$Q|Rao@em)y3y zdS5tJkW=h4$2{gO{#jZ${|mw#3g3WFnuU>gE3if=7Inf5Yd`HtKPlTG2(=nYGI2~V zic~}{E_^B7Vs=s?mr@l6@X*BYD7t)D!56`H%~Be41d?CfVY1^xtE4nK-`8x@)+{c- z-uU7FI~gQqi`Wtf_5=|f?Fw*t(Ch@CYwBNb{Un@LCOdfS=2gOwtECCxPd17p`@X7d zOSyBEP#`6XAW-x{_4u#~=L#zJh^v1Vpq-RJAt!^pLn=g33Nhz0Hv8n=5={AYs}=6( zf8)5HtQ6ejVk+Y`TqZ^!lg+Dr*$s{1i6jnjwV-$0K;yOJ8*^=BD586?%w^w1+Z2s} zxx^J#pZtOXl5D}@MxYF(l@OwoLM*^j$PNg8%1)G=G+bP83?^oC?EBvvwl0eD5GMU~ z5VvYT2G7+=e>_jgYR4U`i_=mZ5uj)F`^XqeDd;@%VPXJ-0JH>S3_yQo2V4;ff(6Gb z0-|(Kp|oGH5U7F`Md{Fl>8J#Q!}Jy4YbaVrjsCS8mvnPA@W$FEy!HS^uZ@B}#XhT3 zz+wn7w;EJEChy@oyC@X&ESOP}B7j?vz!(>jG{NIR{qR8(#>?juY!!^%jUgg9-k=br z%fS*{1`E=Hg(*`+fj)MLnIpejLva+Atp9ChxUHH39pC9X7t^UWwMP$y`7{6Y$fD}k z3(~aRcHs!mjL5rHl|OGQZ-+UKAH0n8uL-f{E8j<)4kdIC)sCwiBSh0iup?No?h1y3 zLxd;;(4A1o=?;Ih^~=(WkOP8rP=fKHIh3L%j-qt>puP?=dB0)|sqb@;yIcMK`vBWQ z9be-Y5hP&Q%h96X9T`^W+STc2nt3IiH8>H(+d{Xf}?;-ZYav5LnC`k}$#z*wJm zf(U3r`+W&QIvM@{nTh(8M&3M!Eg9|}-s66h8vg4g=Z%n;w$J*>MKt^5yyMd=#$wY! zL34%#FI=y$b@Kv?!MyZ)gTvFirze7Y;~#hpq9%~y*j<5Sy;z>e2UT}d1jtF8NX-uH8`uA1djMzW_N&LC&>QS zEHR&RmM(Li62xpPTPq<`SPf@O5Y8SvHD7=}3+|))Ao-5eAR&x+GyfdjE>qha{RoX8 zLxv!@I1)jg?T09U3C2T%4fp+lg(wK)i*fk9J8~8KKZh6f=}re6LV&1s=8#%SW5BG= zb<$267@mPHz{&0PIEgK@b8$4W+*q$6SMF`zU3p64>}@Lby!>zxw^ym|csrx=1qcmR zG?LQ$=EBlR_=?shx+Dr^px=*e_&$@|ba8vFLx*NqOol`F4R%h=sv# zrFgtt0k8IHwNY(kKN_-H+3r6~x(EX5LYaHg;pjdf+6H5OiY<@qn@8V0u82M+Ho`Xh zk?rFHf&#Kd$A3{sts_WkI0+(vh3UEjfK31~UY}lyDxg}BmTFpq*w^2=U*whgKX1Kk zcA6x3SbLPLnBc%WJI!-AbgJXs?6B4%@Cf7i(Pq2&0JXan94xh{1>`B?rwkIi( zXK-E4m@w?7KDRGJ0>qaTKPHP09Y3O#td)}9OiYehuU>D$RFB)2KeC_@$dQg}9^yD~ zQC;K5p@eE@Z5zG2oSZqE(5|ls0qb8OAUDU5kUbFLT!rY^}6r410bNlF5_5Nl0UI`_&PwBpDntrty{jB>}DIV4Z@KU`6de$QeXUR4mk z#HZ$;Vm%#&Vj@ePr(WC?f&1{siu)6b>l@=iv)Du%Vo?&q5m7*_x(C(MgcgVOqHhnr<|W%|Mv{m!hg zZz2NuL)|n9b2NRZzPiKE#y9h);aBe3q^#BWS^17ft2sv$tshO5mKF6$A!P0nM zv6AuWrYz3ZYm#F^Yb+s%!z}-l5jt4o&GQjP&b7;sTdiR)4mKqS3h3FbkO}z&+d_Eb zwrW^eMn9_4>E>=5_MwRRVv0g~0FN+|$<=!hq4|{mYHrUCWhfn{sN%iw=i`Z;OF=5fGEg}H`kZFEl-A78y=qA^r zSa}M)cB|v#@Kb&QxGi15)0dz?7i5mx(|$ymL<_ajO6`&zQhmcJ@$Jc(utHdOcf4** zHCIdffy)e&@x6O7>LSXoq>v&RbC}ghbw_>a#ZVm;X6m#%*8|>ex8f14`(cb7|IzRW zasK3eL}YQbPIm7kyGR%tt76`StY+G+t{0COB)Giy*ZG1U&h9&zCU{}OkltzrhgK@> z`MwfLp-59B@4(m2Rti8vn=*QPpXL{}@hxxbQK<1{LUDa&{5^tvIALvjb1}sF;g;BQ zDx~~wHw@qUqFKm73d1hE?^@=-cEr%CjB!5*g(n}|td$(47ALmL!9b%K%< z3WFfZzx|N_hSD)i2@;|KW5K-S2F1HzKiWhz*Z`Qc>#vJ0YS(~S=L9zdj7m2%R`1G~ z5=SiqwQPAMhB})#)fFal8M>Q zH{ZXq4Cl_J&xEG-B-+G0BGLCTHqiU0(VFz*Q`U zU-t1I5`Iku3-ZDMPMN?*XhMEodDSywZkvtcxx|Xv#P%4K_pH=p9AEX)k{*V9Ka}Z( zb2+$95NaLc1~KVmY}-`S66i>&!)@_TmaY9x$~slj^L$L+87D3$-R9_88`*NR>O}^c zG(7dZKSI)Q^ZtSBx#!VAJ2j?}iW^AUg@(OXTRXbIMMp-qIO6ureRm_5UPN@$FCax) z?W(@2Frg_ucF*;!`d)%I{`D{hjre%zs?8rczF@!C*50m*brK7^m20OyVCMi9J`uMm zJrd%wXx$XSB}b8>4ud)`Dg{QOad0;=YDr8H#3uZ87ALr0sCEkWc3<~Uui*BtPbMJw zu*cRVGLL&x6T zQ)@QmWK)F8m^ZkqYiR2k$S&EvUoDd@`tI5O><8!CWImKpXpzJ`jm7Cmfi=D8wkN#B zlH_{7@-eT6Ad1wwrBzK;?-vcl5a+#%qJA`@;LTHI$)Vu5B0sy!)wWLvnLG#gyp~>4 zqnn8nH{^VL=w4hJ9{j%PBc8_(%M{~(MlVcG~*RufpdjRucW<# z_MB&z?h@nGv)|(A8&eMQlbeUe(k8AW^K7=Z(OK^S>o5xc>x7+8UOCKOZRKX~?B?#p zWKfZA-_^cJe83A2f=*QeS1DBqxUTDR*7hOkDA#3 z_vhs!^*I5DO>kJ5CIKq*Fnx7o_}F~Q{!e$kzV<2Qy0_&|K!yQ=;LzO8%{n?p2H&MY zRcMY_D6zs^cWy%^5(>55VN)D^Es8<_V4r*6Z3q>+OoU^EyjzE0!B&KwCppD_rpf5tWX@W_w5Y5Gu;`$OFJ zZ%j)fSzadM(eVVemAO@m!$G2TbnCrs*n1Qgy46NKxpok?qw0H~AREj4q$69UNf?2S z7O?%>Zmy>1dH~i#T`|D&Sx=7&KK{GC#ZlKfF3EI;JL(0;zK=}}7s<>+U|+s*YFkca zx%>kyPOY^`y!b;Pwg$7nAsgOZifS72G91z#4_`=U+ui*g_okYy`Daw;N-b+B;E;DC zg-zt%UR)OEm`mf)XbN*9&m$v%wO1q%?1V6QJD(CLAG+H;?VQc&ITLdq^uoZUSI63 z`hSw7+>WMLJl7d)C5hvnwr+l;@DlnB_%LkBkK@8#2u&H)zQSlZV{@RSVUZr`WwGjU zm~-7dxQDIF9mN@aZ0PybeB{m=IZfq}=-F{mGC&&o?tPeSI`mt~UwaXF5l^8FbAVQA z?9pwz^x#6{?V!WvtmEx}!TY7~ef|aX_}B}s>D9}hCe%tKpZvh;&CYboAut0b1hr^g z3K>`n&CA%HUB?myb@?do$YJH*irYcO&f_G?|E-SH7u`-2hwNVj1;76D7{+rSKzI@t zEEo&~)47)pUP@$>;<4}FPV^_SAOc|a)p91AWW3GQ#-75_;4zO;)3V2t_rqq}I1+5#LL(!gXf}k-3f)S@VJsO>h zUgjrRq}Gt8p#gQNw-$1ZcOmY56?#ND48tDY?o55lW3>>dDn|MS`({9aB>;)YqS>@z zxyiEGsiUO+1swg_Tr`N(qEgfOs>&c(1;yVx)ckV8NeBgKvgnjFvu`&SA=M`_p6H$h zbao>o(%;Ff5hLDm05+f0NR|?7JlxX=D6vng%-Zp->u`wo!BzPty?+mj7p4^38i*v` z-E56aeI%pr-asBQ|7|F&^34L4i_bf|7(N;v)M>T^F>@COEN*HGHP3W$%S_J zAmQ2)M7hGWW;|-%oXFukd_Nz*?|Lg%qm{zr_WL@Y!M}-Ym{1mrFZaw(m4{r4S~e_N zwNyyak)C!m1hH?GbPJXCzEFw9|S4$P7DQEFcw7r*2@rnZ|)-Fmc^;4h}7Zkpi>m=ZY2 zJxrWW6b66O5RW+0%&1M~8aYY*yIs&AG3?=YhWT3!OkVSdu-*cVAw&&kNK={g2R@_I zz41Mk6gJ5PZisK_wC5M1G&^-kdVhLqORLBB)QoL~s&{Yt0>4&nW3~dw1aUHPx0S3I z4EPs`5C%p*5jTeuLxC6v{fturYH$o>L(xM%Nk~ux#r7q~&RRc5>>$MA`3uimo0(M! z#lprKpEL2Y>-*8diu&03AQUK$P=upVC4&ZF!Zhr$(4gdV#ngdNI$H@$%0r?aacHFc z*Pz@ii!(1z2%Ay3 ze(-NTjhK>j!bep~tZk8snRjmfjdomJ`Y_*Wc+?_V`zWZbrb-VWDOwR#!EWQwL5(W* z=_qa8qi3u2<~Em(YN}0fqr%zC`luJc%o( zZ$CuduN3>#s1c$xhJZeA;Gg@M*{eLT;7(`0&Cz%8P5n^I(FcIq1b9|bt!DC~cfhdX z^~PFd3Kun~t3WU{w#8`7lJw|rR9u|1W3Gv2!!dJOBmE{Z>cpepwI?uilI~RLBBxqo zJ9@E`6N4B%Mns7p9wdc|Kxy1QCO`^!f4hU}Udkvlia{U9i(JeXQV|y=b~;IMT+faa z-B~Y=1bn~g(hO6MhE(X+825Hb+WoGK%ohfS$2;)K&Il$#hodVtfBM~sgDc_#QhA!A z+jUetqY#7vlmgfkLE=6DMQHW@n4oX4E#13dXc(+8rUV%J_H=mt$!x!9r#zwj2Qf?% zj%nnX)q-X}7^f?O#m1evMfhc^G0kMPDZITRP8aC?L@_vm?@lz&)<1Jas7;6;Xf2)i zw)eJGW{PC5ktKk6IAEP{?+AG|?@I;)PE>MTMOkk9QHQLwW=GLWl#QZ_*n2_SVFusZ zEw;1{9n96=mL2%038qKL?~!K6wy(p)k9K)o1D2|=X}pwGs8iDz*ALjRKBc!W=^;K@ z9D0!+D;M{*@;gc$7Fn1+sYh9V&CHrgh%LzAm1P`FKh~&q*1~;hz2N~=5r6CRedR%y z`0ds@MYsI=lvcPVXJy_=$f#y^`n0yTI&+B^4v!G4vzY#MsU=neemup|r#Sydbvj)U z6lIXVs6qf&0C%}{Sf4qYqOj!mIUAHdH_8B&6T_O^u|6I$kPq?m5sY9whR`7#1Ej^8 z0BqL(=?g%P2PkwIHlxE4LJ0!Npne}(ec&AyB9?)qEg4LyXe+$L(Fx8ACiG1bbE8#2 zj~v8S?( zm9#G$?5y;0o`lK0jKL1Ri^Rd16BbmqG;G41CZ?zLXDiu)zPWL2ok`vMp_{->?xC4W zM&tg{k(-==iOI=QYf1M1{#oVDg0euA2RfD=XQxPO3CQOO3rELP zjZGLG26wysu49OPw0^jCI)-U{do7%z6YXuu)W!3^SU#vo4mI;H z_KSuyihx9>M=`7ot)F}p!(kA%ttGGk+TbBaLp^o0B~orfhN9&o;UbCV(6z9&HE(TvhMRUhrtz5hG; zaL9G`$HfK^4wzk%nI%l}W~T34coC~vDRh#)&4fin|NB1A%Vru9GNxLfm{?0JK$BU} zJPNPOHAhPFJ-E({PzQ=JUk{DXC#3Jz(oyj+dy0V0kv|1-lxs3OCui1O{1(S7sH`(m zq?ar7dH6QW@cxp_Zr4TNv(MV~C-}k<$&vb2XtRodfs^Jz9Gdw=`CT?WZupVZ}e7xa<1UIKLa$4@g59QfSNrQM$ z;h1X=zb=Un)LRi7b$YBPyRLBT9d&mX*>@+q^!Y6R`1n><6gsveY(P1S~od=f>r`Qrm_#_VP-SwTDRwtx7yl(z+d5w!_l^HrfIh5>(EzhWV zjo5?%Bm-fawIv|ltQ)R8Ze{X1Ft*@jJCki#6-!rdHa-gs;C3ga)BuOzIK?XpBNixU zi;8B0$V9;$zG%WQY5JJKV6-$r7$G#6NqPx*D?jjjfGQz6jCtdXY3)KV7@>m05R;Y) zxV~MJZSrt?`5feU=l(B;;{R9adYxv}i9#YCp^MG8uSuE~z2(O&g_yH8e)@Eb$T0#y z7Ro9{L`x;2evCZ=75nitdVs8)5{J%~$j;z-%s`fbdmYmIo(&w{hY(AI0Fpuna$wL^ zpfGTXeS~&2ieMs*ZQw#_h1IRJ;1QHW5y7%Ne%yb@Ptj|t|KqEgq97Xp0i8?sNgub@ z#RJ?aPK_@j$Gx8$=TQCk-(ts2h^5VW8%4j4!h+_-qYNnU`4h}QUf*T$BDLL-s zgd*l-h)GF_12%XkiX{O#$pwSa7(>Azi8>1a_)3Q!r5MWkC7v9@CgsN{kUk{ zvZ;~(qa9FvZ((CA624#WL*bfyBzaF;(C74a#N(EE+Vr9Bz&T%06^k_y85=Uf;^2HM zN?vAt1yOUKIDZL4ElNgwZ|Oq`2WVVES_8&U5wHb>vF(T$32D6`^3oOxU-AH4U^%uwpc)aZ2A%tEjTG;91TP+CQW+9@2n~->uRyZB6~9rcM=nE6Ko?Pepyo zD~^Cv+2__X4kt>^poH4>{8+M@!7@A<(88iD4{d)ESKkm6x#bxdgDMqSet){y47=*i zb;Wl-N;h_}s1HUc4C5^?NEB9-HP#ZZi#b#fk;vekvQ!zgC}33M9y=Y6W0Vb{U^|Cw z820)7Qcfefl{g_BFc-|`wr-0c=^rSyBJ7XSQ!j^!}BS7#SzJcV2 z5aI|?N8`JuUIcqEp*IENZS)K|<(EX(qJ&2ZNC(88ZE&=x9+CnWG1yKN=+ZxPrgY*_ z#3i7+AO|HwOQCI|ks~Njo=BO<`mmwN4v$0kC?5aIXY=hkOu~oQSWkX)H)rQbv>4pS zk}A=+Ft@ULt>{o|*&1Ho)+p+W1dJUiEhtB!ge89M4cz{Y6^WO`Cg_oBMh{BZl}HyG zZN;R*R6RacrgO5hSBFBwM(aUP@}so3Zf=qa!If;bS6mM!?~~jS3-}!x84Qn()`h1) z$&~pYxp+}nD^>#tTYN;<fyP_sp4P9SiYi&1;J=wqJM~l7@uttam6AE?jJCA z2{A-;u~UZ#IC+w#2^4~ObXBlkt!p~}QGAtuLj*B$M62tcZ@&)Vgy*Tb5%=G}vVXeh4&4?Y&v3K+0DhKmr5UXd7H5r@<2aUko`i?A`|;lFP=B z*TXUa?l;&_!tTT62J~dbU_TgV*WcTItxMdwpN|>5EdANccJV(@hadC z|9ig6@jptIXu-Q=4VnaXa9>qJU+4(*ujMDcD^e@Rf$Kwto- z4-W%fr=4nz57b8|M!ELhz|kR0s$WVXRxH-!rxbBWh|{kE7u0U#<1=h_WBoUNPlrEt zdks5>Rk%`wHY+WyLv{YIWPL|=Lqnuz1z&6j<9^PH#R_?)rAr(8aas7ss z4>!XLyVj}}gX6 z&hZMmr8I7{6QPI0+aR%y`cr?=xTqq939ZAjOMXe&B{LjxKQl(@@J~OVp73uRU2Urv zxu+6lF570bUBB6j9gctSNxoStk~>$%vG~744H(R9rO|T_*r4WRHnLC8;hZf+cNBD4 zW+^4zAH+}@g(D9pwl&e}IkM>VP0G;y?e>EEe2$#(b7@NHS3fT5|36XlKkA1kzTIOi z(+vO+l4DXQ$D_XrZthk{3rBd|G}P37>sdvbQ%*ymHs=pwQH$> z5~*=F6G_XmE@u)rF_b8GE3p(E0@VgL=?fv|tz|deRZLn*#}F>c2*a@Vy52Qq#B@SAaF<(~sKLQ8wzGcRb-cmh zoRSyM&UkY18dkw;7WznOZ}h8rY;z-$-)5{K@o6kOKiVjAo0>}xdr_Ec<{e5{l=3ZR zuK#D(OlPja8{Q)5MOVz!M9D%p(^>TR*FYa8QL_~0^+?`FqSTH0T6W2w15^~R+7sEs-Zcs1@YWl~w6^(+E#E0I< z0VF&hj2Uz~EK|LxSV&=gyf6xjxMQEt`cUiPVL3?{*!<};Hb266*5kpw)rF6~d4sC< zvn0u0Ac_b5{8C;Pj;3`Ai%vPWwK**C2Q|dYX?^Cf%rgk-WKA62xiUk*yRb&5E477LyT4vYsR;evoK0NLDT%QLD8HWXmNilpow7q?VLif;%Du~~!E^G2Id^Ln z*}|lLJhJG}t)bk*>gopRbj6@jkEidYwHM;(n_ZS?0D$>hEzGr!wvy*iW;o^A`j(`x z5Va5elc3U!Z+d~pxAWa0C`^%TgRj}E9uD@y;_d)YI{ePrly@Anx(f>-fp>ORZj|*4 z^-+UKNDSOcF8)U5Z0>b__-8;~9e4e0<`~h|_6I92Fxz%P{!daU#>-TyKXk0NeX{ur zLu5T>r#lB7SOAJtM;|iy9O&~XT{eff&cK+0z7CephblFzpR!Ce^VGP#`-cQd-cjF26*X2rIDOS7+TH{C zEN%VpuqVB|3t{efv--5b24 zF1Txf{$AWGz7VB)I!VPXK+b~~Oi}+1AYVng`j~`W9}wj1b#=Qo>U_s<<4wDM5?m7) zc&18Yb_(wUl9oSjI6z0-`k06%4F8V_*8I-|@uz2B@;7i&a-oXw4VeB|Om6=9syN6U zWtS0m*rt_)!!6Ryws_ah@vmN36ltqpP^266=xg^2VkcSqcdv(wk=U(40!O*1Yy3dq z+ZHF43TL;X%BANxguxkSXNSgH5pU=F>U@xdsfUBR`=*VmYeds((aI~?_|3A(ff$wO zl=^V`6@5!L)M4-R60u%RzNr!F@O$Hsxd^FB26VfcpmC5$i)6>@A9?%xc=lgaP2=xK zUeP~y@X(B^zQ=neH-uiq=cJN3-zt0m;m(G+bOv5l;vzOu9MJE zpsvkJR5ciF`fU!nXpP)jE;OHr`tKzti)Qo|-^eROkbzAzQ2>v%5QKnIKc$SSwkMaq zpKyl{rO+j?#2GtrhL>}5>A%O<)a8esWYTeWxwKpN;`>?H|H1%hn7G>-YMEx{X4LR* zkN+GexEjt$stxtHDL^u(zXYamPmc_SdccE>?#DYez{dyCt!Pzz9_$SZi zPc_u8y&1iAV}qhwS@Xqw0pSb0JKx`S$(E2m%f|k?ZaTQKDqGyHN;^aEc8*4{Ww%JW zr*O>r;Rzp7fAuo0Zy5lYHg!o8(-(Yr;i~{K5-j00pn61S(r3BT{q4NtDCrOzqt?=X zRQCo%RHy7?kW#oIVF_hX7}{JzUJ5B%*ibYmC@Ap43L#)zW1J+pnZy-TcS4&|tl6;e6B9}_ zA5;5hh1-074sjE|n9db)*=8L0`Fk@m^dsIzAw(og3(_G;IFf+&K zly-V1G$8+G>t*5~<d#*1Chqz4v zR!VVL!tW>^PG~^3v510W-{{4AXs}GeZ}qI^&hNLph57ia8y(0dS`Gek@mt8+5I>*) z!kLp1FgBPJ6sqEfEWYM#{>l#|$KoBDmB>ysApzA^HX57oMw^BkUCc8kPxfJJA*^FA zpZn3utHxKfL3FZLmkC*s?)4&C|DYo-UDxMDe!g;>0n`kMg&E%Eu`aV3e-h%SxE{Eer5=O2+uKMXLsa#=M z@P=k@hdn|}@u11bqzYeHtH5WIeas^h;q*^RNkvI_lWLm>Rk zv&ViE=Cg1$*HOqD7c2PNvkn&z8y+emH$+JuMhp=PMN1KLNAQG@y7v&;a!Dj0gMJ0a zfcWESF9Q{Y5pqOV3;mLnXnepvGC&$o!BLHl21B;!kLNJmtloiA08yTU37_7G|AY z<9s)}!!F5bdq=dGrTux$!MoRT=ZmB2`K?T}KrS`hJ)ozTu(lyTva!f>j+D+WMv#KO zYX#Wf_(s~)8zveu?rJhQByBQ%G6gcm34yl%NTK565ggKQ=Lng^sDP`=P1`Di;BEa? z#MKR)g^a?U#d)ygQVacJ>*-Nl{*Em3>i7KcVH}^$cs1o$bB4%N->TKw?xT}+Nhj9n zi?}+R-5GYjd<1`t-j|%q+C7yrDVASuLG9fA7r79&huy^u!X{WAZMVAICELp>Uq1Sz zm>ZmA{G-=0=a0_4%uZ`>9M~sK*xX-HyOk6w7@w*wU=%2`PZA8t^vZH(RsKu@137s=MbaROz*5)Bx8$`y-637Ya7hZS(!cWd=j)C zkv%TYY9`Is8QyNIY$l#%Tyfr3?42u-#`Bn^e723bTiG!heKF`_^A@v}S5@-S(Gk!g z5VD^j47t3*`vsWl1HP<4zsN8b9!vsd)8_8Av<`}f9pEQI;*y0Z$MjH!rpa?SlvUxbt9fV{Y>qpEXme~7<*R3IyX z>G(8B68d=Asa*DqG%n$`#oS}`*O*Pj3@6sfQ*m5QkCk|t>)DXSY`+|vj8@yh$qzY; zzo$$(bS=#^iB_+#Wn*PY{e#J3Z~-#X+O^%=@QI5(p8=!T0r(~!-VKp2>SzWn^6KzAV>M$Q2WWH<3w$DhS#7NeZhuDt$g=vBzcglAwJW1?}KRG7Sy#CAAn5#bwK7LO}6 zR?#o1s1%_FC-31=x1_UKEEjj-?pga+PEDO%-3FENbIX6fLGq?yFiE8&_c|#ZTJ^Z` zwfgzz0s=$3lsC^|wc5r!=A$mdAy38QUIJ+Bx>+6WUAuRT+@!0IO9M+6XSXkd51x9# zhxKM#6~}jJEj%U8&z+-$N2aeEadJ*eZ{Lpjud-erV4a(sp5I2eCTU5h#b2|WY23Rr zE>B7x_Mr3~wn_J3BtLak$Z%HbF%G{K?$eNf*XsqupQLd=acWe(*lnh@J(pFsxip^( zFsIOKykAH?TP`nq<+s^eNNZ^I5)EHNaXuRMna;F<^apM)3=Y+>4`nCEidI&FkqE_t z$`zy)iZ_%BR_CC^fe9f52V3dV31Oju!f7#hu_YheQO1!)!G3}eFEuIQ^hI=LgKkH! zbf2o#GLK$Cw8UsZQ4XB`vJ=q1!tnbkrR25ULDUkc1^=$mtS!hFU)CMC)KnW#0# ztddTD%4cwAdN83zb03~|QiV?3!W4o1WF^uY#Emx=o%XtaSRvX zMyESN+t5~5+R_?fD5Nq&L2>uTG75S`t$eD8`m0twH-U?V%wMyVD`!U@<@8&x!V7=V zDc+;J>$ZZ9?}I_owL7J0CcN~!zdK3u>=<$Z`Zu2Mv#F<@u^g69maP|K$;FL%ri9mS z5WH=c=!MZ;0Yy-vM^vx)iKlUX{v?nY9StjIH`jS>OL@4giw_! ziPu+l(PcX?L~(X1Ies{fPM<&Za@fx!IGuY{4t1T=bIkYP^3cK=eq0kVV#z*=Nj6k2 z#BGq4Iq!d@+!?b%>Hl(7Ri#!c_X)p%;QgNpot%fhjp}2cf>fWdsCDJmq(;8TqkzLh9u=uuxjQd88HMnK z7Qy(ANk^6fTm~{5Uijeuiuc@`U%kDEVSvtbYaO<$N2d40xFuO{XV%bJ)6aG-pPtsr zcYantEImT+5Dw@hytcsI^Z0J6d7aJM$X6#|^Lpn${N?oH+d-DdjSN`XyM;~fd#yfE z=k>`Cky^s%atw*y(pt{lEU59Tp}n@0Y{9szf_q z{c3oew@zalNKKpmG4;jNyCUQwp*&J79>?8sL79Wu8%TSs2V=O7OB;Lfbf-TqkQ#lw z4}AODjx_yUI(>s{4i6r8AG`yRP+L}ibnnO?b*(w~CUl#aX3Q%gP(+~E^Nb#F$p(Gm zkzM{r;TXkT;i1hyGDtu?pefrT%2$ld8QtVtA3UXB{zW~E8-)zHhe-JQc^9NpAQ+Go z6;dXOZRsxcQ&uF%1f!8d2u1{SO9+t&ISe>_=V7`{DL5d^BTSl1HR7aGnGByy^@3%^ zWZ)lqWXaae7}cdQrWxieUGE8ll^5NA%Rlt5u|D4;*1~@!u-5n;Y*zQ?*h8_`TiBj< zMl`XvwRZ9pb@h~gMshxKFpEC%%|=5)&nQVrm1|1fTjpd{jwOJ_i{InA+f$%qz!JCY zD2wFt#nAIdTqxjKIE&C_qiE_SlXSz7Bxl2LBdI7$i{0t^8&`psx=do~%Agm)(_-*n zU3t5jyn^|Ay{Iz=B*btMoOP4|T(t>;3Rs|erP*~S$>_-`(>ngGrJNKW0~?#Cr#5TA zskZX5OLs3AbB~1kiYe~t;d#OtSG_5QtTs1`bahR}s10HRNCN0H5T-?d6hwux zf~C-@eON(^EGm%*=xpC95dzQ*YiSl0$dMr`XYvFZg&>v!dPzi5bOmSMbV8{SA_-WNMi-Amg#QF5Bl#ko+24 z=RQlZX+tl4GvAwi{y?Jtd%7uD=tj@_A^%p)<_^6Ow^u~&jG-y4oJee9ilcViWCgwM zlXYNO?cnU}{GRphv+RS_!SwQkkP8E}yV4*bsn0 z7&%gqO2*=*ln8OU49t&5K<~@vl|l%nANq^gBScMS?qZ0qh}s}*$Vy_eIZLYdDMtpq zL@V7Ilc`sOYtf@SbV>M>5}ukhN=>d-^=!Qq!lViw+snhDnx z#*q#LpeS-qHsh1i*Q(m0X{f|sm;zrqFWN$QmDUMQt4dyfmAdvxGn;MlWqEkbew3@z z<+_a95@dn=4GiXORc5B360_p?+x2OV(g11@VSxbWV${3F@pw;H_8$*@V4Egw`1QNt zEB`cU&NsD%Dga1ga5}B5&RU*hd99xlzj}K*m=!S-#hyMx^go%)8t34EV%Pax>pqvl zEI>H$QbhO?xoA3D(igj($21TY+(h=mFc8@%OhNBNPkx!CoJR>4NxR94fMpE(lfm!${UFSSc7`xCj9sIVqE65e~w1HCVQ z3VZ4loxs;Kz9AZz|EpR_gZ=j zGcuBw!G=3aaN5`&>WhFHti*@jywxZB_-~Wv76+?s8A(u0LUDz%#CyEI*Jq_C-8}?+ zcvw;Hj%T#>j(&9=hWtOQy=7D!!4@uxBtUQt79=>q-F<>faEIUl0t9yn1Q^`i-66QU zySpX0yW6}b=bZI^+&}lN^=b{Xnwg&J>Rr{fzwMP3y_V%fD|ZmYW3AGW)gI z6e5TZ`N-HWdewg&cxpS7m@zn#yldJXn&7OK7bN*8RR)EljZRA`9!;5c#}p6Pso#QV z$b=Ez`eHyU5`3gzLwYAH{E1UE5rz`MHkwzdrB}opB2D|zH~KZqPEqs?<)(aP9XU)T zU(hg)Gzh9$uWETE=r4JX1z?$YwQ=*GT>Tbq;u7t9AY?r9^bQm#7`wdhaqbUzd_Jw` zD4r&e_U4Bu&7A3@dAS`kT9fItKWus*6V^P16;;C&bE!EhCSMWmZ<{{f5Bwd@R=FhR zL>IoQtxmQ1mi}k^5VbbUs=R8}yM6nt%=NsY_4+}-{-}kgSFyt+PPQ z&GbP&y)Nvj93T2%bO*|6)DN>!H0nnMMJu?GvC0yWDnTH0N*D|%Tj6(Kpb_ADpvg39 zF5|6#u>dx8gI~}zaG-Zzg3$xfp(#E`=Dz(x3(iG^SEMw~9iZs6P52^3_U;$L4GdM0 zdGzY=PfVCtX}RqGS*R`DGW{)k4qM6e0M}UFGpVP(sW#oDKth}chl+}u!ir^Ef%U%(YBV4&(_~W zE5@-Nug4PVd7P1cPMkgOj*5T%Ff!PAgXEuqYCTRn`=*`q^qQIn>IYK#eSgvTTeQ99 zkZH7YqDlSf@2!0^lQ}@xKf+QXd&7zI7PfTu!88+x=7`jBP>-TxWh9N( zo1X}rih~1l%P^IT;ICNUImr-2OXR*;#d+3i`>Umt)y~c>9k{H#Fk!qA`Tfo}3>|n^ zVSiCGj$ZIpqZh_}^C>W$Cdwk|h(L;7*zkYuIK<8;s-(~6Cn&$Z+RkBFws~|7hGtb@ zPpa=?{5x=?5W#WdeIC~9tjQsr;X|O9vP#+;g=LIiE`G%$W0o|kl4cSNtpv5MFu^{ zRS-`lwLY&q+f@FF%{Cd-Wr@kjgwI%ok1a6a-c7kC?K8}3s$o3`@)o2MxoTt7Q zp)rwELDrKp`3yR~wW^R%GES6R!g=FhNZ3-rXq2^7A-B?|)q)!2DWahR~$D z-P+#EE`0k60STLKArc$wjwSa+j@K)M0IC+SLxS6Evu7$tJ_b`tzo{Go+XX8SiQ}P5 zLp1a$j_c8>iX~{v-A5+m#LdEl=tq1-8Hd!`-_$H@eQOHF&C4et`NeJNu2Dd$24gnU$sMG(I zpVgs#J!&bg(-jx$F?@GqEf8 z_`vV1ofzs6O=#@qU8uUE0(iMEEZMcua6oiWE!h@3(iUdJeHT#IOuvbW-xBYpP zLaN*z){{b9ZmAjD{Y*?+-gNkl^HG@sxeZUC+;< z4^1r%>hU$p=)CkSY#atH83n|Ew7R%9!efc%#JXiIt&_~n#@?~qjnLBTQU3K+T$Rz0 zFAvdZ0z5z$#xvpDvZ{W#yGc8p>#iqsHk>wuf!$3D7evIqHG1~jqK%#vm* z&6lI zV+hPFl|z4ACvEjuXD)P)Zlif=>|uV5MI3uDW-CU0ksSK-HdqBASA%{55L?iVjd-_Z zY)h&aBNjjqxD;U!65nFG$b~!lmZBoT;K4u}v1$UvpfC)$+WE7>*P;q);x^b1I=Y$j z9e)Fs69`T;iQb{y_mfE8(a>M}#nO;icJ_xv$6L|)`7EB6%Y7h2ZB6H9nrhDbZf~I5 zuMEnq&}3cAv4#}q`qMe75O%H0u3|4Qe(~7&?%np?W+%F5QASYk>v^!&Rp*kVdd#6= zwbD8=m&z`qKDYO*SM}|d|Ng%6$x9n@A8e0@&hi;{4n5~-6mX}*UiBC`x;%*GY;8BjP1k`7d zp|tdw+A~Fnt+ng`XWTp_j$XMks7yza=cS3ADYqj&hp$46EG8o?T4m*kTEZXRdHV7 znUf(X1YzlNa%=-sKqSD}z;>jVr3u;yBV#!erBUVW<8^SW(9-%=SNQTJ`Yz8n&n`X__d3t6hk~5xLDBc3tja^>wKizq!*en+GNfgn zcTTgr);ms3(*%~KA+V}Xt}pQicU#xl-jLzhOWJG!T~Dk4I9@zu#S^q*bJwfh)km49 zqgE=+UI)7(N22n+J`jwp?c)-)>tc0pue0|Tq8;L4hUCqC0`A!GDAc95F5Fj%LqUum znhRR2?lt!dSV5T2&})p{=}xVWmQG>dfte+)1*OyN6ck4P?hlG$(7C#)79&;~SL;X00n)5lO$P8>(U_4}h0cD3-`nF5c1O(Q21Xl2#Yrzk}6o!wmZ=-y# zun_}vMBEVJ#6%GN?T)~mkz5rbN_qFz`=16?liG@SqSCTwNux!rb=QB>{SAmJ?e;!i zMC`3PSTdQd1v&WeeXZtEkU2rrMUUHk2iL5He~5#y1%k`JxpCVDppD@&O0D@olb#X{ zIhm%a?#Xem_gf7-T_!c_Jhk+6W!3VdH-1Jvn6qbxhd3ZsTQ66&$D?CL)baqFq5=zo z6tJX*9nZQ`a7>x5>QB}SbNUgLa5;|i%gZ<6PJM6`2M@P>-C>I$n9i~ttT-5!^H8Fu zb7JHwb}E+kV#)?u%#gvv-HCfjIU~s9&Nn(+@evk=%G8ibmY~!6k@IHlUg|1#Vi2g+ z8aK0IA0H3J(ZQJG{SG@iM&m-G-LQ39xg`aeMX|zQ^C^GV8czY0n8DWOK*?Zp9Ah-b zAmT7vbt!$VtdfzoyLRwJbjU>txbS@K**zTEOKkK6caBD4O~z(dEe;EVBR@9&(Irt! zIqqhal-C&Nrk#&V8};;QPhfM?dc|7ks*y*A8pR;wbmcD39R*f61E~CvIagMSJhMN- zn_VAfNA>%Yz%@zBXS?c*OQWE@{H$$A<1h3|o%}7*#e=8Gl5CkUh=NL`y_EJSE+2cx(-!rLU5r{%U6!p1Sz7!@iM(vPcUjaulo%&$9K(HyGUQv)Ya9=SWhc(Xa_w zxlTkDfDK6hNvy1|ppelyGrvqDMYyga=%WJZiuhLA68SQWj};gJ2Upk^9@{Np7tMx` z97YtWl^07Iwi+3$c{DQ|DLOPfSCC)3ui%%y#-kh)21}$>#uq)Pp|szu$}!Ht&Y9p~ z6P>rlM4fF$G-|gXyV|;%ebB|qrSH7fx&mj3t1M+}k(MeMaV4qe!SS)=Iq(e?#vlh9 z_kf+fO>Dwhs_TwZc=r=Giu1G$wPRbo4>60BI#3?sjUk!QrU9sUl2FEo^E`wkGA=9b zF%HJCn&Bu&g`5-hJLkK*&uRKfh#Iw!7|Nfqzuk{R+-pw9Q|QpqwT(c-!-*0U7%SA- zdNoKB{hwkg3i5`*-JI@S-OhzZ=6cp?7yVj^Sl-z!x;Js=uY)@V9|kjdb}*b7^29Lwb#*h?8*zUU5g!L8SSKItL#7iCNs+%D#Kv`dn` zA$FJiy}v*jVw>z7RzVjmBVc=RW5Zg+x|u4=7&n-bJ9K1UMU|wJlrHaDE0zCg!cHmf z5Qp~gaDL{y0#4IPLwF+(M0x?OJF@__0Ll;R|pL$slw(Rx{?%Ud(rNQX- z%BLx!&vI)1D^p`rgI#O6mCRz2APU@B#Oo2FMqQnw+(O*hyoB9!IR(i-Dej9kNi9s} z=8^VcjiZAXF0d}LmA_YomB%H=cHc+MMpkyK^ebolV9p;3tzIqMa00N|2c7pC#o@T_ zC}g(htBGSw=He2k?RSkgZ`MIq%d3Vg%WIZD4<=0NLSWz7VOrm2>-q$WQ1==WYhv~!`Lp$Ai=CMQ_9{|LZ9k1DXwhGpKl$qjC9sdKW}J{Ax=6)OUYd(C3h-+XI@U}S}J2`h;0}0 zz;@D-<<=OL*qJCW;~WYB8&Xv(GA77uvlhwbkKC3GK`|;1Y$H3WA710>C))=$X;l0+ zzyHz|bLb=y{ZwTbwEyR;MEURB7M2nfB+)9Cr}nFn5p;OK81qq ztS%BRq{CWql2cKzZ!h0-TG{R5*A0 zL^xzvK1($9GAY!a?ftk9X1MUwumR990t@E9Tj?QA$FSZRNdh?7yp!3xu)9J;%_<|r ztX9kYOmZGwMhnW9YwM7yA9^NTl{U3FjEk4|r#xG%w!Jd(ON_7`-M?s^pgvvONI|k&yA>HmGTq)q^=P&v(h8DZHWeu@W2gqYBT25(3B!*$uLwsN=h5T8^ zy0%faUz?LNc4wBakx+FP*j`hkwoTU~7n@KW3HQ6_V%RU}hA(1jP#@S;*UIZ|?XQux z>l{0GynBlotC5@kmA0&rxOy`o0{2$VF;r6 zF_?ZuSzPt>t4st5t-eg>V)~E-^t1bggzQtpg0jF=a6r#L%w-YXWv$`w@tPaYCzaa;(Ni1tG+dS%p`K<$+y zJ*Vi&%ueWT-8l0$tSNgW$NR%lxoEf{0Z!w-KoE;fS$%FClBnK;%Y$^CMn?sGTg~I4 zWB;!uy2h9D^bi9QAp!g2p3}9y-weX-hKbtl4q13UV+%ZJtr?RswWY@>7s#}O(``^W zF>{umY6_^8T*fX8=F{+9vYzwUgZdJp4`i(c;jAE0&6L|?e~-v*J6Vjncc%*Ewu9Gu@t9Z3jI2iHzLb{axm$Kna{qSUSLZ#cJix5;>6eAYw& z95?j==V|II#4?=-1BX}0CBB=DgZy-$VMZ_~tuD#2c*fpJ+!Wg`DHglui#6)-7iP(^ zFEo-0onQ7a#5(U{xxfa}$W)tOZL12lpk2HDJS9G1Ba$@&PYBJ{XyXyKoym|*l4!Xs zRu*DdY`5;Q*<6(An*D4R@`Vf=@Oo$_ggQfbrOuonir>RozxdO%A!YBEa-uE)IVX2fzm^#cPOC<)lghQi%Sk$*>fS zIy?qX(HoAK8E$!rr}J-$7T6d=5RKDqQypxrZu*E z>o@pyKq{hWi84N`U__@NG%EQvShc?J!;2E?hoXQ8Y|zEdUvee(1oP={@J13dZbx9r zO(&plABp!c6h5ZGsP#fhr_QD7DnpP8rB;#H($Sd2Uzht?7|D&dDhsDV>gv%evl#1v=rMdX*g= z1)XN7=)^KcOD%(Bmry8cKUlm`v_g9{J(A1`1kQqrs7ik;nF_6@z|FgK)<7@N2wM4~ zAAK+3+L|Nt4U95ttQ%YujS*G*jHSSb@~tHUU3cl**S~DTt4cXA26Ll5hlXT^8i_m) zi;G%zJhF`{Q#AA=GGVx=cx;BbY$+LzmphIu=Og^x0#=$~dGGe|Z<8Hv{y=Ft^3cKN z$W^ABnM@E9Mb%~Hh6f~Si6)@#yqI%J6NM5f9#)c1y(a?b@H#cRXfPD0<5t=AIR-|y zb_ZqjEcp0IXxmO@$yHn3jgAEU$b9bY%+%?*7bn{(+a`9NGUa7#)vBrodxhK|1&}5& zu2{qmnKP$Lk&C+p3HU5xNJTC!4)+R2M|i~__axyp@s}L%0Al=6oO^}}5|~+>jHKvo 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 +

Show the 21 findings + 1. **Bug:** `--skip-verification` alone always fails ("--verify-structure and --skip-verification cannot be used together") because `--verify-structure` defaults on (Python `cli.py:1053`). Port: skip wins over the default. The harness uses `--no-verify-structure`. @@ -126,9 +138,11 @@ Images are platform independent. with `RecursionError`. Port: walks PFS trees without recursion and reports directories nested deeper than 1024 levels (PFS and exFAT) as errors instead of ending the process. -## AMPR asset-pack oracle (`build_ampr_goldens.py`) + + +## AMPR asset-pack corpus (`build_ampr_goldens.py`) -Second oracle for `docs/AMPR_PACK_PLAN.md`: ampr_emu `tools/ampr_pack.py` (tool version 4.0) at commit +Second oracle, for the `ampr` commands: ampr_emu `tools/ampr_pack.py` (tool version 4.0) at commit `cfa85df379f6eeeb165d7badf9b648e266fe77b7`, checked out clean at `../ampr_emu`. It needs python-lz4 4.4.5 (bundles liblz4 1.9.4), which the `../MkPFS` environment lacks, so run it with a Python that has it: @@ -179,3 +193,13 @@ byte-identical, `--workers 1` and `8` produce identical packs. Build time about mtime, `false` the AMPRIDX3 mtime (`mtime_preserved` vs `no_preserve_mtime`). 5. MkPFS's `ampr_emu.index` and ampr_emu's `build_ampr_index.py` produce the same bytes for the same tree (checked on the `ampr` tree; only mtimes differ when the copy does not keep them). + +## `ampr profile` check (`check_ampr_profile.py`) + +Generates synthetic APR traces and ZIP support bundles, runs ampr_emu's `ampr_pack_profile.py` and +`mkpfs ampr profile` (`generate` and `batch`) on each, and compares every output file byte for byte +(45 cases). Build MkPFS first; `--python`, `--mkpfs`, `--ampr-emu` and `--out` override the defaults. + +```bash +uv run --no-project --python 3.12 --with lz4==4.4.5 python tools/oracle/check_ampr_profile.py +``` From a2b26c6873da8d7d654ee767e3c550130bf8d6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:41:52 +0000 Subject: [PATCH 28/28] Bump the version to 2.1.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4TzvxgYewNT7gc6MCuF5N --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c10256f..b9ca4ab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ latest true true - 2.0.3 + 2.1.0 arsenic9408 GPL-3.0-only