Merge pull request #22124 from hrydgard/psar-unpack

PSAR file format support: Automatic unpacking of firmware updates
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-08-21 10:31:58 +02:00
commit e64ed141b3
16 files changed
+1488 -2

No files matched your search

+8
View File
@@ -169,6 +169,8 @@ codebase first - PPSSPP already has implementations of many formats (CSO, LZRC,
handlers, PBP, SevenZip, etc.), possibly in several places. Reuse or extend an existing one instead of
writing a new one (e.g. there is an LZRC decompressor in Core/FileSystems/tlzrc.cpp).
For string sanitation, we already have SanitizeString in StringUtils.cpp - add new modes if needed.
## Headless and unittest builds
We have additional PPSSPPHeadless and unit test builds (/headless and /unittest), that have their own separate
@@ -537,6 +539,12 @@ Concretely, as measured against the headless build (2026-08-16), per CPU backend
| `memory.breakpoint.*` (memchecks) | works | **only for constant addresses** - see below |
| `cpu.regBreakpoint.*` | works | never trips (as documented) |
## Commit message style
Keep commit messages focused, not overly long (although sometimes it's motivated if a single commit
is super complex). Do not report things like 100/100 tests passed - that's a given, if tests break
you aren't supposed to make a commit.
## Quick rebuild on Linux
You don't need to do ./b.sh --debug to verify every single little change, instead use this shortcut:
+2
View File
@@ -705,6 +705,8 @@ add_library(Core STATIC
Util/BlockAllocator.h
Util/PPGeDraw.cpp
Util/PPGeDraw.h
Util/PSARUnpack.cpp
Util/PSARUnpack.h
Util/RecentFiles.cpp
Util/RecentFiles.h
${CMAKE_SOURCE_DIR}/ext/disarm.cpp
+2
View File
@@ -192,6 +192,8 @@ static const CommandLineParam g_autoParams[] = {
{POFF(timeout), CmdParamType::Double, "timeout", '\0', "Set the timeout value", CmdLineMode::Headless},
{POFF(maxScreenshotError), CmdParamType::Double, "max-mse", '\0', "Maximum allowed MSE error for screenshot comparison", CmdLineMode::Headless},
{POFF(mountIso), CmdParamType::String, "mount", 'm', "Mount ISO/CSO on umd1:", CmdLineMode::Headless},
{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(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"},
+9
View File
@@ -64,6 +64,15 @@ struct CommandLineOptions {
std::optional<std::string> memStick;
std::optional<std::string> stateToLoad;
// Headless: unpack the firmware inside an official updater EBOOT.PBP (given as the boot
// filename) into this directory, then exit without booting anything.
// See Core/Util/PSARUnpack.h - the API can also filter to a subfolder, which the command line
// deliberately doesn't expose since the in-app use is specifically flash0:/font.
std::optional<std::string> unpackUpdater;
// Headless: which PSP model the unpacker resolves names against - "01g".."12g", or "any"
// (the default) to take whatever file list names each file first.
std::optional<std::string> unpackUpdaterModel;
std::optional<int> memReadAction;
std::optional<int> memWriteAction;
std::optional<int> breakAction;
+2
View File
@@ -917,6 +917,7 @@
<ClCompile Include="Util\PathUtil.cpp" />
<ClCompile Include="Util\PortManager.cpp" />
<ClCompile Include="Util\PPGeDraw.cpp" />
<ClCompile Include="Util\PSARUnpack.cpp" />
<ClCompile Include="..\ext\xxhash.c">
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">MaxSpeed</Optimization>
<IntrinsicFunctions Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</IntrinsicFunctions>
@@ -1280,6 +1281,7 @@
<ClInclude Include="Util\PathUtil.h" />
<ClInclude Include="Util\PortManager.h" />
<ClInclude Include="Util\PPGeDraw.h" />
<ClInclude Include="Util\PSARUnpack.h" />
<ClInclude Include="..\ext\xxhash.h" />
<ClInclude Include="Util\RecentFiles.h" />
<ClInclude Include="Util\VideoPlayer.h" />
+6
View File
@@ -363,6 +363,9 @@
<ClCompile Include="Util\PPGeDraw.cpp">
<Filter>Util</Filter>
</ClCompile>
<ClCompile Include="Util\PSARUnpack.cpp">
<Filter>Util</Filter>
</ClCompile>
<ClCompile Include="HLE\sceFont.cpp">
<Filter>HLE\Libraries</Filter>
</ClCompile>
@@ -1674,6 +1677,9 @@
<ClInclude Include="Util\PPGeDraw.h">
<Filter>Util</Filter>
</ClInclude>
<ClInclude Include="Util\PSARUnpack.h">
<Filter>Util</Filter>
</ClInclude>
<ClInclude Include="HLE\sceFont.h">
<Filter>HLE\Libraries</Filter>
</ClInclude>
+19 -1
View File
@@ -284,12 +284,24 @@ static const u32 g_key_INDEXDAT1xx[] = {
0xA34D8C80, 0x962B235D, 0x3E420548, 0x09CF9FFE, 0xD4883F5C, 0xD90E9CB5,
0x00AEF4E9, 0xF0886DE9, 0x62A58A5B, 0x52A55546, 0x971941B5, 0xF5B79FAC};
// The blocks inside an official updater's DATA.PSAR carry this tag. See Core/Util/PSARUnpack.cpp.
static const u32 g_keyUPDATER_PSAR[] = {
0x77B757DE, 0xEE62DD17, 0x5D03787B, 0x59CA8644, 0x93F68D20, 0x21819328,
0x86A74E71, 0x1B2482CA, 0x5F74AE58, 0x568D016C, 0x9A4D8832, 0x2EA24372,
0x820CF484, 0xFCFC06B9, 0x8A5BFB6A, 0xBF9F9CD7, 0x15850D01, 0x39ED5FBA,
0x4CC38393, 0xED3ADEAF, 0x1AA768BF, 0x89BD8A77, 0x46564165, 0x7333DBD9,
0x62E86C81, 0x03299B96, 0x73AFAE5A, 0x40A05320, 0x10664BE8, 0xE5B76A99,
0x29E0DD70, 0xEA602428, 0x2042AE30, 0x946F8D32, 0xA29E5F71, 0x7C0C7FD5};
struct TAG_INFO
{
u32 tag; // 4 byte value at offset 0xD0 in the PRX file
const u32 *key; // "step1_result" use for XOR step
u8 code;
u8 codeExtra;
// Most keys here are stored already scrambled, so the kirk7 pass the hardware would do is
// skipped. Set this for a key that's stored raw and needs that pass applied first.
bool scrambleKey;
};
static const TAG_INFO g_tagInfo[] =
@@ -310,7 +322,8 @@ static const TAG_INFO g_tagInfo[] =
{ 0x862648D1, g_keyMEIMG260, 0x52, 0x52 },
{ 0x207BBF2F, g_keyUNK1, 0x5A, 0x5A },
{ 0x09000000, g_key_GAMESHARE1xx, 0x4C },
{ 0xBB67C59F, g_key_GAMESHARE2xx, 0x5E, 0x5E }
{ 0xBB67C59F, g_key_GAMESHARE2xx, 0x5E, 0x5E },
{ 0x0E000000, g_keyUPDATER_PSAR, 0x51, 0x00, true }
};
static const TAG_INFO *GetTagInfo(u32 tagFind)
@@ -726,6 +739,11 @@ static int pspDecryptType0(KirkState *kirk, const u8 *inbuf, u8 *outbuf, u32 siz
// normally this would be a kirk7 op, but we have the seed pre-decrypted
std::array<u8, 0x90> xorbuf;
memcpy(xorbuf.data(), reinterpret_cast<const u8 *>(pti->key), xorbuf.size());
if (pti->scrambleKey)
{
// ...except for keys we have in their raw form, which need that pass after all.
kirk7(xorbuf.data(), xorbuf.data(), xorbuf.size(), pti->code);
}
// construct the header format for a type 0 prx
PRXType0 type0(inbuf);
+4 -1
View File
@@ -221,7 +221,10 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin
return IdentifiedFileType::ARCHIVE_7Z;
}
if (id == 'FLE\x7F') {
// "~PSP" is an encrypted PRX. The module loader decrypts those on the way in, so as far as
// identification goes it's the same thing as a plain ELF - which is how the modules in a
// firmware unpacked straight from an updater arrive (see Core/Util/PSARUnpack.cpp).
if (id == 'FLE\x7F' || id == 'PSP~') {
Path filename = fileLoader->GetPath();
// There are a few elfs misnamed as pbp (like Trig Wars), accept that. Also accept extension-less paths.
if (extension == ".plf" || strstr(filename.GetFilename().c_str(), "BOOT.BIN") ||
File diff suppressed because it is too large. Load diff
+120
View File
@@ -0,0 +1,120 @@
// 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 <string>
#include <vector>
#include "Common/CommonTypes.h"
class Path;
// Unpacks the firmware image inside an official PSP updater (PSP/GAME/UPDATE/EBOOT.PBP).
//
// The updater's DATA.PSAR is a flat sequence of encrypted, compressed file records. The real
// updater installs them by executing on the PSP, but the archive can be walked directly - each
// record carries its own name, sizes and a standard PRX-style encryption header, so all it needs
// is the KIRK engine and the PRX decrypter we already have.
//
// The main use is pulling flash0:/font out of an updater the user supplies, which is why a
// prefix filter is part of the API rather than something the caller filters afterwards.
enum class PSARCompression {
None,
Zlib,
KL4E,
KL3E,
LZR,
Unknown,
};
const char *PSARCompressionToString(PSARCompression c);
// PSP hardware revisions, as the updater numbers them. An updater carries one file list per
// model, so which one you resolve names against decides both what a file is called and whether
// it's part of that model's firmware at all.
enum class PSPModelGeneration {
Any = 0, // Whichever list names a file first. Use this to extract everything.
PSP_1000 = 1,
PSP_2000 = 2,
PSP_3000 = 3,
PSP_4000 = 4,
PSP_N1000 = 5, // PSP Go
PSP_6000 = 6,
PSP_7000 = 7,
PSP_9000 = 9,
PSP_11000 = 11,
MAX = 12,
};
const char *PSPModelGenerationToString(PSPModelGeneration generation);
// Accepts "01g".."12g", a bare number, or "any". Returns false if it's none of those.
bool PSPModelGenerationFromString(std::string_view name, PSPModelGeneration *generation);
struct PSARUnpackOptions {
// Only unpack entries whose name starts with this, e.g. "flash0:/font/". Case insensitive.
// Empty means everything. Entries whose real name we can't recover are never matched by a
// non-empty filter.
std::string prefixFilter;
// Which model's file list to resolve names against. Anything that model's list doesn't name
// isn't part of its firmware, and is skipped.
PSPModelGeneration model = PSPModelGeneration::Any;
// Walk and report, but don't write any files.
bool listOnly = false;
// Log a line per entry. Off by default - an updater holds well over a thousand of them.
bool verbose = false;
};
struct PSARUnpackStats {
std::string firmwareVersion;
int entries = 0;
int directories = 0;
int written = 0;
int skippedByFilter = 0;
int nameTables = 0; // Entries that were file lists rather than files.
int unnamed = 0; // Short names no file list claimed.
int otherModel = 0; // Files that belong to a model other than the requested one.
int failed = 0;
// How many entries used each compression, indexed by PSARCompression.
int compressionCounts[6]{};
};
// psar points at the DATA.PSAR contents, starting with the "PSAR" magic.
bool UnpackPSAR(const u8 *psar, size_t psarSize, const Path &outputDir, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error);
// Finds the firmware image in whatever an updater arrives as and unpacks it:
// - a downloaded updater EBOOT.PBP, where the archive is its DATA.PSAR
// - a disc updater's DATA.BIN, which is the same archive without the PBP wrapper
// - a game ISO/CSO/CHD, which is searched for PSP_GAME/SYSDIR/UPDATE/DATA.BIN
// The last one is the interesting case: most UMDs carry a firmware updater, so the fonts can come
// from whatever game the user already has rather than a separate download.
bool UnpackUpdater(const Path &filename, const Path &outputDir, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error);
// The version string an updater advertises ("6.61"), read from the PARAM.SFO next to it - no
// decryption needed, so it's cheap enough to check every disc with. Empty if there's no updater.
std::string ReadUpdaterVersion(const Path &filename);
// The same three, for the disc mounted as disc0: - i.e. the game that's running. These read
// through the mounted filesystem instead of opening the image a second time, which also means
// they work for the shapes that aren't an image at all, like a folder-based "disc".
//
// This is the path the font extraction is meant to take: while a game is running, ask whether its
// disc carries an updater, and if so unpack just flash0:/font out of it.
bool MountedDiscHasUpdater();
std::string ReadMountedDiscUpdaterVersion();
bool UnpackUpdaterFromMountedDisc(const Path &outputDir, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error);
+2
View File
@@ -332,6 +332,7 @@
<ClInclude Include="..\..\Core\Util\DisArm64.h" />
<ClInclude Include="..\..\Core\Util\GameManager.h" />
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
<ClInclude Include="..\..\Core\WaveFile.h" />
<ClInclude Include="..\..\ext\cityhash\city.h" />
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
@@ -646,6 +647,7 @@
<ClCompile Include="..\..\Core\Util\DisArm64.cpp" />
<ClCompile Include="..\..\Core\Util\GameManager.cpp" />
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
<ClCompile Include="..\..\Core\WaveFile.cpp" />
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
<ClCompile Include="..\..\ext\disarm.cpp" />
+2
View File
@@ -277,6 +277,7 @@
<ClCompile Include="..\..\Core\Util\DisArm64.cpp" />
<ClCompile Include="..\..\Core\Util\GameManager.cpp" />
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
<ClCompile Include="..\..\Core\WaveFile.cpp" />
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
<ClCompile Include="..\..\ext\disarm.cpp" />
@@ -662,6 +663,7 @@
<ClInclude Include="..\..\Core\Util\DisArm64.h" />
<ClInclude Include="..\..\Core\Util\GameManager.h" />
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
<ClInclude Include="..\..\Core\WaveFile.h" />
<ClInclude Include="..\..\ext\cityhash\city.h" />
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
+1
View File
@@ -805,6 +805,7 @@ EXEC_AND_LIB_FILES := \
$(SRC)/Core/Util/GameManager.cpp \
$(SRC)/Core/Util/BlockAllocator.cpp \
$(SRC)/Core/Util/PPGeDraw.cpp \
$(SRC)/Core/Util/PSARUnpack.cpp \
$(SRC)/Core/Util/RecentFiles.cpp \
$(SRC)/Core/Util/VideoPlayer.cpp \
$(SRC)/git-version.cpp
+222
View File
@@ -0,0 +1,222 @@
# The PSAR archive format (PSP firmware updaters)
An official PSP firmware updater carries the whole firmware image in a PSAR archive. PPSSPP can
walk one and extract the files out of it without emulating the updater, which is what
`Core/Util/PSARUnpack.cpp` does. The main use is getting `flash0:/font` - the PGF system fonts -
out of an updater the user already has, since most UMDs carry one.
This document is what was learned reading 43 different firmware versions, from 1.50 to 6.61. It's
a description of the format rather than of the code; the code is the same shape but has the error
handling.
## Where an updater lives
The archive turns up in three shapes, all of them the same bytes once you find them:
| Shape | Where | Notes |
| --- | --- | --- |
| Downloaded updater | `EBOOT.PBP`, subfile 7 (`DATA.PSAR`) | The one you get from Sony's site |
| Disc updater | `PSP_GAME/SYSDIR/UPDATE/DATA.BIN` | Not wrapped in a PBP - the bare archive |
| Loose | any file starting with `PSAR` | Whatever someone already pulled out |
A disc updater directory also holds `EBOOT.BIN` (the updater executable, a bare `~PSP` PRX, of no
use to us) and `PARAM.SFO`. That SFO is worth knowing about: its `TITLE` reads
`"PSP™ Update ver 3.95"`, so **the firmware version can be read without decrypting anything**,
which is cheap enough to check a whole game library with. `DISC_ID` is `MSTKUPDATE` and `CATEGORY`
is `MG`.
Of 589 discs scanned across two libraries, 473 carried an updater.
## Archive header
```
+0x00 u32 "PSAR" magic (0x52415350)
+0x04 u8 version. 1 = "oldschool" (see below), 2 and 3 are the common ones
+0x08 u32 total length of the records
+0x20 u32 0x2C333333 if this archive is already decrypted, otherwise part of the ciphertext
```
The length at +0x08 matters: archives have a few bytes of padding after the last record (16 and 32
bytes in the two checked), and a walk that goes by file size instead will try to decode that
padding as a record and report a spurious failure at the very end.
The marker at +0x20 only appears in archives some other tool has already decrypted. In an original
updater those bytes are ciphertext and will not match.
## Records
The archive is a flat sequence of records starting at +0x10. There is no index - you find entry
*n+1* by decoding entry *n* and adding up its sizes. Each record is:
```
[ 0x150 bytes encryption header ] <- absent if the archive is pre-decrypted
[ 0x110 bytes entry description ]
[ variable file contents ] <- has its own 0x150 header, same as above
```
Call the header size *overhead*: 0x150 normally, 0 for a pre-decrypted archive.
### Decoding a block
Every encrypted block - entry descriptions and file contents alike - is decoded the same way:
1. **Demangle.** The 0x130 bytes at block+0x20 are AES-128-CBC encrypted on top of everything
else. Decrypt them with KIRK command 7 (`KIRK_CMD_DECRYPT_IV_0`), key seed **0x55**, IV zero,
writing the result back over block+0x20. This step is skipped for version 1 archives.
The point of it is that it hides the PRX tag: you cannot tell how to decrypt the block until
you have done this.
2. **PRX decrypt.** The block is now an ordinary PRX blob, with its tag at block+0xD0. Run it
through the normal PRX decrypter (`pspDecryptPRX`). The tag official updaters use is
**0x0E000000**; `0x06000000` also appears in the wild.
Note that `0x0E000000`'s key is stored *unscrambled*, unlike almost every other key in
PPSSPP's tag table, so it needs the `kirk7` pass applied before use - that's what the
`scrambleKey` flag on `TAG_INFO` is for.
One wrinkle worth knowing: the decrypter reads a little past the end of the block and **uses what
it finds there**, so a decoder should hand it about 16 bytes of slack from the archive. The last
record ends flush with the end of the archive and has no slack to give; zero-filling it there is
fine, but zero-filling it everywhere breaks every archive that does have those bytes.
### Entry description
0x110 bytes once decoded:
```
+0x004 char[] entry name, NUL padded (see "Naming" below)
+0x100 u32 always zero. If it isn't, you've lost your place in the archive
+0x104 u32 size of the contents block that follows, including its 0x150 header
+0x108 u32 uncompressed size of the file. 0 means this entry is a directory
```
A directory entry still has a contents block to skip over - advance by `+0x104` either way.
### The first two records
The first record (at +0x10) is not a file. Decoded, it holds a text string at +0x10 whose last
comma-separated field is the firmware version, e.g. `...,6.61`. That's the authoritative version
for choosing decryption keys further down.
There is a second record after it whose length isn't recorded anywhere. In practice it is
`overhead + 100`, or `overhead + 144` on 2.7x, or `overhead + u16 at +0x90 of the first record`.
Try them in that order and take the first that decodes. Round the result up to a multiple of 16
before advancing. Version 1 archives don't have this record at all.
## Contents
The contents block decodes to a compressed stream. Sniff the first bytes:
| Magic | Format |
| --- | --- |
| `78 9C` | zlib |
| `KL4E` | KL4E - Sony's own, not implemented in PPSSPP |
| `KL3E` | KL3E - likewise |
| `2RLZ` | LZR |
Across all 43 firmware versions examined, **every file in every archive was zlib**. KL4E does turn
up on the PSP, but a layer further in: two kernel modules (`memlmd_01g.prx` and `loadexec_01g.prx`)
are themselves KL4E-compressed *inside* their own PRX, which is a separate problem from this one.
## Naming
This is the part that changed most across firmware generations, and the part most likely to catch
out an implementation tested against only one updater.
### 1.x and 2.x - real paths
Entries are named outright: `flash0:/vsh/resource/opening_plugin.rco`, `flash1:/registry`,
`ipl:/psp_nandipl.bin`. Nothing else is needed. Note `ipl:` - it is not enough to recognise
`flash0:` and `flash1:`; better to treat any `<device>:/` shape as a real path.
### 3.x - grouped short names
Entries are named `<group>:<5 digits>`, where the group is `com` for files every model gets, or
`01g`, `02g`, ... for one model's. `<group>:00000` is that group's **file list**, and the rest of
that group's entries are looked up in it by their number alone (entry `com:00004` is key `00004`).
### 5.x and 6.x - flat short names with per-model lists
Entries are a bare 5-digit number. The low numbers are file lists, one per PSP model
(`00001` = 01g, `00002` = 02g, ...) and the rest are files that any of the lists may name.
**Which numbers are lists is not fixed.** 6.61 uses 1-11; 6.00 has real files at `00010`-`00012`.
Since a list always decrypts successfully (the PRX layer inside validates a hash), the robust rule
is: try to decrypt a candidate as a list, and if that fails, treat it as an ordinary file.
### File lists
A list decodes to plain text, one `shortname<separator>path` per line. Both the separator and the
path style depend on the generation:
| Generation | Separator | Path written as |
| --- | --- | --- |
| 3.x | `\|` | `flash0/font/ltn0.pgf` |
| 5.x, 6.x | `,` | `flash0:/font/ltn0.pgf` |
Worth normalising to one form early - otherwise a filter like `flash0:/font/` silently matches
nothing on a 3.x archive.
### List encryption
The lists are encrypted on top of everything else, with **DES** - the tables in the reference
implementations are DES's own: a 56-entry PC-1, a 48-entry PC-2, eight 64-entry 4-bit S-boxes, a
32-entry P permutation, and textbook IP/FP bit-shuffling constants.
It is DES-CBC decrypt, with the key assembled from two 32-bit words as `(high << 32) | low` in
big-endian byte order, and an IV alongside them. There is one such set per firmware series, and
which one applies is chosen from the version string in the archive's first record:
| Firmware | Key set |
| --- | --- |
| 1.x, 2.x, 3.0 - 3.7 | 0 |
| 3.8, 3.9 | 1 |
| 4.x | 2 |
| 5.x | 3 |
| 6.x | 4 |
A sixth set exists that nothing selects by version. The values themselves are in `kTableKeys` in
`Core/Util/PSARUnpack.cpp` and aren't repeated here.
Underneath the DES layer is an ordinary PRX blob, so run the result through `pspDecryptPRX` as
well; what comes out of *that* is the text.
A good check while implementing: after the DES pass but before the PRX pass, the u32 at +0xD0
should be a recognisable PRX tag. A wrong key gives a random value there, so that single word
tells you whether the cipher is right before anything else has to work.
## What the archives look like in practice
Three structural eras, visible in the shape of the output:
| Firmware | Naming | Directory entries | Fonts |
| --- | --- | --- | --- |
| 1.50 - 3.52 | real paths | 0-16 | 17-20 |
| 3.71 - 4.05 | `com:`/`NNg:` groups | 50 | 21 |
| 5.01 - 6.61 | flat + per-model lists | 16-17 | 21 |
A 6.61 archive holds 435 records: 411 files, 17 directories and 7 file lists. Among the files are
295 `~PSP` modules, 61 PRF files, 7 encrypted XMB indices (`PSPsysGP`), and 21 files in
`flash0:/font` - 19 of them PGF, plus `gb3s1518.bwfon` and `imagefont.bin`.
The "Fonts" column above counts files in `flash0:/font`, not PGFs specifically.
## Gotchas, collected
- Walk by the length at +0x08, not the file size, or the trailing padding decodes as a bad record.
- Stop when fewer than `overhead + 0x110` bytes remain rather than when the position passes the
end - the two differ by exactly one spurious record.
- Give the block decrypter its 16 bytes of slack, but don't require them for the last record.
- Don't assume which entry numbers are file lists; find out by trying.
- Don't assume there are file lists at all - 1.x and 2.x have none.
- `ipl:` is a device too.
- A 3.x path has no colon; a 6.x path does.
## See also
- `Core/Util/PSARUnpack.{cpp,h}` - the implementation, and the only consumer of most of this.
- `Core/ELF/PrxDecrypter.cpp` - the PRX layer every block goes through.
- `ext/libkirk` - `kirk7()` is the demangle step.
- `PPSSPPHeadless --unpack-updater=DIR <updater|disc|PSAR>` unpacks one from the command line;
`--unpack-updater-model=01g..12g` picks a model.
+35
View File
@@ -57,6 +57,7 @@
#include "Core/EmuThread.h"
#include "Core/MIPS/MIPSTables.h"
#include "Core/System.h"
#include "Core/Util/PSARUnpack.h"
#include "Core/WebServer.h"
#include "Core/HLE/sceUtility.h"
#include "Core/SaveState.h"
@@ -612,6 +613,40 @@ int main(int argc, const char* argv[]) {
g_logManager.EnableOutput(LogOutput::Printf);
}
// Unpacking an updater is a standalone action - no emulation involved, so do it here and exit
// before any of the setup below.
if (cmdLineOptions.unpackUpdater.has_value()) {
if (cmdLineOptions.bootFilenames.size() != 1) {
fprintf(stderr, "--unpack-updater takes exactly one updater, disc image or PSAR\n");
return 1;
}
PSARUnpackOptions unpackOptions;
unpackOptions.verbose = testOptions.verbose;
if (cmdLineOptions.unpackUpdaterModel.has_value() &&
!PSPModelGenerationFromString(cmdLineOptions.unpackUpdaterModel.value(), &unpackOptions.model)) {
fprintf(stderr, "Unknown PSP model '%s' - expected 01g..12g or any\n", cmdLineOptions.unpackUpdaterModel.value().c_str());
return 1;
}
PSARUnpackStats stats;
std::string unpackError;
const bool ok = UnpackUpdater(Path(cmdLineOptions.bootFilenames[0]), Path(cmdLineOptions.unpackUpdater.value()), unpackOptions, &stats, &unpackError);
if (!ok) {
fprintf(stderr, "Unpacking failed: %s\n", unpackError.c_str());
}
printf("Firmware %s (model %s): %d entries, %d files written, %d directories, %d unresolved names, %d for other models, %d failed\n",
stats.firmwareVersion.c_str(), PSPModelGenerationToString(unpackOptions.model), stats.entries, stats.written,
stats.directories, stats.unnamed, stats.otherModel, stats.failed);
printf("Compression: none=%d zlib=%d KL4E=%d KL3E=%d LZR=%d unknown=%d\n",
stats.compressionCounts[(int)PSARCompression::None],
stats.compressionCounts[(int)PSARCompression::Zlib],
stats.compressionCounts[(int)PSARCompression::KL4E],
stats.compressionCounts[(int)PSARCompression::KL3E],
stats.compressionCounts[(int)PSARCompression::LZR],
stats.compressionCounts[(int)PSARCompression::Unknown]);
return ok ? 0 : 1;
}
g_Config.RestoreDefaults(RestoreSettingsBits::SETTINGS | RestoreSettingsBits::CONTROLS | RestoreSettingsBits::RECENT, false);
Core_RegisterDebugOutputListeners(&SendDebugOutput, &SendDebugScreenshot);
+1
View File
@@ -895,6 +895,7 @@ SOURCES_CXX += \
$(COREDIR)/Util/BlockAllocator.cpp \
$(COREDIR)/Util/MemStick.cpp \
$(COREDIR)/Util/PPGeDraw.cpp \
$(COREDIR)/Util/PSARUnpack.cpp \
$(COREDIR)/Util/RecentFiles.cpp \
$(COREDIR)/Util/AudioFormat.cpp \
$(COREDIR)/Util/PathUtil.cpp \