Merge pull request #22258 from hrydgard/pkg-research

PKG install support
This commit is contained in:
Henrik Rydgård
2026-09-08 13:44:42 -06:00
committed by GitHub
43 changed files with 1960 additions and 51 deletions
+2
View File
@@ -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
+1
View File
@@ -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"},
+6
View File
@@ -82,6 +82,12 @@ struct CommandLineOptions {
// libraries. Needs a firmware dump under the NAND directory.
std::optional<int> 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/<DISC_ID>, but here the caller picks. See
// Core/Util/PkgUnpack.h.
std::optional<std::string> installPkg;
std::optional<int> memReadAction;
std::optional<int> memWriteAction;
std::optional<int> breakAction;
+2
View File
@@ -924,6 +924,7 @@
<ClCompile Include="Util\PPGeDraw.cpp" />
<ClCompile Include="Util\KL4E.cpp" />
<ClCompile Include="Util\PSARUnpack.cpp" />
<ClCompile Include="Util\PkgUnpack.cpp" />
<ClCompile Include="..\ext\xxhash.c">
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">MaxSpeed</Optimization>
<IntrinsicFunctions Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</IntrinsicFunctions>
@@ -1294,6 +1295,7 @@
<ClInclude Include="Util\PPGeDraw.h" />
<ClInclude Include="Util\KL4E.h" />
<ClInclude Include="Util\PSARUnpack.h" />
<ClInclude Include="Util\PkgUnpack.h" />
<ClInclude Include="..\ext\xxhash.h" />
<ClInclude Include="Util\RecentFiles.h" />
<ClInclude Include="Util\VideoPlayer.h" />
+6
View File
@@ -369,6 +369,9 @@
<ClCompile Include="Util\PSARUnpack.cpp">
<Filter>Util</Filter>
</ClCompile>
<ClCompile Include="Util\PkgUnpack.cpp">
<Filter>Util</Filter>
</ClCompile>
<ClCompile Include="HLE\sceFont.cpp">
<Filter>HLE\Libraries</Filter>
</ClCompile>
@@ -1698,6 +1701,9 @@
<ClInclude Include="Util\PSARUnpack.h">
<Filter>Util</Filter>
</ClInclude>
<ClInclude Include="Util\PkgUnpack.h">
<Filter>Util</Filter>
</ClInclude>
<ClInclude Include="HLE\sceFont.h">
<Filter>HLE\Libraries</Filter>
</ClInclude>
+19 -4
View File
@@ -2,11 +2,8 @@
#include <array>
#include <string.h>
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"
@@ -14,6 +11,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 +977,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);
-3
View File
@@ -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);
-2
View File
@@ -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"
+39 -4
View File
@@ -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
@@ -2311,6 +2314,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);
@@ -2339,7 +2373,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) {
@@ -2375,7 +2409,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);
}
-3
View File
@@ -80,7 +80,4 @@ void __KernelSemaInit();
void __KernelSemaDoState(PointerWrap &p);
KernelObject *__KernelSemaphoreObject();
extern "C"
{
#include "ext/libkirk/kirk_engine.h"
}
+43
View File
@@ -1,7 +1,11 @@
#include "ext/libkirk/AES.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 +136,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>, "sceNpDrmSetLicenseeKey", 'i', "x"},
{0X9B745542, &WrapI_V<sceNpDrmClearLicenseeKey>, "sceNpDrmClearLicenseeKey", 'i', "" },
+12
View File
@@ -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);
+7
View File
@@ -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";
+2
View File
@@ -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,
+105 -31
View File
@@ -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/<DISC_ID>/,
// 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<u8> 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.
+1
View File
@@ -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;
+65
View File
@@ -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<FileLoader> 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");
+5
View File
@@ -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/<DISC_ID>. 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);
-2
View File
@@ -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
+549
View File
@@ -0,0 +1,549 @@
// 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 <algorithm>
#include <cstring>
#include <memory>
#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"
#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<size_t>(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<u8> 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<u8> *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<u8> 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<u8> 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<u8> 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<u8> 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<void(float)> &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<u8> 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<u64>(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;
}
bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update) {
if (discId.empty()) {
return false;
}
const Path folder = GetSysDirectory(DIRECTORY_GAME) / std::string(discId);
const Path pbootPath = folder / "PBOOT.PBP";
if (!File::Exists(pbootPath)) {
return false;
}
// The version we want is in the PBOOT's own PARAM.SFO, which isn't encrypted.
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pbootPath));
if (!loader) {
return false;
}
PBPReader pbp(loader.get());
std::vector<u8> 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<s64>(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);
}
+130
View File
@@ -0,0 +1,130 @@
// 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 <functional>
#include <string>
#include <vector>
#include "Common/CommonTypes.h"
#include "Common/File/Path.h"
class FileLoader;
// 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/<DISC_ID>/, 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<PkgItem> 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<u8> *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/<DISC_ID>). progress is called with 0..1 as it goes, and may be null.
bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function<void(float)> &progress, std::string *error);
// An update that has been installed, i.e. what's left over in PSP/GAME/<DISC_ID> 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);
Executable
+198
View File
@@ -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("<III", data[0x08:0x14])
out = {}
for i in range(count):
e = 0x14 + i * 0x10
if e + 0x10 > len(data):
break
ko, fmt, ln, maxln, do = struct.unpack("<HHIII", data[e:e+0x10])
name = data[key_tab+ko:data.index(b'\0', key_tab+ko)].decode('ascii', 'replace')
raw = data[data_tab+do:data_tab+do+ln]
if fmt == 0x0404:
out[name] = struct.unpack("<I", raw[:4])[0]
else:
out[name] = raw.split(b'\0')[0].decode('utf-8', 'replace')
return out
def info(p, list_items=True):
print("== %s" % os.path.basename(p.path))
print(" content_id %s key_id %d type %d content_type 0x%x (%s)" % (
p.content_id, p.key_id, p.type, p.content_type,
CONTENT_TYPES.get(p.content_type, "?")))
print(" items %d data 0x%x+0x%x total 0x%x" % (
p.item_cnt, p.data_off, p.data_size, p.total_size))
sfo = p.sfo()
if sfo:
print(" SFO: " + ", ".join("%s=%r" % kv for kv in sfo.items()))
if list_items:
for it in p.items():
print(" %-10s pt=%02x %10d %s" % (
TYPES.get(it.flags, "0x%x" % it.flags), it.psp_type, it.size, it.name))
def extract(p, outdir):
for it in p.items():
dst = os.path.join(outdir, it.name)
if it.is_dir():
os.makedirs(dst, exist_ok=True)
continue
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
with open(dst, 'wb') as o:
left, off = it.size, it.off
while left:
n = min(left, 1 << 20)
o.write(p.dec(off, n, it.key))
off += n
left -= n
print(" wrote %s (%d)" % (it.name, it.size))
if __name__ == '__main__':
args = sys.argv[1:]
out = None
if args and args[0] == '-x':
out = args[1]
args = args[2:]
for path in args:
p = Pkg(path)
info(p)
if out:
extract(p, out)
print()
+2
View File
@@ -103,6 +103,8 @@ list(APPEND UISource
InstallUpdateScreen.cpp
InstallZipScreen.h
InstallZipScreen.cpp
InstallPkgScreen.h
InstallPkgScreen.cpp
JitCompareScreen.h
JitCompareScreen.cpp
MemStickScreen.h
+4 -2
View File
@@ -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) {
+2
View File
@@ -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;
+62
View File
@@ -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;
+10
View File
@@ -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/<DISC_ID>, which is what actually runs when there is one.
InstalledGameUpdate installedUpdate_;
bool hasInstalledUpdate_ = false;
std::shared_ptr<GameInfo> info_;
mutable std::string titleCache_;
};
+193
View File
@@ -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 <memory>
#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<FileLoader> 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 &params) {
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();
}
+66
View File
@@ -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/<DISC_ID>, 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 &params);
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;
};
+4
View File
@@ -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.
+2
View File
@@ -79,6 +79,7 @@
<ClCompile Include="TiltAnalogSettingsScreen.cpp" />
<ClCompile Include="TouchControlLayoutScreen.cpp" />
<ClCompile Include="TouchControlVisibilityScreen.cpp" />
<ClCompile Include="InstallPkgScreen.cpp" />
<ClCompile Include="InstallUpdateScreen.cpp" />
<ClCompile Include="InstallZipScreen.cpp" />
<ClCompile Include="Theme.cpp" />
@@ -137,6 +138,7 @@
<ClInclude Include="TiltAnalogSettingsScreen.h" />
<ClInclude Include="TouchControlLayoutScreen.h" />
<ClInclude Include="TouchControlVisibilityScreen.h" />
<ClInclude Include="InstallPkgScreen.h" />
<ClInclude Include="InstallUpdateScreen.h" />
<ClInclude Include="InstallZipScreen.h" />
<ClInclude Include="Theme.h" />
+6
View File
@@ -40,6 +40,9 @@
<ClCompile Include="TiltAnalogSettingsScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="InstallPkgScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="InstallUpdateScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
@@ -194,6 +197,9 @@
<ClInclude Include="TiltAnalogSettingsScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="InstallPkgScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="InstallUpdateScreen.h">
<Filter>Screens</Filter>
</ClInclude>
+2
View File
@@ -339,6 +339,7 @@
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
<ClInclude Include="..\..\Core\Util\KL4E.h" />
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
<ClInclude Include="..\..\Core\Util\PkgUnpack.h" />
<ClInclude Include="..\..\Core\WaveFile.h" />
<ClInclude Include="..\..\ext\cityhash\city.h" />
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
@@ -660,6 +661,7 @@
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
<ClCompile Include="..\..\Core\Util\KL4E.cpp" />
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
<ClCompile Include="..\..\Core\Util\PkgUnpack.cpp" />
<ClCompile Include="..\..\Core\WaveFile.cpp" />
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
<ClCompile Include="..\..\ext\disarm.cpp" />
+2
View File
@@ -282,6 +282,7 @@
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
<ClCompile Include="..\..\Core\Util\KL4E.cpp" />
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
<ClCompile Include="..\..\Core\Util\PkgUnpack.cpp" />
<ClCompile Include="..\..\Core\WaveFile.cpp" />
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
<ClCompile Include="..\..\ext\disarm.cpp" />
@@ -673,6 +674,7 @@
<ClInclude Include="..\..\Core\Util\GameManager.h" />
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
<ClInclude Include="..\..\Core\Util\PkgUnpack.h" />
<ClInclude Include="..\..\Core\WaveFile.h" />
<ClInclude Include="..\..\ext\cityhash\city.h" />
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
+2
View File
@@ -114,6 +114,7 @@
<ClInclude Include="..\..\UI\ImDebugger\ImJitViewer.h" />
<ClInclude Include="..\..\UI\ImDebugger\ImMemView.h" />
<ClInclude Include="..\..\UI\ImDebugger\ImStructViewer.h" />
<ClInclude Include="..\..\UI\InstallPkgScreen.h" />
<ClInclude Include="..\..\UI\InstallUpdateScreen.h" />
<ClInclude Include="..\..\UI\InstallZipScreen.h" />
<ClInclude Include="..\..\UI\JitCompareScreen.h" />
@@ -173,6 +174,7 @@
<ClCompile Include="..\..\UI\ImDebugger\ImJitViewer.cpp" />
<ClCompile Include="..\..\UI\ImDebugger\ImMemView.cpp" />
<ClCompile Include="..\..\UI\ImDebugger\ImStructViewer.cpp" />
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp" />
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp" />
<ClCompile Include="..\..\UI\InstallZipScreen.cpp" />
<ClCompile Include="..\..\UI\JitCompareScreen.cpp" />
+6
View File
@@ -73,6 +73,9 @@
<ClCompile Include="..\..\UI\IAPScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
@@ -219,6 +222,9 @@
<ClInclude Include="..\..\UI\IAPScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="..\..\UI\InstallPkgScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="..\..\UI\InstallUpdateScreen.h">
<Filter>Screens</Filter>
</ClInclude>
+2
View File
@@ -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
@@ -988,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 \
+348
View File
@@ -0,0 +1,348 @@
# 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.
+8
View File
@@ -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 */
+8
View File
@@ -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
+5
View File
@@ -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 <stddef.h>
#include "kirk_common.h"
#include "SHA1.h"
#include "AES.h"
+33
View File
@@ -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<FileLoader> 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);
+1
View File
@@ -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 \