diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 6a5bfad016..1cdc681e25 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -699,6 +699,8 @@ add_library(Core STATIC Util/KL4E.h Util/PSARUnpack.cpp Util/PSARUnpack.h + Util/PkgUnpack.cpp + Util/PkgUnpack.h Util/RecentFiles.cpp Util/RecentFiles.h ${CMAKE_SOURCE_DIR}/ext/disarm.cpp diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 81f0f3b505..25ce9f2a14 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -211,6 +211,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(unpackUpdater), CmdParamType::String, "unpack-updater", '\0', "Unpack the firmware in an updater EBOOT.PBP into DIR and exit", CmdLineMode::Headless}, {POFF(unpackUpdaterModel), CmdParamType::String, "unpack-updater-model", '\0', "PSP model to unpack for (01g..12g, default any)", CmdLineMode::Headless}, {POFF(unpackUpdaterFilter), CmdParamType::String, "unpack-updater-filter", '\0', "Only unpack entries under this path, e.g. flash0:/font/", CmdLineMode::Headless}, + {POFF(installPkg), CmdParamType::String, "install-pkg", '\0', "Install the game update in a .pkg into DIR and exit", CmdLineMode::Headless}, {POFF(odsLog), CmdParamType::Bool, "odslog", 'o', "Also log through OutputDebugString (Windows)", CmdLineMode::Headless}, {POFF(generateInterpreterDispatch), CmdParamType::Bool, "generate-interpreter-dispatch", '\0', "Generate C++ interpreter dispatch code (ExecInstruction) to stdout and exit", CmdLineMode::Headless}, {POFF(resolutionScale), CmdParamType::Int, "resolution-scale", '\0', "Set the resolution scale factor"}, diff --git a/Core/CmdLine.h b/Core/CmdLine.h index fdcb755277..0abf20a8e5 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -82,6 +82,12 @@ struct CommandLineOptions { // libraries. Needs a firmware dump under the NAND directory. std::optional disableHLE; + // Headless: install the game update in a .pkg (given as the boot filename) into this + // directory, then exit without booting anything. The directory is the game folder itself - + // the app puts that under PSP/GAME/, but here the caller picks. See + // Core/Util/PkgUnpack.h. + std::optional installPkg; + std::optional memReadAction; std::optional memWriteAction; std::optional breakAction; diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj index b1b51f8aad..c5b395d226 100644 --- a/Core/Core.vcxproj +++ b/Core/Core.vcxproj @@ -924,6 +924,7 @@ + MaxSpeed true @@ -1294,6 +1295,7 @@ + diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters index a7de4d3b8a..7d94c8a878 100644 --- a/Core/Core.vcxproj.filters +++ b/Core/Core.vcxproj.filters @@ -369,6 +369,9 @@ Util + + Util + HLE\Libraries @@ -1698,6 +1701,9 @@ Util + + Util + HLE\Libraries diff --git a/Core/ELF/PrxDecrypter.cpp b/Core/ELF/PrxDecrypter.cpp index 2ae7b4633f..a3b8fe46c4 100644 --- a/Core/ELF/PrxDecrypter.cpp +++ b/Core/ELF/PrxDecrypter.cpp @@ -2,11 +2,8 @@ #include #include -extern "C" -{ #include "ext/libkirk/kirk_engine.h" #include "ext/libkirk/SHA1.h" -} #include "Common/Common.h" #include "Common/Log.h" #include "Common/Swap.h" @@ -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); diff --git a/Core/FileSystems/BlockDevices.cpp b/Core/FileSystems/BlockDevices.cpp index b9d4729140..68d9469860 100644 --- a/Core/FileSystems/BlockDevices.cpp +++ b/Core/FileSystems/BlockDevices.cpp @@ -35,12 +35,9 @@ #include "Core/Util/PathUtil.h" #include "libchdr/chd.h" -extern "C" -{ #include "zlib.h" #include "ext/libkirk/amctrl.h" #include "ext/libkirk/kirk_engine.h" -}; static u16 ReadLE16(const u8 *ptr) { return ptr[0] | (ptr[1] << 8); diff --git a/Core/HLE/sceIo.cpp b/Core/HLE/sceIo.cpp index 293b388ca0..bb275b111a 100644 --- a/Core/HLE/sceIo.cpp +++ b/Core/HLE/sceIo.cpp @@ -58,9 +58,7 @@ #include "Core/FileSystems/ISOFileSystem.h" #include "Core/FileSystems/DirectoryFileSystem.h" -extern "C" { #include "ext/libkirk/amctrl.h" -}; #include "Core/HLE/sceIo.h" #include "Core/HLE/sceRtc.h" diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index bc30b219f8..057f821174 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -48,6 +48,7 @@ #include "Core/ELF/ElfReader.h" #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" +#include "Core/HLE/scePspNpDrm_user.h" #include "Core/Util/KL4E.h" #include "Core/FileSystems/FileSystem.h" #include "Core/FileSystems/MetaFileSystem.h" @@ -1216,7 +1217,9 @@ static void LoadAndStartVshKernelModules() { } // filename is only used for dumping/metadata. -static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error) { +// prxSeed is the extra key a module that came out of an NPDRM container needs to decrypt - see +// NpDrmDeriveModuleKey(). Null for everything else, which is the overwhelming majority. +static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error, const u8 *prxSeed = nullptr) { // The magic reads below need four bytes, and the ~SCE branch another four after that. Everything // downstream checks its own sizes; this is just so we can look at the magic at all. The PBP path // in __KernelLoadModule computes elfSize from two offsets in the file and doesn't floor it. @@ -1289,7 +1292,7 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load newptr = new u8[maxElfSize]; elfSize = maxElfSize; ptr = newptr; - int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size); + int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size, prxSeed); // If decryption got us nowhere, the PRX may simply not be encrypted - in which case the ELF // starts right after the header. Check the source buffer, not the destination: on the paths // where decryption bails early nothing has been written to newptr yet, so this used to read @@ -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); } diff --git a/Core/HLE/sceKernelSemaphore.h b/Core/HLE/sceKernelSemaphore.h index 74002ff9d6..410fbe2f17 100644 --- a/Core/HLE/sceKernelSemaphore.h +++ b/Core/HLE/sceKernelSemaphore.h @@ -80,7 +80,4 @@ void __KernelSemaInit(); void __KernelSemaDoState(PointerWrap &p); KernelObject *__KernelSemaphoreObject(); -extern "C" -{ #include "ext/libkirk/kirk_engine.h" -} diff --git a/Core/HLE/scePspNpDrm_user.cpp b/Core/HLE/scePspNpDrm_user.cpp index c81acd7a51..6ef44d4ebf 100644 --- a/Core/HLE/scePspNpDrm_user.cpp +++ b/Core/HLE/scePspNpDrm_user.cpp @@ -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", 'i', "x"}, {0X9B745542, &WrapI_V, "sceNpDrmClearLicenseeKey", 'i', "" }, diff --git a/Core/HLE/scePspNpDrm_user.h b/Core/HLE/scePspNpDrm_user.h index 3e59c6aa39..35af832eee 100644 --- a/Core/HLE/scePspNpDrm_user.h +++ b/Core/HLE/scePspNpDrm_user.h @@ -1,5 +1,17 @@ #pragma once +#include "Common/CommonTypes.h" + class PointerWrap; void Register_sceNpDrm(); + +// A module wrapped in an NPDRM "\0PSPEDAT" container has its PRX encrypted against a key built +// from that container's header and, usually, the licensee key the game handed over through +// sceNpDrmSetLicenseeKey - which is why this lives here rather than with the PRX decrypter. +// Writes 16 bytes to keyOut, to be passed to pspDecryptPRX() as the seed. +// +// edatHeader must point at the 0x90 readable bytes of the container header. Returns false if the +// header asks for something we can't build, notably a licensee key the game never set. +// See docs/pkg_notes.md for the layout and where this came from. +bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut); diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 0c85f8975c..287789bea1 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -219,6 +219,8 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin return IdentifiedFileType::ARCHIVE_RAR; } else if (!memcmp(&id, "\x37\x7A\xBC\xAF", 4)) { return IdentifiedFileType::ARCHIVE_7Z; + } else if (!memcmp(&id, "\x7F""PKG", 4)) { + return IdentifiedFileType::PSP_PKG; } // "~PSP" is an encrypted PRX. The module loader decrypts those on the way in, so as far as @@ -279,6 +281,10 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin return IdentifiedFileType::ARCHIVE_RAR; } else if (extension == ".7z") { return IdentifiedFileType::ARCHIVE_7Z; + } else if (extension == ".pkg") { + // Magic didn't match, but the name says what it was meant to be - report it as a PKG so + // the install screen can explain what's wrong with it. + return IdentifiedFileType::PSP_PKG; } return IdentifiedFileType::UNKNOWN; } @@ -673,6 +679,7 @@ const char *IdentifiedFileTypeToString(IdentifiedFileType type) { case IdentifiedFileType::UNKNOWN_ISO: return "UNKNOWN_ISO"; case IdentifiedFileType::ARCHIVE_RAR: return "ARCHIVE_RAR"; case IdentifiedFileType::ARCHIVE_ZIP: return "ARCHIVE_ZIP"; + case IdentifiedFileType::PSP_PKG: return "PSP_PKG"; case IdentifiedFileType::ARCHIVE_7Z: return "ARCHIVE_7Z"; case IdentifiedFileType::PSP_PS1_PBP: return "PSP_PS1_PBP"; case IdentifiedFileType::PSX_ISO: return "PSX_ISO"; diff --git a/Core/Loaders.h b/Core/Loaders.h index 6804abbdf2..a9d86dea99 100644 --- a/Core/Loaders.h +++ b/Core/Loaders.h @@ -40,6 +40,8 @@ enum class IdentifiedFileType { ARCHIVE_RAR, ARCHIVE_ZIP, ARCHIVE_7Z, + // A .pkg game update - a container we install from rather than boot. See Core/Util/PkgUnpack.h. + PSP_PKG, PSP_PS1_PBP, PSX_ISO, PS2_ISO, diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 72fe344601..091bdcc95f 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -260,44 +260,118 @@ static const char * const altBootNames[] = { //"disc0:/PSP_GAME/SYSDIR/ss.RAW",//Code Geass: Lost Colors chinese version }; -bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) { - std::string bootpath("disc0:/PSP_GAME/SYSDIR/EBOOT.BIN"); +// A game update installed from a .pkg (see Core/Util/PkgUnpack.h) lands in PSP/GAME//, +// with the patched executable as PBOOT.PBP. The PSP boots that instead of the disc's own EBOOT, +// leaving the disc mounted - so the update overrides the files it ships and the disc supplies +// everything else. - // Bypass Chinese translation patches, see comment above. - for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) { - if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) { - WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str()); - bootpath = altBootNames[i]; - // break; // should have a break here, but it would effectively reverse the evaluation order. - } +static bool ReadPBPParamSFO(const std::string &path, ParamSFOData *sfo) { + const int fd = pspFileSystem.OpenFile(path, FILEACCESS_READ); + if (fd < 0) { + return false; } - // Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game. - std::string id = g_paramSFO.GetValueString("DISC_ID"); - if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) { - bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN"; - } - if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) { - bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL"; - } - - bool hasEncrypted = false; - int fd; - if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) { - u8 head[4]{}; - // A file shorter than the magic used to leave head partly uninitialized, and then decided - // which boot file to use by comparing against it. - if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) { - if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) { - hasEncrypted = true; + bool success = false; + // A PBP starts with its magic, a version, and eight little-endian subfile offsets. PARAM.SFO + // is the first subfile, so it runs from its own offset to ICON0.PNG's. + u8 header[0x28]; + if (pspFileSystem.ReadFile(fd, header, sizeof(header)) == sizeof(header) && !memcmp(header, "\0PBP", 4)) { + u32_le sfoOffset, iconOffset; + memcpy(&sfoOffset, header + 0x08, sizeof(sfoOffset)); + memcpy(&iconOffset, header + 0x0C, sizeof(iconOffset)); + const u32 sfoSize = iconOffset - sfoOffset; + if (sfoOffset >= sizeof(header) && iconOffset > sfoOffset && sfoSize <= 64 * 1024) { + std::vector sfoData(sfoSize); + if (pspFileSystem.SeekFile(fd, sfoOffset, FILEMOVE_BEGIN) >= 0 && + pspFileSystem.ReadFile(fd, sfoData.data(), sfoSize) == sfoSize) { + success = sfo->ReadSFO(sfoData); } } - pspFileSystem.CloseFile(fd); + } + pspFileSystem.CloseFile(fd); + return success; +} + +// Returns the path of the update to boot, or an empty string to boot the disc normally. +static std::string FindGameUpdatePBOOT(const std::string &discId, const std::string &discVersion) { + if (discId.empty()) { + return std::string(); + } + const std::string path = "ms0:/PSP/GAME/" + discId + "/PBOOT.PBP"; + if (!pspFileSystem.GetFileInfo(path).exists) { + return std::string(); } - if (!hasEncrypted) { - // try unencrypted Boot.BIN - bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN"; + // Check what the update claims to patch before handing it the boot. + ParamSFOData sfo; + if (!ReadPBPParamSFO(path, &sfo)) { + WARN_LOG(Log::Loader, "Ignoring '%s': couldn't read its PARAM.SFO", path.c_str()); + return std::string(); + } + + const std::string updateDiscId = sfo.GetValueString("DISC_ID"); + if (updateDiscId != discId) { + WARN_LOG(Log::Loader, "Ignoring '%s': it's an update for %s, not %s", path.c_str(), updateDiscId.c_str(), discId.c_str()); + return std::string(); + } + + // The disc version is advisory here. An update is built against one specific revision of a + // disc, but refusing to run one on a slightly different dump is a worse failure than letting + // the user find out - they went and installed it on purpose. + const std::string updateDiscVersion = sfo.GetValueString("DISC_VERSION"); + if (!updateDiscVersion.empty() && !discVersion.empty() && updateDiscVersion != discVersion) { + WARN_LOG(Log::Loader, "Game update '%s' is for disc version %s, but this disc is %s. Booting it anyway.", + path.c_str(), updateDiscVersion.c_str(), discVersion.c_str()); + } + + NOTICE_LOG(Log::Loader, "Booting game update '%s' (app version %s) instead of the disc's executable", + path.c_str(), sfo.GetValueString("APP_VER").c_str()); + return path; +} + +bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) { + const std::string id = g_paramSFO.GetValueString("DISC_ID"); + + // An installed game update replaces the disc's executable - see FindGameUpdatePBOOT above. + std::string bootpath = FindGameUpdatePBOOT(id, g_paramSFO.GetValueString("DISC_VERSION")); + if (bootpath.empty()) { + bootpath = "disc0:/PSP_GAME/SYSDIR/EBOOT.BIN"; + + // Bypass Chinese translation patches, see comment above. + for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) { + if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) { + WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str()); + bootpath = altBootNames[i]; + // break; // should have a break here, but it would effectively reverse the evaluation order. + } + } + + // Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game. + if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) { + bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN"; + } + if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) { + bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL"; + } + + bool hasEncrypted = false; + int fd; + if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) { + u8 head[4]{}; + // A file shorter than the magic used to leave head partly uninitialized, and then decided + // which boot file to use by comparing against it. + if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) { + if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) { + hasEncrypted = true; + } + } + pspFileSystem.CloseFile(fd); + } + + if (!hasEncrypted) { + // try unencrypted Boot.BIN + bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN"; + } } // Fail early with a clearer message for some types of ISOs. diff --git a/Core/System.cpp b/Core/System.cpp index e13d417678..f90d35f494 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -255,6 +255,7 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) { break; case IdentifiedFileType::ARCHIVE_7Z: *errorString = "7z file detected (Require 7-Zip)"; break; + case IdentifiedFileType::PSP_PKG: *errorString = "PKG game updates need to be installed, not booted."; break; case IdentifiedFileType::PSX_ISO: *errorString = "PSX game image detected."; break; case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break; diff --git a/Core/Util/GameManager.cpp b/Core/Util/GameManager.cpp index ba4eb5be27..19d23555c7 100644 --- a/Core/Util/GameManager.cpp +++ b/Core/Util/GameManager.cpp @@ -52,6 +52,7 @@ #include "Core/FileSystems/ISOFileSystem.h" #include "Core/Util/GameManager.h" #include "Core/Util/PathUtil.h" +#include "Core/Util/PkgUnpack.h" #include "Core/Util/RecentFiles.h" #include "Common/Data/Text/I18n.h" @@ -902,6 +903,70 @@ bool GameManager::InstallZipOnThread(ZipFileTask task) { return true; } +// Installing a game update from a .pkg. Unlike a zip there's nothing to guess about - the +// package says which disc it patches, and that decides the destination folder. +void GameManager::InstallPkgContents(Path pkgPath, bool deleteAfter) { + SetCurrentThreadName("InstallPkgContents"); + + AndroidJNIThreadContext context; // Destructor detaches. + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, 0.0f, 0.1f); + + bool success = false; + std::string error; + std::unique_ptr loader(ConstructFileLoader(pkgPath)); + PkgReader reader; + if (!loader || !reader.Open(loader.get(), &error)) { + ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str()); + SetInstallError(iz->T("This PKG file isn't a PSP game update")); + } else if (!reader.Info().isGameUpdate) { + SetInstallError(iz->T("This PKG file isn't a PSP game update")); + } else { + const Path destination = GetSysDirectory(DIRECTORY_GAME) / reader.Info().discId; + success = InstallPkg(reader, destination, [this](float progress) { + installProgress_ = progress; + auto di = GetI18NCategory(I18NCat::DIALOG); + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f); + }, &error); + if (!success) { + ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str()); + SetInstallError(iz->T("Failed to install the game update")); + } + } + + // Close the package before anything tries to delete it. + loader.reset(); + + if (deleteAfter && success) { + if (System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN)) { + System_MoveToTrash(pkgPath); + } else { + File::Delete(pkgPath); + } + } + + g_OSD.RemoveProgressBar("install", success, 0.5f); + installProgress_ = 1.0f; + InstallDone(); + if (success) { + ResetInstallError(); + } +} + +bool GameManager::InstallPkgOnThread(const Path &pkgPath, bool deleteAfter) { + if (InstallInProgress() || installDonePending_) { + return false; + } + + installThread_ = std::thread([this, pkgPath, deleteAfter]() { + InstallPkgContents(pkgPath, deleteAfter); + }); + return true; +} + bool GameManager::UninstallGameOnThread(const std::string &name) { if (name.empty()) { ERROR_LOG(Log::HLE, "Cannot uninstall an empty-named game"); diff --git a/Core/Util/GameManager.h b/Core/Util/GameManager.h index d3b6ee3803..556e5c30eb 100644 --- a/Core/Util/GameManager.h +++ b/Core/Util/GameManager.h @@ -85,6 +85,10 @@ public: // Only returns false if there's already an installation in progress. bool InstallZipOnThread(ZipFileTask task); + // Installs a game update from a .pkg into PSP/GAME/. Same contract as the above - + // only returns false if something else is already installing. See Core/Util/PkgUnpack.h. + bool InstallPkgOnThread(const Path &pkgPath, bool deleteAfter); + // Separate kind of functionality from InstallZipOnThread, so doesn't re-use the task struct. bool UninstallGameOnThread(const std::string &name); @@ -94,6 +98,7 @@ public: private: void InstallZipContents(ZipFileTask task); + void InstallPkgContents(Path pkgPath, bool deleteAfter); bool InstallMemstickZip(const Path &zipFile, const Path &dest, const ZipFileInfo &info); bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir); void UninstallGame(const std::string &name); diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index e7aff71097..a0880bc6fa 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -37,9 +37,7 @@ #include "Core/System.h" #include "Core/Util/PSARUnpack.h" -extern "C" { #include "ext/libkirk/kirk_engine.h" -} // A PSAR record is [header][entry], where the header is 0x150 bytes of PRX-style encryption // metadata and the entry is 0x110 bytes describing one file. Pre-decrypted archives (rare, and diff --git a/Core/Util/PkgUnpack.cpp b/Core/Util/PkgUnpack.cpp new file mode 100644 index 0000000000..48d03a9a66 --- /dev/null +++ b/Core/Util/PkgUnpack.cpp @@ -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 +#include +#include + +#include "Common/File/FileUtil.h" +#include "Common/File/Path.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/ELF/PBPReader.h" +#include "Core/Loaders.h" +#include "Core/System.h" +#include "Core/Util/PkgUnpack.h" + +#include "ext/libkirk/AES.h" + +// See docs/pkg_notes.md. Field offsets in the 0xC0-byte header: +static const u32 PKG_MAGIC = 0x7F504B47; // "\x7FPKG" +static const u32 PKG_TYPE_PSP = 2; // 1 is PS3. +// 0xC0 of header plus the 0x40-byte extended header after it, which is where the key index is. +static const size_t PKG_HEADER_SIZE = 0x100; +static const size_t PKG_ITEM_SIZE = 0x20; + +// The two keys a PSP package's contents are encrypted with, picked per item. Vita packages derive +// theirs from the riv instead, which we don't handle - nothing here reads Vita packages. +static const u8 PKG_PSP_KEY[16] = { + 0x07, 0xf2, 0xc6, 0x82, 0x90, 0xb5, 0x0d, 0x2c, 0x33, 0x81, 0x8d, 0x70, 0x9b, 0x60, 0xe6, 0x2b, +}; +static const u8 PKG_PS3_KEY[16] = { + 0x2e, 0x7b, 0x71, 0xd7, 0xc9, 0xc9, 0xa1, 0x4e, 0xa3, 0x22, 0x1f, 0x18, 0x88, 0x28, 0xb8, 0xf8, +}; + +// An item table with more entries than this is corrupt, not something we should try to allocate +// for. The biggest update package seen has a few hundred. +static const u32 PKG_MAX_ITEMS = 65536; +// Same idea for a single filename. +static const u32 PKG_MAX_NAME = 1024; + +static const size_t PKG_COPY_BLOCK = 512 * 1024; + +// Everything in a PKG header, its metadata and its item table is big-endian; the PBP inside is +// little-endian like the rest of the PSP world. None of it is guaranteed aligned, so read bytes. +static u16 Read16BE(const u8 *p) { + return (u16)(((u32)p[0] << 8) | p[1]); +} +static u32 Read32BE(const u8 *p) { + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3]; +} +static u64 Read64BE(const u8 *p) { + return ((u64)Read32BE(p) << 32) | (u64)Read32BE(p + 4); +} +static u32 Read32LE(const u8 *p) { + return ((u32)p[3] << 24) | ((u32)p[2] << 16) | ((u32)p[1] << 8) | (u32)p[0]; +} + +// AES-128-CTR, with the counter block starting at `iv` and incrementing once per 16 bytes. Both +// the counter and the increment are big-endian, and the counter wraps over the whole 128 bits. +static void AesCtrXor(const u8 *key, const u8 *iv, u64 blockIndex, u8 *data, size_t size) { + AES_ctx ctx; + AES_set_key(&ctx, key, 128); + + u8 counter[16]; + memcpy(counter, iv, 16); + // Add blockIndex to the 128-bit big-endian counter. + u32 carry = 0; + for (int i = 15; i >= 0; i--) { + const u32 sum = (u32)counter[i] + (u32)(blockIndex & 0xFF) + carry; + counter[i] = (u8)sum; + carry = sum >> 8; + blockIndex >>= 8; + } + + u8 stream[16]; + for (size_t pos = 0; pos < size; pos += 16) { + AES_encrypt(&ctx, counter, stream); + const size_t chunk = std::min(16, size - pos); + for (size_t i = 0; i < chunk; i++) { + data[pos + i] ^= stream[i]; + } + // Increment the counter block. + for (int i = 15; i >= 0; i--) { + if (++counter[i] != 0) + break; + } + } +} + +const u8 *PkgReader::ItemKey(const PkgItem &item) const { + return item.pspType == 0x90 ? mainKey_ : PKG_PS3_KEY; +} + +bool PkgReader::ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out) { + if (size == 0) { + return true; + } + // The counter runs on 16-byte boundaries, so read from the containing block and skip the + // leading bytes afterwards. + const size_t skip = (size_t)(offset & 0xF); + const u64 base = offset - skip; + const size_t alignedSize = (skip + size + 15) & ~(size_t)0xF; + + if (base > dataSize_ || alignedSize > dataSize_ - base) { + return false; + } + + std::vector buf(alignedSize); + if (loader_->ReadAt(dataOffset_ + base, alignedSize, buf.data()) != alignedSize) { + return false; + } + AesCtrXor(key, riv_, base / 16, buf.data(), alignedSize); + memcpy(out, buf.data() + skip, size); + return true; +} + +bool PkgReader::ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out) { + if (offset > item.dataSize || size > item.dataSize - offset) { + return false; + } + return ReadEncrypted(item.dataOffset + offset, size, ItemKey(item), out); +} + +bool PkgReader::ReadItem(const PkgItem &item, std::vector *out, size_t maxSize) { + if (item.dataSize > maxSize) { + return false; + } + out->resize((size_t)item.dataSize); + return ReadItemData(item, 0, out->size(), out->data()); +} + +bool PkgReader::Open(FileLoader *loader, std::string *error) { + loader_ = loader; + info_ = PkgInfo(); + + u8 header[PKG_HEADER_SIZE]; + if (!loader || loader->ReadAt(0, sizeof(header), header) != sizeof(header)) { + *error = "Not a PKG file: too short"; + return false; + } + if (Read32BE(header) != PKG_MAGIC) { + *error = "Not a PKG file"; + return false; + } + + const u32 type = Read16BE(header + 0x06); + if (type != PKG_TYPE_PSP) { + *error = "Not a PSP PKG file"; + return false; + } + // Key index 1 is the only one a PSP package uses; 2-4 are Vita, and derive a key from the riv. + const u32 keyIndex = header[0xE7] & 7; + if (keyIndex != 1) { + *error = StringFromFormat("Unsupported PKG key index %d", keyIndex); + return false; + } + memcpy(mainKey_, PKG_PSP_KEY, sizeof(mainKey_)); + + const u32 metaOffset = Read32BE(header + 0x08); + const u32 metaCount = Read32BE(header + 0x0C); + const u32 itemCount = Read32BE(header + 0x14); + const u64 totalSize = Read64BE(header + 0x18); + dataOffset_ = Read64BE(header + 0x20); + dataSize_ = Read64BE(header + 0x28); + memcpy(riv_, header + 0x70, sizeof(riv_)); + + char contentId[0x31]{}; + memcpy(contentId, header + 0x30, 0x30); + info_.contentId = contentId; + + const s64 fileSize = loader->FileSize(); + if (fileSize < 0 || (u64)fileSize < totalSize || dataOffset_ + dataSize_ > (u64)fileSize) { + *error = "PKG file is truncated"; + return false; + } + if (itemCount == 0 || itemCount > PKG_MAX_ITEMS) { + *error = "PKG file has a broken item table"; + return false; + } + + // The metadata is in the clear. We only need three things out of it, and one of them (the item + // table offset) is zero in every update package seen - but read it rather than assume. + u32 itemsOffset = 0; + u64 metaPos = metaOffset; + for (u32 i = 0; i < metaCount; i++) { + u8 rec[8]; + if (loader->ReadAt(metaPos, sizeof(rec), rec) != sizeof(rec)) { + *error = "PKG metadata is truncated"; + return false; + } + const u32 id = Read32BE(rec); + const u32 size = Read32BE(rec + 4); + if (size > 0x1000) { + *error = "PKG metadata is corrupt"; + return false; + } + std::vector value(size); + if (size && loader->ReadAt(metaPos + 8, size, value.data()) != size) { + *error = "PKG metadata is truncated"; + return false; + } + switch (id) { + case 2: + if (size >= 4) { + info_.contentType = Read32BE(value.data()); + } + break; + case 6: + info_.titleId = std::string((const char *)value.data(), strnlen((const char *)value.data(), size)); + break; + case 13: + if (size >= 4) { + itemsOffset = Read32BE(value.data()); + } + break; + default: + break; + } + metaPos += 8 + size; + } + + if (info_.contentType != kPkgContentTypePSP) { + *error = StringFromFormat("PKG holds content type 0x%x, not a PSP game", info_.contentType); + return false; + } + + // Item table, then the filenames it points at. Both live in the encrypted area, but a + // filename is encrypted with its own item's key rather than the table's. + std::vector table((size_t)itemCount * PKG_ITEM_SIZE); + if (!ReadEncrypted(itemsOffset, table.size(), mainKey_, table.data())) { + *error = "Failed to read the PKG item table"; + return false; + } + + info_.items.reserve(itemCount); + for (u32 i = 0; i < itemCount; i++) { + const u8 *rec = table.data() + (size_t)i * PKG_ITEM_SIZE; + const u32 nameOffset = Read32BE(rec); + const u32 nameSize = Read32BE(rec + 4); + + PkgItem item; + item.dataOffset = Read64BE(rec + 8); + item.dataSize = Read64BE(rec + 16); + item.pspType = rec[0x18]; + item.flags = rec[0x1B]; + + if (item.dataOffset > dataSize_ || item.dataSize > dataSize_ - item.dataOffset) { + *error = "PKG item points outside the file"; + return false; + } + if (nameSize == 0 || nameSize > PKG_MAX_NAME) { + *error = "PKG item has a broken name"; + return false; + } + item.name.resize(nameSize); + if (!ReadEncrypted(nameOffset, nameSize, ItemKey(item), (u8 *)item.name.data())) { + *error = "Failed to read a PKG item name"; + return false; + } + // Names aren't terminated, but be forgiving if one is anyway. + item.name.resize(strnlen(item.name.c_str(), item.name.size())); + info_.items.push_back(item); + } + + // The package's own PARAM.SFO. There's no reliable pointer to it in the metadata for PSP + // packages (the field is empty), so go by name. + for (const PkgItem &item : info_.items) { + if (item.name != "PARAM.SFO") { + continue; + } + std::vector sfoData; + ParamSFOData sfo; + if (ReadItem(item, &sfoData, 64 * 1024) && sfo.ReadSFO(sfoData)) { + info_.title = sfo.GetValueString("TITLE"); + info_.category = sfo.GetValueString("CATEGORY"); + if (info_.titleId.empty()) { + info_.titleId = sfo.GetValueString("TITLE_ID"); + } + } + break; + } + + for (const PkgItem &item : info_.items) { + if (item.name == "USRDIR/CONTENT/PBOOT.PBP" && !item.IsDirectory()) { + ReadPBOOTInfo(item); + break; + } + } + + INFO_LOG(Log::Loader, "PKG: %s (%s), %d items, update=%d for %s v%s", + info_.contentId.c_str(), info_.category.c_str(), (int)info_.items.size(), + (int)info_.isGameUpdate, info_.discId.c_str(), info_.discVersion.c_str()); + return true; +} + +// The PBOOT is a normal PBP - only its DATA.PSP is encrypted, and its PARAM.SFO is the one that +// says which disc and disc version this patches. That's what makes matching an installed update +// against a game at boot time possible without decrypting anything. +bool PkgReader::ReadPBOOTInfo(const PkgItem &pboot) { + // PBP header: magic, version, then eight little-endian subfile offsets. + u8 header[0x28]; + if (pboot.dataSize < sizeof(header) || !ReadItemData(pboot, 0, sizeof(header), header)) { + return false; + } + if (memcmp(header, "\0PBP", 4) != 0) { + WARN_LOG(Log::Loader, "PKG: PBOOT.PBP isn't a PBP"); + return false; + } + const u32 sfoOffset = Read32LE(header + 0x08); + const u32 iconOffset = Read32LE(header + 0x0C); + if (sfoOffset > iconOffset || iconOffset > pboot.dataSize) { + return false; + } + const u32 sfoSize = iconOffset - sfoOffset; + if (sfoSize == 0 || sfoSize > 64 * 1024) { + return false; + } + + std::vector sfoData(sfoSize); + if (!ReadItemData(pboot, sfoOffset, sfoSize, sfoData.data())) { + return false; + } + ParamSFOData sfo; + if (!sfo.ReadSFO(sfoData)) { + return false; + } + + info_.discId = sfo.GetValueString("DISC_ID"); + info_.discVersion = sfo.GetValueString("DISC_VERSION"); + info_.appVer = sfo.GetValueString("APP_VER"); + info_.systemVer = sfo.GetValueString("PSP_SYSTEM_VER"); + info_.pbootTitle = sfo.GetValueString("PBOOT_TITLE"); + if (info_.title.empty()) { + info_.title = sfo.GetValueString("TITLE"); + } + if (info_.discVersion.empty()) { + info_.discVersion = "1.00"; + } + // A disc ID is what the install is keyed on, so without one there's nowhere to put this. + info_.isGameUpdate = !info_.discId.empty(); + return info_.isGameUpdate; +} + +// Rejects anything that could escape the destination directory. Package filenames are attacker +// data as far as we're concerned. +static bool IsSafeRelativePath(std::string_view name) { + if (name.empty() || name.size() > PKG_MAX_NAME) { + return false; + } + if (name.front() == '/' || name.find('\\') != std::string_view::npos || name.find(':') != std::string_view::npos) { + return false; + } + size_t start = 0; + while (start <= name.size()) { + const size_t slash = name.find('/', start); + const std::string_view part = name.substr(start, slash == std::string_view::npos ? std::string_view::npos : slash - start); + if (part.empty() || part == "." || part == "..") { + return false; + } + for (char c : part) { + // Control characters in a filename are never legitimate here. + if ((unsigned char)c < 0x20) { + return false; + } + } + if (slash == std::string_view::npos) { + break; + } + start = slash + 1; + } + return true; +} + +std::string PkgItemInstallPath(const PkgItem &item) { + // A package wraps its payload PS3-style. USRDIR/CONTENT/ is where the patch's files live, and + // USRDIR/ itself only ever holds ISO.BIN.EDAT; both map to the game folder root. + std::string_view name = item.name; + if (name == "USRDIR" || name == "USRDIR/CONTENT") { + // The wrappers themselves. Both stand for the game folder, which already exists. + return std::string(); + } + if (startsWith(name, "USRDIR/CONTENT/")) { + name = name.substr(strlen("USRDIR/CONTENT/")); + } else if (startsWith(name, "USRDIR/")) { + name = name.substr(strlen("USRDIR/")); + } else { + // Everything at the root is store metadata - PARAM.SFO, PS3LOGO.DAT, ICON0.PNG and + // friends. Installing the SFO would make PPSSPP mistake the folder for save data. + return std::string(); + } + if (name.empty() || !IsSafeRelativePath(name)) { + return std::string(); + } + return std::string(name); +} + +u64 PkgInstalledSize(const PkgInfo &info) { + u64 total = 0; + for (const PkgItem &item : info.items) { + if (!item.IsDirectory() && !PkgItemInstallPath(item).empty()) { + total += item.dataSize; + } + } + return total; +} + +bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error) { + const PkgInfo &info = reader.Info(); + + const u64 totalBytes = PkgInstalledSize(info); + if (totalBytes == 0) { + *error = "Nothing to install in this PKG"; + return false; + } + + if (!File::CreateFullPath(destDir)) { + *error = "Failed to create the destination folder"; + return false; + } + + u64 writtenBytes = 0; + std::vector buffer(PKG_COPY_BLOCK); + + for (const PkgItem &item : info.items) { + const std::string relative = PkgItemInstallPath(item); + if (relative.empty()) { + if (!item.IsDirectory() && !startsWith(item.name, "USRDIR")) { + // Expected - store metadata. Anything else is worth a line in the log. + VERBOSE_LOG(Log::Loader, "PKG: skipping '%s'", item.name.c_str()); + } + continue; + } + const Path destPath = destDir / relative; + if (item.IsDirectory()) { + if (!File::CreateFullPath(destPath)) { + *error = "Failed to create a folder in the destination"; + return false; + } + continue; + } + if (!File::CreateFullPath(destPath.NavigateUp())) { + *error = "Failed to create a folder in the destination"; + return false; + } + + FILE *f = File::OpenCFile(destPath, "wb"); + if (!f) { + *error = "Failed to write to the destination folder"; + return false; + } + + u64 pos = 0; + bool failed = false; + while (pos < item.dataSize) { + const size_t chunk = (size_t)std::min(buffer.size(), item.dataSize - pos); + if (!reader.ReadItemData(item, pos, chunk, buffer.data())) { + *error = "Failed to read from the PKG file"; + failed = true; + break; + } + if (fwrite(buffer.data(), 1, chunk, f) != chunk) { + *error = "Failed to write to the destination folder"; + failed = true; + break; + } + pos += chunk; + writtenBytes += chunk; + if (progress) { + progress((float)((double)writtenBytes / (double)totalBytes)); + } + } + fclose(f); + if (failed) { + File::Delete(destPath); + return false; + } + INFO_LOG(Log::Loader, "PKG: installed %s (%lld bytes)", relative.c_str(), (long long)item.dataSize); + } + + if (progress) { + progress(1.0f); + } + return true; +} + +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 loader(ConstructFileLoader(pbootPath)); + if (!loader) { + return false; + } + PBPReader pbp(loader.get()); + std::vector sfoData; + ParamSFOData sfo; + if (!pbp.IsValid() || !pbp.GetSubFile(PBP_PARAM_SFO, &sfoData) || !sfo.ReadSFO(sfoData)) { + WARN_LOG(Log::Loader, "'%s' doesn't look like a game update", pbootPath.c_str()); + return false; + } + + update->folder = folder; + update->pbootPath = pbootPath; + update->appVer = sfo.GetValueString("APP_VER"); + update->discVersion = sfo.GetValueString("DISC_VERSION"); + update->title = sfo.GetValueString("PBOOT_TITLE"); + update->sharesFolderWithGame = File::Exists(folder / "EBOOT.PBP"); + update->sizeOnDisk = update->sharesFolderWithGame + ? (u64)std::max(0, File::GetFileSize(pbootPath)) + : File::ComputeRecursiveDirectorySize(folder); + return true; +} + +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update) { + const bool useTrash = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + // Only the PBOOT when the folder is a game in its own right - see the struct's comment. + const Path target = update.sharesFolderWithGame ? update.pbootPath : update.folder; + INFO_LOG(Log::Loader, "Removing game update '%s'", target.c_str()); + if (useTrash) { + // TODO: No way to tell whether this succeeded. + System_MoveToTrash(target); + return true; + } + return update.sharesFolderWithGame ? File::Delete(target) : File::DeleteDirRecursively(target); +} diff --git a/Core/Util/PkgUnpack.h b/Core/Util/PkgUnpack.h new file mode 100644 index 0000000000..8b8b2637f8 --- /dev/null +++ b/Core/Util/PkgUnpack.h @@ -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 +#include +#include + +#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//, and the game +// then boots from there with the original disc still supplying everything the patch doesn't +// override - see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp. +// +// The whole package past the header is AES-128-CTR, and every PBOOT seen so far is encrypted with +// a PRX tag we already have a key for, so nothing here needs new crypto secrets. +// +// docs/pkg_notes.md describes the format and what these packages turned out to contain. + +struct PkgItem { + std::string name; + u64 dataOffset = 0; // Relative to the encrypted area, not to the file. + u64 dataSize = 0; + u8 pspType = 0; // 0x90 selects the PSP key, anything else the PS3 key. + u8 flags = 0; // Content type. See docs/pkg_notes.md for the values. + + bool IsDirectory() const { return flags == 4 || flags == 18; } +}; + +struct PkgInfo { + std::string contentId; // "JP0177-ULJM05681_00-PJD2UPDATEVR0101" + std::string titleId; // "ULJM05681" + u32 contentType = 0; // 7 for PSP. See kPkgContentTypePSP. + std::string title; // From the package's own PARAM.SFO. + std::string category; // "PP" for a game update. + + // A game update carries a PBOOT.PBP whose own PARAM.SFO says what it patches. Without one + // there's nothing here we know how to install. + bool isGameUpdate = false; + std::string discId; // The disc this patches, e.g. "ULJM05681". + std::string discVersion; // The disc version it patches, e.g. "1.00". + std::string appVer; // The patch's own version, e.g. "01.01". + std::string systemVer; // Firmware the patch asks for, e.g. "6.20". + std::string pbootTitle; // "Update 2.01", when the patch bothers to name itself. + + std::vector items; +}; + +const u32 kPkgContentTypePSP = 7; + +class PkgReader { +public: + // Parses the header, the item table and the two PARAM.SFOs. Doesn't take ownership of the + // loader, which has to outlive the reader. + bool Open(FileLoader *loader, std::string *error); + + const PkgInfo &Info() const { return info_; } + + // Decrypts `size` bytes at `offset` within an item. + bool ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out); + // The whole item, for small ones. Fails rather than allocating more than maxSize. + bool ReadItem(const PkgItem &item, std::vector *out, size_t maxSize = 4 * 1024 * 1024); + +private: + bool ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out); + const u8 *ItemKey(const PkgItem &item) const; + bool ReadPBOOTInfo(const PkgItem &pboot); + + FileLoader *loader_ = nullptr; + u64 dataOffset_ = 0; + u64 dataSize_ = 0; + u8 riv_[16]{}; + u8 mainKey_[16]{}; + PkgInfo info_; +}; + +// Where an item ends up inside the installed game folder, or empty for the ones that shouldn't be +// installed at all. A package wraps its payload in USRDIR/CONTENT/, PS3-style, and also carries +// store metadata (PARAM.SFO, PS3LOGO.DAT, the icons) that isn't part of the PSP-side install. +std::string PkgItemInstallPath(const PkgItem &item); + +// What the install will take up on disk. Package contents aren't compressed, so this is exact +// rather than an estimate - modulo the filesystem's own per-file overhead. +u64 PkgInstalledSize(const PkgInfo &info); + +// Unpacks the installable items into destDir, which should be the game folder itself +// (PSP/GAME/). progress is called with 0..1 as it goes, and may be null. +bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error); + +// An update that has been installed, i.e. what's left over in PSP/GAME/ afterwards. +struct InstalledGameUpdate { + Path folder; + Path pbootPath; + std::string appVer; // The update's version, from PBOOT.PBP's own PARAM.SFO. + std::string discVersion; // The disc version it was built against. + std::string title; // PBOOT_TITLE, when the update names itself ("Update 2.01"). + u64 sizeOnDisk = 0; // How much deleting it would actually free. + // True when a game shares the folder - a digital title, whose EBOOT.PBP sits right next to the + // update. Deleting the whole folder would take the game with it. + bool sharesFolderWithGame = false; +}; + +// Looks for an update installed for discId. Cheap enough to call while building a UI - a stat and +// a small read, plus a directory walk for the size. +bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update); + +// Removes an installed update, to the trash where there is one. Takes the whole folder when the +// update is all that's in it, and only PBOOT.PBP when a game shares the folder - that stops the +// update from being used, and it's the only part we can still identify after the fact. +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update); diff --git a/Tools/pkg.py b/Tools/pkg.py new file mode 100755 index 0000000000..dc63ad9335 --- /dev/null +++ b/Tools/pkg.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""PSP .PKG (NPDRM package) parser/extractor. + +Lists or extracts a package - PSP game updates in particular, which pkg2zip skips. +See docs/pkg_notes.md for the format, and for what these packages turn out to contain. + + pkg.py FILE.pkg ... list contents + pkg.py -x OUTDIR FILE.pkg ... extract into OUTDIR + +Needs the "cryptography" module for AES. +""" +import sys, os, struct +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +PKG_PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b") +PKG_PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8") +PKG_VITA_2 = bytes.fromhex("e31a70c9ce1dd72bf3c0622963f2eccb") +PKG_VITA_3 = bytes.fromhex("423aca3a2bd5649f9686abad6fd8801f") +PKG_VITA_4 = bytes.fromhex("af07fd59652527baf13389668b17d9ea") + +def aes_ecb_enc(key, block): + c = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return c.update(block) + c.finalize() + +def ctr_xor(key, iv, block_index, data): + ctr = (int.from_bytes(iv, 'big') + block_index) % (1 << 128) + c = Cipher(algorithms.AES(key), modes.CTR(ctr.to_bytes(16, 'big'))).decryptor() + return c.update(data) + c.finalize() + +TYPES = {0: "?", 1: "NPDRM", 2: "NPDRM_EDAT", 3: "FILE", 4: "DIRECTORY", + 9: "SELF", 11: "PSP_FILE", 18: "DIRECTORY2"} + +META_NAMES = {1: "DRM_TYPE", 2: "CONTENT_TYPE", 3: "PACKAGE_TYPE", 4: "PACKAGE_SIZE", + 5: "SDK/NPDRM_REV", 6: "TITLE_ID", 7: "QA_DIGEST", 8: "UNK_0x8", + 9: "UNK_0x9", 10: "INSTALL_DIR", 11: "UNK_0xB", 12: "UNK_0xC", + 13: "ITEMS_TABLE", 14: "PARAM_SFO", 15: "UNK_0xF"} + +CONTENT_TYPES = {0x4: "PS3_GameData", 0x5: "PS3_GameExec", 0x6: "PS1_PSN", + 0x7: "PSP_PSN", 0x9: "Theme", 0xB: "License", 0xE: "PSP_PCEngine", + 0xF: "PSP_Minis", 0x10: "PSP_NeoGeo", 0x15: "PSVita_App", + 0x16: "PSVita_DLC", 0x18: "PSM"} + + +class Item: + __slots__ = ("name", "off", "size", "psp_type", "flags", "key") + def is_dir(self): + return self.flags in (4, 18) + + +class Pkg: + def __init__(self, path): + self.path = path + self.f = open(path, 'rb') + h = self.f.read(0x100) + if h[:4] != b'\x7fPKG': + raise ValueError("not a PKG") + (self.rev, self.type, self.meta_off, self.meta_cnt, self.meta_size, + self.item_cnt, self.total_size, self.data_off, self.data_size) = \ + struct.unpack(">HHIIIIQQQ", h[4:0x30]) + self.content_id = h[0x30:0x60].split(b'\0')[0].decode('ascii', 'replace') + self.digest = h[0x60:0x70] + self.riv = h[0x70:0x80] + self.key_id = h[0xE7] & 7 + self.ext = h[0xC0:0xC4] == b'\x7fext' + + self.meta = self._read_meta() + self.content_type = 0 + self.items_off = 0 + self.sfo_off = self.sfo_size = 0 + for ident, val in self.meta: + if ident == 2: + self.content_type = struct.unpack(">I", val[:4])[0] + elif ident == 13: + self.items_off, self.items_size = struct.unpack(">II", val[:8]) + elif ident == 14: + self.sfo_off, self.sfo_size = struct.unpack(">II", val[:8]) + + if self.type == 2: # PSP / Vita + if self.key_id == 1: + self.main_key = PKG_PSP_KEY + else: + vk = {2: PKG_VITA_2, 3: PKG_VITA_3, 4: PKG_VITA_4}[self.key_id] + self.main_key = aes_ecb_enc(vk, self.riv) + else: + self.main_key = PKG_PS3_KEY + self.iv = self.riv + + def _read_meta(self): + self.f.seek(self.meta_off) + buf = self.f.read(self.meta_size if self.meta_size else 0x1000) + out, p = [], 0 + for _ in range(self.meta_cnt): + if p + 8 > len(buf): + break + ident, size = struct.unpack(">II", buf[p:p+8]) + out.append((ident, buf[p+8:p+8+size])) + p += 8 + size + return out + + def dec(self, offset, size, key=None): + """Read+decrypt `size` bytes at `offset` relative to the encrypted data area.""" + key = key or self.main_key + pre = offset & 0xF + base = offset - pre + self.f.seek(self.data_off + base) + raw = self.f.read(((size + pre + 15) // 16) * 16) + return ctr_xor(key, self.iv, base // 16, raw)[pre:pre+size] + + def items(self): + tbl = self.dec(self.items_off, self.item_cnt * 0x20) + res = [] + for i in range(self.item_cnt): + no, ns, doff, dsize, psp_type, _, _, flags = struct.unpack( + ">IIQQBBBB", tbl[i*0x20:i*0x20+0x1C]) + it = Item() + it.psp_type, it.flags, it.off, it.size = psp_type, flags, doff, dsize + it.key = self.main_key if (self.type != 2 or psp_type == 0x90) else PKG_PS3_KEY + it.name = self.dec(no, ns, it.key).decode('utf-8', 'replace') + res.append(it) + return res + + def read_item(self, it, maxsize=None): + n = it.size if maxsize is None else min(it.size, maxsize) + return self.dec(it.off, n, it.key) + + def sfo(self): + if self.sfo_size: + return parse_sfo(self.dec(self.sfo_off, self.sfo_size)) + for it in self.items(): + if it.name.upper().endswith("PARAM.SFO"): + return parse_sfo(self.read_item(it)) + return {} + + +def parse_sfo(data): + if len(data) < 0x14 or data[:4] != b'\0PSF': + return {} + key_tab, data_tab, count = struct.unpack(" len(data): + break + ko, fmt, ln, maxln, do = struct.unpack("SetTitle(info_->GetFilePath().GetFilename()); info_->icon.dataLoaded = true; break; diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index 28fe1705ab..66f071efb0 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -262,6 +262,15 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { } } +void GameScreen::RefreshInstalledUpdate() { + hasInstalledUpdate_ = false; + // Homebrew reuses real disc IDs often enough that we'd get false positives. + if (isHomebrew_ || !(knownFlags_ & GameInfoFlags::PARAM_SFO)) { + return; + } + hasInstalledUpdate_ = FindInstalledGameUpdate(info_->id, &installedUpdate_); +} + void GameScreen::CreateContentViews(UI::ViewGroup *parent) { if (!info_) { // Shouldn't happen @@ -505,6 +514,24 @@ void GameScreen::CreateContentViews(UI::ViewGroup *parent) { } } + // An installed game update replaces the disc's executable, so it's worth saying so here - + // otherwise there's nothing in the UI to explain why a patched game is running. + RefreshInstalledUpdate(); + if (hasInstalledUpdate_) { + infoLayout->Add(new ItemHeader(ga->T("Game update"))); + std::string updateLine = installedUpdate_.title; + if (!installedUpdate_.appVer.empty()) { + const std::string version = ApplySafeSubstitutions(ga->T("Version %1"), installedUpdate_.appVer); + updateLine = updateLine.empty() ? version : updateLine + " - " + version; + } + if (updateLine.empty()) { + updateLine = ga->T("Installed"); + } + updateLine += " - " + NiceSizeFormat(installedUpdate_.sizeOnDisk); + infoLayout->Add(new TextView(updateLine, ALIGN_LEFT, true))->SetBullet(true); + infoLayout->Add(new TextView(GetFriendlyPath(installedUpdate_.folder), ALIGN_LEFT | FLAG_WRAP_TEXT, true))->SetBullet(true); + } + // Show plugin info_, if any. Later might add checkboxes. auto plugins = HLEPlugins::FindPlugins(info_->id, g_Config.sLanguageIni); if (!plugins.empty()) { @@ -625,6 +652,12 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { }); } + RefreshInstalledUpdate(); + if (!inGame_ && hasInstalledUpdate_) { + Choice *btnDeleteUpdate = parent->Add(new Choice(ga->T("Delete Game Update"), ImageID("I_TRASHCAN"))); + btnDeleteUpdate->OnClick.Handle(this, &GameScreen::OnDeleteGameUpdate); + } + // Don't want to be able to delete the game while it's running. if (!inGame_) { Choice *deleteChoice = parent->Add(new Choice(ga->T("Delete Game"), ImageID("I_WARNING"))); @@ -632,6 +665,35 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { } } +void GameScreen::OnDeleteGameUpdate(UI::EventParams &e) { + if (!hasInstalledUpdate_) { + return; + } + auto di = GetI18NCategory(I18NCat::DIALOG); + auto ga = GetI18NCategory(I18NCat::GAME); + + std::string prompt(ga->T("DeleteConfirmGameUpdate", "Do you really want to remove the installed update?\nThe game will go back to running the version on the disc.")); + prompt += "\n\n"; + // Say exactly what disappears - for a digital game the folder holds the game itself, so only + // the update's executable goes. + prompt += GetFriendlyPath(installedUpdate_.sharesFolderWithGame ? installedUpdate_.pbootPath : installedUpdate_.folder); + + const bool trashAvailable = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + const InstalledGameUpdate update = installedUpdate_; + screenManager()->push( + new UI::MessagePopupScreen(ga->T("Delete Game Update"), prompt, trashAvailable ? di->T("Move to trash") : di->T("Delete"), di->T("Cancel"), + [this, update](bool yes) { + if (!yes) { + return; + } + if (!DeleteInstalledGameUpdate(update)) { + auto er = GetI18NCategory(I18NCat::ERRORS); + g_OSD.Show(OSDType::MESSAGE_ERROR, er->T("Failed to delete the game update")); + } + RecreateViews(); + })); +} + void GameScreen::OnCreateConfig(UI::EventParams &e) { if (!info_->Ready(GameInfoFlags::PARAM_SFO)) { return; diff --git a/UI/GameScreen.h b/UI/GameScreen.h index 398a2a8c0c..7b1e5c8e3b 100644 --- a/UI/GameScreen.h +++ b/UI/GameScreen.h @@ -23,6 +23,7 @@ #include "Common/UI/UIScreen.h" #include "Common/File/Path.h" #include "UI/GameInfoCache.h" +#include "Core/Util/PkgUnpack.h" #include "UI/SimpleDialogScreen.h" @@ -58,6 +59,11 @@ private: void OnCreateConfig(UI::EventParams &e); void OnDeleteConfig(UI::EventParams &e); void OnSetBackground(UI::EventParams &e); + void OnDeleteGameUpdate(UI::EventParams &e); + + // Checks whether a game update is installed for this game. Both the info pane and the context + // menu need the answer, and either can be built first. + void RefreshInstalledUpdate(); std::string CRC32string; @@ -69,6 +75,10 @@ private: bool knownHasCRC_ = false; + // A game update installed in PSP/GAME/, which is what actually runs when there is one. + InstalledGameUpdate installedUpdate_; + bool hasInstalledUpdate_ = false; + std::shared_ptr info_; mutable std::string titleCache_; }; diff --git a/UI/InstallPkgScreen.cpp b/UI/InstallPkgScreen.cpp new file mode 100644 index 0000000000..aa41a36c45 --- /dev/null +++ b/UI/InstallPkgScreen.cpp @@ -0,0 +1,193 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include + +#include "Common/Data/Text/I18n.h" +#include "Common/Data/Text/Parsers.h" +#include "Common/File/DiskFree.h" +#include "Common/File/FileUtil.h" +#include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" +#include "Common/UI/Context.h" +#include "Common/UI/UI.h" +#include "Common/UI/View.h" +#include "Common/UI/ViewGroup.h" + +#include "Core/Loaders.h" +#include "Core/System.h" +#include "Core/Util/GameManager.h" +#include "Core/Util/PathUtil.h" + +#include "UI/InstallPkgScreen.h" + +InstallPkgScreen::InstallPkgScreen(const Path &pkgPath) + : UITwoPaneBaseDialogScreen(Path(), TwoPaneFlags::SettingsToTheRight | TwoPaneFlags::ContentsCanScroll), pkgPath_(pkgPath) { + g_GameManager.ResetInstallError(); + + // Reading the package is cheap - the item table and two small PARAM.SFOs. We only keep the + // info; the install re-opens the file on its own thread. + std::unique_ptr loader(ConstructFileLoader(pkgPath_)); + PkgReader reader; + if (!loader || !reader.Open(loader.get(), &pkgError_)) { + return; + } + pkgInfo_ = reader.Info(); + if (!pkgInfo_.isGameUpdate) { + // Everything we can read is a game update; anything else got rejected above with a better + // message than this. + pkgError_ = "This PKG file isn't a PSP game update"; + return; + } + + canInstall_ = true; + installSize_ = PkgInstalledSize(pkgInfo_); + destination_ = GetSysDirectory(DIRECTORY_GAME) / pkgInfo_.discId; + alreadyInstalled_ = File::Exists(destination_ / "PBOOT.PBP"); + + int64_t space = 0; + if (free_disk_space(GetSysDirectory(DIRECTORY_GAME), space)) { + freeSpace_ = space; + } +} + +std::string_view InstallPkgScreen::GetTitle() const { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + return iz->T("Game update"); +} + +void InstallPkgScreen::CreateSettingsViews(UI::ViewGroup *parent) { + using namespace UI; + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + installChoice_ = nullptr; + + if (canInstall_) { + installChoice_ = parent->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD"))); + installChoice_->OnClick.Handle(this, &InstallPkgScreen::OnInstall); + } + + if (System_GetPropertyBool(SYSPROP_CAN_SHOW_FILE)) { + parent->Add(new Spacer(12.0f)); + parent->Add(new Choice(di->T("Show in folder")))->OnClick.Add([this](UI::EventParams &) { + System_ShowFileInFolder(pkgPath_); + }); + } + + if (canInstall_) { + parent->Add(new Spacer(12.0f)); + parent->Add(new CheckBox(&deletePkgFile_, iz->T("Delete PKG file"))); + } +} + +void InstallPkgScreen::CreateContentViews(UI::ViewGroup *parent) { + using namespace UI; + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + auto er = GetI18NCategory(I18NCat::ERRORS); + + LinearLayout *leftColumn = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8)))); + + if (!canInstall_) { + leftColumn->Add(new TextView(GetFriendlyPath(pkgPath_))); + leftColumn->Add(new NoticeView(NoticeLevel::ERROR, iz->T(pkgError_.empty() ? "This PKG file isn't a PSP game update" : pkgError_), "")); + doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", "")); + doneView_->SetVisibility(Visibility::V_GONE); + return; + } + + leftColumn->Add(new TextView(iz->T("Install game update?")))->SetBig(true); + leftColumn->Add(new TextView(pkgPath_.GetFilename())); + if (!pkgInfo_.title.empty()) { + leftColumn->Add(new TextView(pkgInfo_.title)); + } + if (!pkgInfo_.pbootTitle.empty()) { + leftColumn->Add(new TextView(pkgInfo_.pbootTitle)); + } + + leftColumn->Add(new Spacer(8.0f)); + + // What it patches, and to what. The disc version is the one the update was built against. + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2 (%3)", iz->T("Game"), pkgInfo_.discId, pkgInfo_.discVersion))); + if (!pkgInfo_.appVer.empty()) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Update version"), pkgInfo_.appVer))); + } + if (!pkgInfo_.systemVer.empty()) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Requires firmware"), pkgInfo_.systemVer))); + } + + // Package contents aren't compressed, so this is what it'll actually take up. + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Space needed"), NiceSizeFormat(installSize_)))); + if (freeSpace_ >= 0) { + leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Free space"), NiceSizeFormat((u64)freeSpace_)))); + } + + leftColumn->Add(new Spacer(8.0f)); + leftColumn->Add(new TextView(iz->T("Install into folder"))); + leftColumn->Add(new TextView(GetFriendlyPath(destination_)))->SetAlign(FLAG_WRAP_TEXT); + + doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", "")); + doneView_->SetVisibility(Visibility::V_GONE); + + if (freeSpace_ >= 0 && (u64)freeSpace_ < installSize_) { + leftColumn->Add(new NoticeView(NoticeLevel::ERROR, er->T("Not enough free space"), "")); + } + if (alreadyInstalled_) { + leftColumn->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), iz->T("An update for this game is already installed"))); + } +} + +bool InstallPkgScreen::key(const KeyInput &key) { + // Ignore key presses while installing, so the user can't escape mid-write. + if (g_GameManager.GetState() == GameManagerState::IDLE) { + return UIDialogScreen::key(key); + } + return false; +} + +void InstallPkgScreen::OnInstall(UI::EventParams ¶ms) { + if (!canInstall_) { + return; + } + if (g_GameManager.InstallPkgOnThread(pkgPath_, deletePkgFile_)) { + installStarted_ = true; + if (installChoice_) { + installChoice_->SetEnabled(false); + } + } +} + +void InstallPkgScreen::update() { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + using namespace UI; + if (g_GameManager.GetState() == GameManagerState::IDLE && doneView_) { + const std::string err = g_GameManager.GetInstallError(); + if (!err.empty()) { + doneView_->SetLevelAndText(NoticeLevel::ERROR, iz->T(err)); + doneView_->SetVisibility(Visibility::V_VISIBLE); + } else if (installStarted_) { + doneView_->SetLevelAndText(NoticeLevel::SUCCESS, iz->T("Installed!")); + doneView_->SetVisibility(Visibility::V_VISIBLE); + } + } + UIBaseDialogScreen::update(); +} diff --git a/UI/InstallPkgScreen.h b/UI/InstallPkgScreen.h new file mode 100644 index 0000000000..187ef188d5 --- /dev/null +++ b/UI/InstallPkgScreen.h @@ -0,0 +1,66 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include "Common/File/Path.h" +#include "Common/UI/Notice.h" +#include "Common/UI/UIScreen.h" +#include "Common/UI/View.h" + +#include "Core/Util/PkgUnpack.h" +#include "UI/BaseScreens.h" +#include "UI/SimpleDialogScreen.h" + +// Offers to install a PSP game update from a .pkg file, the way InstallZipScreen does for zips. +// The package goes into PSP/GAME/, and from then on booting that disc runs the update - +// see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp. +class InstallPkgScreen : public UITwoPaneBaseDialogScreen { +public: + InstallPkgScreen(const Path &pkgPath); + + void update() override; + bool key(const KeyInput &key) override; + + const char *tag() const override { return "InstallPkg"; } + +protected: + void CreateSettingsViews(UI::ViewGroup *parent) override; + void CreateContentViews(UI::ViewGroup *parent) override; + std::string_view GetTitle() const override; + ViewLayoutMode LayoutMode() const override { + return ViewLayoutMode::ApplyInsets; + } + +private: + void OnInstall(UI::EventParams ¶ms); + + Path pkgPath_; + Path destination_; + + PkgInfo pkgInfo_; + std::string pkgError_; // Why the package can't be installed, if it can't. + bool canInstall_ = false; + u64 installSize_ = 0; + s64 freeSpace_ = -1; // Negative if we couldn't find out. + bool alreadyInstalled_ = false; + + UI::Choice *installChoice_ = nullptr; + NoticeView *doneView_ = nullptr; + bool installStarted_ = false; + bool deletePkgFile_ = false; +}; diff --git a/UI/MainScreen.cpp b/UI/MainScreen.cpp index 232c10cef4..3419450e2b 100644 --- a/UI/MainScreen.cpp +++ b/UI/MainScreen.cpp @@ -51,6 +51,7 @@ #include "UI/SavedataScreen.h" #include "UI/InstallUpdateScreen.h" #include "UI/InstallZipScreen.h" +#include "UI/InstallPkgScreen.h" #include "UI/Background.h" #include "UI/GameBrowser.h" #include "Core/Config.h" @@ -67,6 +68,9 @@ static void LaunchFile(ScreenManager *screenManager, Screen *currentScreen, cons if (extension == ".zip" || extension == ".7z") { // If is a zip file, we have a screen for that. screenManager->push(new InstallZipScreen(path)); + } else if (extension == ".pkg") { + // A game update package - not something to boot, something to install. + screenManager->push(new InstallPkgScreen(path)); } else { // Check if we already know that this game isn't playable. // If coming from the main screen, the info will already be computed here since the icon is displayed etc. diff --git a/UI/UI.vcxproj b/UI/UI.vcxproj index 1c89ea0cd5..e74c8805fe 100644 --- a/UI/UI.vcxproj +++ b/UI/UI.vcxproj @@ -79,6 +79,7 @@ + @@ -137,6 +138,7 @@ + diff --git a/UI/UI.vcxproj.filters b/UI/UI.vcxproj.filters index d48a5f6306..c56a1b7fb3 100644 --- a/UI/UI.vcxproj.filters +++ b/UI/UI.vcxproj.filters @@ -40,6 +40,9 @@ Screens + + Screens + Screens @@ -194,6 +197,9 @@ Screens + + Screens + Screens diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj index 35426278f8..f3e3aab116 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj +++ b/UWP/CoreUWP/CoreUWP.vcxproj @@ -339,6 +339,7 @@ + @@ -660,6 +661,7 @@ + diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters index 45d4805f88..96576d9464 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj.filters +++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters @@ -282,6 +282,7 @@ + @@ -673,6 +674,7 @@ + diff --git a/UWP/UI_UWP/UI_UWP.vcxproj b/UWP/UI_UWP/UI_UWP.vcxproj index 32b48346fa..3da7949f38 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj +++ b/UWP/UI_UWP/UI_UWP.vcxproj @@ -114,6 +114,7 @@ + @@ -173,6 +174,7 @@ + diff --git a/UWP/UI_UWP/UI_UWP.vcxproj.filters b/UWP/UI_UWP/UI_UWP.vcxproj.filters index 4d0d433135..4274cc244d 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj.filters +++ b/UWP/UI_UWP/UI_UWP.vcxproj.filters @@ -73,6 +73,9 @@ Screens + + Screens + Screens @@ -219,6 +222,9 @@ Screens + + Screens + Screens diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 4d3052016c..afdccb3ec6 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -814,6 +814,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/Core/Util/PPGeDraw.cpp \ $(SRC)/Core/Util/KL4E.cpp \ $(SRC)/Core/Util/PSARUnpack.cpp \ + $(SRC)/Core/Util/PkgUnpack.cpp \ $(SRC)/Core/Util/RecentFiles.cpp \ $(SRC)/Core/Util/VideoPlayer.cpp \ $(SRC)/git-version.cpp @@ -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 \ diff --git a/docs/pkg_notes.md b/docs/pkg_notes.md new file mode 100644 index 0000000000..720e35ff08 --- /dev/null +++ b/docs/pkg_notes.md @@ -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//`. 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: , 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= \ + --mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \ + /PSP/GAME/ULJM05681/EBOOT.PBP + ``` + + It logs `Decrypting tag 2E5E10F0`, loads the ELF (`tag=ELF/PdvApp`), resolves its imports, and + reads `Diva2Data.cpk` / `Diva2Script.cpk` / `Diva2Sound.cpk` off `disc0:`. Runs without error. + (`--mount` is what makes that work: `Load_PSP_ELF_PBP` in `Core/PSPLoaders.cpp` mounts the ISO + on `disc0:`, `umd:` and `umd1:` when booting an ELF or PBP.) + +**pkg2zip cannot extract update packages**, so it is not an alternative here. Its PSP path only +recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an ISO), +`PSP-KEY.EDAT` and `CONTENT.DAT`, and `continue`s past everything else - `PBOOT.PBP` and every +patch file are silently dropped, with no warning that anything was skipped. + +## 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/` folder when the update is all + that's in it, and only `PBOOT.PBP` when a digital game shares the folder - nothing records what + an install wrote, so the executable is the only part still identifiable afterwards. +- **`FindGameUpdatePBOOT()` in `Core/PSPLoaders.cpp`** is the boot-time half. Starting a disc looks + for `ms0:/PSP/GAME//PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN` + if it's there, leaving the disc mounted. + +`PPSSPPHeadless --install-pkg=DIR ` does an install without the UI, which is how the +above got tested. It prints what the package is and installs into DIR exactly (the app picks +`PSP/GAME/` itself). + +### Install layout + +The package's own PS3-style wrapping is stripped: `USRDIR/CONTENT/` and `USRDIR/` both become +`` in the game folder, and the `USRDIR` and `USRDIR/CONTENT` directory entries are dropped +rather than created. The root-level files - `PARAM.SFO`, `PS3LOGO.DAT`, `ICON0.PNG`, `PIC0.PNG`, +`PIC2.PNG` - are store metadata and are **not** installed. Writing that `PARAM.SFO` in particular +would be actively wrong: a folder holding a `PARAM.SFO` and no `EBOOT.PBP` is what PPSSPP +identifies as *save data*, so the update would show up in the savedata list. + +### Which updates get used + +The update's `DISC_ID` has to match the disc's, or it's ignored with a warning - that's what keeps +an update from being applied to the wrong game. + +`DISC_VERSION` is advisory. An update is built against one specific disc revision, and PPSSPP logs +a warning when they differ, but still boots it: refusing outright is a worse failure mode than +letting the user find out, since they installed it deliberately. This is not what a real PSP does. +It comes up in practice - the LittleBigPlanet v2.05 update targets disc version 1.00, and the +common European dump is 1.01, and it works. + +There's no setting to turn this off. Anyone who has a `PBOOT.PBP` sitting in a game folder either +installed it here or copied it off a real memory stick, and in both cases booting it is what they +were after. + +### Verified + +- All packages parse and install through the C++ path, byte-identical to `Tools/pkg.py` - each + one reports its disc ID and versions and writes its payload without an error. +- Hatsune Miku Project DIVA 2nd (ULJM05681): install the v1.01 update, boot the UMD, PPSSPP boots + `PBOOT.PBP` and the game reads its CPKs off `disc0:`. +- LittleBigPlanet (UCES01264): install the v2.05 update, boot the v1.01 UMD, and the patched game + opens `ms0:/PSP/GAME/UCES01264/PATCH.ARC` alongside the disc's own `lbp_archive.arc` - the update + is actually in use, not just booted. +- `python3 test.py -g --graphics=software`: no failures, so restructuring the disc boot + path didn't disturb anything. +- Truncated and item-table-corrupted packages are refused with a message rather than crashing. + Corruption *inside* file data still installs - nothing here verifies the package CMAC, same as + every other PKG tool. +- The browser listing, install screen and install itself were checked by hand in the app. + +## Digital (NP\*) titles + +About half of the packages patch a digital title rather than a UMD, and that half is tested too. + +The one that settles the question is **Super Robot Taisen Operation Extend (NPJH50521)**, because +it's a real NPUMDIMG `EBOOT.PBP` rather than a decrypted ISO dump - `NPDRM: PSAR ID: 4d55504e`, +mounted on `disc0:` by the NPDRM block device, with the game's own 560 MB EBOOT sitting in the same +folder as the update. All eight of its update revisions were installed and booted in turn, and each +one loads a distinguishably different executable: + +``` +disc executable .text 0x419b1c +v1.01 0x41fb8c v1.02 0x4233dc v1.03 0x42952c v1.04 0x42a53c +v1.05 0x42a61c v1.06 0x42a59c v1.07 0x42b48c v1.08 0x42b7dc +``` + +So **`ISO.BIN.EDAT` does not re-key the PBOOT**, which was the open worry: a digital title's patched +executable is encrypted exactly like a UMD one, and needs nothing PPSSPP doesn't already have. + +**`DISC_VERSION` being advisory matters far more than expected.** Most of the pairs hit a mismatch, +because the dumps in circulation are later disc revisions than the updates were built against. +Refusing outright would make most of them unusable. Elminage Original was the one clean +exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning. + +### 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. diff --git a/ext/libkirk/AES.h b/ext/libkirk/AES.h index ee61f46490..08e2260c99 100644 --- a/ext/libkirk/AES.h +++ b/ext/libkirk/AES.h @@ -3,6 +3,10 @@ #include "kirk_common.h" +#ifdef __cplusplus +extern "C" { +#endif + #define AES_KEY_LEN_128 (128) #define AES_KEY_LEN_192 (192) #define AES_KEY_LEN_256 (256) @@ -48,4 +52,8 @@ int rijndaelKeySetupEnc(u32 [], const u8 [], int); int rijndaelKeySetupDec(u32 [], const u8 [], int); void rijndaelEncrypt(const u32 [], int, const u8 pt[16], u8 ct[16]); +#ifdef __cplusplus +} +#endif + #endif /* __RIJNDAEL_H */ diff --git a/ext/libkirk/SHA1.h b/ext/libkirk/SHA1.h index 43493e3319..2735c3faf2 100644 --- a/ext/libkirk/SHA1.h +++ b/ext/libkirk/SHA1.h @@ -2,6 +2,10 @@ #include "kirk_common.h" +#ifdef __cplusplus +extern "C" { +#endif + /* POINTER defines a generic pointer type */ typedef unsigned char *POINTER; typedef const unsigned char *CONST_POINTER; @@ -34,3 +38,7 @@ void SHAUpdate(SHA_CTX *, const BYTE *buffer, int count); void SHAFinal(BYTE *output, SHA_CTX *); void endianTest(int *endianness); + +#ifdef __cplusplus +} +#endif diff --git a/ext/libkirk/kirk_engine.h b/ext/libkirk/kirk_engine.h index de90c4d48e..153e8c0b34 100644 --- a/ext/libkirk/kirk_engine.h +++ b/ext/libkirk/kirk_engine.h @@ -26,6 +26,11 @@ #pragma once +// kirk4()/kirk7() below take a size_t, and nothing here provided it - the header only ever +// compiled because whatever included it had pulled in a definition first. Core/HLE/scePspNpDrm_user.cpp +// includes libkirk before anything else, so it doesn't. +#include + #include "kirk_common.h" #include "SHA1.h" #include "AES.h" diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 92dc444804..7bb4b9b326 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -58,6 +58,7 @@ #include "Core/MIPS/MIPSTables.h" #include "Core/System.h" #include "Core/Util/PSARUnpack.h" +#include "Core/Util/PkgUnpack.h" #include "Core/WebServer.h" #include "Core/HLE/sceUtility.h" #include "Core/SaveState.h" @@ -671,6 +672,38 @@ int main(int argc, const char* argv[]) { return ok ? 0 : 1; } + // Same deal for installing a game update package. + if (cmdLineOptions.installPkg.has_value()) { + if (cmdLineOptions.bootFilenames.size() != 1) { + fprintf(stderr, "--install-pkg takes exactly one .pkg file\n"); + return 1; + } + std::unique_ptr loader(ConstructFileLoader(Path(cmdLineOptions.bootFilenames[0]))); + PkgReader reader; + std::string pkgError; + if (!loader || !reader.Open(loader.get(), &pkgError)) { + fprintf(stderr, "Not a usable PKG: %s\n", pkgError.c_str()); + return 1; + } + const PkgInfo &info = reader.Info(); + printf("%s (%s)\n", info.title.c_str(), info.contentId.c_str()); + printf("Category %s, %d items, %lld bytes installed\n", info.category.c_str(), + (int)info.items.size(), (long long)PkgInstalledSize(info)); + if (info.isGameUpdate) { + printf("Game update for %s v%s -> app version %s (firmware %s)\n", info.discId.c_str(), + info.discVersion.c_str(), info.appVer.c_str(), info.systemVer.c_str()); + } else { + fprintf(stderr, "This PKG isn't a game update - nothing we know how to install\n"); + return 1; + } + if (!InstallPkg(reader, Path(cmdLineOptions.installPkg.value()), nullptr, &pkgError)) { + fprintf(stderr, "Install failed: %s\n", pkgError.c_str()); + return 1; + } + printf("Installed into %s\n", cmdLineOptions.installPkg.value().c_str()); + return 0; + } + g_Config.RestoreDefaults(RestoreSettingsBits::SETTINGS | RestoreSettingsBits::CONTROLS | RestoreSettingsBits::RECENT, false); Core_RegisterDebugOutputListeners(&SendDebugOutput, &SendDebugScreenshot); diff --git a/libretro/Makefile.common b/libretro/Makefile.common index b197b43ffe..c0af28cc5b 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -902,6 +902,7 @@ SOURCES_CXX += \ $(COREDIR)/Util/PPGeDraw.cpp \ $(COREDIR)/Util/KL4E.cpp \ $(COREDIR)/Util/PSARUnpack.cpp \ + $(COREDIR)/Util/PkgUnpack.cpp \ $(COREDIR)/Util/RecentFiles.cpp \ $(COREDIR)/Util/AudioFormat.cpp \ $(COREDIR)/Util/PathUtil.cpp \