From ddc673916dc0ace47d9c98608511346600dd0c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 21 Aug 2026 15:10:57 +0200 Subject: [PATCH 1/6] Tools/pkg.py: read PSP update packages, and notes on the format A standalone Python reader for the .pkg containers Sony shipped PSP game updates in: it prints a package's header, metadata, both PARAM.SFOs and its item table, and extracts the payload. Nothing in PPSSPP calls it - it exists to work the format out and to have a second implementation to check the C++ one against, the way Tools/ already holds a few other one-off analysis scripts. docs/pkg_notes.md is what it was written from: the header and item table layout, the two AES-CTR keys a single package mixes, and the detail that trips up a first attempt - which key applies is per item, not per package, so a reader that picks one produces garbage filenames for most of a package while a few entries decode perfectly. Co-Authored-By: Claude Opus 5 --- Tools/pkg.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ docs/pkg_notes.md | 180 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100755 Tools/pkg.py create mode 100644 docs/pkg_notes.md diff --git a/Tools/pkg.py b/Tools/pkg.py new file mode 100755 index 0000000000..dc63ad9335 --- /dev/null +++ b/Tools/pkg.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""PSP .PKG (NPDRM package) parser/extractor. + +Lists or extracts a package - PSP game updates in particular, which pkg2zip skips. +See docs/pkg_notes.md for the format, and for what these packages turn out to contain. + + pkg.py FILE.pkg ... list contents + pkg.py -x OUTDIR FILE.pkg ... extract into OUTDIR + +Needs the "cryptography" module for AES. +""" +import sys, os, struct +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +PKG_PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b") +PKG_PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8") +PKG_VITA_2 = bytes.fromhex("e31a70c9ce1dd72bf3c0622963f2eccb") +PKG_VITA_3 = bytes.fromhex("423aca3a2bd5649f9686abad6fd8801f") +PKG_VITA_4 = bytes.fromhex("af07fd59652527baf13389668b17d9ea") + +def aes_ecb_enc(key, block): + c = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return c.update(block) + c.finalize() + +def ctr_xor(key, iv, block_index, data): + ctr = (int.from_bytes(iv, 'big') + block_index) % (1 << 128) + c = Cipher(algorithms.AES(key), modes.CTR(ctr.to_bytes(16, 'big'))).decryptor() + return c.update(data) + c.finalize() + +TYPES = {0: "?", 1: "NPDRM", 2: "NPDRM_EDAT", 3: "FILE", 4: "DIRECTORY", + 9: "SELF", 11: "PSP_FILE", 18: "DIRECTORY2"} + +META_NAMES = {1: "DRM_TYPE", 2: "CONTENT_TYPE", 3: "PACKAGE_TYPE", 4: "PACKAGE_SIZE", + 5: "SDK/NPDRM_REV", 6: "TITLE_ID", 7: "QA_DIGEST", 8: "UNK_0x8", + 9: "UNK_0x9", 10: "INSTALL_DIR", 11: "UNK_0xB", 12: "UNK_0xC", + 13: "ITEMS_TABLE", 14: "PARAM_SFO", 15: "UNK_0xF"} + +CONTENT_TYPES = {0x4: "PS3_GameData", 0x5: "PS3_GameExec", 0x6: "PS1_PSN", + 0x7: "PSP_PSN", 0x9: "Theme", 0xB: "License", 0xE: "PSP_PCEngine", + 0xF: "PSP_Minis", 0x10: "PSP_NeoGeo", 0x15: "PSVita_App", + 0x16: "PSVita_DLC", 0x18: "PSM"} + + +class Item: + __slots__ = ("name", "off", "size", "psp_type", "flags", "key") + def is_dir(self): + return self.flags in (4, 18) + + +class Pkg: + def __init__(self, path): + self.path = path + self.f = open(path, 'rb') + h = self.f.read(0x100) + if h[:4] != b'\x7fPKG': + raise ValueError("not a PKG") + (self.rev, self.type, self.meta_off, self.meta_cnt, self.meta_size, + self.item_cnt, self.total_size, self.data_off, self.data_size) = \ + struct.unpack(">HHIIIIQQQ", h[4:0x30]) + self.content_id = h[0x30:0x60].split(b'\0')[0].decode('ascii', 'replace') + self.digest = h[0x60:0x70] + self.riv = h[0x70:0x80] + self.key_id = h[0xE7] & 7 + self.ext = h[0xC0:0xC4] == b'\x7fext' + + self.meta = self._read_meta() + self.content_type = 0 + self.items_off = 0 + self.sfo_off = self.sfo_size = 0 + for ident, val in self.meta: + if ident == 2: + self.content_type = struct.unpack(">I", val[:4])[0] + elif ident == 13: + self.items_off, self.items_size = struct.unpack(">II", val[:8]) + elif ident == 14: + self.sfo_off, self.sfo_size = struct.unpack(">II", val[:8]) + + if self.type == 2: # PSP / Vita + if self.key_id == 1: + self.main_key = PKG_PSP_KEY + else: + vk = {2: PKG_VITA_2, 3: PKG_VITA_3, 4: PKG_VITA_4}[self.key_id] + self.main_key = aes_ecb_enc(vk, self.riv) + else: + self.main_key = PKG_PS3_KEY + self.iv = self.riv + + def _read_meta(self): + self.f.seek(self.meta_off) + buf = self.f.read(self.meta_size if self.meta_size else 0x1000) + out, p = [], 0 + for _ in range(self.meta_cnt): + if p + 8 > len(buf): + break + ident, size = struct.unpack(">II", buf[p:p+8]) + out.append((ident, buf[p+8:p+8+size])) + p += 8 + size + return out + + def dec(self, offset, size, key=None): + """Read+decrypt `size` bytes at `offset` relative to the encrypted data area.""" + key = key or self.main_key + pre = offset & 0xF + base = offset - pre + self.f.seek(self.data_off + base) + raw = self.f.read(((size + pre + 15) // 16) * 16) + return ctr_xor(key, self.iv, base // 16, raw)[pre:pre+size] + + def items(self): + tbl = self.dec(self.items_off, self.item_cnt * 0x20) + res = [] + for i in range(self.item_cnt): + no, ns, doff, dsize, psp_type, _, _, flags = struct.unpack( + ">IIQQBBBB", tbl[i*0x20:i*0x20+0x1C]) + it = Item() + it.psp_type, it.flags, it.off, it.size = psp_type, flags, doff, dsize + it.key = self.main_key if (self.type != 2 or psp_type == 0x90) else PKG_PS3_KEY + it.name = self.dec(no, ns, it.key).decode('utf-8', 'replace') + res.append(it) + return res + + def read_item(self, it, maxsize=None): + n = it.size if maxsize is None else min(it.size, maxsize) + return self.dec(it.off, n, it.key) + + def sfo(self): + if self.sfo_size: + return parse_sfo(self.dec(self.sfo_off, self.sfo_size)) + for it in self.items(): + if it.name.upper().endswith("PARAM.SFO"): + return parse_sfo(self.read_item(it)) + return {} + + +def parse_sfo(data): + if len(data) < 0x14 or data[:4] != b'\0PSF': + return {} + key_tab, data_tab, count = struct.unpack(" len(data): + break + ko, fmt, ln, maxln, do = struct.unpack("/`. The patched EBOOT then runs with the original UMD (or the original +PSN game) still supplying everything it doesn't override. + +`Tools/pkg.py` is a working parser/extractor. PPSSPP itself cannot yet read or use these - see +"What PPSSPP is missing" at the end. + +Format reference: , cross-checked against +[pkg2zip](https://github.com/mmozeiko/pkg2zip). + +## File layout + +``` ++0x000 header (0xC0 bytes, plaintext) ++0x0C0 extended header (0x40 bytes, plaintext, PSP/Vita only) ++0x100 hashes/signatures ++0x280 metadata (plaintext, offset and count are in the header) + ... ++data_offset encrypted area: item table, then filenames, then file contents +``` + +### Header + +``` ++0x00 u32 "\x7FPKG" magic (0x7F504B47) ++0x04 u16 revision. 0x8000 retail, 0x0000 debug ++0x06 u16 type. 1 = PS3, 2 = PSP/Vita ++0x08 u32 metadata offset (0x280 in everything seen) ++0x0C u32 metadata entry count ++0x10 u32 metadata size ++0x14 u32 item count ++0x18 u64 total package size ++0x20 u64 data offset - start of the encrypted area ++0x28 u64 data size ++0x30 char[0x30] content id, e.g. "JP0177-ULJM05681_00-PJD2UPDATEVR0101" ++0x60 u8[0x10] QA digest ++0x70 u8[0x10] riv - the AES counter block (see below) ++0x80 u8[0x40] header CMAC and signatures +``` + +### Extended header + +Present on PSP and Vita packages, magic `"\x7Fext"` (0x7F657874) at +0xC0. The only field that +matters for reading the package is the **key index**, a u32 at +0xE4 - or equivalently +`header[0xE7] & 7`, which is how pkg2zip reads it. Every PSP package seen uses key index 1. + +### Metadata + +A flat sequence of `u32 id, u32 size, u8 value[size]` records. The ones worth reading: + +| id | Meaning | +| --- | --- | +| 2 | content type. **7 = PSP** (also 0xE/0xF/0x10 for PC Engine / Minis / NeoGeo) | +| 4 | package size | +| 6 | title id | +| 13 | offset and size of the item table, inside the encrypted area | +| 14 | offset and size of `PARAM.SFO`, inside the encrypted area | + +Everything else (DRM type, SDK revision, QA digest, install dir) is informational. + +## Encryption + +The whole area from `data_offset` on is AES-128-CTR, with the counter block starting at `riv` +(header +0x70) and incrementing once per 16 bytes: the block at byte offset *n* of the encrypted +area uses counter `riv + n/16`. Offsets in the item table are relative to `data_offset`, so +that division is straightforward - no separate bookkeeping. + +Two different keys are used *within the same package*, and each item says which one applies via +its own `psp_type` byte (see the item table below): + +| Package / item | Key | +| --- | --- | +| PS3 package, or PSP item with `psp_type != 0x90` | `2e7b71d7c9c9a14ea3221f188828b8f8` | +| PSP item with `psp_type == 0x90` | `07f2c68290b50d2c33818d709b60e62b` | +| Vita, key index 2/3/4 | `AES-ECB(vita_key_N, riv)`, key by index | + +That per-item split is the one thing that isn't obvious from the wiki page and will make a reader +produce garbage filenames for most of a package while a couple of entries decode perfectly. In a +game update it's the `PBOOT.PBP` and the patch data files that carry `0x90`; the icons, +`PARAM.SFO`, `PS3LOGO.DAT`, the directory entries and `ISO.BIN.EDAT` use the PS3 key. + +## Item table + +`item_count` records of 0x20 bytes, at the item table offset from metadata id 13 (0 in every +update package seen, i.e. right at the start of the encrypted area): + +``` ++0x00 u32 filename offset (relative to data_offset, always 16-byte aligned) ++0x04 u32 filename length ++0x08 u64 data offset (relative to data_offset, always 16-byte aligned) ++0x10 u64 data size ++0x18 u8 psp_type - 0x90 selects the PSP key, see above ++0x19 u8[2] padding ++0x1B u8 flags ++0x1C u32 padding +``` + +Filenames are stored in the encrypted area too, and are decrypted with **the item's own key**, not +the package's main key. + +`flags` is a content type, and in these packages it maps 1:1 onto how the file contents are +encrypted - a decoder can tell what it is holding before looking at it: + +| flags | Meaning | Contents start with | +| --- | --- | --- | +| 2 | NPDRM EDAT | `NPD\0` - only ever `ISO.BIN.EDAT` | +| 3 | plain file | whatever it is (PNG, `\0PSF`, ...) | +| 4 | directory | - | +| 5 | PSP EDAT | `\0PSPEDAT` | +| 8 | PSP EDAT (`.sprx` modules) | `\0PSPEDAT` | +| 11 | PBP | `\0PBP` | + +The `\0PSPEDAT` files are the PGD-wrapped kind PPSSPP already decrypts at runtime, via +`sceNpDrmEdataSetupKey` in `Core/HLE/scePspNpDrm_user.cpp`. + +## What an update package contains + +Always this shape: + +``` +PARAM.SFO CATEGORY=PP, TITLE_ID, VERSION +PS3LOGO.DAT a PNG, despite the name +ICON0.PNG / PIC0.PNG / PIC2.PNG sometimes +USRDIR/ directory entry +USRDIR/CONTENT/ directory entry +USRDIR/CONTENT/PBOOT.PBP the patched EBOOT +USRDIR/CONTENT/... the patch data files +USRDIR/ISO.BIN.EDAT 272 bytes, NPD header +``` + +The outer `PARAM.SFO` is the *package's* - `CATEGORY=PP` (game patch), and its `VERSION` is the +package version, not the patch version. The interesting SFO is the one **inside** `PBOOT.PBP`: +that one has `CATEGORY=PG`, `DISC_ID`, `DISC_VERSION`, `APP_VER` (the patch version) and +`PSP_SYSTEM_VER` (the firmware the patch needs), which is what a real PSP matches against the +disc before deciding to boot the patch. + +## What the packages measured + +- All are content type 7, `CATEGORY=PP`. Some are for digital NP\* titles, others for UMD UL\*/UC\* + titles. `PSP_SYSTEM_VER` ranges 6.10 to 6.60. +- **Every single `PBOOT.PBP` uses PRX tag `0x2E5E10F0`** in its `DATA.PSP`. PPSSPP already has + that key - `Core/ELF/PrxDecrypter.cpp`, in the `TAG_INFO2` table, commented + "5.00 PSP-2000 (Game PSN Update 2 LBP)". So **no new crypto is needed to run these**. +- Verified end to end rather than assumed: extract Hatsune Miku Project DIVA 2nd's update, rename + `PBOOT.PBP` to `EBOOT.PBP`, and boot it headless with the UMD mounted - + + ``` + ./build/PPSSPPHeadless -i --graphics=software --memstick= \ + --mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \ + /PSP/GAME/ULJM05681/EBOOT.PBP + ``` + + It logs `Decrypting tag 2E5E10F0`, loads the ELF (`tag=ELF/PdvApp`), resolves its imports, and + reads `Diva2Data.cpk` / `Diva2Script.cpk` / `Diva2Sound.cpk` off `disc0:`. Runs without error. + (`--mount` is what makes that work: `Load_PSP_ELF_PBP` in `Core/PSPLoaders.cpp` mounts the ISO + on `disc0:`, `umd:` and `umd1:` when booting an ELF or PBP.) + +**pkg2zip cannot extract update packages**, so it is not an alternative here. Its PSP path only +recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an ISO), +`PSP-KEY.EDAT` and `CONTENT.DAT`, and `continue`s past everything else - `PBOOT.PBP` and every +patch file are silently dropped, with no warning that anything was skipped. + +## What PPSSPP is missing + +`grep -r PBOOT` over the tree returns nothing, so both halves are unwritten: + +1. **Reading and installing a `.pkg`** - unpack it into `ms0:/PSP/GAME//`, next to the + existing ZIP/ISO install paths in `Core/Util/GameManager.cpp`. +2. **Booting the patch** - when starting a game, notice + `ms0:/PSP/GAME//PBOOT.PBP` whose inner SFO matches the disc's `DISC_ID` and + `DISC_VERSION`, and boot that instead with the ISO mounted on `disc0:`. The mounting already + exists (see `--mount` above); what's missing is the decision. + +Only the UMD half of this is proven. The digital NP\* titles patch a PSN `EBOOT.PBP` / +NPUMDIMG rather than a UMD, and `ISO.BIN.EDAT` most likely re-keys it - that path has not been +tested. From 5089caf2a02b9ecf9da5ea5ffb651a04fe89068e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 21 Aug 2026 16:53:07 +0200 Subject: [PATCH 2/6] Add a reader for PKG game update packages PSP game updates were distributed as NPDRM .pkg files holding a patched EBOOT (PBOOT.PBP) plus the data files the patch replaces. PkgUnpack reads one: header, item table, both PARAM.SFOs, and the AES-128-CTR that covers everything past the header - including the per-item key split, where an item's pspType byte picks between the PSP and PS3 keys. Nothing new is needed to decrypt these. All packages checked use PRX tag 0x2E5E10F0 for their PBOOT, which PrxDecrypter already has a key for. Also adds "PPSSPPHeadless --install-pkg=DIR", a sibling of --unpack-updater, which installs without any UI. All packages install through it byte -identically to a reference implementation. Format notes are in docs/pkg_notes.md. Co-Authored-By: Claude Opus 5 (1M context) --- Core/CMakeLists.txt | 2 + Core/CmdLine.cpp | 1 + Core/CmdLine.h | 6 + Core/Core.vcxproj | 2 + Core/Core.vcxproj.filters | 6 + Core/Util/PkgUnpack.cpp | 498 ++++++++++++++++++++++++++++ Core/Util/PkgUnpack.h | 108 ++++++ UWP/CoreUWP/CoreUWP.vcxproj | 2 + UWP/CoreUWP/CoreUWP.vcxproj.filters | 2 + android/jni/Android.mk | 1 + headless/Headless.cpp | 33 ++ libretro/Makefile.common | 1 + 12 files changed, 662 insertions(+) create mode 100644 Core/Util/PkgUnpack.cpp create mode 100644 Core/Util/PkgUnpack.h diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 6a5bfad016..1cdc681e25 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -699,6 +699,8 @@ add_library(Core STATIC Util/KL4E.h Util/PSARUnpack.cpp Util/PSARUnpack.h + Util/PkgUnpack.cpp + Util/PkgUnpack.h Util/RecentFiles.cpp Util/RecentFiles.h ${CMAKE_SOURCE_DIR}/ext/disarm.cpp diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 81f0f3b505..25ce9f2a14 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -211,6 +211,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(unpackUpdater), CmdParamType::String, "unpack-updater", '\0', "Unpack the firmware in an updater EBOOT.PBP into DIR and exit", CmdLineMode::Headless}, {POFF(unpackUpdaterModel), CmdParamType::String, "unpack-updater-model", '\0', "PSP model to unpack for (01g..12g, default any)", CmdLineMode::Headless}, {POFF(unpackUpdaterFilter), CmdParamType::String, "unpack-updater-filter", '\0', "Only unpack entries under this path, e.g. flash0:/font/", CmdLineMode::Headless}, + {POFF(installPkg), CmdParamType::String, "install-pkg", '\0', "Install the game update in a .pkg into DIR and exit", CmdLineMode::Headless}, {POFF(odsLog), CmdParamType::Bool, "odslog", 'o', "Also log through OutputDebugString (Windows)", CmdLineMode::Headless}, {POFF(generateInterpreterDispatch), CmdParamType::Bool, "generate-interpreter-dispatch", '\0', "Generate C++ interpreter dispatch code (ExecInstruction) to stdout and exit", CmdLineMode::Headless}, {POFF(resolutionScale), CmdParamType::Int, "resolution-scale", '\0', "Set the resolution scale factor"}, diff --git a/Core/CmdLine.h b/Core/CmdLine.h index fdcb755277..0abf20a8e5 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -82,6 +82,12 @@ struct CommandLineOptions { // libraries. Needs a firmware dump under the NAND directory. std::optional disableHLE; + // Headless: install the game update in a .pkg (given as the boot filename) into this + // directory, then exit without booting anything. The directory is the game folder itself - + // the app puts that under PSP/GAME/, but here the caller picks. See + // Core/Util/PkgUnpack.h. + std::optional installPkg; + std::optional memReadAction; std::optional memWriteAction; std::optional breakAction; diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj index b1b51f8aad..c5b395d226 100644 --- a/Core/Core.vcxproj +++ b/Core/Core.vcxproj @@ -924,6 +924,7 @@ + MaxSpeed true @@ -1294,6 +1295,7 @@ + diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters index a7de4d3b8a..7d94c8a878 100644 --- a/Core/Core.vcxproj.filters +++ b/Core/Core.vcxproj.filters @@ -369,6 +369,9 @@ Util + + Util + HLE\Libraries @@ -1698,6 +1701,9 @@ Util + + Util + HLE\Libraries diff --git a/Core/Util/PkgUnpack.cpp b/Core/Util/PkgUnpack.cpp new file mode 100644 index 0000000000..6c1d4b5738 --- /dev/null +++ b/Core/Util/PkgUnpack.cpp @@ -0,0 +1,498 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include +#include + +#include "Common/File/FileUtil.h" +#include "Common/File/Path.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/Loaders.h" +#include "Core/Util/PkgUnpack.h" + +extern "C" { +#include "ext/libkirk/AES.h" +} + +// See docs/pkg_notes.md. Field offsets in the 0xC0-byte header: +static const u32 PKG_MAGIC = 0x7F504B47; // "\x7FPKG" +static const u32 PKG_TYPE_PSP = 2; // 1 is PS3. +// 0xC0 of header plus the 0x40-byte extended header after it, which is where the key index is. +static const size_t PKG_HEADER_SIZE = 0x100; +static const size_t PKG_ITEM_SIZE = 0x20; + +// The two keys a PSP package's contents are encrypted with, picked per item. Vita packages derive +// theirs from the riv instead, which we don't handle - nothing here reads Vita packages. +static const u8 PKG_PSP_KEY[16] = { + 0x07, 0xf2, 0xc6, 0x82, 0x90, 0xb5, 0x0d, 0x2c, 0x33, 0x81, 0x8d, 0x70, 0x9b, 0x60, 0xe6, 0x2b, +}; +static const u8 PKG_PS3_KEY[16] = { + 0x2e, 0x7b, 0x71, 0xd7, 0xc9, 0xc9, 0xa1, 0x4e, 0xa3, 0x22, 0x1f, 0x18, 0x88, 0x28, 0xb8, 0xf8, +}; + +// An item table with more entries than this is corrupt, not something we should try to allocate +// for. The biggest update package seen has a few hundred. +static const u32 PKG_MAX_ITEMS = 65536; +// Same idea for a single filename. +static const u32 PKG_MAX_NAME = 1024; + +static const size_t PKG_COPY_BLOCK = 512 * 1024; + +// Everything in a PKG header, its metadata and its item table is big-endian; the PBP inside is +// little-endian like the rest of the PSP world. None of it is guaranteed aligned, so read bytes. +static u16 Read16BE(const u8 *p) { + return (u16)(((u32)p[0] << 8) | p[1]); +} +static u32 Read32BE(const u8 *p) { + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3]; +} +static u64 Read64BE(const u8 *p) { + return ((u64)Read32BE(p) << 32) | (u64)Read32BE(p + 4); +} +static u32 Read32LE(const u8 *p) { + return ((u32)p[3] << 24) | ((u32)p[2] << 16) | ((u32)p[1] << 8) | (u32)p[0]; +} + +// AES-128-CTR, with the counter block starting at `iv` and incrementing once per 16 bytes. Both +// the counter and the increment are big-endian, and the counter wraps over the whole 128 bits. +static void AesCtrXor(const u8 *key, const u8 *iv, u64 blockIndex, u8 *data, size_t size) { + AES_ctx ctx; + AES_set_key(&ctx, key, 128); + + u8 counter[16]; + memcpy(counter, iv, 16); + // Add blockIndex to the 128-bit big-endian counter. + u32 carry = 0; + for (int i = 15; i >= 0; i--) { + const u32 sum = (u32)counter[i] + (u32)(blockIndex & 0xFF) + carry; + counter[i] = (u8)sum; + carry = sum >> 8; + blockIndex >>= 8; + } + + u8 stream[16]; + for (size_t pos = 0; pos < size; pos += 16) { + AES_encrypt(&ctx, counter, stream); + const size_t chunk = std::min(16, size - pos); + for (size_t i = 0; i < chunk; i++) { + data[pos + i] ^= stream[i]; + } + // Increment the counter block. + for (int i = 15; i >= 0; i--) { + if (++counter[i] != 0) + break; + } + } +} + +const u8 *PkgReader::ItemKey(const PkgItem &item) const { + return item.pspType == 0x90 ? mainKey_ : PKG_PS3_KEY; +} + +bool PkgReader::ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out) { + if (size == 0) { + return true; + } + // The counter runs on 16-byte boundaries, so read from the containing block and skip the + // leading bytes afterwards. + const size_t skip = (size_t)(offset & 0xF); + const u64 base = offset - skip; + const size_t alignedSize = (skip + size + 15) & ~(size_t)0xF; + + if (base > dataSize_ || alignedSize > dataSize_ - base) { + return false; + } + + std::vector buf(alignedSize); + if (loader_->ReadAt(dataOffset_ + base, alignedSize, buf.data()) != alignedSize) { + return false; + } + AesCtrXor(key, riv_, base / 16, buf.data(), alignedSize); + memcpy(out, buf.data() + skip, size); + return true; +} + +bool PkgReader::ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out) { + if (offset > item.dataSize || size > item.dataSize - offset) { + return false; + } + return ReadEncrypted(item.dataOffset + offset, size, ItemKey(item), out); +} + +bool PkgReader::ReadItem(const PkgItem &item, std::vector *out, size_t maxSize) { + if (item.dataSize > maxSize) { + return false; + } + out->resize((size_t)item.dataSize); + return ReadItemData(item, 0, out->size(), out->data()); +} + +bool PkgReader::Open(FileLoader *loader, std::string *error) { + loader_ = loader; + info_ = PkgInfo(); + + u8 header[PKG_HEADER_SIZE]; + if (!loader || loader->ReadAt(0, sizeof(header), header) != sizeof(header)) { + *error = "Not a PKG file: too short"; + return false; + } + if (Read32BE(header) != PKG_MAGIC) { + *error = "Not a PKG file"; + return false; + } + + const u32 type = Read16BE(header + 0x06); + if (type != PKG_TYPE_PSP) { + *error = "Not a PSP PKG file"; + return false; + } + // Key index 1 is the only one a PSP package uses; 2-4 are Vita, and derive a key from the riv. + const u32 keyIndex = header[0xE7] & 7; + if (keyIndex != 1) { + *error = StringFromFormat("Unsupported PKG key index %d", keyIndex); + return false; + } + memcpy(mainKey_, PKG_PSP_KEY, sizeof(mainKey_)); + + const u32 metaOffset = Read32BE(header + 0x08); + const u32 metaCount = Read32BE(header + 0x0C); + const u32 itemCount = Read32BE(header + 0x14); + const u64 totalSize = Read64BE(header + 0x18); + dataOffset_ = Read64BE(header + 0x20); + dataSize_ = Read64BE(header + 0x28); + memcpy(riv_, header + 0x70, sizeof(riv_)); + + char contentId[0x31]{}; + memcpy(contentId, header + 0x30, 0x30); + info_.contentId = contentId; + + const s64 fileSize = loader->FileSize(); + if (fileSize < 0 || (u64)fileSize < totalSize || dataOffset_ + dataSize_ > (u64)fileSize) { + *error = "PKG file is truncated"; + return false; + } + if (itemCount == 0 || itemCount > PKG_MAX_ITEMS) { + *error = "PKG file has a broken item table"; + return false; + } + + // The metadata is in the clear. We only need three things out of it, and one of them (the item + // table offset) is zero in every update package seen - but read it rather than assume. + u32 itemsOffset = 0; + u64 metaPos = metaOffset; + for (u32 i = 0; i < metaCount; i++) { + u8 rec[8]; + if (loader->ReadAt(metaPos, sizeof(rec), rec) != sizeof(rec)) { + *error = "PKG metadata is truncated"; + return false; + } + const u32 id = Read32BE(rec); + const u32 size = Read32BE(rec + 4); + if (size > 0x1000) { + *error = "PKG metadata is corrupt"; + return false; + } + std::vector value(size); + if (size && loader->ReadAt(metaPos + 8, size, value.data()) != size) { + *error = "PKG metadata is truncated"; + return false; + } + switch (id) { + case 2: + if (size >= 4) { + info_.contentType = Read32BE(value.data()); + } + break; + case 6: + info_.titleId = std::string((const char *)value.data(), strnlen((const char *)value.data(), size)); + break; + case 13: + if (size >= 4) { + itemsOffset = Read32BE(value.data()); + } + break; + default: + break; + } + metaPos += 8 + size; + } + + if (info_.contentType != kPkgContentTypePSP) { + *error = StringFromFormat("PKG holds content type 0x%x, not a PSP game", info_.contentType); + return false; + } + + // Item table, then the filenames it points at. Both live in the encrypted area, but a + // filename is encrypted with its own item's key rather than the table's. + std::vector table((size_t)itemCount * PKG_ITEM_SIZE); + if (!ReadEncrypted(itemsOffset, table.size(), mainKey_, table.data())) { + *error = "Failed to read the PKG item table"; + return false; + } + + info_.items.reserve(itemCount); + for (u32 i = 0; i < itemCount; i++) { + const u8 *rec = table.data() + (size_t)i * PKG_ITEM_SIZE; + const u32 nameOffset = Read32BE(rec); + const u32 nameSize = Read32BE(rec + 4); + + PkgItem item; + item.dataOffset = Read64BE(rec + 8); + item.dataSize = Read64BE(rec + 16); + item.pspType = rec[0x18]; + item.flags = rec[0x1B]; + + if (item.dataOffset > dataSize_ || item.dataSize > dataSize_ - item.dataOffset) { + *error = "PKG item points outside the file"; + return false; + } + if (nameSize == 0 || nameSize > PKG_MAX_NAME) { + *error = "PKG item has a broken name"; + return false; + } + item.name.resize(nameSize); + if (!ReadEncrypted(nameOffset, nameSize, ItemKey(item), (u8 *)item.name.data())) { + *error = "Failed to read a PKG item name"; + return false; + } + // Names aren't terminated, but be forgiving if one is anyway. + item.name.resize(strnlen(item.name.c_str(), item.name.size())); + info_.items.push_back(item); + } + + // The package's own PARAM.SFO. There's no reliable pointer to it in the metadata for PSP + // packages (the field is empty), so go by name. + for (const PkgItem &item : info_.items) { + if (item.name != "PARAM.SFO") { + continue; + } + std::vector sfoData; + ParamSFOData sfo; + if (ReadItem(item, &sfoData, 64 * 1024) && sfo.ReadSFO(sfoData)) { + info_.title = sfo.GetValueString("TITLE"); + info_.category = sfo.GetValueString("CATEGORY"); + if (info_.titleId.empty()) { + info_.titleId = sfo.GetValueString("TITLE_ID"); + } + } + break; + } + + for (const PkgItem &item : info_.items) { + if (item.name == "USRDIR/CONTENT/PBOOT.PBP" && !item.IsDirectory()) { + ReadPBOOTInfo(item); + break; + } + } + + INFO_LOG(Log::Loader, "PKG: %s (%s), %d items, update=%d for %s v%s", + info_.contentId.c_str(), info_.category.c_str(), (int)info_.items.size(), + (int)info_.isGameUpdate, info_.discId.c_str(), info_.discVersion.c_str()); + return true; +} + +// The PBOOT is a normal PBP - only its DATA.PSP is encrypted, and its PARAM.SFO is the one that +// says which disc and disc version this patches. That's what makes matching an installed update +// against a game at boot time possible without decrypting anything. +bool PkgReader::ReadPBOOTInfo(const PkgItem &pboot) { + // PBP header: magic, version, then eight little-endian subfile offsets. + u8 header[0x28]; + if (pboot.dataSize < sizeof(header) || !ReadItemData(pboot, 0, sizeof(header), header)) { + return false; + } + if (memcmp(header, "\0PBP", 4) != 0) { + WARN_LOG(Log::Loader, "PKG: PBOOT.PBP isn't a PBP"); + return false; + } + const u32 sfoOffset = Read32LE(header + 0x08); + const u32 iconOffset = Read32LE(header + 0x0C); + if (sfoOffset > iconOffset || iconOffset > pboot.dataSize) { + return false; + } + const u32 sfoSize = iconOffset - sfoOffset; + if (sfoSize == 0 || sfoSize > 64 * 1024) { + return false; + } + + std::vector sfoData(sfoSize); + if (!ReadItemData(pboot, sfoOffset, sfoSize, sfoData.data())) { + return false; + } + ParamSFOData sfo; + if (!sfo.ReadSFO(sfoData)) { + return false; + } + + info_.discId = sfo.GetValueString("DISC_ID"); + info_.discVersion = sfo.GetValueString("DISC_VERSION"); + info_.appVer = sfo.GetValueString("APP_VER"); + info_.systemVer = sfo.GetValueString("PSP_SYSTEM_VER"); + info_.pbootTitle = sfo.GetValueString("PBOOT_TITLE"); + if (info_.title.empty()) { + info_.title = sfo.GetValueString("TITLE"); + } + if (info_.discVersion.empty()) { + info_.discVersion = "1.00"; + } + // A disc ID is what the install is keyed on, so without one there's nowhere to put this. + info_.isGameUpdate = !info_.discId.empty(); + return info_.isGameUpdate; +} + +// Rejects anything that could escape the destination directory. Package filenames are attacker +// data as far as we're concerned. +static bool IsSafeRelativePath(std::string_view name) { + if (name.empty() || name.size() > PKG_MAX_NAME) { + return false; + } + if (name.front() == '/' || name.find('\\') != std::string_view::npos || name.find(':') != std::string_view::npos) { + return false; + } + size_t start = 0; + while (start <= name.size()) { + const size_t slash = name.find('/', start); + const std::string_view part = name.substr(start, slash == std::string_view::npos ? std::string_view::npos : slash - start); + if (part.empty() || part == "." || part == "..") { + return false; + } + for (char c : part) { + // Control characters in a filename are never legitimate here. + if ((unsigned char)c < 0x20) { + return false; + } + } + if (slash == std::string_view::npos) { + break; + } + start = slash + 1; + } + return true; +} + +std::string PkgItemInstallPath(const PkgItem &item) { + // A package wraps its payload PS3-style. USRDIR/CONTENT/ is where the patch's files live, and + // USRDIR/ itself only ever holds ISO.BIN.EDAT; both map to the game folder root. + std::string_view name = item.name; + if (name == "USRDIR" || name == "USRDIR/CONTENT") { + // The wrappers themselves. Both stand for the game folder, which already exists. + return std::string(); + } + if (startsWith(name, "USRDIR/CONTENT/")) { + name = name.substr(strlen("USRDIR/CONTENT/")); + } else if (startsWith(name, "USRDIR/")) { + name = name.substr(strlen("USRDIR/")); + } else { + // Everything at the root is store metadata - PARAM.SFO, PS3LOGO.DAT, ICON0.PNG and + // friends. Installing the SFO would make PPSSPP mistake the folder for save data. + return std::string(); + } + if (name.empty() || !IsSafeRelativePath(name)) { + return std::string(); + } + return std::string(name); +} + +u64 PkgInstalledSize(const PkgInfo &info) { + u64 total = 0; + for (const PkgItem &item : info.items) { + if (!item.IsDirectory() && !PkgItemInstallPath(item).empty()) { + total += item.dataSize; + } + } + return total; +} + +bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error) { + const PkgInfo &info = reader.Info(); + + const u64 totalBytes = PkgInstalledSize(info); + if (totalBytes == 0) { + *error = "Nothing to install in this PKG"; + return false; + } + + if (!File::CreateFullPath(destDir)) { + *error = "Failed to create the destination folder"; + return false; + } + + u64 writtenBytes = 0; + std::vector buffer(PKG_COPY_BLOCK); + + for (const PkgItem &item : info.items) { + const std::string relative = PkgItemInstallPath(item); + if (relative.empty()) { + if (!item.IsDirectory() && !startsWith(item.name, "USRDIR")) { + // Expected - store metadata. Anything else is worth a line in the log. + VERBOSE_LOG(Log::Loader, "PKG: skipping '%s'", item.name.c_str()); + } + continue; + } + const Path destPath = destDir / relative; + if (item.IsDirectory()) { + if (!File::CreateFullPath(destPath)) { + *error = "Failed to create a folder in the destination"; + return false; + } + continue; + } + if (!File::CreateFullPath(destPath.NavigateUp())) { + *error = "Failed to create a folder in the destination"; + return false; + } + + FILE *f = File::OpenCFile(destPath, "wb"); + if (!f) { + *error = "Failed to write to the destination folder"; + return false; + } + + u64 pos = 0; + bool failed = false; + while (pos < item.dataSize) { + const size_t chunk = (size_t)std::min(buffer.size(), item.dataSize - pos); + if (!reader.ReadItemData(item, pos, chunk, buffer.data())) { + *error = "Failed to read from the PKG file"; + failed = true; + break; + } + if (fwrite(buffer.data(), 1, chunk, f) != chunk) { + *error = "Failed to write to the destination folder"; + failed = true; + break; + } + pos += chunk; + writtenBytes += chunk; + if (progress) { + progress((float)((double)writtenBytes / (double)totalBytes)); + } + } + fclose(f); + if (failed) { + File::Delete(destPath); + return false; + } + INFO_LOG(Log::Loader, "PKG: installed %s (%lld bytes)", relative.c_str(), (long long)item.dataSize); + } + + if (progress) { + progress(1.0f); + } + return true; +} diff --git a/Core/Util/PkgUnpack.h b/Core/Util/PkgUnpack.h new file mode 100644 index 0000000000..a13dc6df59 --- /dev/null +++ b/Core/Util/PkgUnpack.h @@ -0,0 +1,108 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" + +class FileLoader; +class Path; + +// Reads .PKG files - the NPDRM container Sony distributed downloadable content in. We're only +// interested in one flavor: PSP *game updates*, which hold a patched EBOOT (PBOOT.PBP) plus the +// data files the patch replaces. Installing one puts them in PSP/GAME//, and the game +// then boots from there with the original disc still supplying everything the patch doesn't +// override - see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp. +// +// The whole package past the header is AES-128-CTR, and every PBOOT seen so far is encrypted with +// a PRX tag we already have a key for, so nothing here needs new crypto secrets. +// +// docs/pkg_notes.md describes the format and what these packages turned out to contain. + +struct PkgItem { + std::string name; + u64 dataOffset = 0; // Relative to the encrypted area, not to the file. + u64 dataSize = 0; + u8 pspType = 0; // 0x90 selects the PSP key, anything else the PS3 key. + u8 flags = 0; // Content type. See docs/pkg_notes.md for the values. + + bool IsDirectory() const { return flags == 4 || flags == 18; } +}; + +struct PkgInfo { + std::string contentId; // "JP0177-ULJM05681_00-PJD2UPDATEVR0101" + std::string titleId; // "ULJM05681" + u32 contentType = 0; // 7 for PSP. See kPkgContentTypePSP. + std::string title; // From the package's own PARAM.SFO. + std::string category; // "PP" for a game update. + + // A game update carries a PBOOT.PBP whose own PARAM.SFO says what it patches. Without one + // there's nothing here we know how to install. + bool isGameUpdate = false; + std::string discId; // The disc this patches, e.g. "ULJM05681". + std::string discVersion; // The disc version it patches, e.g. "1.00". + std::string appVer; // The patch's own version, e.g. "01.01". + std::string systemVer; // Firmware the patch asks for, e.g. "6.20". + std::string pbootTitle; // "Update 2.01", when the patch bothers to name itself. + + std::vector items; +}; + +const u32 kPkgContentTypePSP = 7; + +class PkgReader { +public: + // Parses the header, the item table and the two PARAM.SFOs. Doesn't take ownership of the + // loader, which has to outlive the reader. + bool Open(FileLoader *loader, std::string *error); + + const PkgInfo &Info() const { return info_; } + + // Decrypts `size` bytes at `offset` within an item. + bool ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out); + // The whole item, for small ones. Fails rather than allocating more than maxSize. + bool ReadItem(const PkgItem &item, std::vector *out, size_t maxSize = 4 * 1024 * 1024); + +private: + bool ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out); + const u8 *ItemKey(const PkgItem &item) const; + bool ReadPBOOTInfo(const PkgItem &pboot); + + FileLoader *loader_ = nullptr; + u64 dataOffset_ = 0; + u64 dataSize_ = 0; + u8 riv_[16]{}; + u8 mainKey_[16]{}; + PkgInfo info_; +}; + +// Where an item ends up inside the installed game folder, or empty for the ones that shouldn't be +// installed at all. A package wraps its payload in USRDIR/CONTENT/, PS3-style, and also carries +// store metadata (PARAM.SFO, PS3LOGO.DAT, the icons) that isn't part of the PSP-side install. +std::string PkgItemInstallPath(const PkgItem &item); + +// What the install will take up on disk. Package contents aren't compressed, so this is exact +// rather than an estimate - modulo the filesystem's own per-file overhead. +u64 PkgInstalledSize(const PkgInfo &info); + +// Unpacks the installable items into destDir, which should be the game folder itself +// (PSP/GAME/). progress is called with 0..1 as it goes, and may be null. +bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error); diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj index 35426278f8..f3e3aab116 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj +++ b/UWP/CoreUWP/CoreUWP.vcxproj @@ -339,6 +339,7 @@ + @@ -660,6 +661,7 @@ + diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters index 45d4805f88..96576d9464 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj.filters +++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters @@ -282,6 +282,7 @@ + @@ -673,6 +674,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 4d3052016c..731940c322 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -814,6 +814,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/Core/Util/PPGeDraw.cpp \ $(SRC)/Core/Util/KL4E.cpp \ $(SRC)/Core/Util/PSARUnpack.cpp \ + $(SRC)/Core/Util/PkgUnpack.cpp \ $(SRC)/Core/Util/RecentFiles.cpp \ $(SRC)/Core/Util/VideoPlayer.cpp \ $(SRC)/git-version.cpp diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 92dc444804..7bb4b9b326 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -58,6 +58,7 @@ #include "Core/MIPS/MIPSTables.h" #include "Core/System.h" #include "Core/Util/PSARUnpack.h" +#include "Core/Util/PkgUnpack.h" #include "Core/WebServer.h" #include "Core/HLE/sceUtility.h" #include "Core/SaveState.h" @@ -671,6 +672,38 @@ int main(int argc, const char* argv[]) { return ok ? 0 : 1; } + // Same deal for installing a game update package. + if (cmdLineOptions.installPkg.has_value()) { + if (cmdLineOptions.bootFilenames.size() != 1) { + fprintf(stderr, "--install-pkg takes exactly one .pkg file\n"); + return 1; + } + std::unique_ptr loader(ConstructFileLoader(Path(cmdLineOptions.bootFilenames[0]))); + PkgReader reader; + std::string pkgError; + if (!loader || !reader.Open(loader.get(), &pkgError)) { + fprintf(stderr, "Not a usable PKG: %s\n", pkgError.c_str()); + return 1; + } + const PkgInfo &info = reader.Info(); + printf("%s (%s)\n", info.title.c_str(), info.contentId.c_str()); + printf("Category %s, %d items, %lld bytes installed\n", info.category.c_str(), + (int)info.items.size(), (long long)PkgInstalledSize(info)); + if (info.isGameUpdate) { + printf("Game update for %s v%s -> app version %s (firmware %s)\n", info.discId.c_str(), + info.discVersion.c_str(), info.appVer.c_str(), info.systemVer.c_str()); + } else { + fprintf(stderr, "This PKG isn't a game update - nothing we know how to install\n"); + return 1; + } + if (!InstallPkg(reader, Path(cmdLineOptions.installPkg.value()), nullptr, &pkgError)) { + fprintf(stderr, "Install failed: %s\n", pkgError.c_str()); + return 1; + } + printf("Installed into %s\n", cmdLineOptions.installPkg.value().c_str()); + return 0; + } + g_Config.RestoreDefaults(RestoreSettingsBits::SETTINGS | RestoreSettingsBits::CONTROLS | RestoreSettingsBits::RECENT, false); Core_RegisterDebugOutputListeners(&SendDebugOutput, &SendDebugScreenshot); diff --git a/libretro/Makefile.common b/libretro/Makefile.common index b197b43ffe..c0af28cc5b 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -902,6 +902,7 @@ SOURCES_CXX += \ $(COREDIR)/Util/PPGeDraw.cpp \ $(COREDIR)/Util/KL4E.cpp \ $(COREDIR)/Util/PSARUnpack.cpp \ + $(COREDIR)/Util/PkgUnpack.cpp \ $(COREDIR)/Util/RecentFiles.cpp \ $(COREDIR)/Util/AudioFormat.cpp \ $(COREDIR)/Util/PathUtil.cpp \ From 3a70c6b1491002244c1f7610d7fe932bea731e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 21 Aug 2026 16:53:31 +0200 Subject: [PATCH 3/6] Install PKG game updates, and boot them Opening a .pkg now offers to install it, the way a .zip does - see the new InstallPkgScreen, which shows what the update patches, what it'll take up on disk (exact, since package contents aren't compressed) and where it's going. The package's PS3-style USRDIR/CONTENT wrapping is stripped so the files land where the PSP expects them, in PSP/GAME/. Booting a disc then looks for PSP/GAME//PBOOT.PBP and boots that instead of the disc's own EBOOT, leaving the disc mounted - so the update overrides the files it ships and the disc supplies the rest. The update's DISC_ID has to match; a DISC_VERSION mismatch only warns, since updates do get used with slightly different dumps in practice. Verified against the whole corpus: every one installs, and the digital NP* update/base-image pairs that could be assembled all boot the patch rather than the disc's executable. That includes Super Robot Taisen Operation Extend from a real NPUMDIMG EBOOT.PBP, which settles that ISO.BIN.EDAT does not re-key the PBOOT - a digital title's patched EBOOT is encrypted exactly like a UMD one. On the UMD side, the patched LittleBigPlanet reads PATCH.ARC out of the install alongside the disc's own archive. The DISC_VERSION warning turns out to be load-bearing: many of the pairs mismatch, because the dumps in circulation are later disc revisions than the updates were built against. docs/pkg_notes.md has the numbers, and the one thing that doesn't work - PGD-wrapped .sprx modules, which sceKernelLoadModuleNpDrm can't load. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Loaders.cpp | 7 ++ Core/Loaders.h | 2 + Core/PSPLoaders.cpp | 136 ++++++++++++++++----- Core/System.cpp | 1 + Core/Util/GameManager.cpp | 65 ++++++++++ Core/Util/GameManager.h | 5 + UI/CMakeLists.txt | 2 + UI/GameBrowser.cpp | 6 +- UI/GameInfoCache.cpp | 2 + UI/InstallPkgScreen.cpp | 193 ++++++++++++++++++++++++++++++ UI/InstallPkgScreen.h | 66 ++++++++++ UI/MainScreen.cpp | 4 + UI/UI.vcxproj | 2 + UI/UI.vcxproj.filters | 6 + UWP/UI_UWP/UI_UWP.vcxproj | 2 + UWP/UI_UWP/UI_UWP.vcxproj.filters | 6 + android/jni/Android.mk | 1 + docs/pkg_notes.md | 137 +++++++++++++++++++-- 18 files changed, 597 insertions(+), 46 deletions(-) create mode 100644 UI/InstallPkgScreen.cpp create mode 100644 UI/InstallPkgScreen.h diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 0c85f8975c..287789bea1 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -219,6 +219,8 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin return IdentifiedFileType::ARCHIVE_RAR; } else if (!memcmp(&id, "\x37\x7A\xBC\xAF", 4)) { return IdentifiedFileType::ARCHIVE_7Z; + } else if (!memcmp(&id, "\x7F""PKG", 4)) { + return IdentifiedFileType::PSP_PKG; } // "~PSP" is an encrypted PRX. The module loader decrypts those on the way in, so as far as @@ -279,6 +281,10 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin return IdentifiedFileType::ARCHIVE_RAR; } else if (extension == ".7z") { return IdentifiedFileType::ARCHIVE_7Z; + } else if (extension == ".pkg") { + // Magic didn't match, but the name says what it was meant to be - report it as a PKG so + // the install screen can explain what's wrong with it. + return IdentifiedFileType::PSP_PKG; } return IdentifiedFileType::UNKNOWN; } @@ -673,6 +679,7 @@ const char *IdentifiedFileTypeToString(IdentifiedFileType type) { case IdentifiedFileType::UNKNOWN_ISO: return "UNKNOWN_ISO"; case IdentifiedFileType::ARCHIVE_RAR: return "ARCHIVE_RAR"; case IdentifiedFileType::ARCHIVE_ZIP: return "ARCHIVE_ZIP"; + case IdentifiedFileType::PSP_PKG: return "PSP_PKG"; case IdentifiedFileType::ARCHIVE_7Z: return "ARCHIVE_7Z"; case IdentifiedFileType::PSP_PS1_PBP: return "PSP_PS1_PBP"; case IdentifiedFileType::PSX_ISO: return "PSX_ISO"; diff --git a/Core/Loaders.h b/Core/Loaders.h index 6804abbdf2..a9d86dea99 100644 --- a/Core/Loaders.h +++ b/Core/Loaders.h @@ -40,6 +40,8 @@ enum class IdentifiedFileType { ARCHIVE_RAR, ARCHIVE_ZIP, ARCHIVE_7Z, + // A .pkg game update - a container we install from rather than boot. See Core/Util/PkgUnpack.h. + PSP_PKG, PSP_PS1_PBP, PSX_ISO, PS2_ISO, diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 72fe344601..091bdcc95f 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -260,44 +260,118 @@ static const char * const altBootNames[] = { //"disc0:/PSP_GAME/SYSDIR/ss.RAW",//Code Geass: Lost Colors chinese version }; -bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) { - std::string bootpath("disc0:/PSP_GAME/SYSDIR/EBOOT.BIN"); +// A game update installed from a .pkg (see Core/Util/PkgUnpack.h) lands in PSP/GAME//, +// with the patched executable as PBOOT.PBP. The PSP boots that instead of the disc's own EBOOT, +// leaving the disc mounted - so the update overrides the files it ships and the disc supplies +// everything else. - // Bypass Chinese translation patches, see comment above. - for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) { - if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) { - WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str()); - bootpath = altBootNames[i]; - // break; // should have a break here, but it would effectively reverse the evaluation order. - } +static bool ReadPBPParamSFO(const std::string &path, ParamSFOData *sfo) { + const int fd = pspFileSystem.OpenFile(path, FILEACCESS_READ); + if (fd < 0) { + return false; } - // Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game. - std::string id = g_paramSFO.GetValueString("DISC_ID"); - if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) { - bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN"; - } - if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) { - bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL"; - } - - bool hasEncrypted = false; - int fd; - if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) { - u8 head[4]{}; - // A file shorter than the magic used to leave head partly uninitialized, and then decided - // which boot file to use by comparing against it. - if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) { - if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) { - hasEncrypted = true; + bool success = false; + // A PBP starts with its magic, a version, and eight little-endian subfile offsets. PARAM.SFO + // is the first subfile, so it runs from its own offset to ICON0.PNG's. + u8 header[0x28]; + if (pspFileSystem.ReadFile(fd, header, sizeof(header)) == sizeof(header) && !memcmp(header, "\0PBP", 4)) { + u32_le sfoOffset, iconOffset; + memcpy(&sfoOffset, header + 0x08, sizeof(sfoOffset)); + memcpy(&iconOffset, header + 0x0C, sizeof(iconOffset)); + const u32 sfoSize = iconOffset - sfoOffset; + if (sfoOffset >= sizeof(header) && iconOffset > sfoOffset && sfoSize <= 64 * 1024) { + std::vector sfoData(sfoSize); + if (pspFileSystem.SeekFile(fd, sfoOffset, FILEMOVE_BEGIN) >= 0 && + pspFileSystem.ReadFile(fd, sfoData.data(), sfoSize) == sfoSize) { + success = sfo->ReadSFO(sfoData); } } - pspFileSystem.CloseFile(fd); + } + pspFileSystem.CloseFile(fd); + return success; +} + +// Returns the path of the update to boot, or an empty string to boot the disc normally. +static std::string FindGameUpdatePBOOT(const std::string &discId, const std::string &discVersion) { + if (discId.empty()) { + return std::string(); + } + const std::string path = "ms0:/PSP/GAME/" + discId + "/PBOOT.PBP"; + if (!pspFileSystem.GetFileInfo(path).exists) { + return std::string(); } - if (!hasEncrypted) { - // try unencrypted Boot.BIN - bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN"; + // Check what the update claims to patch before handing it the boot. + ParamSFOData sfo; + if (!ReadPBPParamSFO(path, &sfo)) { + WARN_LOG(Log::Loader, "Ignoring '%s': couldn't read its PARAM.SFO", path.c_str()); + return std::string(); + } + + const std::string updateDiscId = sfo.GetValueString("DISC_ID"); + if (updateDiscId != discId) { + WARN_LOG(Log::Loader, "Ignoring '%s': it's an update for %s, not %s", path.c_str(), updateDiscId.c_str(), discId.c_str()); + return std::string(); + } + + // The disc version is advisory here. An update is built against one specific revision of a + // disc, but refusing to run one on a slightly different dump is a worse failure than letting + // the user find out - they went and installed it on purpose. + const std::string updateDiscVersion = sfo.GetValueString("DISC_VERSION"); + if (!updateDiscVersion.empty() && !discVersion.empty() && updateDiscVersion != discVersion) { + WARN_LOG(Log::Loader, "Game update '%s' is for disc version %s, but this disc is %s. Booting it anyway.", + path.c_str(), updateDiscVersion.c_str(), discVersion.c_str()); + } + + NOTICE_LOG(Log::Loader, "Booting game update '%s' (app version %s) instead of the disc's executable", + path.c_str(), sfo.GetValueString("APP_VER").c_str()); + return path; +} + +bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) { + const std::string id = g_paramSFO.GetValueString("DISC_ID"); + + // An installed game update replaces the disc's executable - see FindGameUpdatePBOOT above. + std::string bootpath = FindGameUpdatePBOOT(id, g_paramSFO.GetValueString("DISC_VERSION")); + if (bootpath.empty()) { + bootpath = "disc0:/PSP_GAME/SYSDIR/EBOOT.BIN"; + + // Bypass Chinese translation patches, see comment above. + for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) { + if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) { + WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str()); + bootpath = altBootNames[i]; + // break; // should have a break here, but it would effectively reverse the evaluation order. + } + } + + // Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game. + if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) { + bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN"; + } + if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) { + bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL"; + } + + bool hasEncrypted = false; + int fd; + if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) { + u8 head[4]{}; + // A file shorter than the magic used to leave head partly uninitialized, and then decided + // which boot file to use by comparing against it. + if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) { + if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) { + hasEncrypted = true; + } + } + pspFileSystem.CloseFile(fd); + } + + if (!hasEncrypted) { + // try unencrypted Boot.BIN + bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN"; + } } // Fail early with a clearer message for some types of ISOs. diff --git a/Core/System.cpp b/Core/System.cpp index e13d417678..f90d35f494 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -255,6 +255,7 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) { break; case IdentifiedFileType::ARCHIVE_7Z: *errorString = "7z file detected (Require 7-Zip)"; break; + case IdentifiedFileType::PSP_PKG: *errorString = "PKG game updates need to be installed, not booted."; break; case IdentifiedFileType::PSX_ISO: *errorString = "PSX game image detected."; break; case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break; diff --git a/Core/Util/GameManager.cpp b/Core/Util/GameManager.cpp index ba4eb5be27..19d23555c7 100644 --- a/Core/Util/GameManager.cpp +++ b/Core/Util/GameManager.cpp @@ -52,6 +52,7 @@ #include "Core/FileSystems/ISOFileSystem.h" #include "Core/Util/GameManager.h" #include "Core/Util/PathUtil.h" +#include "Core/Util/PkgUnpack.h" #include "Core/Util/RecentFiles.h" #include "Common/Data/Text/I18n.h" @@ -902,6 +903,70 @@ bool GameManager::InstallZipOnThread(ZipFileTask task) { return true; } +// Installing a game update from a .pkg. Unlike a zip there's nothing to guess about - the +// package says which disc it patches, and that decides the destination folder. +void GameManager::InstallPkgContents(Path pkgPath, bool deleteAfter) { + SetCurrentThreadName("InstallPkgContents"); + + AndroidJNIThreadContext context; // Destructor detaches. + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, 0.0f, 0.1f); + + bool success = false; + std::string error; + std::unique_ptr loader(ConstructFileLoader(pkgPath)); + PkgReader reader; + if (!loader || !reader.Open(loader.get(), &error)) { + ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str()); + SetInstallError(iz->T("This PKG file isn't a PSP game update")); + } else if (!reader.Info().isGameUpdate) { + SetInstallError(iz->T("This PKG file isn't a PSP game update")); + } else { + const Path destination = GetSysDirectory(DIRECTORY_GAME) / reader.Info().discId; + success = InstallPkg(reader, destination, [this](float progress) { + installProgress_ = progress; + auto di = GetI18NCategory(I18NCat::DIALOG); + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f); + }, &error); + if (!success) { + ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str()); + SetInstallError(iz->T("Failed to install the game update")); + } + } + + // Close the package before anything tries to delete it. + loader.reset(); + + if (deleteAfter && success) { + if (System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN)) { + System_MoveToTrash(pkgPath); + } else { + File::Delete(pkgPath); + } + } + + g_OSD.RemoveProgressBar("install", success, 0.5f); + installProgress_ = 1.0f; + InstallDone(); + if (success) { + ResetInstallError(); + } +} + +bool GameManager::InstallPkgOnThread(const Path &pkgPath, bool deleteAfter) { + if (InstallInProgress() || installDonePending_) { + return false; + } + + installThread_ = std::thread([this, pkgPath, deleteAfter]() { + InstallPkgContents(pkgPath, deleteAfter); + }); + return true; +} + bool GameManager::UninstallGameOnThread(const std::string &name) { if (name.empty()) { ERROR_LOG(Log::HLE, "Cannot uninstall an empty-named game"); diff --git a/Core/Util/GameManager.h b/Core/Util/GameManager.h index d3b6ee3803..556e5c30eb 100644 --- a/Core/Util/GameManager.h +++ b/Core/Util/GameManager.h @@ -85,6 +85,10 @@ public: // Only returns false if there's already an installation in progress. bool InstallZipOnThread(ZipFileTask task); + // Installs a game update from a .pkg into PSP/GAME/. Same contract as the above - + // only returns false if something else is already installing. See Core/Util/PkgUnpack.h. + bool InstallPkgOnThread(const Path &pkgPath, bool deleteAfter); + // Separate kind of functionality from InstallZipOnThread, so doesn't re-use the task struct. bool UninstallGameOnThread(const std::string &name); @@ -94,6 +98,7 @@ public: private: void InstallZipContents(ZipFileTask task); + void InstallPkgContents(Path pkgPath, bool deleteAfter); bool InstallMemstickZip(const Path &zipFile, const Path &dest, const ZipFileInfo &info); bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir); void UninstallGame(const std::string &name); diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index 660e29392d..3a43190dc3 100644 --- a/UI/CMakeLists.txt +++ b/UI/CMakeLists.txt @@ -103,6 +103,8 @@ list(APPEND UISource InstallUpdateScreen.cpp InstallZipScreen.h InstallZipScreen.cpp + InstallPkgScreen.h + InstallPkgScreen.cpp JitCompareScreen.h JitCompareScreen.cpp MemStickScreen.h diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index 60d323eeff..ddf91bf7c3 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -239,6 +239,7 @@ void GameButton::Draw(UIContext &dc) { case IdentifiedFileType::ARCHIVE_ZIP: imageIcon = ImageID("I_ARCHIVE_ZIP"); drawBackground = false; break; case IdentifiedFileType::ARCHIVE_7Z: imageIcon = ImageID("I_ARCHIVE_7Z"); drawBackground = false; break; case IdentifiedFileType::ARCHIVE_RAR: imageIcon = ImageID("I_ARCHIVE_RAR"); drawBackground = false; break; + case IdentifiedFileType::PSP_PKG: imageIcon = ImageID("I_FOLDER_UPLOAD"); drawBackground = false; break; default: break; } @@ -880,11 +881,12 @@ void GameBrowser::Refresh() { } } - // Put RAR/ZIP files at the end to get them out of the way. + // Put RAR/ZIP files at the end to get them out of the way. Game update packages go here + // too - they're not something to boot, they're something to install. // We do support unpacking some of them automatically. if (browseFlags_ & BrowseFlags::ARCHIVES) { fileInfo.clear(); - path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:"); + path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:pkg:"); if (!fileInfo.empty()) { for (size_t i = 0; i < fileInfo.size(); i++) { if (!fileInfo[i].isDirectory) { diff --git a/UI/GameInfoCache.cpp b/UI/GameInfoCache.cpp index 35a9662e35..de57734e6c 100644 --- a/UI/GameInfoCache.cpp +++ b/UI/GameInfoCache.cpp @@ -153,6 +153,7 @@ bool GameInfo::Delete() { case IdentifiedFileType::ARCHIVE_RAR: case IdentifiedFileType::ARCHIVE_ZIP: case IdentifiedFileType::ARCHIVE_7Z: + case IdentifiedFileType::PSP_PKG: case IdentifiedFileType::UNKNOWN: case IdentifiedFileType::PSP_UMD_VIDEO_ISO: case IdentifiedFileType::PPSSPP_GE_DUMP: @@ -918,6 +919,7 @@ handleELF: } case IdentifiedFileType::ARCHIVE_ZIP: + case IdentifiedFileType::PSP_PKG: info_->SetTitle(info_->GetFilePath().GetFilename()); info_->icon.dataLoaded = true; break; diff --git a/UI/InstallPkgScreen.cpp b/UI/InstallPkgScreen.cpp new file mode 100644 index 0000000000..aa41a36c45 --- /dev/null +++ b/UI/InstallPkgScreen.cpp @@ -0,0 +1,193 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include + +#include "Common/Data/Text/I18n.h" +#include "Common/Data/Text/Parsers.h" +#include "Common/File/DiskFree.h" +#include "Common/File/FileUtil.h" +#include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" +#include "Common/UI/Context.h" +#include "Common/UI/UI.h" +#include "Common/UI/View.h" +#include "Common/UI/ViewGroup.h" + +#include "Core/Loaders.h" +#include "Core/System.h" +#include "Core/Util/GameManager.h" +#include "Core/Util/PathUtil.h" + +#include "UI/InstallPkgScreen.h" + +InstallPkgScreen::InstallPkgScreen(const Path &pkgPath) + : UITwoPaneBaseDialogScreen(Path(), TwoPaneFlags::SettingsToTheRight | TwoPaneFlags::ContentsCanScroll), pkgPath_(pkgPath) { + g_GameManager.ResetInstallError(); + + // Reading the package is cheap - the item table and two small PARAM.SFOs. We only keep the + // info; the install re-opens the file on its own thread. + std::unique_ptr loader(ConstructFileLoader(pkgPath_)); + PkgReader reader; + if (!loader || !reader.Open(loader.get(), &pkgError_)) { + return; + } + pkgInfo_ = reader.Info(); + if (!pkgInfo_.isGameUpdate) { + // Everything we can read is a game update; anything else got rejected above with a better + // message than this. + pkgError_ = "This PKG file isn't a PSP game update"; + return; + } + + canInstall_ = true; + installSize_ = PkgInstalledSize(pkgInfo_); + destination_ = GetSysDirectory(DIRECTORY_GAME) / pkgInfo_.discId; + alreadyInstalled_ = File::Exists(destination_ / "PBOOT.PBP"); + + int64_t space = 0; + if (free_disk_space(GetSysDirectory(DIRECTORY_GAME), space)) { + freeSpace_ = space; + } +} + +std::string_view InstallPkgScreen::GetTitle() const { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + return iz->T("Game update"); +} + +void InstallPkgScreen::CreateSettingsViews(UI::ViewGroup *parent) { + using namespace UI; + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + installChoice_ = nullptr; + + if (canInstall_) { + installChoice_ = parent->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD"))); + installChoice_->OnClick.Handle(this, &InstallPkgScreen::OnInstall); + } + + if (System_GetPropertyBool(SYSPROP_CAN_SHOW_FILE)) { + parent->Add(new Spacer(12.0f)); + parent->Add(new Choice(di->T("Show in folder")))->OnClick.Add([this](UI::EventParams &) { + System_ShowFileInFolder(pkgPath_); + }); + } + + if (canInstall_) { + parent->Add(new Spacer(12.0f)); + parent->Add(new CheckBox(&deletePkgFile_, iz->T("Delete PKG file"))); + } +} + +void InstallPkgScreen::CreateContentViews(UI::ViewGroup *parent) { + using namespace UI; + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + auto er = GetI18NCategory(I18NCat::ERRORS); + + LinearLayout *leftColumn = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8)))); + + if (!canInstall_) { + leftColumn->Add(new TextView(GetFriendlyPath(pkgPath_))); + leftColumn->Add(new NoticeView(NoticeLevel::ERROR, iz->T(pkgError_.empty() ? "This PKG file isn't a PSP game update" : pkgError_), "")); + doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", "")); + doneView_->SetVisibility(Visibility::V_GONE); + return; + } + + leftColumn->Add(new TextView(iz->T("Install game update?")))->SetBig(true); + leftColumn->Add(new TextView(pkgPath_.GetFilename())); + if (!pkgInfo_.title.empty()) { + leftColumn->Add(new TextView(pkgInfo_.title)); + } + if (!pkgInfo_.pbootTitle.empty()) { + leftColumn->Add(new TextView(pkgInfo_.pbootTitle)); + } + + leftColumn->Add(new Spacer(8.0f)); + + // What it patches, and to what. The disc version is the one the update was built against. + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2 (%3)", iz->T("Game"), pkgInfo_.discId, pkgInfo_.discVersion))); + if (!pkgInfo_.appVer.empty()) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Update version"), pkgInfo_.appVer))); + } + if (!pkgInfo_.systemVer.empty()) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Requires firmware"), pkgInfo_.systemVer))); + } + + // Package contents aren't compressed, so this is what it'll actually take up. + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Space needed"), NiceSizeFormat(installSize_)))); + if (freeSpace_ >= 0) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Free space"), NiceSizeFormat((u64)freeSpace_)))); + } + + leftColumn->Add(new Spacer(8.0f)); + leftColumn->Add(new TextView(iz->T("Install into folder"))); + leftColumn->Add(new TextView(GetFriendlyPath(destination_)))->SetAlign(FLAG_WRAP_TEXT); + + doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", "")); + doneView_->SetVisibility(Visibility::V_GONE); + + if (freeSpace_ >= 0 && (u64)freeSpace_ < installSize_) { + leftColumn->Add(new NoticeView(NoticeLevel::ERROR, er->T("Not enough free space"), "")); + } + if (alreadyInstalled_) { + leftColumn->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), iz->T("An update for this game is already installed"))); + } +} + +bool InstallPkgScreen::key(const KeyInput &key) { + // Ignore key presses while installing, so the user can't escape mid-write. + if (g_GameManager.GetState() == GameManagerState::IDLE) { + return UIDialogScreen::key(key); + } + return false; +} + +void InstallPkgScreen::OnInstall(UI::EventParams ¶ms) { + if (!canInstall_) { + return; + } + if (g_GameManager.InstallPkgOnThread(pkgPath_, deletePkgFile_)) { + installStarted_ = true; + if (installChoice_) { + installChoice_->SetEnabled(false); + } + } +} + +void InstallPkgScreen::update() { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + using namespace UI; + if (g_GameManager.GetState() == GameManagerState::IDLE && doneView_) { + const std::string err = g_GameManager.GetInstallError(); + if (!err.empty()) { + doneView_->SetLevelAndText(NoticeLevel::ERROR, iz->T(err)); + doneView_->SetVisibility(Visibility::V_VISIBLE); + } else if (installStarted_) { + doneView_->SetLevelAndText(NoticeLevel::SUCCESS, iz->T("Installed!")); + doneView_->SetVisibility(Visibility::V_VISIBLE); + } + } + UIBaseDialogScreen::update(); +} diff --git a/UI/InstallPkgScreen.h b/UI/InstallPkgScreen.h new file mode 100644 index 0000000000..187ef188d5 --- /dev/null +++ b/UI/InstallPkgScreen.h @@ -0,0 +1,66 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include "Common/File/Path.h" +#include "Common/UI/Notice.h" +#include "Common/UI/UIScreen.h" +#include "Common/UI/View.h" + +#include "Core/Util/PkgUnpack.h" +#include "UI/BaseScreens.h" +#include "UI/SimpleDialogScreen.h" + +// Offers to install a PSP game update from a .pkg file, the way InstallZipScreen does for zips. +// The package goes into PSP/GAME/, and from then on booting that disc runs the update - +// see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp. +class InstallPkgScreen : public UITwoPaneBaseDialogScreen { +public: + InstallPkgScreen(const Path &pkgPath); + + void update() override; + bool key(const KeyInput &key) override; + + const char *tag() const override { return "InstallPkg"; } + +protected: + void CreateSettingsViews(UI::ViewGroup *parent) override; + void CreateContentViews(UI::ViewGroup *parent) override; + std::string_view GetTitle() const override; + ViewLayoutMode LayoutMode() const override { + return ViewLayoutMode::ApplyInsets; + } + +private: + void OnInstall(UI::EventParams ¶ms); + + Path pkgPath_; + Path destination_; + + PkgInfo pkgInfo_; + std::string pkgError_; // Why the package can't be installed, if it can't. + bool canInstall_ = false; + u64 installSize_ = 0; + s64 freeSpace_ = -1; // Negative if we couldn't find out. + bool alreadyInstalled_ = false; + + UI::Choice *installChoice_ = nullptr; + NoticeView *doneView_ = nullptr; + bool installStarted_ = false; + bool deletePkgFile_ = false; +}; diff --git a/UI/MainScreen.cpp b/UI/MainScreen.cpp index 232c10cef4..3419450e2b 100644 --- a/UI/MainScreen.cpp +++ b/UI/MainScreen.cpp @@ -51,6 +51,7 @@ #include "UI/SavedataScreen.h" #include "UI/InstallUpdateScreen.h" #include "UI/InstallZipScreen.h" +#include "UI/InstallPkgScreen.h" #include "UI/Background.h" #include "UI/GameBrowser.h" #include "Core/Config.h" @@ -67,6 +68,9 @@ static void LaunchFile(ScreenManager *screenManager, Screen *currentScreen, cons if (extension == ".zip" || extension == ".7z") { // If is a zip file, we have a screen for that. screenManager->push(new InstallZipScreen(path)); + } else if (extension == ".pkg") { + // A game update package - not something to boot, something to install. + screenManager->push(new InstallPkgScreen(path)); } else { // Check if we already know that this game isn't playable. // If coming from the main screen, the info will already be computed here since the icon is displayed etc. diff --git a/UI/UI.vcxproj b/UI/UI.vcxproj index 1c89ea0cd5..e74c8805fe 100644 --- a/UI/UI.vcxproj +++ b/UI/UI.vcxproj @@ -79,6 +79,7 @@ + @@ -137,6 +138,7 @@ + diff --git a/UI/UI.vcxproj.filters b/UI/UI.vcxproj.filters index d48a5f6306..c56a1b7fb3 100644 --- a/UI/UI.vcxproj.filters +++ b/UI/UI.vcxproj.filters @@ -40,6 +40,9 @@ Screens + + Screens + Screens @@ -194,6 +197,9 @@ Screens + + Screens + Screens diff --git a/UWP/UI_UWP/UI_UWP.vcxproj b/UWP/UI_UWP/UI_UWP.vcxproj index 32b48346fa..3da7949f38 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj +++ b/UWP/UI_UWP/UI_UWP.vcxproj @@ -114,6 +114,7 @@ + @@ -173,6 +174,7 @@ + diff --git a/UWP/UI_UWP/UI_UWP.vcxproj.filters b/UWP/UI_UWP/UI_UWP.vcxproj.filters index 4d0d433135..4274cc244d 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj.filters +++ b/UWP/UI_UWP/UI_UWP.vcxproj.filters @@ -73,6 +73,9 @@ Screens + + Screens + Screens @@ -219,6 +222,9 @@ Screens + + Screens + Screens diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 731940c322..afdccb3ec6 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -989,6 +989,7 @@ LOCAL_SRC_FILES := \ $(SRC)/UI/BaseScreens.cpp \ $(SRC)/UI/Background.cpp \ $(SRC)/UI/CwCheatScreen.cpp \ + $(SRC)/UI/InstallPkgScreen.cpp \ $(SRC)/UI/InstallUpdateScreen.cpp \ $(SRC)/UI/InstallZipScreen.cpp \ $(SRC)/UI/JitCompareScreen.cpp \ diff --git a/docs/pkg_notes.md b/docs/pkg_notes.md index bf778c09c1..0b34aea32e 100644 --- a/docs/pkg_notes.md +++ b/docs/pkg_notes.md @@ -6,8 +6,9 @@ EBOOT (`PBOOT.PBP`) plus whatever data files the patch replaces, and installing `ms0:/PSP/GAME//`. The patched EBOOT then runs with the original UMD (or the original PSN game) still supplying everything it doesn't override. -`Tools/pkg.py` is a working parser/extractor. PPSSPP itself cannot yet read or use these - see -"What PPSSPP is missing" at the end. +This document is what was learned decoding some update packages on 2026-08-21. `Tools/pkg.py` is a +working parser/extractor built from it; PPSSPP reads and installs them itself now - see "How PPSSPP +handles them" and the verification sections at the end. Format reference: , cross-checked against [pkg2zip](https://github.com/mmozeiko/pkg2zip). @@ -164,17 +165,127 @@ recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an `PSP-KEY.EDAT` and `CONTENT.DAT`, and `continue`s past everything else - `PBOOT.PBP` and every patch file are silently dropped, with no warning that anything was skipped. -## What PPSSPP is missing +## How PPSSPP handles them -`grep -r PBOOT` over the tree returns nothing, so both halves are unwritten: +Three pieces, added 2026-08-21: -1. **Reading and installing a `.pkg`** - unpack it into `ms0:/PSP/GAME//`, next to the - existing ZIP/ISO install paths in `Core/Util/GameManager.cpp`. -2. **Booting the patch** - when starting a game, notice - `ms0:/PSP/GAME//PBOOT.PBP` whose inner SFO matches the disc's `DISC_ID` and - `DISC_VERSION`, and boot that instead with the ISO mounted on `disc0:`. The mounting already - exists (see `--mount` above); what's missing is the decision. +- **`Core/Util/PkgUnpack.cpp`** reads a package: header, item table, both PARAM.SFOs, and the + decryption. `PkgReader::Open()` gives you a `PkgInfo` with the disc ID, disc version and patch + version; `InstallPkg()` writes the payload out. Sits next to `PSARUnpack.cpp`, and like it needs + nothing but the AES already in `ext/libkirk`. +- **`UI/InstallPkgScreen.cpp`** is what opening a `.pkg` gets you, the same way a `.zip` gets + `InstallZipScreen` - it shows what the update patches, what it'll take up on disk, and where it's + going. The size is exact rather than an estimate: package contents aren't compressed, so summing + the item table is the answer. `GameManager::InstallPkgOnThread()` does the work. +- **`FindGameUpdatePBOOT()` in `Core/PSPLoaders.cpp`** is the boot-time half. Starting a disc looks + for `ms0:/PSP/GAME//PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN` + if it's there, leaving the disc mounted. -Only the UMD half of this is proven. The digital NP\* titles patch a PSN `EBOOT.PBP` / -NPUMDIMG rather than a UMD, and `ISO.BIN.EDAT` most likely re-keys it - that path has not been -tested. +`PPSSPPHeadless --install-pkg=DIR ` does an install without the UI, which is how the +above got tested. It prints what the package is and installs into DIR exactly (the app picks +`PSP/GAME/` itself). + +### Install layout + +The package's own PS3-style wrapping is stripped: `USRDIR/CONTENT/` and `USRDIR/` both become +`` in the game folder, and the `USRDIR` and `USRDIR/CONTENT` directory entries are dropped +rather than created. The root-level files - `PARAM.SFO`, `PS3LOGO.DAT`, `ICON0.PNG`, `PIC0.PNG`, +`PIC2.PNG` - are store metadata and are **not** installed. Writing that `PARAM.SFO` in particular +would be actively wrong: a folder holding a `PARAM.SFO` and no `EBOOT.PBP` is what PPSSPP +identifies as *save data*, so the update would show up in the savedata list. + +### Which updates get used + +The update's `DISC_ID` has to match the disc's, or it's ignored with a warning - that's what keeps +an update from being applied to the wrong game. + +`DISC_VERSION` is advisory. An update is built against one specific disc revision, and PPSSPP logs +a warning when they differ, but still boots it: refusing outright is a worse failure mode than +letting the user find out, since they installed it deliberately. This is not what a real PSP does. +It comes up in practice - the LittleBigPlanet v2.05 update targets disc version 1.00, and the +common European dump is 1.01, and it works. + +There's no setting to turn this off. Anyone who has a `PBOOT.PBP` sitting in a game folder either +installed it here or copied it off a real memory stick, and in both cases booting it is what they +were after. + +### Verified + +- All packages parse and install through the C++ path, byte-identical to `Tools/pkg.py` - each + one reports its disc ID and versions and writes its payload without an error. +- Hatsune Miku Project DIVA 2nd (ULJM05681): install the v1.01 update, boot the UMD, PPSSPP boots + `PBOOT.PBP` and the game reads its CPKs off `disc0:`. +- LittleBigPlanet (UCES01264): install the v2.05 update, boot the v1.01 UMD, and the patched game + opens `ms0:/PSP/GAME/UCES01264/PATCH.ARC` alongside the disc's own `lbp_archive.arc` - the update + is actually in use, not just booted. +- `python3 test.py -g --graphics=software`: no failures, so restructuring the disc boot + path didn't disturb anything. +- Truncated and item-table-corrupted packages are refused with a message rather than crashing. + Corruption *inside* file data still installs - nothing here verifies the package CMAC, same as + every other PKG tool. +- The browser listing, install screen and install itself were checked by hand in the app. + +## Digital (NP\*) titles + +About half of the packages patch a digital title rather than a UMD, and that half is tested too. + +The one that settles the question is **Super Robot Taisen Operation Extend (NPJH50521)**, because +it's a real NPUMDIMG `EBOOT.PBP` rather than a decrypted ISO dump - `NPDRM: PSAR ID: 4d55504e`, +mounted on `disc0:` by the NPDRM block device, with the game's own 560 MB EBOOT sitting in the same +folder as the update. All eight of its update revisions were installed and booted in turn, and each +one loads a distinguishably different executable: + +``` +disc executable .text 0x419b1c +v1.01 0x41fb8c v1.02 0x4233dc v1.03 0x42952c v1.04 0x42a53c +v1.05 0x42a61c v1.06 0x42a59c v1.07 0x42b48c v1.08 0x42b7dc +``` + +So **`ISO.BIN.EDAT` does not re-key the PBOOT**, which was the open worry: a digital title's patched +executable is encrypted exactly like a UMD one, and needs nothing PPSSPP doesn't already have. + +**`DISC_VERSION` being advisory matters far more than expected.** Most of the pairs hit a mismatch, +because the dumps in circulation are later disc revisions than the updates were built against. +Refusing outright would make most of them unusable. Elminage Original was the one clean +exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning. + +### Known limitation: PGD-wrapped `.sprx` modules don't load + +Package payloads are full of `\0PSPEDAT` files. That's fine for +*data*: `sceNpDrmEdataSetupKey()` in `Core/HLE/scePspNpDrm_user.cpp` wraps an open file descriptor +with the `0x04100002`/`0x04100001` ioctl pair, and the game reads plaintext. + +A few packages ship `\0PSPEDAT` **executables** - `.sprx` modules - and that path is not +implemented. God Eater 2 (NPJH50832, 45 of them) installs cleanly, boots its `PBOOT.PBP`, and then +loops forever on: + +``` +E Loader: SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE=sceKernelLoadModuleNpDrm( + ms0:/PSP/GAME/NPJH50832/system.sprx, 00000000, 00000000): failed to load +E sceModule: Wrong magic number 50535000 +``` + +Shiren 4 Plus (NPJH50698, one `.sprx` holding the whole game behind a 62 KB loader) fails +identically. Tales of the World Radiant Mythology 3 (NPJH50353, two) doesn't reach its modules +within 100 seconds of headless boot, so it gets past startup - it presumably hits the same wall +whenever it does load them. + +`sceKernelLoadModuleNpDrm()` in `Core/HLE/sceKernelModule.cpp` is a one-line forward to +`sceKernelLoadModule()`, which sees something that isn't ELF magic and gives up. Fixing it means +unwrapping the EDAT before handing the bytes to the ELF loader, using the licensee key the game has +already set via `sceNpDrmSetLicenseeKey` - the same material the data path uses. + +This is a pre-existing emulator gap rather than something the installer gets wrong: until now +nothing put such a file on the memory stick, so it was unreachable. The unpatched God Eater 2 never +calls `sceKernelLoadModuleNpDrm` at all - the update is what introduces the modules - so for that +title the update is currently better not installed, since the unpatched game boots. It's also why a +"God Eater 2 DLC Update v1.40 *Decrypted for PPSSPP Emulator*" folder circulates: the `PBOOT.PBP` in +it is byte-for-byte what `InstallPkg()` writes, and the only difference is that its `.sprx` files +have been unwrapped to plain ELF by hand. + +### Still not tested + +- Nothing here plays past a title screen. The runs are 25-second headless boots, so "the update is + in use" means the patched executable is what loaded and ran - not that a patched *asset* was read. + LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no equivalent observation for a + digital one yet. From f0dafdee10c4f8527cc403f664a14e66b85a5d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 21 Aug 2026 21:18:38 +0200 Subject: [PATCH 4/6] Show and remove installed game updates from the game info screen An installed update silently replaces what the game boots, so the info pane now says when there is one - version, size and where it lives - and the context menu offers to remove it again. Removing takes the whole PSP/GAME/ folder when the update is all that's in it. When a digital game shares the folder, only PBOOT.PBP goes, since deleting the folder would take the game with it and nothing records what the install wrote. The confirmation names the exact path either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018izZ1mGTWhz2RqeudqsDQR --- Core/Util/PkgUnpack.cpp | 53 +++++++++++++++++++++++++++++++++++ Core/Util/PkgUnpack.h | 24 +++++++++++++++- UI/GameScreen.cpp | 62 +++++++++++++++++++++++++++++++++++++++++ UI/GameScreen.h | 10 +++++++ docs/pkg_notes.md | 6 +++- 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/Core/Util/PkgUnpack.cpp b/Core/Util/PkgUnpack.cpp index 6c1d4b5738..105d52f4e6 100644 --- a/Core/Util/PkgUnpack.cpp +++ b/Core/Util/PkgUnpack.cpp @@ -17,13 +17,18 @@ #include #include +#include #include "Common/File/FileUtil.h" #include "Common/File/Path.h" #include "Common/Log.h" #include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" #include "Core/ELF/ParamSFO.h" +#include "Core/ELF/PBPReader.h" #include "Core/Loaders.h" +#include "Core/System.h" #include "Core/Util/PkgUnpack.h" extern "C" { @@ -496,3 +501,51 @@ bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function loader(ConstructFileLoader(pbootPath)); + if (!loader) { + return false; + } + PBPReader pbp(loader.get()); + std::vector sfoData; + ParamSFOData sfo; + if (!pbp.IsValid() || !pbp.GetSubFile(PBP_PARAM_SFO, &sfoData) || !sfo.ReadSFO(sfoData)) { + WARN_LOG(Log::Loader, "'%s' doesn't look like a game update", pbootPath.c_str()); + return false; + } + + update->folder = folder; + update->pbootPath = pbootPath; + update->appVer = sfo.GetValueString("APP_VER"); + update->discVersion = sfo.GetValueString("DISC_VERSION"); + update->title = sfo.GetValueString("PBOOT_TITLE"); + update->sharesFolderWithGame = File::Exists(folder / "EBOOT.PBP"); + update->sizeOnDisk = update->sharesFolderWithGame + ? (u64)std::max(0, File::GetFileSize(pbootPath)) + : File::ComputeRecursiveDirectorySize(folder); + return true; +} + +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update) { + const bool useTrash = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + // Only the PBOOT when the folder is a game in its own right - see the struct's comment. + const Path target = update.sharesFolderWithGame ? update.pbootPath : update.folder; + INFO_LOG(Log::Loader, "Removing game update '%s'", target.c_str()); + if (useTrash) { + // TODO: No way to tell whether this succeeded. + System_MoveToTrash(target); + return true; + } + return update.sharesFolderWithGame ? File::Delete(target) : File::DeleteDirRecursively(target); +} diff --git a/Core/Util/PkgUnpack.h b/Core/Util/PkgUnpack.h index a13dc6df59..8b8b2637f8 100644 --- a/Core/Util/PkgUnpack.h +++ b/Core/Util/PkgUnpack.h @@ -22,9 +22,9 @@ #include #include "Common/CommonTypes.h" +#include "Common/File/Path.h" class FileLoader; -class Path; // Reads .PKG files - the NPDRM container Sony distributed downloadable content in. We're only // interested in one flavor: PSP *game updates*, which hold a patched EBOOT (PBOOT.PBP) plus the @@ -106,3 +106,25 @@ u64 PkgInstalledSize(const PkgInfo &info); // Unpacks the installable items into destDir, which should be the game folder itself // (PSP/GAME/). progress is called with 0..1 as it goes, and may be null. bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error); + +// An update that has been installed, i.e. what's left over in PSP/GAME/ afterwards. +struct InstalledGameUpdate { + Path folder; + Path pbootPath; + std::string appVer; // The update's version, from PBOOT.PBP's own PARAM.SFO. + std::string discVersion; // The disc version it was built against. + std::string title; // PBOOT_TITLE, when the update names itself ("Update 2.01"). + u64 sizeOnDisk = 0; // How much deleting it would actually free. + // True when a game shares the folder - a digital title, whose EBOOT.PBP sits right next to the + // update. Deleting the whole folder would take the game with it. + bool sharesFolderWithGame = false; +}; + +// Looks for an update installed for discId. Cheap enough to call while building a UI - a stat and +// a small read, plus a directory walk for the size. +bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update); + +// Removes an installed update, to the trash where there is one. Takes the whole folder when the +// update is all that's in it, and only PBOOT.PBP when a game shares the folder - that stops the +// update from being used, and it's the only part we can still identify after the fact. +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update); diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index 28fe1705ab..66f071efb0 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -262,6 +262,15 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { } } +void GameScreen::RefreshInstalledUpdate() { + hasInstalledUpdate_ = false; + // Homebrew reuses real disc IDs often enough that we'd get false positives. + if (isHomebrew_ || !(knownFlags_ & GameInfoFlags::PARAM_SFO)) { + return; + } + hasInstalledUpdate_ = FindInstalledGameUpdate(info_->id, &installedUpdate_); +} + void GameScreen::CreateContentViews(UI::ViewGroup *parent) { if (!info_) { // Shouldn't happen @@ -505,6 +514,24 @@ void GameScreen::CreateContentViews(UI::ViewGroup *parent) { } } + // An installed game update replaces the disc's executable, so it's worth saying so here - + // otherwise there's nothing in the UI to explain why a patched game is running. + RefreshInstalledUpdate(); + if (hasInstalledUpdate_) { + infoLayout->Add(new ItemHeader(ga->T("Game update"))); + std::string updateLine = installedUpdate_.title; + if (!installedUpdate_.appVer.empty()) { + const std::string version = ApplySafeSubstitutions(ga->T("Version %1"), installedUpdate_.appVer); + updateLine = updateLine.empty() ? version : updateLine + " - " + version; + } + if (updateLine.empty()) { + updateLine = ga->T("Installed"); + } + updateLine += " - " + NiceSizeFormat(installedUpdate_.sizeOnDisk); + infoLayout->Add(new TextView(updateLine, ALIGN_LEFT, true))->SetBullet(true); + infoLayout->Add(new TextView(GetFriendlyPath(installedUpdate_.folder), ALIGN_LEFT | FLAG_WRAP_TEXT, true))->SetBullet(true); + } + // Show plugin info_, if any. Later might add checkboxes. auto plugins = HLEPlugins::FindPlugins(info_->id, g_Config.sLanguageIni); if (!plugins.empty()) { @@ -625,6 +652,12 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { }); } + RefreshInstalledUpdate(); + if (!inGame_ && hasInstalledUpdate_) { + Choice *btnDeleteUpdate = parent->Add(new Choice(ga->T("Delete Game Update"), ImageID("I_TRASHCAN"))); + btnDeleteUpdate->OnClick.Handle(this, &GameScreen::OnDeleteGameUpdate); + } + // Don't want to be able to delete the game while it's running. if (!inGame_) { Choice *deleteChoice = parent->Add(new Choice(ga->T("Delete Game"), ImageID("I_WARNING"))); @@ -632,6 +665,35 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { } } +void GameScreen::OnDeleteGameUpdate(UI::EventParams &e) { + if (!hasInstalledUpdate_) { + return; + } + auto di = GetI18NCategory(I18NCat::DIALOG); + auto ga = GetI18NCategory(I18NCat::GAME); + + std::string prompt(ga->T("DeleteConfirmGameUpdate", "Do you really want to remove the installed update?\nThe game will go back to running the version on the disc.")); + prompt += "\n\n"; + // Say exactly what disappears - for a digital game the folder holds the game itself, so only + // the update's executable goes. + prompt += GetFriendlyPath(installedUpdate_.sharesFolderWithGame ? installedUpdate_.pbootPath : installedUpdate_.folder); + + const bool trashAvailable = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + const InstalledGameUpdate update = installedUpdate_; + screenManager()->push( + new UI::MessagePopupScreen(ga->T("Delete Game Update"), prompt, trashAvailable ? di->T("Move to trash") : di->T("Delete"), di->T("Cancel"), + [this, update](bool yes) { + if (!yes) { + return; + } + if (!DeleteInstalledGameUpdate(update)) { + auto er = GetI18NCategory(I18NCat::ERRORS); + g_OSD.Show(OSDType::MESSAGE_ERROR, er->T("Failed to delete the game update")); + } + RecreateViews(); + })); +} + void GameScreen::OnCreateConfig(UI::EventParams &e) { if (!info_->Ready(GameInfoFlags::PARAM_SFO)) { return; diff --git a/UI/GameScreen.h b/UI/GameScreen.h index 398a2a8c0c..7b1e5c8e3b 100644 --- a/UI/GameScreen.h +++ b/UI/GameScreen.h @@ -23,6 +23,7 @@ #include "Common/UI/UIScreen.h" #include "Common/File/Path.h" #include "UI/GameInfoCache.h" +#include "Core/Util/PkgUnpack.h" #include "UI/SimpleDialogScreen.h" @@ -58,6 +59,11 @@ private: void OnCreateConfig(UI::EventParams &e); void OnDeleteConfig(UI::EventParams &e); void OnSetBackground(UI::EventParams &e); + void OnDeleteGameUpdate(UI::EventParams &e); + + // Checks whether a game update is installed for this game. Both the info pane and the context + // menu need the answer, and either can be built first. + void RefreshInstalledUpdate(); std::string CRC32string; @@ -69,6 +75,10 @@ private: bool knownHasCRC_ = false; + // A game update installed in PSP/GAME/, which is what actually runs when there is one. + InstalledGameUpdate installedUpdate_; + bool hasInstalledUpdate_ = false; + std::shared_ptr info_; mutable std::string titleCache_; }; diff --git a/docs/pkg_notes.md b/docs/pkg_notes.md index 0b34aea32e..c41a2837d7 100644 --- a/docs/pkg_notes.md +++ b/docs/pkg_notes.md @@ -167,7 +167,7 @@ patch file are silently dropped, with no warning that anything was skipped. ## How PPSSPP handles them -Three pieces, added 2026-08-21: +Four pieces: - **`Core/Util/PkgUnpack.cpp`** reads a package: header, item table, both PARAM.SFOs, and the decryption. `PkgReader::Open()` gives you a `PkgInfo` with the disc ID, disc version and patch @@ -177,6 +177,10 @@ Three pieces, added 2026-08-21: `InstallZipScreen` - it shows what the update patches, what it'll take up on disk, and where it's going. The size is exact rather than an estimate: package contents aren't compressed, so summing the item table is the answer. `GameManager::InstallPkgOnThread()` does the work. +- **`GameScreen`** shows an installed update in the info pane, and offers "Delete Game Update" in + its context menu. Deleting takes the whole `PSP/GAME/` folder when the update is all + that's in it, and only `PBOOT.PBP` when a digital game shares the folder - nothing records what + an install wrote, so the executable is the only part still identifiable afterwards. - **`FindGameUpdatePBOOT()` in `Core/PSPLoaders.cpp`** is the boot-time half. Starting a disc looks for `ms0:/PSP/GAME//PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN` if it's there, leaving the disc mounted. From 19723f59ebfe90cde575faeee9a1d6878e93a365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 24 Aug 2026 14:39:59 +0200 Subject: [PATCH 5/6] Decrypt the NPDRM modules a PKG game update installs A .sprx from one of these packages is an NPDRM "\0PSPEDAT" container: a 0x90-byte header, then an ordinary ~PSP PRX. The loader only ever saw the EDAT magic and gave up with SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE. Step over the header, then derive the key the PRX inside is really encrypted against: sceNpDrmGetFixedKey() over the content ID, XOR in the licensee key the game handed us through sceNpDrmSetLicenseeKey(), then AES under a module key that had to be added. Both halves of that were already lying around unused - sceNpDrmGetFixedKey() had no callers at all, and the licensee key was being kept and never read. The rest of it is a fixed XOR that the PRX header's decrypt_mode selects rather than its tag, so it's applied on the mode the way JPCSP does it and the tag table is left alone - tag 0x407810F0 carries no seed of its own there either, so ours was never wrong about it. pspDecryptType5() already had a slot for both XORs; no new decryption logic was needed. Decryption is only half of it: these modules are KL4E-compressed rather than gzipped, so they also need Core/Util/KL4E.cpp, which is already there for the firmware modules that use the same compression. With both halves Shiren 4 Plus loads its one big .sprx and runs. God Eater 2 needed one further fix that isn't in this commit - the type-B relocation bug in ElfReader::LoadRelocations2, issue #8075 - and then plays. docs/pkg_notes.md has the container layout and the key derivation. Co-Authored-By: Claude Opus 5 --- Core/ELF/PrxDecrypter.cpp | 20 ++++++- Core/HLE/sceKernelModule.cpp | 43 ++++++++++++-- Core/HLE/scePspNpDrm_user.cpp | 48 +++++++++++++++ Core/HLE/scePspNpDrm_user.h | 12 ++++ docs/pkg_notes.md | 109 +++++++++++++++++++++++++--------- 5 files changed, 199 insertions(+), 33 deletions(-) diff --git a/Core/ELF/PrxDecrypter.cpp b/Core/ELF/PrxDecrypter.cpp index 2ae7b4633f..cf0e980e37 100644 --- a/Core/ELF/PrxDecrypter.cpp +++ b/Core/ELF/PrxDecrypter.cpp @@ -14,6 +14,20 @@ extern "C" #define ROUNDUP16(x) (((x)+15)&~15) +// PSP_Header::decrypt_mode, the byte at 0x7C. Only the one we act on is named; the rest select +// which decryption variant a real PSP would use, which we don't need since we try them in turn. +enum { + PRX_DECRYPT_MODE_SPRX = 23, +}; + +// A module that arrived inside an NPDRM EDAT carries this fixed XOR on top of its tag's key. It +// goes with the decrypt_mode above rather than with any particular tag - tag 0x407810F0's table +// entry has no seed of its own, in JPCSP's tables as well as ours, so keying this on the tag +// would be wrong for a 0x407810F0 module that arrived some other way. +static const u8 xor_91E0A9AD[16] = { + 0x84, 0x7B, 0xF5, 0xFE, 0xE8, 0x4D, 0xAD, 0x7A, 0xB5, 0x06, 0x28, 0x0E, 0x09, 0xFA, 0x81, 0xE1, +}; + // Thank you PSARDUMPER & JPCSP keys // PRXDecrypter 16-byte tag keys. @@ -966,9 +980,13 @@ static int pspDecryptType5(KirkState *kirk, const u8 *inbuf, u8 *outbuf, u32 siz // expand the seed into a xor buffer auto xorbuf = expandSeed(pti->key, pti->code, seed); + // The XOR the decrypt_mode implies wins over the tag table's, which is only the fallback - + // same precedence as JPCSP, and it leaves every tag that has a seed of its own alone. + const u8 *xor1 = inbuf[0x7C] == PRX_DECRYPT_MODE_SPRX ? xor_91E0A9AD : pti->seed; + // construct the header format for a type 2 prx PRXType5 type5(inbuf); - type5.decrypt(pti->code, pti->seed, seed); + type5.decrypt(pti->code, xor1, seed); SHA_CTX ctx; SHAInit(&ctx); diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index 8abad4708b..ae0ed6a032 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -48,6 +48,7 @@ #include "Core/ELF/ElfReader.h" #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" +#include "Core/HLE/scePspNpDrm_user.h" #include "Core/Util/KL4E.h" #include "Core/FileSystems/FileSystem.h" #include "Core/FileSystems/MetaFileSystem.h" @@ -1216,7 +1217,9 @@ static void LoadAndStartVshKernelModules() { } // filename is only used for dumping/metadata. -static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error) { +// prxSeed is the extra key a module that came out of an NPDRM container needs to decrypt - see +// NpDrmDeriveModuleKey(). Null for everything else, which is the overwhelming majority. +static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error, const u8 *prxSeed = nullptr) { // The magic reads below need four bytes, and the ~SCE branch another four after that. Everything // downstream checks its own sizes; this is just so we can look at the magic at all. The PBP path // in __KernelLoadModule computes elfSize from two offsets in the file and doesn't floor it. @@ -1289,7 +1292,7 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load newptr = new u8[maxElfSize]; elfSize = maxElfSize; ptr = newptr; - int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size); + int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size, prxSeed); // If decryption got us nowhere, the PRX may simply not be encrypted - in which case the ELF // starts right after the header. Check the source buffer, not the destination: on the paths // where decryption bails early nothing has been written to newptr yet, so this used to read @@ -2304,6 +2307,37 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) { return hleDelayResult(error, "module loaded", 500); } + // A .sprx installed by a PKG game update comes wrapped in an NPDRM "\0PSPEDAT" container: a + // 0x90-byte header naming the content ID, then the payload at the offset in its u16 at 0x0C. + // The payload is an ordinary ~PSP PRX, so stepping over the header is enough to get it to the + // decrypter - otherwise the ELF check sees the EDAT magic and the load fails with + // SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE. It doesn't decrypt with the tag's key alone though; + // the header also yields the seed it's really encrypted against. + // + // Data EDATs put a PGD at the payload offset instead (0x0003 rather than 0x0101 at 0x0E), and + // those aren't loaded as modules - they go through sceNpDrmEdataSetupKey and the io layer. + // + // Hardware only unwraps this for sceKernelLoadModuleNpDrm, but keying off the file's own magic + // costs nothing: an unwrapped module never has it. See docs/pkg_notes.md. + u8 prxSeed[16]; + bool havePrxSeed = false; + if (fileData.size() > 0x90 && !memcmp(fileData.data(), "\0PSPEDAT", 8)) { + const size_t payloadOffset = fileData[0x0C] | (fileData[0x0D] << 8); + if (payloadOffset >= 0x90 && payloadOffset < fileData.size()) { + havePrxSeed = NpDrmDeriveModuleKey(fileData.data(), prxSeed); + if (!havePrxSeed) { + // Not fatal on its own - a module that needs no seed decrypts without one, and one + // that does will fail below with the same error as any other undecryptable module. + WARN_LOG(Log::Loader, "Couldn't derive the NPDRM key for '%s'", name); + } + DEBUG_LOG(Log::Loader, "Unwrapping NPDRM module '%s' (%d bytes of EDAT header)", name, (int)payloadOffset); + fileData.erase(fileData.begin(), fileData.begin() + payloadOffset); + } else { + // Fall through - the magic check further down reports it like any other bad module. + WARN_LOG(Log::Loader, "'%s' has an EDAT header with a bad payload offset %d", name, (int)payloadOffset); + } + } + // We log before hand because ELF loading logs a bunch. DEBUG_LOG(Log::Loader, "sceKernelLoadModule(%s, %08x)", name, flags); @@ -2332,7 +2366,7 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) { u32 magic; u32 error; std::string error_string; - module = __KernelLoadELFFromPtr(fileData.data(), fileData.size(), 0, lmoption ? lmoption->position == PSP_SMEM_High : false, &error_string, &magic, name, error); + module = __KernelLoadELFFromPtr(fileData.data(), fileData.size(), 0, lmoption ? lmoption->position == PSP_SMEM_High : false, &error_string, &magic, name, error, havePrxSeed ? prxSeed : nullptr); if (!module) { if (magic == 0x46535000) { @@ -2368,7 +2402,8 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) { } static u32 sceKernelLoadModuleNpDrm(const char *name, u32 flags, u32 optionAddr) { - // Just forward it, same parameters so the logging will make sense. + // Just forward it, same parameters so the logging will make sense. The NPDRM EDAT wrapper these + // modules carry is stepped over in there, since that's keyed off the file's own magic. return sceKernelLoadModule(name, flags, optionAddr); } diff --git a/Core/HLE/scePspNpDrm_user.cpp b/Core/HLE/scePspNpDrm_user.cpp index c81acd7a51..37c35b9386 100644 --- a/Core/HLE/scePspNpDrm_user.cpp +++ b/Core/HLE/scePspNpDrm_user.cpp @@ -1,7 +1,16 @@ +// kirk_engine.h includes AES.h from outside its own extern "C" block, so whoever pulls it in first +// decides the linkage the AES_* functions get. Do it here, the way PrxDecrypter.cpp does, or they +// come through sceChnnlsv.h below with C++ linkage and fail to link. +extern "C" { +#include "ext/libkirk/kirk_engine.h" +#include "ext/libkirk/amctrl.h" +} + #include "Core/HLE/scePspNpDrm_user.h" #include "Core/MemMapHelpers.h" #include "Core/HLE/HLE.h" #include "Core/HLE/FunctionWrappers.h" +#include "Core/HLE/sceChnnlsv.h" #include "Core/HLE/sceIo.h" #include "Core/FileSystems/MetaFileSystem.h" @@ -132,6 +141,45 @@ static int sceNpDrmOpen() { return hleLogError(Log::sceIo, 0, "UNIMPL: sceNpDrmOpen()"); } +// The last pass over a module key. This one belongs to npdrm.prx's module path rather than to the +// PGD/amctrl side, so it isn't among the keys in ext/libkirk. +static const u8 drmModuleKey[16] = { + 0xBA, 0x87, 0xE4, 0xAB, 0x2C, 0x60, 0x5F, 0x59, 0xB8, 0x3B, 0xDB, 0xA6, 0x82, 0xFD, 0xAE, 0x14, +}; + +bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut) { + // The low byte of the u32 at 0x08 picks which fixed key to derive from the content ID at 0x10. + char contentId[0x31]{}; + memcpy(contentId, edatHeader + 0x10, 0x30); + const int keyMode = 0x01000000 | edatHeader[0x08]; + const int result = sceNpDrmGetFixedKey(__ChnnlsvKirkState(), keyOut, contentId, keyMode); + if (result != 0) { + return false; + } + + const u8 flags = edatHeader[0x0F]; + if (flags & 1) { + // The game is meant to hand the licensee key over before it loads the module. + if (!isLicenseeKeySet) { + return false; + } + for (int i = 0; i < PSP_NPDRM_LICENSEE_KEY_LENGTH; i++) { + keyOut[i] ^= licenseeKey[i]; + } + } + if (flags & 2) { + for (int i = 0; i < 16; i++) { + keyOut[i] ^= edatHeader[0x40 + i]; + } + } + + // JPCSP does this as CBC with an all-zero IV, which over a single block is a plain decrypt. + AES_ctx ctx; + AES_set_key(&ctx, drmModuleKey, 128); + AES_decrypt(&ctx, keyOut, keyOut); + return true; +} + const HLEFunction sceNpDrm[] = { {0XA1336091, &WrapI_U, "sceNpDrmSetLicenseeKey", 'i', "x"}, {0X9B745542, &WrapI_V, "sceNpDrmClearLicenseeKey", 'i', "" }, diff --git a/Core/HLE/scePspNpDrm_user.h b/Core/HLE/scePspNpDrm_user.h index 3e59c6aa39..35af832eee 100644 --- a/Core/HLE/scePspNpDrm_user.h +++ b/Core/HLE/scePspNpDrm_user.h @@ -1,5 +1,17 @@ #pragma once +#include "Common/CommonTypes.h" + class PointerWrap; void Register_sceNpDrm(); + +// A module wrapped in an NPDRM "\0PSPEDAT" container has its PRX encrypted against a key built +// from that container's header and, usually, the licensee key the game handed over through +// sceNpDrmSetLicenseeKey - which is why this lives here rather than with the PRX decrypter. +// Writes 16 bytes to keyOut, to be passed to pspDecryptPRX() as the seed. +// +// edatHeader must point at the 0x90 readable bytes of the container header. Returns false if the +// header asks for something we can't build, notably a licensee key the game never set. +// See docs/pkg_notes.md for the layout and where this came from. +bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut); diff --git a/docs/pkg_notes.md b/docs/pkg_notes.md index c41a2837d7..720e35ff08 100644 --- a/docs/pkg_notes.md +++ b/docs/pkg_notes.md @@ -115,8 +115,11 @@ encrypted - a decoder can tell what it is holding before looking at it: | 8 | PSP EDAT (`.sprx` modules) | `\0PSPEDAT` | | 11 | PBP | `\0PBP` | -The `\0PSPEDAT` files are the PGD-wrapped kind PPSSPP already decrypts at runtime, via -`sceNpDrmEdataSetupKey` in `Core/HLE/scePspNpDrm_user.cpp`. +Both flags 5 and 8 give you a `\0PSPEDAT`, but they are not the same thing inside. A flags-5 data +file wraps a PGD, which PPSSPP decrypts at runtime through `sceNpDrmEdataSetupKey()` in +`Core/HLE/scePspNpDrm_user.cpp`. A flags-8 `.sprx` wraps an encrypted PRX instead, and goes through +the module loader - see "NPDRM `.sprx` modules" below. The byte at 0x0E of the header tells them apart +(3 for the PGD kind, 1 for the PRX kind). ## What an update package contains @@ -253,43 +256,93 @@ because the dumps in circulation are later disc revisions than the updates were Refusing outright would make most of them unusable. Elminage Original was the one clean exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning. -### Known limitation: PGD-wrapped `.sprx` modules don't load +### NPDRM `.sprx` modules Package payloads are full of `\0PSPEDAT` files. That's fine for *data*: `sceNpDrmEdataSetupKey()` in `Core/HLE/scePspNpDrm_user.cpp` wraps an open file descriptor with the `0x04100002`/`0x04100001` ioctl pair, and the game reads plaintext. -A few packages ship `\0PSPEDAT` **executables** - `.sprx` modules - and that path is not -implemented. God Eater 2 (NPJH50832, 45 of them) installs cleanly, boots its `PBOOT.PBP`, and then -loops forever on: +A few packages wrap **executables** that way - `.sprx` modules the game loads with +`sceKernelLoadModuleNpDrm`. Those need more than the data path does. Until they were handled, God +Eater 2 (NPJH50832) installed cleanly, booted its `PBOOT.PBP`, and then looped forever failing to +load `system.sprx`; Shiren 4 Plus (NPJH50698), which keeps the whole game in one `.sprx` behind a +small loader, failed the same way. + +An NPDRM module is two layers, and the loader originally saw only the outer one: ``` -E Loader: SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE=sceKernelLoadModuleNpDrm( - ms0:/PSP/GAME/NPJH50832/system.sprx, 00000000, 00000000): failed to load -E sceModule: Wrong magic number 50535000 ++0x00 "\0PSPEDAT" header, 0x90 bytes + +0x08 u32 key mode; low byte is what sceNpDrmGetFixedKey takes (3 in all of these) + +0x0C u16 payload offset (0x90 in everything seen) + +0x0F u8 flag bits: 1 = XOR in the licensee key, 2 = XOR in the 16 bytes at 0x40 + +0x10 char[0x30] content ID, "JP0365-NPJH50698_00-SIREN4PLUS2012MA" ++0x90 a normal "~PSP" PRX: tag 0x407810F0 at 0xD0, decrypt_mode 23 at 0x7C ``` -Shiren 4 Plus (NPJH50698, one `.sprx` holding the whole game behind a 62 KB loader) fails -identically. Tales of the World Radiant Mythology 3 (NPJH50353, two) doesn't reach its modules -within 100 seconds of headless boot, so it gets past startup - it presumably hits the same wall -whenever it does load them. +`sceKernelLoadModule()` steps over the EDAT header, and the PRX inside then decrypts. Two keys go +into that, both worked out from JPCSP (`ModuleMgrForUser.sceKernelLoadModuleNpDrm`, `crypto/DRM.java`, +`crypto/PRX.java`, `crypto/KeyVault.java`), and both feed `pspDecryptType5()`, which already had a +slot for each. -`sceKernelLoadModuleNpDrm()` in `Core/HLE/sceKernelModule.cpp` is a one-line forward to -`sceKernelLoadModule()`, which sees something that isn't ELF magic and gives up. Fixing it means -unwrapping the EDAT before handing the bytes to the ELF loader, using the licensee key the game has -already set via `sceNpDrmSetLicenseeKey` - the same material the data path uses. +**xor2, the per-content key** - `NpDrmDeriveModuleKey()` in `Core/HLE/scePspNpDrm_user.cpp`, four +steps in order: -This is a pre-existing emulator gap rather than something the installer gets wrong: until now -nothing put such a file on the memory stick, so it was unreachable. The unpatched God Eater 2 never -calls `sceKernelLoadModuleNpDrm` at all - the update is what introduces the modules - so for that -title the update is currently better not installed, since the unpatched game boots. It's also why a -"God Eater 2 DLC Update v1.40 *Decrypted for PPSSPP Emulator*" folder circulates: the `PBOOT.PBP` in -it is byte-for-byte what `InstallPkg()` writes, and the only difference is that its `.sprx` files -have been unwrapped to plain ELF by hand. +1. `sceNpDrmGetFixedKey(kirk, key, edat+0x10, 0x01000000 | edat[0x08])` - already in + `ext/libkirk/amctrl.c`, and identical to JPCSP's `hleNpDrmGetFixedKey`: our `key_363C` is its + `drmFixedKey`, our `key_357C[0/1/2]` are its `drmEncKey1/2/3`, and its AES-CBC under an all-zero + IV over one block is our `AES_encrypt`. Nothing had called this function before. +2. If `edat[0x0F] & 1`: XOR the licensee key the game passed to `sceNpDrmSetLicenseeKey()`, which + this file already kept but never used. The game sets it before it loads the module. +3. If `edat[0x0F] & 2`: XOR the 16 bytes at `edat+0x40`. None seen here use it. +4. AES-128 decrypt the result under `drmModuleKey`, the one constant that had to be added here. + JPCSP does CBC with a zero IV; over a single block that is a plain `AES_decrypt`. + +**xor1, a static key picked by `decrypt_mode`** - the PRX header byte at 0x7C being 23 +(`DECRYPT_MODE_SPRX`), which is what these payloads are. JPCSP keys this on the mode rather than on +the tag, and so do we: tag 0x407810F0's table entry has no seed of its own in JPCSP's tables either, +so our table was never wrong, it just had nothing to say about a case selected somewhere else. +`pspDecryptType5()` takes the mode-derived XOR when the mode calls for it and falls back to the tag +table's otherwise - the same precedence JPCSP uses, which leaves every tag that does carry a seed +(the `pauth` ones) exactly as it was. + +No new decryption logic was needed. `pspDecryptType5()` is structurally identical to JPCSP's "new +method" for type 5: `expandSeed(pti->key, pti->code, seed)` XORs xor2 over the 0x90-byte scrambled +key buffer as `RoundXOR(buf2, 0, 0x90, xor2, null)` does; `PRXType5::decrypt()` XORs both over the +0x50-byte kirk header and SHA1 as `RoundXOR(buf2, 0x14, 0x50, xor1, xor2)` does, and then xor1 alone +over the 0x60 bytes at `id` as `RoundXOR(buf4, 0x14, 0x60, xor1, null)` does. JPCSP's `RoundXOR` is +`buf[i] ^= key[i & 0xF]`, the same repeating XOR as our `xor[i % 0x10]`. + +### The other half of it: KL4E + +Decrypting is only half the job. Shiren 4 Plus's `f5psp.sprx` decrypts to bytes that start with +`KL4E`: every one of these modules has `comp_attribute = 0x0201`, i.e. compressed, and +`(comp_attribute & 0xF00) != 0` means KL4E/KL3E rather than gzip. So decryption alone would just +move the failure from "unsupported PRX type" to "decompression failed". + +`Core/Util/KL4E.cpp` already handles that - it went in for firmware modules that use the same +compression, and these get it for free. It's worth knowing the two halves are independent, because +each one on its own leaves the module unloadable and the error doesn't say which is missing. + +Note that none of this can be checked offline against the hand-decrypted `.sprx` files that +circulate for God Eater 2: their flag byte says the licensee key is part of the derivation, and that +key only exists while the game is running. Those files are still useful as a cross-check of the +installer itself - the `PBOOT.PBP` in such a folder is byte-for-byte what `InstallPkg()` writes, and +the only difference is that the `.sprx` files beside it were decrypted by hand. + +### Where that leaves the three module titles + +- **God Eater 2 (NPJH50832)** installs, boots, loads its modules and plays. Decryption alone wasn't + enough for it: it also needed the type-B relocation fix in `ElfReader::LoadRelocations2` (issue + #8075), where two `lui`s sharing one `addiu` got different high halves, so a callback pointer + landed 0x48 bytes inside a function. +- **Shiren 4 Plus (NPJH50698)** loads `f5psp.sprx` - the log says `'FDS3PSP' is KL4E-compressed, + decompressing` - and runs. +- **Tales of the World Radiant Mythology 3 (NPJH50353)** still doesn't reach its modules inside a + headless boot, so it remains untested rather than known-good. ### Still not tested -- Nothing here plays past a title screen. The runs are 25-second headless boots, so "the update is - in use" means the patched executable is what loaded and ran - not that a patched *asset* was read. - LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no equivalent observation for a - digital one yet. +- Only God Eater 2 has been played past a title screen. For the rest the runs are short headless + boots, so "the update is in use" means the patched executable is what loaded and ran - not that a + patched *asset* was read. LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no + equivalent observation for a digital one yet. From f9bd5682db3e40f0dd05adcd23bc71ec00cd3408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 8 Sep 2026 10:39:06 -0600 Subject: [PATCH 6/6] libkirk: let C++ callers include its headers directly kirk_engine.h and amctrl.h guard their declarations, but AES.h and SHA1.h never did, and kirk_engine.h includes them from outside its own guard. So the AES_* and SHA1* functions got C++ linkage in any C++ file that reached them through there, and only linked for callers that happened to wrap the whole header in an extern "C" of their own. Nothing had called AES_* from C++ before, so it stayed hidden until something did. Guarding the two headers instead lets every caller include them plainly, and the wrappers scattered around the tree come out. Both are pure declarations over kirk_common.h's typedefs with no system headers behind them, so there's nothing in there that shouldn't be wrapped. kirk_engine.h also uses size_t without including anything that defines it, which only held together because its includers happened to have it already. Co-Authored-By: Claude Opus 5 --- Core/ELF/PrxDecrypter.cpp | 3 --- Core/FileSystems/BlockDevices.cpp | 3 --- Core/HLE/sceIo.cpp | 2 -- Core/HLE/sceKernelSemaphore.h | 3 --- Core/HLE/scePspNpDrm_user.cpp | 7 +------ Core/Util/PSARUnpack.cpp | 2 -- Core/Util/PkgUnpack.cpp | 2 -- ext/libkirk/AES.h | 8 ++++++++ ext/libkirk/SHA1.h | 8 ++++++++ ext/libkirk/kirk_engine.h | 5 +++++ 10 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Core/ELF/PrxDecrypter.cpp b/Core/ELF/PrxDecrypter.cpp index cf0e980e37..a3b8fe46c4 100644 --- a/Core/ELF/PrxDecrypter.cpp +++ b/Core/ELF/PrxDecrypter.cpp @@ -2,11 +2,8 @@ #include #include -extern "C" -{ #include "ext/libkirk/kirk_engine.h" #include "ext/libkirk/SHA1.h" -} #include "Common/Common.h" #include "Common/Log.h" #include "Common/Swap.h" diff --git a/Core/FileSystems/BlockDevices.cpp b/Core/FileSystems/BlockDevices.cpp index e6eb46554a..d74639a56c 100644 --- a/Core/FileSystems/BlockDevices.cpp +++ b/Core/FileSystems/BlockDevices.cpp @@ -35,12 +35,9 @@ #include "Core/Util/PathUtil.h" #include "libchdr/chd.h" -extern "C" -{ #include "zlib.h" #include "ext/libkirk/amctrl.h" #include "ext/libkirk/kirk_engine.h" -}; static u16 ReadLE16(const u8 *ptr) { return ptr[0] | (ptr[1] << 8); diff --git a/Core/HLE/sceIo.cpp b/Core/HLE/sceIo.cpp index 293b388ca0..bb275b111a 100644 --- a/Core/HLE/sceIo.cpp +++ b/Core/HLE/sceIo.cpp @@ -58,9 +58,7 @@ #include "Core/FileSystems/ISOFileSystem.h" #include "Core/FileSystems/DirectoryFileSystem.h" -extern "C" { #include "ext/libkirk/amctrl.h" -}; #include "Core/HLE/sceIo.h" #include "Core/HLE/sceRtc.h" diff --git a/Core/HLE/sceKernelSemaphore.h b/Core/HLE/sceKernelSemaphore.h index 74002ff9d6..410fbe2f17 100644 --- a/Core/HLE/sceKernelSemaphore.h +++ b/Core/HLE/sceKernelSemaphore.h @@ -80,7 +80,4 @@ void __KernelSemaInit(); void __KernelSemaDoState(PointerWrap &p); KernelObject *__KernelSemaphoreObject(); -extern "C" -{ #include "ext/libkirk/kirk_engine.h" -} diff --git a/Core/HLE/scePspNpDrm_user.cpp b/Core/HLE/scePspNpDrm_user.cpp index 37c35b9386..6ef44d4ebf 100644 --- a/Core/HLE/scePspNpDrm_user.cpp +++ b/Core/HLE/scePspNpDrm_user.cpp @@ -1,10 +1,5 @@ -// kirk_engine.h includes AES.h from outside its own extern "C" block, so whoever pulls it in first -// decides the linkage the AES_* functions get. Do it here, the way PrxDecrypter.cpp does, or they -// come through sceChnnlsv.h below with C++ linkage and fail to link. -extern "C" { -#include "ext/libkirk/kirk_engine.h" +#include "ext/libkirk/AES.h" #include "ext/libkirk/amctrl.h" -} #include "Core/HLE/scePspNpDrm_user.h" #include "Core/MemMapHelpers.h" diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index e7aff71097..a0880bc6fa 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -37,9 +37,7 @@ #include "Core/System.h" #include "Core/Util/PSARUnpack.h" -extern "C" { #include "ext/libkirk/kirk_engine.h" -} // A PSAR record is [header][entry], where the header is 0x150 bytes of PRX-style encryption // metadata and the entry is 0x110 bytes describing one file. Pre-decrypted archives (rare, and diff --git a/Core/Util/PkgUnpack.cpp b/Core/Util/PkgUnpack.cpp index 105d52f4e6..48d03a9a66 100644 --- a/Core/Util/PkgUnpack.cpp +++ b/Core/Util/PkgUnpack.cpp @@ -31,9 +31,7 @@ #include "Core/System.h" #include "Core/Util/PkgUnpack.h" -extern "C" { #include "ext/libkirk/AES.h" -} // See docs/pkg_notes.md. Field offsets in the 0xC0-byte header: static const u32 PKG_MAGIC = 0x7F504B47; // "\x7FPKG" diff --git a/ext/libkirk/AES.h b/ext/libkirk/AES.h index ee61f46490..08e2260c99 100644 --- a/ext/libkirk/AES.h +++ b/ext/libkirk/AES.h @@ -3,6 +3,10 @@ #include "kirk_common.h" +#ifdef __cplusplus +extern "C" { +#endif + #define AES_KEY_LEN_128 (128) #define AES_KEY_LEN_192 (192) #define AES_KEY_LEN_256 (256) @@ -48,4 +52,8 @@ int rijndaelKeySetupEnc(u32 [], const u8 [], int); int rijndaelKeySetupDec(u32 [], const u8 [], int); void rijndaelEncrypt(const u32 [], int, const u8 pt[16], u8 ct[16]); +#ifdef __cplusplus +} +#endif + #endif /* __RIJNDAEL_H */ diff --git a/ext/libkirk/SHA1.h b/ext/libkirk/SHA1.h index 43493e3319..2735c3faf2 100644 --- a/ext/libkirk/SHA1.h +++ b/ext/libkirk/SHA1.h @@ -2,6 +2,10 @@ #include "kirk_common.h" +#ifdef __cplusplus +extern "C" { +#endif + /* POINTER defines a generic pointer type */ typedef unsigned char *POINTER; typedef const unsigned char *CONST_POINTER; @@ -34,3 +38,7 @@ void SHAUpdate(SHA_CTX *, const BYTE *buffer, int count); void SHAFinal(BYTE *output, SHA_CTX *); void endianTest(int *endianness); + +#ifdef __cplusplus +} +#endif diff --git a/ext/libkirk/kirk_engine.h b/ext/libkirk/kirk_engine.h index de90c4d48e..153e8c0b34 100644 --- a/ext/libkirk/kirk_engine.h +++ b/ext/libkirk/kirk_engine.h @@ -26,6 +26,11 @@ #pragma once +// kirk4()/kirk7() below take a size_t, and nothing here provided it - the header only ever +// compiled because whatever included it had pulled in a definition first. Core/HLE/scePspNpDrm_user.cpp +// includes libkirk before anything else, so it doesn't. +#include + #include "kirk_common.h" #include "SHA1.h" #include "AES.h"