Files
ppsspp/docs/pkg_notes.md
T
Henrik RydgårdandClaude Opus 5 19723f59eb 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 <noreply@anthropic.com>
2026-09-08 11:15:15 -06:00

349 lines
18 KiB
Markdown

# The PKG package format (PSP game updates)
A `.pkg` is the NPDRM container Sony distributed downloadable content in - full PSN games, DLC,
themes, and the thing these notes are about: **game updates**. An update package holds a patched
EBOOT (`PBOOT.PBP`) plus whatever data files the patch replaces, and installing it drops them in
`ms0:/PSP/GAME/<DISC_ID>/`. The patched EBOOT then runs with the original UMD (or the original
PSN game) still supplying everything it doesn't override.
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: <https://www.psdevwiki.com/ps3/PKG_files>, 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` |
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
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=<ms> \
--mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \
<ms>/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.
## How PPSSPP handles them
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
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.
- **`GameScreen`** shows an installed update in the info pane, and offers "Delete Game Update" in
its context menu. Deleting takes the whole `PSP/GAME/<DISC_ID>` 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/<DISC_ID>/PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN`
if it's there, leaving the disc mounted.
`PPSSPPHeadless --install-pkg=DIR <file.pkg>` 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/<DISC_ID>` itself).
### Install layout
The package's own PS3-style wrapping is stripped: `USRDIR/CONTENT/<x>` and `USRDIR/<x>` both become
`<x>` 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.
### 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 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:
```
+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
```
`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.
**xor2, the per-content key** - `NpDrmDeriveModuleKey()` in `Core/HLE/scePspNpDrm_user.cpp`, four
steps in order:
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
- 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.