mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-09-19 19:07:48 +02:00
Merge pull request #22258 from hrydgard/pkg-research
PKG install support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -82,6 +82,12 @@ struct CommandLineOptions {
|
||||
// libraries. Needs a firmware dump under the NAND directory.
|
||||
std::optional<int> disableHLE;
|
||||
|
||||
// Headless: install the game update in a .pkg (given as the boot filename) into this
|
||||
// directory, then exit without booting anything. The directory is the game folder itself -
|
||||
// the app puts that under PSP/GAME/<DISC_ID>, but here the caller picks. See
|
||||
// Core/Util/PkgUnpack.h.
|
||||
std::optional<std::string> installPkg;
|
||||
|
||||
std::optional<int> memReadAction;
|
||||
std::optional<int> memWriteAction;
|
||||
std::optional<int> breakAction;
|
||||
|
||||
@@ -924,6 +924,7 @@
|
||||
<ClCompile Include="Util\PPGeDraw.cpp" />
|
||||
<ClCompile Include="Util\KL4E.cpp" />
|
||||
<ClCompile Include="Util\PSARUnpack.cpp" />
|
||||
<ClCompile Include="Util\PkgUnpack.cpp" />
|
||||
<ClCompile Include="..\ext\xxhash.c">
|
||||
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">MaxSpeed</Optimization>
|
||||
<IntrinsicFunctions Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</IntrinsicFunctions>
|
||||
@@ -1294,6 +1295,7 @@
|
||||
<ClInclude Include="Util\PPGeDraw.h" />
|
||||
<ClInclude Include="Util\KL4E.h" />
|
||||
<ClInclude Include="Util\PSARUnpack.h" />
|
||||
<ClInclude Include="Util\PkgUnpack.h" />
|
||||
<ClInclude Include="..\ext\xxhash.h" />
|
||||
<ClInclude Include="Util\RecentFiles.h" />
|
||||
<ClInclude Include="Util\VideoPlayer.h" />
|
||||
|
||||
@@ -369,6 +369,9 @@
|
||||
<ClCompile Include="Util\PSARUnpack.cpp">
|
||||
<Filter>Util</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="Util\PkgUnpack.cpp">
|
||||
<Filter>Util</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="HLE\sceFont.cpp">
|
||||
<Filter>HLE\Libraries</Filter>
|
||||
</ClCompile>
|
||||
@@ -1698,6 +1701,9 @@
|
||||
<ClInclude Include="Util\PSARUnpack.h">
|
||||
<Filter>Util</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="Util\PkgUnpack.h">
|
||||
<Filter>Util</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="HLE\sceFont.h">
|
||||
<Filter>HLE\Libraries</Filter>
|
||||
</ClInclude>
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
#include <array>
|
||||
#include <string.h>
|
||||
|
||||
extern "C"
|
||||
{
|
||||
#include "ext/libkirk/kirk_engine.h"
|
||||
#include "ext/libkirk/SHA1.h"
|
||||
}
|
||||
#include "Common/Common.h"
|
||||
#include "Common/Log.h"
|
||||
#include "Common/Swap.h"
|
||||
@@ -14,6 +11,20 @@ extern "C"
|
||||
|
||||
#define ROUNDUP16(x) (((x)+15)&~15)
|
||||
|
||||
// PSP_Header::decrypt_mode, the byte at 0x7C. Only the one we act on is named; the rest select
|
||||
// which decryption variant a real PSP would use, which we don't need since we try them in turn.
|
||||
enum {
|
||||
PRX_DECRYPT_MODE_SPRX = 23,
|
||||
};
|
||||
|
||||
// A module that arrived inside an NPDRM EDAT carries this fixed XOR on top of its tag's key. It
|
||||
// goes with the decrypt_mode above rather than with any particular tag - tag 0x407810F0's table
|
||||
// entry has no seed of its own, in JPCSP's tables as well as ours, so keying this on the tag
|
||||
// would be wrong for a 0x407810F0 module that arrived some other way.
|
||||
static const u8 xor_91E0A9AD[16] = {
|
||||
0x84, 0x7B, 0xF5, 0xFE, 0xE8, 0x4D, 0xAD, 0x7A, 0xB5, 0x06, 0x28, 0x0E, 0x09, 0xFA, 0x81, 0xE1,
|
||||
};
|
||||
|
||||
// Thank you PSARDUMPER & JPCSP keys
|
||||
|
||||
// PRXDecrypter 16-byte tag keys.
|
||||
@@ -966,9 +977,13 @@ static int pspDecryptType5(KirkState *kirk, const u8 *inbuf, u8 *outbuf, u32 siz
|
||||
// expand the seed into a xor buffer
|
||||
auto xorbuf = expandSeed(pti->key, pti->code, seed);
|
||||
|
||||
// The XOR the decrypt_mode implies wins over the tag table's, which is only the fallback -
|
||||
// same precedence as JPCSP, and it leaves every tag that has a seed of its own alone.
|
||||
const u8 *xor1 = inbuf[0x7C] == PRX_DECRYPT_MODE_SPRX ? xor_91E0A9AD : pti->seed;
|
||||
|
||||
// construct the header format for a type 2 prx
|
||||
PRXType5 type5(inbuf);
|
||||
type5.decrypt(pti->code, pti->seed, seed);
|
||||
type5.decrypt(pti->code, xor1, seed);
|
||||
|
||||
SHA_CTX ctx;
|
||||
SHAInit(&ctx);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,4 @@ void __KernelSemaInit();
|
||||
void __KernelSemaDoState(PointerWrap &p);
|
||||
KernelObject *__KernelSemaphoreObject();
|
||||
|
||||
extern "C"
|
||||
{
|
||||
#include "ext/libkirk/kirk_engine.h"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include "ext/libkirk/AES.h"
|
||||
#include "ext/libkirk/amctrl.h"
|
||||
|
||||
#include "Core/HLE/scePspNpDrm_user.h"
|
||||
#include "Core/MemMapHelpers.h"
|
||||
#include "Core/HLE/HLE.h"
|
||||
#include "Core/HLE/FunctionWrappers.h"
|
||||
#include "Core/HLE/sceChnnlsv.h"
|
||||
#include "Core/HLE/sceIo.h"
|
||||
#include "Core/FileSystems/MetaFileSystem.h"
|
||||
|
||||
@@ -132,6 +136,45 @@ static int sceNpDrmOpen() {
|
||||
return hleLogError(Log::sceIo, 0, "UNIMPL: sceNpDrmOpen()");
|
||||
}
|
||||
|
||||
// The last pass over a module key. This one belongs to npdrm.prx's module path rather than to the
|
||||
// PGD/amctrl side, so it isn't among the keys in ext/libkirk.
|
||||
static const u8 drmModuleKey[16] = {
|
||||
0xBA, 0x87, 0xE4, 0xAB, 0x2C, 0x60, 0x5F, 0x59, 0xB8, 0x3B, 0xDB, 0xA6, 0x82, 0xFD, 0xAE, 0x14,
|
||||
};
|
||||
|
||||
bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut) {
|
||||
// The low byte of the u32 at 0x08 picks which fixed key to derive from the content ID at 0x10.
|
||||
char contentId[0x31]{};
|
||||
memcpy(contentId, edatHeader + 0x10, 0x30);
|
||||
const int keyMode = 0x01000000 | edatHeader[0x08];
|
||||
const int result = sceNpDrmGetFixedKey(__ChnnlsvKirkState(), keyOut, contentId, keyMode);
|
||||
if (result != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const u8 flags = edatHeader[0x0F];
|
||||
if (flags & 1) {
|
||||
// The game is meant to hand the licensee key over before it loads the module.
|
||||
if (!isLicenseeKeySet) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < PSP_NPDRM_LICENSEE_KEY_LENGTH; i++) {
|
||||
keyOut[i] ^= licenseeKey[i];
|
||||
}
|
||||
}
|
||||
if (flags & 2) {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
keyOut[i] ^= edatHeader[0x40 + i];
|
||||
}
|
||||
}
|
||||
|
||||
// JPCSP does this as CBC with an all-zero IV, which over a single block is a plain decrypt.
|
||||
AES_ctx ctx;
|
||||
AES_set_key(&ctx, drmModuleKey, 128);
|
||||
AES_decrypt(&ctx, keyOut, keyOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
const HLEFunction sceNpDrm[] = {
|
||||
{0XA1336091, &WrapI_U<sceNpDrmSetLicenseeKey>, "sceNpDrmSetLicenseeKey", 'i', "x"},
|
||||
{0X9B745542, &WrapI_V<sceNpDrmClearLicenseeKey>, "sceNpDrmClearLicenseeKey", 'i', "" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -40,6 +40,8 @@ enum class IdentifiedFileType {
|
||||
ARCHIVE_RAR,
|
||||
ARCHIVE_ZIP,
|
||||
ARCHIVE_7Z,
|
||||
// A .pkg game update - a container we install from rather than boot. See Core/Util/PkgUnpack.h.
|
||||
PSP_PKG,
|
||||
PSP_PS1_PBP,
|
||||
PSX_ISO,
|
||||
PS2_ISO,
|
||||
|
||||
+105
-31
@@ -260,44 +260,118 @@ static const char * const altBootNames[] = {
|
||||
//"disc0:/PSP_GAME/SYSDIR/ss.RAW",//Code Geass: Lost Colors chinese version
|
||||
};
|
||||
|
||||
bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) {
|
||||
std::string bootpath("disc0:/PSP_GAME/SYSDIR/EBOOT.BIN");
|
||||
// A game update installed from a .pkg (see Core/Util/PkgUnpack.h) lands in PSP/GAME/<DISC_ID>/,
|
||||
// with the patched executable as PBOOT.PBP. The PSP boots that instead of the disc's own EBOOT,
|
||||
// leaving the disc mounted - so the update overrides the files it ships and the disc supplies
|
||||
// everything else.
|
||||
|
||||
// Bypass Chinese translation patches, see comment above.
|
||||
for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) {
|
||||
if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) {
|
||||
WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str());
|
||||
bootpath = altBootNames[i];
|
||||
// break; // should have a break here, but it would effectively reverse the evaluation order.
|
||||
}
|
||||
static bool ReadPBPParamSFO(const std::string &path, ParamSFOData *sfo) {
|
||||
const int fd = pspFileSystem.OpenFile(path, FILEACCESS_READ);
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game.
|
||||
std::string id = g_paramSFO.GetValueString("DISC_ID");
|
||||
if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) {
|
||||
bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN";
|
||||
}
|
||||
if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) {
|
||||
bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL";
|
||||
}
|
||||
|
||||
bool hasEncrypted = false;
|
||||
int fd;
|
||||
if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) {
|
||||
u8 head[4]{};
|
||||
// A file shorter than the magic used to leave head partly uninitialized, and then decided
|
||||
// which boot file to use by comparing against it.
|
||||
if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) {
|
||||
if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) {
|
||||
hasEncrypted = true;
|
||||
bool success = false;
|
||||
// A PBP starts with its magic, a version, and eight little-endian subfile offsets. PARAM.SFO
|
||||
// is the first subfile, so it runs from its own offset to ICON0.PNG's.
|
||||
u8 header[0x28];
|
||||
if (pspFileSystem.ReadFile(fd, header, sizeof(header)) == sizeof(header) && !memcmp(header, "\0PBP", 4)) {
|
||||
u32_le sfoOffset, iconOffset;
|
||||
memcpy(&sfoOffset, header + 0x08, sizeof(sfoOffset));
|
||||
memcpy(&iconOffset, header + 0x0C, sizeof(iconOffset));
|
||||
const u32 sfoSize = iconOffset - sfoOffset;
|
||||
if (sfoOffset >= sizeof(header) && iconOffset > sfoOffset && sfoSize <= 64 * 1024) {
|
||||
std::vector<u8> sfoData(sfoSize);
|
||||
if (pspFileSystem.SeekFile(fd, sfoOffset, FILEMOVE_BEGIN) >= 0 &&
|
||||
pspFileSystem.ReadFile(fd, sfoData.data(), sfoSize) == sfoSize) {
|
||||
success = sfo->ReadSFO(sfoData);
|
||||
}
|
||||
}
|
||||
pspFileSystem.CloseFile(fd);
|
||||
}
|
||||
pspFileSystem.CloseFile(fd);
|
||||
return success;
|
||||
}
|
||||
|
||||
// Returns the path of the update to boot, or an empty string to boot the disc normally.
|
||||
static std::string FindGameUpdatePBOOT(const std::string &discId, const std::string &discVersion) {
|
||||
if (discId.empty()) {
|
||||
return std::string();
|
||||
}
|
||||
const std::string path = "ms0:/PSP/GAME/" + discId + "/PBOOT.PBP";
|
||||
if (!pspFileSystem.GetFileInfo(path).exists) {
|
||||
return std::string();
|
||||
}
|
||||
|
||||
if (!hasEncrypted) {
|
||||
// try unencrypted Boot.BIN
|
||||
bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN";
|
||||
// Check what the update claims to patch before handing it the boot.
|
||||
ParamSFOData sfo;
|
||||
if (!ReadPBPParamSFO(path, &sfo)) {
|
||||
WARN_LOG(Log::Loader, "Ignoring '%s': couldn't read its PARAM.SFO", path.c_str());
|
||||
return std::string();
|
||||
}
|
||||
|
||||
const std::string updateDiscId = sfo.GetValueString("DISC_ID");
|
||||
if (updateDiscId != discId) {
|
||||
WARN_LOG(Log::Loader, "Ignoring '%s': it's an update for %s, not %s", path.c_str(), updateDiscId.c_str(), discId.c_str());
|
||||
return std::string();
|
||||
}
|
||||
|
||||
// The disc version is advisory here. An update is built against one specific revision of a
|
||||
// disc, but refusing to run one on a slightly different dump is a worse failure than letting
|
||||
// the user find out - they went and installed it on purpose.
|
||||
const std::string updateDiscVersion = sfo.GetValueString("DISC_VERSION");
|
||||
if (!updateDiscVersion.empty() && !discVersion.empty() && updateDiscVersion != discVersion) {
|
||||
WARN_LOG(Log::Loader, "Game update '%s' is for disc version %s, but this disc is %s. Booting it anyway.",
|
||||
path.c_str(), updateDiscVersion.c_str(), discVersion.c_str());
|
||||
}
|
||||
|
||||
NOTICE_LOG(Log::Loader, "Booting game update '%s' (app version %s) instead of the disc's executable",
|
||||
path.c_str(), sfo.GetValueString("APP_VER").c_str());
|
||||
return path;
|
||||
}
|
||||
|
||||
bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) {
|
||||
const std::string id = g_paramSFO.GetValueString("DISC_ID");
|
||||
|
||||
// An installed game update replaces the disc's executable - see FindGameUpdatePBOOT above.
|
||||
std::string bootpath = FindGameUpdatePBOOT(id, g_paramSFO.GetValueString("DISC_VERSION"));
|
||||
if (bootpath.empty()) {
|
||||
bootpath = "disc0:/PSP_GAME/SYSDIR/EBOOT.BIN";
|
||||
|
||||
// Bypass Chinese translation patches, see comment above.
|
||||
for (size_t i = 0; i < ARRAY_SIZE(altBootNames); i++) {
|
||||
if (pspFileSystem.GetFileInfo(altBootNames[i]).exists) {
|
||||
WARN_LOG(Log::Boot, "Bypassing suspected translation patch. Booting '%s' instead of '%s'.", altBootNames[i], bootpath.c_str());
|
||||
bootpath = altBootNames[i];
|
||||
// break; // should have a break here, but it would effectively reverse the evaluation order.
|
||||
}
|
||||
}
|
||||
|
||||
// Bypass another more dangerous one where the file is in USRDIR - this could collide with files in some game.
|
||||
if (id == "NPJH50624" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN").exists) {
|
||||
bootpath = "disc0:/PSP_GAME/USRDIR/PAKFILE2.BIN";
|
||||
}
|
||||
if (id == "NPJH00100" && pspFileSystem.GetFileInfo("disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL").exists) {
|
||||
bootpath = "disc0:/PSP_GAME/USRDIR/DATA/GIM/GBL";
|
||||
}
|
||||
|
||||
bool hasEncrypted = false;
|
||||
int fd;
|
||||
if ((fd = pspFileSystem.OpenFile(bootpath, FILEACCESS_READ)) >= 0) {
|
||||
u8 head[4]{};
|
||||
// A file shorter than the magic used to leave head partly uninitialized, and then decided
|
||||
// which boot file to use by comparing against it.
|
||||
if (pspFileSystem.ReadFile(fd, head, sizeof(head)) == sizeof(head)) {
|
||||
if (memcmp(head, "~PSP", 4) == 0 || memcmp(head, "\x7F""ELF", 4) == 0) {
|
||||
hasEncrypted = true;
|
||||
}
|
||||
}
|
||||
pspFileSystem.CloseFile(fd);
|
||||
}
|
||||
|
||||
if (!hasEncrypted) {
|
||||
// try unencrypted Boot.BIN
|
||||
bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN";
|
||||
}
|
||||
}
|
||||
|
||||
// Fail early with a clearer message for some types of ISOs.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
#include "Core/FileSystems/ISOFileSystem.h"
|
||||
#include "Core/Util/GameManager.h"
|
||||
#include "Core/Util/PathUtil.h"
|
||||
#include "Core/Util/PkgUnpack.h"
|
||||
#include "Core/Util/RecentFiles.h"
|
||||
#include "Common/Data/Text/I18n.h"
|
||||
|
||||
@@ -902,6 +903,70 @@ bool GameManager::InstallZipOnThread(ZipFileTask task) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Installing a game update from a .pkg. Unlike a zip there's nothing to guess about - the
|
||||
// package says which disc it patches, and that decides the destination folder.
|
||||
void GameManager::InstallPkgContents(Path pkgPath, bool deleteAfter) {
|
||||
SetCurrentThreadName("InstallPkgContents");
|
||||
|
||||
AndroidJNIThreadContext context; // Destructor detaches.
|
||||
|
||||
auto di = GetI18NCategory(I18NCat::DIALOG);
|
||||
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
|
||||
|
||||
g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, 0.0f, 0.1f);
|
||||
|
||||
bool success = false;
|
||||
std::string error;
|
||||
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pkgPath));
|
||||
PkgReader reader;
|
||||
if (!loader || !reader.Open(loader.get(), &error)) {
|
||||
ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str());
|
||||
SetInstallError(iz->T("This PKG file isn't a PSP game update"));
|
||||
} else if (!reader.Info().isGameUpdate) {
|
||||
SetInstallError(iz->T("This PKG file isn't a PSP game update"));
|
||||
} else {
|
||||
const Path destination = GetSysDirectory(DIRECTORY_GAME) / reader.Info().discId;
|
||||
success = InstallPkg(reader, destination, [this](float progress) {
|
||||
installProgress_ = progress;
|
||||
auto di = GetI18NCategory(I18NCat::DIALOG);
|
||||
g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f);
|
||||
}, &error);
|
||||
if (!success) {
|
||||
ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str());
|
||||
SetInstallError(iz->T("Failed to install the game update"));
|
||||
}
|
||||
}
|
||||
|
||||
// Close the package before anything tries to delete it.
|
||||
loader.reset();
|
||||
|
||||
if (deleteAfter && success) {
|
||||
if (System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN)) {
|
||||
System_MoveToTrash(pkgPath);
|
||||
} else {
|
||||
File::Delete(pkgPath);
|
||||
}
|
||||
}
|
||||
|
||||
g_OSD.RemoveProgressBar("install", success, 0.5f);
|
||||
installProgress_ = 1.0f;
|
||||
InstallDone();
|
||||
if (success) {
|
||||
ResetInstallError();
|
||||
}
|
||||
}
|
||||
|
||||
bool GameManager::InstallPkgOnThread(const Path &pkgPath, bool deleteAfter) {
|
||||
if (InstallInProgress() || installDonePending_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
installThread_ = std::thread([this, pkgPath, deleteAfter]() {
|
||||
InstallPkgContents(pkgPath, deleteAfter);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GameManager::UninstallGameOnThread(const std::string &name) {
|
||||
if (name.empty()) {
|
||||
ERROR_LOG(Log::HLE, "Cannot uninstall an empty-named game");
|
||||
|
||||
@@ -85,6 +85,10 @@ public:
|
||||
// Only returns false if there's already an installation in progress.
|
||||
bool InstallZipOnThread(ZipFileTask task);
|
||||
|
||||
// Installs a game update from a .pkg into PSP/GAME/<DISC_ID>. Same contract as the above -
|
||||
// only returns false if something else is already installing. See Core/Util/PkgUnpack.h.
|
||||
bool InstallPkgOnThread(const Path &pkgPath, bool deleteAfter);
|
||||
|
||||
// Separate kind of functionality from InstallZipOnThread, so doesn't re-use the task struct.
|
||||
bool UninstallGameOnThread(const std::string &name);
|
||||
|
||||
@@ -94,6 +98,7 @@ public:
|
||||
|
||||
private:
|
||||
void InstallZipContents(ZipFileTask task);
|
||||
void InstallPkgContents(Path pkgPath, bool deleteAfter);
|
||||
bool InstallMemstickZip(const Path &zipFile, const Path &dest, const ZipFileInfo &info);
|
||||
bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir);
|
||||
void UninstallGame(const std::string &name);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
// Copyright (c) 2026- PPSSPP Project.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, version 2.0 or later versions.
|
||||
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License 2.0 for more details.
|
||||
|
||||
// A copy of the GPL 2.0 should have been included with the program.
|
||||
// If not, see http://www.gnu.org/licenses/
|
||||
|
||||
// Official git repository and contact information can be found at
|
||||
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "Common/File/FileUtil.h"
|
||||
#include "Common/File/Path.h"
|
||||
#include "Common/Log.h"
|
||||
#include "Common/StringUtils.h"
|
||||
#include "Common/System/Request.h"
|
||||
#include "Common/System/System.h"
|
||||
#include "Core/ELF/ParamSFO.h"
|
||||
#include "Core/ELF/PBPReader.h"
|
||||
#include "Core/Loaders.h"
|
||||
#include "Core/System.h"
|
||||
#include "Core/Util/PkgUnpack.h"
|
||||
|
||||
#include "ext/libkirk/AES.h"
|
||||
|
||||
// See docs/pkg_notes.md. Field offsets in the 0xC0-byte header:
|
||||
static const u32 PKG_MAGIC = 0x7F504B47; // "\x7FPKG"
|
||||
static const u32 PKG_TYPE_PSP = 2; // 1 is PS3.
|
||||
// 0xC0 of header plus the 0x40-byte extended header after it, which is where the key index is.
|
||||
static const size_t PKG_HEADER_SIZE = 0x100;
|
||||
static const size_t PKG_ITEM_SIZE = 0x20;
|
||||
|
||||
// The two keys a PSP package's contents are encrypted with, picked per item. Vita packages derive
|
||||
// theirs from the riv instead, which we don't handle - nothing here reads Vita packages.
|
||||
static const u8 PKG_PSP_KEY[16] = {
|
||||
0x07, 0xf2, 0xc6, 0x82, 0x90, 0xb5, 0x0d, 0x2c, 0x33, 0x81, 0x8d, 0x70, 0x9b, 0x60, 0xe6, 0x2b,
|
||||
};
|
||||
static const u8 PKG_PS3_KEY[16] = {
|
||||
0x2e, 0x7b, 0x71, 0xd7, 0xc9, 0xc9, 0xa1, 0x4e, 0xa3, 0x22, 0x1f, 0x18, 0x88, 0x28, 0xb8, 0xf8,
|
||||
};
|
||||
|
||||
// An item table with more entries than this is corrupt, not something we should try to allocate
|
||||
// for. The biggest update package seen has a few hundred.
|
||||
static const u32 PKG_MAX_ITEMS = 65536;
|
||||
// Same idea for a single filename.
|
||||
static const u32 PKG_MAX_NAME = 1024;
|
||||
|
||||
static const size_t PKG_COPY_BLOCK = 512 * 1024;
|
||||
|
||||
// Everything in a PKG header, its metadata and its item table is big-endian; the PBP inside is
|
||||
// little-endian like the rest of the PSP world. None of it is guaranteed aligned, so read bytes.
|
||||
static u16 Read16BE(const u8 *p) {
|
||||
return (u16)(((u32)p[0] << 8) | p[1]);
|
||||
}
|
||||
static u32 Read32BE(const u8 *p) {
|
||||
return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3];
|
||||
}
|
||||
static u64 Read64BE(const u8 *p) {
|
||||
return ((u64)Read32BE(p) << 32) | (u64)Read32BE(p + 4);
|
||||
}
|
||||
static u32 Read32LE(const u8 *p) {
|
||||
return ((u32)p[3] << 24) | ((u32)p[2] << 16) | ((u32)p[1] << 8) | (u32)p[0];
|
||||
}
|
||||
|
||||
// AES-128-CTR, with the counter block starting at `iv` and incrementing once per 16 bytes. Both
|
||||
// the counter and the increment are big-endian, and the counter wraps over the whole 128 bits.
|
||||
static void AesCtrXor(const u8 *key, const u8 *iv, u64 blockIndex, u8 *data, size_t size) {
|
||||
AES_ctx ctx;
|
||||
AES_set_key(&ctx, key, 128);
|
||||
|
||||
u8 counter[16];
|
||||
memcpy(counter, iv, 16);
|
||||
// Add blockIndex to the 128-bit big-endian counter.
|
||||
u32 carry = 0;
|
||||
for (int i = 15; i >= 0; i--) {
|
||||
const u32 sum = (u32)counter[i] + (u32)(blockIndex & 0xFF) + carry;
|
||||
counter[i] = (u8)sum;
|
||||
carry = sum >> 8;
|
||||
blockIndex >>= 8;
|
||||
}
|
||||
|
||||
u8 stream[16];
|
||||
for (size_t pos = 0; pos < size; pos += 16) {
|
||||
AES_encrypt(&ctx, counter, stream);
|
||||
const size_t chunk = std::min<size_t>(16, size - pos);
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
data[pos + i] ^= stream[i];
|
||||
}
|
||||
// Increment the counter block.
|
||||
for (int i = 15; i >= 0; i--) {
|
||||
if (++counter[i] != 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const u8 *PkgReader::ItemKey(const PkgItem &item) const {
|
||||
return item.pspType == 0x90 ? mainKey_ : PKG_PS3_KEY;
|
||||
}
|
||||
|
||||
bool PkgReader::ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out) {
|
||||
if (size == 0) {
|
||||
return true;
|
||||
}
|
||||
// The counter runs on 16-byte boundaries, so read from the containing block and skip the
|
||||
// leading bytes afterwards.
|
||||
const size_t skip = (size_t)(offset & 0xF);
|
||||
const u64 base = offset - skip;
|
||||
const size_t alignedSize = (skip + size + 15) & ~(size_t)0xF;
|
||||
|
||||
if (base > dataSize_ || alignedSize > dataSize_ - base) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<u8> buf(alignedSize);
|
||||
if (loader_->ReadAt(dataOffset_ + base, alignedSize, buf.data()) != alignedSize) {
|
||||
return false;
|
||||
}
|
||||
AesCtrXor(key, riv_, base / 16, buf.data(), alignedSize);
|
||||
memcpy(out, buf.data() + skip, size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PkgReader::ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out) {
|
||||
if (offset > item.dataSize || size > item.dataSize - offset) {
|
||||
return false;
|
||||
}
|
||||
return ReadEncrypted(item.dataOffset + offset, size, ItemKey(item), out);
|
||||
}
|
||||
|
||||
bool PkgReader::ReadItem(const PkgItem &item, std::vector<u8> *out, size_t maxSize) {
|
||||
if (item.dataSize > maxSize) {
|
||||
return false;
|
||||
}
|
||||
out->resize((size_t)item.dataSize);
|
||||
return ReadItemData(item, 0, out->size(), out->data());
|
||||
}
|
||||
|
||||
bool PkgReader::Open(FileLoader *loader, std::string *error) {
|
||||
loader_ = loader;
|
||||
info_ = PkgInfo();
|
||||
|
||||
u8 header[PKG_HEADER_SIZE];
|
||||
if (!loader || loader->ReadAt(0, sizeof(header), header) != sizeof(header)) {
|
||||
*error = "Not a PKG file: too short";
|
||||
return false;
|
||||
}
|
||||
if (Read32BE(header) != PKG_MAGIC) {
|
||||
*error = "Not a PKG file";
|
||||
return false;
|
||||
}
|
||||
|
||||
const u32 type = Read16BE(header + 0x06);
|
||||
if (type != PKG_TYPE_PSP) {
|
||||
*error = "Not a PSP PKG file";
|
||||
return false;
|
||||
}
|
||||
// Key index 1 is the only one a PSP package uses; 2-4 are Vita, and derive a key from the riv.
|
||||
const u32 keyIndex = header[0xE7] & 7;
|
||||
if (keyIndex != 1) {
|
||||
*error = StringFromFormat("Unsupported PKG key index %d", keyIndex);
|
||||
return false;
|
||||
}
|
||||
memcpy(mainKey_, PKG_PSP_KEY, sizeof(mainKey_));
|
||||
|
||||
const u32 metaOffset = Read32BE(header + 0x08);
|
||||
const u32 metaCount = Read32BE(header + 0x0C);
|
||||
const u32 itemCount = Read32BE(header + 0x14);
|
||||
const u64 totalSize = Read64BE(header + 0x18);
|
||||
dataOffset_ = Read64BE(header + 0x20);
|
||||
dataSize_ = Read64BE(header + 0x28);
|
||||
memcpy(riv_, header + 0x70, sizeof(riv_));
|
||||
|
||||
char contentId[0x31]{};
|
||||
memcpy(contentId, header + 0x30, 0x30);
|
||||
info_.contentId = contentId;
|
||||
|
||||
const s64 fileSize = loader->FileSize();
|
||||
if (fileSize < 0 || (u64)fileSize < totalSize || dataOffset_ + dataSize_ > (u64)fileSize) {
|
||||
*error = "PKG file is truncated";
|
||||
return false;
|
||||
}
|
||||
if (itemCount == 0 || itemCount > PKG_MAX_ITEMS) {
|
||||
*error = "PKG file has a broken item table";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The metadata is in the clear. We only need three things out of it, and one of them (the item
|
||||
// table offset) is zero in every update package seen - but read it rather than assume.
|
||||
u32 itemsOffset = 0;
|
||||
u64 metaPos = metaOffset;
|
||||
for (u32 i = 0; i < metaCount; i++) {
|
||||
u8 rec[8];
|
||||
if (loader->ReadAt(metaPos, sizeof(rec), rec) != sizeof(rec)) {
|
||||
*error = "PKG metadata is truncated";
|
||||
return false;
|
||||
}
|
||||
const u32 id = Read32BE(rec);
|
||||
const u32 size = Read32BE(rec + 4);
|
||||
if (size > 0x1000) {
|
||||
*error = "PKG metadata is corrupt";
|
||||
return false;
|
||||
}
|
||||
std::vector<u8> value(size);
|
||||
if (size && loader->ReadAt(metaPos + 8, size, value.data()) != size) {
|
||||
*error = "PKG metadata is truncated";
|
||||
return false;
|
||||
}
|
||||
switch (id) {
|
||||
case 2:
|
||||
if (size >= 4) {
|
||||
info_.contentType = Read32BE(value.data());
|
||||
}
|
||||
break;
|
||||
case 6:
|
||||
info_.titleId = std::string((const char *)value.data(), strnlen((const char *)value.data(), size));
|
||||
break;
|
||||
case 13:
|
||||
if (size >= 4) {
|
||||
itemsOffset = Read32BE(value.data());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
metaPos += 8 + size;
|
||||
}
|
||||
|
||||
if (info_.contentType != kPkgContentTypePSP) {
|
||||
*error = StringFromFormat("PKG holds content type 0x%x, not a PSP game", info_.contentType);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Item table, then the filenames it points at. Both live in the encrypted area, but a
|
||||
// filename is encrypted with its own item's key rather than the table's.
|
||||
std::vector<u8> table((size_t)itemCount * PKG_ITEM_SIZE);
|
||||
if (!ReadEncrypted(itemsOffset, table.size(), mainKey_, table.data())) {
|
||||
*error = "Failed to read the PKG item table";
|
||||
return false;
|
||||
}
|
||||
|
||||
info_.items.reserve(itemCount);
|
||||
for (u32 i = 0; i < itemCount; i++) {
|
||||
const u8 *rec = table.data() + (size_t)i * PKG_ITEM_SIZE;
|
||||
const u32 nameOffset = Read32BE(rec);
|
||||
const u32 nameSize = Read32BE(rec + 4);
|
||||
|
||||
PkgItem item;
|
||||
item.dataOffset = Read64BE(rec + 8);
|
||||
item.dataSize = Read64BE(rec + 16);
|
||||
item.pspType = rec[0x18];
|
||||
item.flags = rec[0x1B];
|
||||
|
||||
if (item.dataOffset > dataSize_ || item.dataSize > dataSize_ - item.dataOffset) {
|
||||
*error = "PKG item points outside the file";
|
||||
return false;
|
||||
}
|
||||
if (nameSize == 0 || nameSize > PKG_MAX_NAME) {
|
||||
*error = "PKG item has a broken name";
|
||||
return false;
|
||||
}
|
||||
item.name.resize(nameSize);
|
||||
if (!ReadEncrypted(nameOffset, nameSize, ItemKey(item), (u8 *)item.name.data())) {
|
||||
*error = "Failed to read a PKG item name";
|
||||
return false;
|
||||
}
|
||||
// Names aren't terminated, but be forgiving if one is anyway.
|
||||
item.name.resize(strnlen(item.name.c_str(), item.name.size()));
|
||||
info_.items.push_back(item);
|
||||
}
|
||||
|
||||
// The package's own PARAM.SFO. There's no reliable pointer to it in the metadata for PSP
|
||||
// packages (the field is empty), so go by name.
|
||||
for (const PkgItem &item : info_.items) {
|
||||
if (item.name != "PARAM.SFO") {
|
||||
continue;
|
||||
}
|
||||
std::vector<u8> sfoData;
|
||||
ParamSFOData sfo;
|
||||
if (ReadItem(item, &sfoData, 64 * 1024) && sfo.ReadSFO(sfoData)) {
|
||||
info_.title = sfo.GetValueString("TITLE");
|
||||
info_.category = sfo.GetValueString("CATEGORY");
|
||||
if (info_.titleId.empty()) {
|
||||
info_.titleId = sfo.GetValueString("TITLE_ID");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
for (const PkgItem &item : info_.items) {
|
||||
if (item.name == "USRDIR/CONTENT/PBOOT.PBP" && !item.IsDirectory()) {
|
||||
ReadPBOOTInfo(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
INFO_LOG(Log::Loader, "PKG: %s (%s), %d items, update=%d for %s v%s",
|
||||
info_.contentId.c_str(), info_.category.c_str(), (int)info_.items.size(),
|
||||
(int)info_.isGameUpdate, info_.discId.c_str(), info_.discVersion.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// The PBOOT is a normal PBP - only its DATA.PSP is encrypted, and its PARAM.SFO is the one that
|
||||
// says which disc and disc version this patches. That's what makes matching an installed update
|
||||
// against a game at boot time possible without decrypting anything.
|
||||
bool PkgReader::ReadPBOOTInfo(const PkgItem &pboot) {
|
||||
// PBP header: magic, version, then eight little-endian subfile offsets.
|
||||
u8 header[0x28];
|
||||
if (pboot.dataSize < sizeof(header) || !ReadItemData(pboot, 0, sizeof(header), header)) {
|
||||
return false;
|
||||
}
|
||||
if (memcmp(header, "\0PBP", 4) != 0) {
|
||||
WARN_LOG(Log::Loader, "PKG: PBOOT.PBP isn't a PBP");
|
||||
return false;
|
||||
}
|
||||
const u32 sfoOffset = Read32LE(header + 0x08);
|
||||
const u32 iconOffset = Read32LE(header + 0x0C);
|
||||
if (sfoOffset > iconOffset || iconOffset > pboot.dataSize) {
|
||||
return false;
|
||||
}
|
||||
const u32 sfoSize = iconOffset - sfoOffset;
|
||||
if (sfoSize == 0 || sfoSize > 64 * 1024) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<u8> sfoData(sfoSize);
|
||||
if (!ReadItemData(pboot, sfoOffset, sfoSize, sfoData.data())) {
|
||||
return false;
|
||||
}
|
||||
ParamSFOData sfo;
|
||||
if (!sfo.ReadSFO(sfoData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
info_.discId = sfo.GetValueString("DISC_ID");
|
||||
info_.discVersion = sfo.GetValueString("DISC_VERSION");
|
||||
info_.appVer = sfo.GetValueString("APP_VER");
|
||||
info_.systemVer = sfo.GetValueString("PSP_SYSTEM_VER");
|
||||
info_.pbootTitle = sfo.GetValueString("PBOOT_TITLE");
|
||||
if (info_.title.empty()) {
|
||||
info_.title = sfo.GetValueString("TITLE");
|
||||
}
|
||||
if (info_.discVersion.empty()) {
|
||||
info_.discVersion = "1.00";
|
||||
}
|
||||
// A disc ID is what the install is keyed on, so without one there's nowhere to put this.
|
||||
info_.isGameUpdate = !info_.discId.empty();
|
||||
return info_.isGameUpdate;
|
||||
}
|
||||
|
||||
// Rejects anything that could escape the destination directory. Package filenames are attacker
|
||||
// data as far as we're concerned.
|
||||
static bool IsSafeRelativePath(std::string_view name) {
|
||||
if (name.empty() || name.size() > PKG_MAX_NAME) {
|
||||
return false;
|
||||
}
|
||||
if (name.front() == '/' || name.find('\\') != std::string_view::npos || name.find(':') != std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
size_t start = 0;
|
||||
while (start <= name.size()) {
|
||||
const size_t slash = name.find('/', start);
|
||||
const std::string_view part = name.substr(start, slash == std::string_view::npos ? std::string_view::npos : slash - start);
|
||||
if (part.empty() || part == "." || part == "..") {
|
||||
return false;
|
||||
}
|
||||
for (char c : part) {
|
||||
// Control characters in a filename are never legitimate here.
|
||||
if ((unsigned char)c < 0x20) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (slash == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
start = slash + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PkgItemInstallPath(const PkgItem &item) {
|
||||
// A package wraps its payload PS3-style. USRDIR/CONTENT/ is where the patch's files live, and
|
||||
// USRDIR/ itself only ever holds ISO.BIN.EDAT; both map to the game folder root.
|
||||
std::string_view name = item.name;
|
||||
if (name == "USRDIR" || name == "USRDIR/CONTENT") {
|
||||
// The wrappers themselves. Both stand for the game folder, which already exists.
|
||||
return std::string();
|
||||
}
|
||||
if (startsWith(name, "USRDIR/CONTENT/")) {
|
||||
name = name.substr(strlen("USRDIR/CONTENT/"));
|
||||
} else if (startsWith(name, "USRDIR/")) {
|
||||
name = name.substr(strlen("USRDIR/"));
|
||||
} else {
|
||||
// Everything at the root is store metadata - PARAM.SFO, PS3LOGO.DAT, ICON0.PNG and
|
||||
// friends. Installing the SFO would make PPSSPP mistake the folder for save data.
|
||||
return std::string();
|
||||
}
|
||||
if (name.empty() || !IsSafeRelativePath(name)) {
|
||||
return std::string();
|
||||
}
|
||||
return std::string(name);
|
||||
}
|
||||
|
||||
u64 PkgInstalledSize(const PkgInfo &info) {
|
||||
u64 total = 0;
|
||||
for (const PkgItem &item : info.items) {
|
||||
if (!item.IsDirectory() && !PkgItemInstallPath(item).empty()) {
|
||||
total += item.dataSize;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function<void(float)> &progress, std::string *error) {
|
||||
const PkgInfo &info = reader.Info();
|
||||
|
||||
const u64 totalBytes = PkgInstalledSize(info);
|
||||
if (totalBytes == 0) {
|
||||
*error = "Nothing to install in this PKG";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File::CreateFullPath(destDir)) {
|
||||
*error = "Failed to create the destination folder";
|
||||
return false;
|
||||
}
|
||||
|
||||
u64 writtenBytes = 0;
|
||||
std::vector<u8> buffer(PKG_COPY_BLOCK);
|
||||
|
||||
for (const PkgItem &item : info.items) {
|
||||
const std::string relative = PkgItemInstallPath(item);
|
||||
if (relative.empty()) {
|
||||
if (!item.IsDirectory() && !startsWith(item.name, "USRDIR")) {
|
||||
// Expected - store metadata. Anything else is worth a line in the log.
|
||||
VERBOSE_LOG(Log::Loader, "PKG: skipping '%s'", item.name.c_str());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const Path destPath = destDir / relative;
|
||||
if (item.IsDirectory()) {
|
||||
if (!File::CreateFullPath(destPath)) {
|
||||
*error = "Failed to create a folder in the destination";
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!File::CreateFullPath(destPath.NavigateUp())) {
|
||||
*error = "Failed to create a folder in the destination";
|
||||
return false;
|
||||
}
|
||||
|
||||
FILE *f = File::OpenCFile(destPath, "wb");
|
||||
if (!f) {
|
||||
*error = "Failed to write to the destination folder";
|
||||
return false;
|
||||
}
|
||||
|
||||
u64 pos = 0;
|
||||
bool failed = false;
|
||||
while (pos < item.dataSize) {
|
||||
const size_t chunk = (size_t)std::min<u64>(buffer.size(), item.dataSize - pos);
|
||||
if (!reader.ReadItemData(item, pos, chunk, buffer.data())) {
|
||||
*error = "Failed to read from the PKG file";
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
if (fwrite(buffer.data(), 1, chunk, f) != chunk) {
|
||||
*error = "Failed to write to the destination folder";
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
pos += chunk;
|
||||
writtenBytes += chunk;
|
||||
if (progress) {
|
||||
progress((float)((double)writtenBytes / (double)totalBytes));
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (failed) {
|
||||
File::Delete(destPath);
|
||||
return false;
|
||||
}
|
||||
INFO_LOG(Log::Loader, "PKG: installed %s (%lld bytes)", relative.c_str(), (long long)item.dataSize);
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
progress(1.0f);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update) {
|
||||
if (discId.empty()) {
|
||||
return false;
|
||||
}
|
||||
const Path folder = GetSysDirectory(DIRECTORY_GAME) / std::string(discId);
|
||||
const Path pbootPath = folder / "PBOOT.PBP";
|
||||
if (!File::Exists(pbootPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The version we want is in the PBOOT's own PARAM.SFO, which isn't encrypted.
|
||||
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pbootPath));
|
||||
if (!loader) {
|
||||
return false;
|
||||
}
|
||||
PBPReader pbp(loader.get());
|
||||
std::vector<u8> sfoData;
|
||||
ParamSFOData sfo;
|
||||
if (!pbp.IsValid() || !pbp.GetSubFile(PBP_PARAM_SFO, &sfoData) || !sfo.ReadSFO(sfoData)) {
|
||||
WARN_LOG(Log::Loader, "'%s' doesn't look like a game update", pbootPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
update->folder = folder;
|
||||
update->pbootPath = pbootPath;
|
||||
update->appVer = sfo.GetValueString("APP_VER");
|
||||
update->discVersion = sfo.GetValueString("DISC_VERSION");
|
||||
update->title = sfo.GetValueString("PBOOT_TITLE");
|
||||
update->sharesFolderWithGame = File::Exists(folder / "EBOOT.PBP");
|
||||
update->sizeOnDisk = update->sharesFolderWithGame
|
||||
? (u64)std::max<s64>(0, File::GetFileSize(pbootPath))
|
||||
: File::ComputeRecursiveDirectorySize(folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update) {
|
||||
const bool useTrash = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN);
|
||||
// Only the PBOOT when the folder is a game in its own right - see the struct's comment.
|
||||
const Path target = update.sharesFolderWithGame ? update.pbootPath : update.folder;
|
||||
INFO_LOG(Log::Loader, "Removing game update '%s'", target.c_str());
|
||||
if (useTrash) {
|
||||
// TODO: No way to tell whether this succeeded.
|
||||
System_MoveToTrash(target);
|
||||
return true;
|
||||
}
|
||||
return update.sharesFolderWithGame ? File::Delete(target) : File::DeleteDirRecursively(target);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026- PPSSPP Project.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, version 2.0 or later versions.
|
||||
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License 2.0 for more details.
|
||||
|
||||
// A copy of the GPL 2.0 should have been included with the program.
|
||||
// If not, see http://www.gnu.org/licenses/
|
||||
|
||||
// Official git repository and contact information can be found at
|
||||
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Common/CommonTypes.h"
|
||||
#include "Common/File/Path.h"
|
||||
|
||||
class FileLoader;
|
||||
|
||||
// Reads .PKG files - the NPDRM container Sony distributed downloadable content in. We're only
|
||||
// interested in one flavor: PSP *game updates*, which hold a patched EBOOT (PBOOT.PBP) plus the
|
||||
// data files the patch replaces. Installing one puts them in PSP/GAME/<DISC_ID>/, and the game
|
||||
// then boots from there with the original disc still supplying everything the patch doesn't
|
||||
// override - see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp.
|
||||
//
|
||||
// The whole package past the header is AES-128-CTR, and every PBOOT seen so far is encrypted with
|
||||
// a PRX tag we already have a key for, so nothing here needs new crypto secrets.
|
||||
//
|
||||
// docs/pkg_notes.md describes the format and what these packages turned out to contain.
|
||||
|
||||
struct PkgItem {
|
||||
std::string name;
|
||||
u64 dataOffset = 0; // Relative to the encrypted area, not to the file.
|
||||
u64 dataSize = 0;
|
||||
u8 pspType = 0; // 0x90 selects the PSP key, anything else the PS3 key.
|
||||
u8 flags = 0; // Content type. See docs/pkg_notes.md for the values.
|
||||
|
||||
bool IsDirectory() const { return flags == 4 || flags == 18; }
|
||||
};
|
||||
|
||||
struct PkgInfo {
|
||||
std::string contentId; // "JP0177-ULJM05681_00-PJD2UPDATEVR0101"
|
||||
std::string titleId; // "ULJM05681"
|
||||
u32 contentType = 0; // 7 for PSP. See kPkgContentTypePSP.
|
||||
std::string title; // From the package's own PARAM.SFO.
|
||||
std::string category; // "PP" for a game update.
|
||||
|
||||
// A game update carries a PBOOT.PBP whose own PARAM.SFO says what it patches. Without one
|
||||
// there's nothing here we know how to install.
|
||||
bool isGameUpdate = false;
|
||||
std::string discId; // The disc this patches, e.g. "ULJM05681".
|
||||
std::string discVersion; // The disc version it patches, e.g. "1.00".
|
||||
std::string appVer; // The patch's own version, e.g. "01.01".
|
||||
std::string systemVer; // Firmware the patch asks for, e.g. "6.20".
|
||||
std::string pbootTitle; // "Update 2.01", when the patch bothers to name itself.
|
||||
|
||||
std::vector<PkgItem> items;
|
||||
};
|
||||
|
||||
const u32 kPkgContentTypePSP = 7;
|
||||
|
||||
class PkgReader {
|
||||
public:
|
||||
// Parses the header, the item table and the two PARAM.SFOs. Doesn't take ownership of the
|
||||
// loader, which has to outlive the reader.
|
||||
bool Open(FileLoader *loader, std::string *error);
|
||||
|
||||
const PkgInfo &Info() const { return info_; }
|
||||
|
||||
// Decrypts `size` bytes at `offset` within an item.
|
||||
bool ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out);
|
||||
// The whole item, for small ones. Fails rather than allocating more than maxSize.
|
||||
bool ReadItem(const PkgItem &item, std::vector<u8> *out, size_t maxSize = 4 * 1024 * 1024);
|
||||
|
||||
private:
|
||||
bool ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out);
|
||||
const u8 *ItemKey(const PkgItem &item) const;
|
||||
bool ReadPBOOTInfo(const PkgItem &pboot);
|
||||
|
||||
FileLoader *loader_ = nullptr;
|
||||
u64 dataOffset_ = 0;
|
||||
u64 dataSize_ = 0;
|
||||
u8 riv_[16]{};
|
||||
u8 mainKey_[16]{};
|
||||
PkgInfo info_;
|
||||
};
|
||||
|
||||
// Where an item ends up inside the installed game folder, or empty for the ones that shouldn't be
|
||||
// installed at all. A package wraps its payload in USRDIR/CONTENT/, PS3-style, and also carries
|
||||
// store metadata (PARAM.SFO, PS3LOGO.DAT, the icons) that isn't part of the PSP-side install.
|
||||
std::string PkgItemInstallPath(const PkgItem &item);
|
||||
|
||||
// What the install will take up on disk. Package contents aren't compressed, so this is exact
|
||||
// rather than an estimate - modulo the filesystem's own per-file overhead.
|
||||
u64 PkgInstalledSize(const PkgInfo &info);
|
||||
|
||||
// Unpacks the installable items into destDir, which should be the game folder itself
|
||||
// (PSP/GAME/<DISC_ID>). progress is called with 0..1 as it goes, and may be null.
|
||||
bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function<void(float)> &progress, std::string *error);
|
||||
|
||||
// An update that has been installed, i.e. what's left over in PSP/GAME/<DISC_ID> afterwards.
|
||||
struct InstalledGameUpdate {
|
||||
Path folder;
|
||||
Path pbootPath;
|
||||
std::string appVer; // The update's version, from PBOOT.PBP's own PARAM.SFO.
|
||||
std::string discVersion; // The disc version it was built against.
|
||||
std::string title; // PBOOT_TITLE, when the update names itself ("Update 2.01").
|
||||
u64 sizeOnDisk = 0; // How much deleting it would actually free.
|
||||
// True when a game shares the folder - a digital title, whose EBOOT.PBP sits right next to the
|
||||
// update. Deleting the whole folder would take the game with it.
|
||||
bool sharesFolderWithGame = false;
|
||||
};
|
||||
|
||||
// Looks for an update installed for discId. Cheap enough to call while building a UI - a stat and
|
||||
// a small read, plus a directory walk for the size.
|
||||
bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update);
|
||||
|
||||
// Removes an installed update, to the trash where there is one. Takes the whole folder when the
|
||||
// update is all that's in it, and only PBOOT.PBP when a game shares the folder - that stops the
|
||||
// update from being used, and it's the only part we can still identify after the fact.
|
||||
bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update);
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PSP .PKG (NPDRM package) parser/extractor.
|
||||
|
||||
Lists or extracts a package - PSP game updates in particular, which pkg2zip skips.
|
||||
See docs/pkg_notes.md for the format, and for what these packages turn out to contain.
|
||||
|
||||
pkg.py FILE.pkg ... list contents
|
||||
pkg.py -x OUTDIR FILE.pkg ... extract into OUTDIR
|
||||
|
||||
Needs the "cryptography" module for AES.
|
||||
"""
|
||||
import sys, os, struct
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
PKG_PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
||||
PKG_PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
||||
PKG_VITA_2 = bytes.fromhex("e31a70c9ce1dd72bf3c0622963f2eccb")
|
||||
PKG_VITA_3 = bytes.fromhex("423aca3a2bd5649f9686abad6fd8801f")
|
||||
PKG_VITA_4 = bytes.fromhex("af07fd59652527baf13389668b17d9ea")
|
||||
|
||||
def aes_ecb_enc(key, block):
|
||||
c = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
|
||||
return c.update(block) + c.finalize()
|
||||
|
||||
def ctr_xor(key, iv, block_index, data):
|
||||
ctr = (int.from_bytes(iv, 'big') + block_index) % (1 << 128)
|
||||
c = Cipher(algorithms.AES(key), modes.CTR(ctr.to_bytes(16, 'big'))).decryptor()
|
||||
return c.update(data) + c.finalize()
|
||||
|
||||
TYPES = {0: "?", 1: "NPDRM", 2: "NPDRM_EDAT", 3: "FILE", 4: "DIRECTORY",
|
||||
9: "SELF", 11: "PSP_FILE", 18: "DIRECTORY2"}
|
||||
|
||||
META_NAMES = {1: "DRM_TYPE", 2: "CONTENT_TYPE", 3: "PACKAGE_TYPE", 4: "PACKAGE_SIZE",
|
||||
5: "SDK/NPDRM_REV", 6: "TITLE_ID", 7: "QA_DIGEST", 8: "UNK_0x8",
|
||||
9: "UNK_0x9", 10: "INSTALL_DIR", 11: "UNK_0xB", 12: "UNK_0xC",
|
||||
13: "ITEMS_TABLE", 14: "PARAM_SFO", 15: "UNK_0xF"}
|
||||
|
||||
CONTENT_TYPES = {0x4: "PS3_GameData", 0x5: "PS3_GameExec", 0x6: "PS1_PSN",
|
||||
0x7: "PSP_PSN", 0x9: "Theme", 0xB: "License", 0xE: "PSP_PCEngine",
|
||||
0xF: "PSP_Minis", 0x10: "PSP_NeoGeo", 0x15: "PSVita_App",
|
||||
0x16: "PSVita_DLC", 0x18: "PSM"}
|
||||
|
||||
|
||||
class Item:
|
||||
__slots__ = ("name", "off", "size", "psp_type", "flags", "key")
|
||||
def is_dir(self):
|
||||
return self.flags in (4, 18)
|
||||
|
||||
|
||||
class Pkg:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.f = open(path, 'rb')
|
||||
h = self.f.read(0x100)
|
||||
if h[:4] != b'\x7fPKG':
|
||||
raise ValueError("not a PKG")
|
||||
(self.rev, self.type, self.meta_off, self.meta_cnt, self.meta_size,
|
||||
self.item_cnt, self.total_size, self.data_off, self.data_size) = \
|
||||
struct.unpack(">HHIIIIQQQ", h[4:0x30])
|
||||
self.content_id = h[0x30:0x60].split(b'\0')[0].decode('ascii', 'replace')
|
||||
self.digest = h[0x60:0x70]
|
||||
self.riv = h[0x70:0x80]
|
||||
self.key_id = h[0xE7] & 7
|
||||
self.ext = h[0xC0:0xC4] == b'\x7fext'
|
||||
|
||||
self.meta = self._read_meta()
|
||||
self.content_type = 0
|
||||
self.items_off = 0
|
||||
self.sfo_off = self.sfo_size = 0
|
||||
for ident, val in self.meta:
|
||||
if ident == 2:
|
||||
self.content_type = struct.unpack(">I", val[:4])[0]
|
||||
elif ident == 13:
|
||||
self.items_off, self.items_size = struct.unpack(">II", val[:8])
|
||||
elif ident == 14:
|
||||
self.sfo_off, self.sfo_size = struct.unpack(">II", val[:8])
|
||||
|
||||
if self.type == 2: # PSP / Vita
|
||||
if self.key_id == 1:
|
||||
self.main_key = PKG_PSP_KEY
|
||||
else:
|
||||
vk = {2: PKG_VITA_2, 3: PKG_VITA_3, 4: PKG_VITA_4}[self.key_id]
|
||||
self.main_key = aes_ecb_enc(vk, self.riv)
|
||||
else:
|
||||
self.main_key = PKG_PS3_KEY
|
||||
self.iv = self.riv
|
||||
|
||||
def _read_meta(self):
|
||||
self.f.seek(self.meta_off)
|
||||
buf = self.f.read(self.meta_size if self.meta_size else 0x1000)
|
||||
out, p = [], 0
|
||||
for _ in range(self.meta_cnt):
|
||||
if p + 8 > len(buf):
|
||||
break
|
||||
ident, size = struct.unpack(">II", buf[p:p+8])
|
||||
out.append((ident, buf[p+8:p+8+size]))
|
||||
p += 8 + size
|
||||
return out
|
||||
|
||||
def dec(self, offset, size, key=None):
|
||||
"""Read+decrypt `size` bytes at `offset` relative to the encrypted data area."""
|
||||
key = key or self.main_key
|
||||
pre = offset & 0xF
|
||||
base = offset - pre
|
||||
self.f.seek(self.data_off + base)
|
||||
raw = self.f.read(((size + pre + 15) // 16) * 16)
|
||||
return ctr_xor(key, self.iv, base // 16, raw)[pre:pre+size]
|
||||
|
||||
def items(self):
|
||||
tbl = self.dec(self.items_off, self.item_cnt * 0x20)
|
||||
res = []
|
||||
for i in range(self.item_cnt):
|
||||
no, ns, doff, dsize, psp_type, _, _, flags = struct.unpack(
|
||||
">IIQQBBBB", tbl[i*0x20:i*0x20+0x1C])
|
||||
it = Item()
|
||||
it.psp_type, it.flags, it.off, it.size = psp_type, flags, doff, dsize
|
||||
it.key = self.main_key if (self.type != 2 or psp_type == 0x90) else PKG_PS3_KEY
|
||||
it.name = self.dec(no, ns, it.key).decode('utf-8', 'replace')
|
||||
res.append(it)
|
||||
return res
|
||||
|
||||
def read_item(self, it, maxsize=None):
|
||||
n = it.size if maxsize is None else min(it.size, maxsize)
|
||||
return self.dec(it.off, n, it.key)
|
||||
|
||||
def sfo(self):
|
||||
if self.sfo_size:
|
||||
return parse_sfo(self.dec(self.sfo_off, self.sfo_size))
|
||||
for it in self.items():
|
||||
if it.name.upper().endswith("PARAM.SFO"):
|
||||
return parse_sfo(self.read_item(it))
|
||||
return {}
|
||||
|
||||
|
||||
def parse_sfo(data):
|
||||
if len(data) < 0x14 or data[:4] != b'\0PSF':
|
||||
return {}
|
||||
key_tab, data_tab, count = struct.unpack("<III", data[0x08:0x14])
|
||||
out = {}
|
||||
for i in range(count):
|
||||
e = 0x14 + i * 0x10
|
||||
if e + 0x10 > len(data):
|
||||
break
|
||||
ko, fmt, ln, maxln, do = struct.unpack("<HHIII", data[e:e+0x10])
|
||||
name = data[key_tab+ko:data.index(b'\0', key_tab+ko)].decode('ascii', 'replace')
|
||||
raw = data[data_tab+do:data_tab+do+ln]
|
||||
if fmt == 0x0404:
|
||||
out[name] = struct.unpack("<I", raw[:4])[0]
|
||||
else:
|
||||
out[name] = raw.split(b'\0')[0].decode('utf-8', 'replace')
|
||||
return out
|
||||
|
||||
|
||||
def info(p, list_items=True):
|
||||
print("== %s" % os.path.basename(p.path))
|
||||
print(" content_id %s key_id %d type %d content_type 0x%x (%s)" % (
|
||||
p.content_id, p.key_id, p.type, p.content_type,
|
||||
CONTENT_TYPES.get(p.content_type, "?")))
|
||||
print(" items %d data 0x%x+0x%x total 0x%x" % (
|
||||
p.item_cnt, p.data_off, p.data_size, p.total_size))
|
||||
sfo = p.sfo()
|
||||
if sfo:
|
||||
print(" SFO: " + ", ".join("%s=%r" % kv for kv in sfo.items()))
|
||||
if list_items:
|
||||
for it in p.items():
|
||||
print(" %-10s pt=%02x %10d %s" % (
|
||||
TYPES.get(it.flags, "0x%x" % it.flags), it.psp_type, it.size, it.name))
|
||||
|
||||
|
||||
def extract(p, outdir):
|
||||
for it in p.items():
|
||||
dst = os.path.join(outdir, it.name)
|
||||
if it.is_dir():
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||
with open(dst, 'wb') as o:
|
||||
left, off = it.size, it.off
|
||||
while left:
|
||||
n = min(left, 1 << 20)
|
||||
o.write(p.dec(off, n, it.key))
|
||||
off += n
|
||||
left -= n
|
||||
print(" wrote %s (%d)" % (it.name, it.size))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = sys.argv[1:]
|
||||
out = None
|
||||
if args and args[0] == '-x':
|
||||
out = args[1]
|
||||
args = args[2:]
|
||||
for path in args:
|
||||
p = Pkg(path)
|
||||
info(p)
|
||||
if out:
|
||||
extract(p, out)
|
||||
print()
|
||||
@@ -103,6 +103,8 @@ list(APPEND UISource
|
||||
InstallUpdateScreen.cpp
|
||||
InstallZipScreen.h
|
||||
InstallZipScreen.cpp
|
||||
InstallPkgScreen.h
|
||||
InstallPkgScreen.cpp
|
||||
JitCompareScreen.h
|
||||
JitCompareScreen.cpp
|
||||
MemStickScreen.h
|
||||
|
||||
+4
-2
@@ -239,6 +239,7 @@ void GameButton::Draw(UIContext &dc) {
|
||||
case IdentifiedFileType::ARCHIVE_ZIP: imageIcon = ImageID("I_ARCHIVE_ZIP"); drawBackground = false; break;
|
||||
case IdentifiedFileType::ARCHIVE_7Z: imageIcon = ImageID("I_ARCHIVE_7Z"); drawBackground = false; break;
|
||||
case IdentifiedFileType::ARCHIVE_RAR: imageIcon = ImageID("I_ARCHIVE_RAR"); drawBackground = false; break;
|
||||
case IdentifiedFileType::PSP_PKG: imageIcon = ImageID("I_FOLDER_UPLOAD"); drawBackground = false; break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
@@ -880,11 +881,12 @@ void GameBrowser::Refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// Put RAR/ZIP files at the end to get them out of the way.
|
||||
// Put RAR/ZIP files at the end to get them out of the way. Game update packages go here
|
||||
// too - they're not something to boot, they're something to install.
|
||||
// We do support unpacking some of them automatically.
|
||||
if (browseFlags_ & BrowseFlags::ARCHIVES) {
|
||||
fileInfo.clear();
|
||||
path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:");
|
||||
path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:pkg:");
|
||||
if (!fileInfo.empty()) {
|
||||
for (size_t i = 0; i < fileInfo.size(); i++) {
|
||||
if (!fileInfo[i].isDirectory) {
|
||||
|
||||
@@ -153,6 +153,7 @@ bool GameInfo::Delete() {
|
||||
case IdentifiedFileType::ARCHIVE_RAR:
|
||||
case IdentifiedFileType::ARCHIVE_ZIP:
|
||||
case IdentifiedFileType::ARCHIVE_7Z:
|
||||
case IdentifiedFileType::PSP_PKG:
|
||||
case IdentifiedFileType::UNKNOWN:
|
||||
case IdentifiedFileType::PSP_UMD_VIDEO_ISO:
|
||||
case IdentifiedFileType::PPSSPP_GE_DUMP:
|
||||
@@ -918,6 +919,7 @@ handleELF:
|
||||
}
|
||||
|
||||
case IdentifiedFileType::ARCHIVE_ZIP:
|
||||
case IdentifiedFileType::PSP_PKG:
|
||||
info_->SetTitle(info_->GetFilePath().GetFilename());
|
||||
info_->icon.dataLoaded = true;
|
||||
break;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "Common/UI/UIScreen.h"
|
||||
#include "Common/File/Path.h"
|
||||
#include "UI/GameInfoCache.h"
|
||||
#include "Core/Util/PkgUnpack.h"
|
||||
#include "UI/SimpleDialogScreen.h"
|
||||
|
||||
|
||||
@@ -58,6 +59,11 @@ private:
|
||||
void OnCreateConfig(UI::EventParams &e);
|
||||
void OnDeleteConfig(UI::EventParams &e);
|
||||
void OnSetBackground(UI::EventParams &e);
|
||||
void OnDeleteGameUpdate(UI::EventParams &e);
|
||||
|
||||
// Checks whether a game update is installed for this game. Both the info pane and the context
|
||||
// menu need the answer, and either can be built first.
|
||||
void RefreshInstalledUpdate();
|
||||
|
||||
std::string CRC32string;
|
||||
|
||||
@@ -69,6 +75,10 @@ private:
|
||||
|
||||
bool knownHasCRC_ = false;
|
||||
|
||||
// A game update installed in PSP/GAME/<DISC_ID>, which is what actually runs when there is one.
|
||||
InstalledGameUpdate installedUpdate_;
|
||||
bool hasInstalledUpdate_ = false;
|
||||
|
||||
std::shared_ptr<GameInfo> info_;
|
||||
mutable std::string titleCache_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) 2026- PPSSPP Project.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, version 2.0 or later versions.
|
||||
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License 2.0 for more details.
|
||||
|
||||
// A copy of the GPL 2.0 should have been included with the program.
|
||||
// If not, see http://www.gnu.org/licenses/
|
||||
|
||||
// Official git repository and contact information can be found at
|
||||
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "Common/Data/Text/I18n.h"
|
||||
#include "Common/Data/Text/Parsers.h"
|
||||
#include "Common/File/DiskFree.h"
|
||||
#include "Common/File/FileUtil.h"
|
||||
#include "Common/StringUtils.h"
|
||||
#include "Common/System/Request.h"
|
||||
#include "Common/System/System.h"
|
||||
#include "Common/UI/Context.h"
|
||||
#include "Common/UI/UI.h"
|
||||
#include "Common/UI/View.h"
|
||||
#include "Common/UI/ViewGroup.h"
|
||||
|
||||
#include "Core/Loaders.h"
|
||||
#include "Core/System.h"
|
||||
#include "Core/Util/GameManager.h"
|
||||
#include "Core/Util/PathUtil.h"
|
||||
|
||||
#include "UI/InstallPkgScreen.h"
|
||||
|
||||
InstallPkgScreen::InstallPkgScreen(const Path &pkgPath)
|
||||
: UITwoPaneBaseDialogScreen(Path(), TwoPaneFlags::SettingsToTheRight | TwoPaneFlags::ContentsCanScroll), pkgPath_(pkgPath) {
|
||||
g_GameManager.ResetInstallError();
|
||||
|
||||
// Reading the package is cheap - the item table and two small PARAM.SFOs. We only keep the
|
||||
// info; the install re-opens the file on its own thread.
|
||||
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pkgPath_));
|
||||
PkgReader reader;
|
||||
if (!loader || !reader.Open(loader.get(), &pkgError_)) {
|
||||
return;
|
||||
}
|
||||
pkgInfo_ = reader.Info();
|
||||
if (!pkgInfo_.isGameUpdate) {
|
||||
// Everything we can read is a game update; anything else got rejected above with a better
|
||||
// message than this.
|
||||
pkgError_ = "This PKG file isn't a PSP game update";
|
||||
return;
|
||||
}
|
||||
|
||||
canInstall_ = true;
|
||||
installSize_ = PkgInstalledSize(pkgInfo_);
|
||||
destination_ = GetSysDirectory(DIRECTORY_GAME) / pkgInfo_.discId;
|
||||
alreadyInstalled_ = File::Exists(destination_ / "PBOOT.PBP");
|
||||
|
||||
int64_t space = 0;
|
||||
if (free_disk_space(GetSysDirectory(DIRECTORY_GAME), space)) {
|
||||
freeSpace_ = space;
|
||||
}
|
||||
}
|
||||
|
||||
std::string_view InstallPkgScreen::GetTitle() const {
|
||||
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
|
||||
return iz->T("Game update");
|
||||
}
|
||||
|
||||
void InstallPkgScreen::CreateSettingsViews(UI::ViewGroup *parent) {
|
||||
using namespace UI;
|
||||
|
||||
auto di = GetI18NCategory(I18NCat::DIALOG);
|
||||
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
|
||||
|
||||
installChoice_ = nullptr;
|
||||
|
||||
if (canInstall_) {
|
||||
installChoice_ = parent->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD")));
|
||||
installChoice_->OnClick.Handle(this, &InstallPkgScreen::OnInstall);
|
||||
}
|
||||
|
||||
if (System_GetPropertyBool(SYSPROP_CAN_SHOW_FILE)) {
|
||||
parent->Add(new Spacer(12.0f));
|
||||
parent->Add(new Choice(di->T("Show in folder")))->OnClick.Add([this](UI::EventParams &) {
|
||||
System_ShowFileInFolder(pkgPath_);
|
||||
});
|
||||
}
|
||||
|
||||
if (canInstall_) {
|
||||
parent->Add(new Spacer(12.0f));
|
||||
parent->Add(new CheckBox(&deletePkgFile_, iz->T("Delete PKG file")));
|
||||
}
|
||||
}
|
||||
|
||||
void InstallPkgScreen::CreateContentViews(UI::ViewGroup *parent) {
|
||||
using namespace UI;
|
||||
|
||||
auto di = GetI18NCategory(I18NCat::DIALOG);
|
||||
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
|
||||
auto er = GetI18NCategory(I18NCat::ERRORS);
|
||||
|
||||
LinearLayout *leftColumn = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8))));
|
||||
|
||||
if (!canInstall_) {
|
||||
leftColumn->Add(new TextView(GetFriendlyPath(pkgPath_)));
|
||||
leftColumn->Add(new NoticeView(NoticeLevel::ERROR, iz->T(pkgError_.empty() ? "This PKG file isn't a PSP game update" : pkgError_), ""));
|
||||
doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", ""));
|
||||
doneView_->SetVisibility(Visibility::V_GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
leftColumn->Add(new TextView(iz->T("Install game update?")))->SetBig(true);
|
||||
leftColumn->Add(new TextView(pkgPath_.GetFilename()));
|
||||
if (!pkgInfo_.title.empty()) {
|
||||
leftColumn->Add(new TextView(pkgInfo_.title));
|
||||
}
|
||||
if (!pkgInfo_.pbootTitle.empty()) {
|
||||
leftColumn->Add(new TextView(pkgInfo_.pbootTitle));
|
||||
}
|
||||
|
||||
leftColumn->Add(new Spacer(8.0f));
|
||||
|
||||
// What it patches, and to what. The disc version is the one the update was built against.
|
||||
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2 (%3)", iz->T("Game"), pkgInfo_.discId, pkgInfo_.discVersion)));
|
||||
if (!pkgInfo_.appVer.empty()) {
|
||||
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Update version"), pkgInfo_.appVer)));
|
||||
}
|
||||
if (!pkgInfo_.systemVer.empty()) {
|
||||
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Requires firmware"), pkgInfo_.systemVer)));
|
||||
}
|
||||
|
||||
// Package contents aren't compressed, so this is what it'll actually take up.
|
||||
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Space needed"), NiceSizeFormat(installSize_))));
|
||||
if (freeSpace_ >= 0) {
|
||||
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Free space"), NiceSizeFormat((u64)freeSpace_))));
|
||||
}
|
||||
|
||||
leftColumn->Add(new Spacer(8.0f));
|
||||
leftColumn->Add(new TextView(iz->T("Install into folder")));
|
||||
leftColumn->Add(new TextView(GetFriendlyPath(destination_)))->SetAlign(FLAG_WRAP_TEXT);
|
||||
|
||||
doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", ""));
|
||||
doneView_->SetVisibility(Visibility::V_GONE);
|
||||
|
||||
if (freeSpace_ >= 0 && (u64)freeSpace_ < installSize_) {
|
||||
leftColumn->Add(new NoticeView(NoticeLevel::ERROR, er->T("Not enough free space"), ""));
|
||||
}
|
||||
if (alreadyInstalled_) {
|
||||
leftColumn->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), iz->T("An update for this game is already installed")));
|
||||
}
|
||||
}
|
||||
|
||||
bool InstallPkgScreen::key(const KeyInput &key) {
|
||||
// Ignore key presses while installing, so the user can't escape mid-write.
|
||||
if (g_GameManager.GetState() == GameManagerState::IDLE) {
|
||||
return UIDialogScreen::key(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void InstallPkgScreen::OnInstall(UI::EventParams ¶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();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026- PPSSPP Project.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, version 2.0 or later versions.
|
||||
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License 2.0 for more details.
|
||||
|
||||
// A copy of the GPL 2.0 should have been included with the program.
|
||||
// If not, see http://www.gnu.org/licenses/
|
||||
|
||||
// Official git repository and contact information can be found at
|
||||
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Common/File/Path.h"
|
||||
#include "Common/UI/Notice.h"
|
||||
#include "Common/UI/UIScreen.h"
|
||||
#include "Common/UI/View.h"
|
||||
|
||||
#include "Core/Util/PkgUnpack.h"
|
||||
#include "UI/BaseScreens.h"
|
||||
#include "UI/SimpleDialogScreen.h"
|
||||
|
||||
// Offers to install a PSP game update from a .pkg file, the way InstallZipScreen does for zips.
|
||||
// The package goes into PSP/GAME/<DISC_ID>, and from then on booting that disc runs the update -
|
||||
// see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp.
|
||||
class InstallPkgScreen : public UITwoPaneBaseDialogScreen {
|
||||
public:
|
||||
InstallPkgScreen(const Path &pkgPath);
|
||||
|
||||
void update() override;
|
||||
bool key(const KeyInput &key) override;
|
||||
|
||||
const char *tag() const override { return "InstallPkg"; }
|
||||
|
||||
protected:
|
||||
void CreateSettingsViews(UI::ViewGroup *parent) override;
|
||||
void CreateContentViews(UI::ViewGroup *parent) override;
|
||||
std::string_view GetTitle() const override;
|
||||
ViewLayoutMode LayoutMode() const override {
|
||||
return ViewLayoutMode::ApplyInsets;
|
||||
}
|
||||
|
||||
private:
|
||||
void OnInstall(UI::EventParams ¶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;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<ClCompile Include="TiltAnalogSettingsScreen.cpp" />
|
||||
<ClCompile Include="TouchControlLayoutScreen.cpp" />
|
||||
<ClCompile Include="TouchControlVisibilityScreen.cpp" />
|
||||
<ClCompile Include="InstallPkgScreen.cpp" />
|
||||
<ClCompile Include="InstallUpdateScreen.cpp" />
|
||||
<ClCompile Include="InstallZipScreen.cpp" />
|
||||
<ClCompile Include="Theme.cpp" />
|
||||
@@ -137,6 +138,7 @@
|
||||
<ClInclude Include="TiltAnalogSettingsScreen.h" />
|
||||
<ClInclude Include="TouchControlLayoutScreen.h" />
|
||||
<ClInclude Include="TouchControlVisibilityScreen.h" />
|
||||
<ClInclude Include="InstallPkgScreen.h" />
|
||||
<ClInclude Include="InstallUpdateScreen.h" />
|
||||
<ClInclude Include="InstallZipScreen.h" />
|
||||
<ClInclude Include="Theme.h" />
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<ClCompile Include="TiltAnalogSettingsScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="InstallPkgScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="InstallUpdateScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
@@ -194,6 +197,9 @@
|
||||
<ClInclude Include="TiltAnalogSettingsScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="InstallPkgScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="InstallUpdateScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
|
||||
@@ -339,6 +339,7 @@
|
||||
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
|
||||
<ClInclude Include="..\..\Core\Util\KL4E.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PkgUnpack.h" />
|
||||
<ClInclude Include="..\..\Core\WaveFile.h" />
|
||||
<ClInclude Include="..\..\ext\cityhash\city.h" />
|
||||
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
|
||||
@@ -660,6 +661,7 @@
|
||||
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\KL4E.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\PkgUnpack.cpp" />
|
||||
<ClCompile Include="..\..\Core\WaveFile.cpp" />
|
||||
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
|
||||
<ClCompile Include="..\..\ext\disarm.cpp" />
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
<ClCompile Include="..\..\Core\Util\PPGeDraw.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\KL4E.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\PSARUnpack.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\PkgUnpack.cpp" />
|
||||
<ClCompile Include="..\..\Core\WaveFile.cpp" />
|
||||
<ClCompile Include="..\..\ext\cityhash\city.cpp" />
|
||||
<ClCompile Include="..\..\ext\disarm.cpp" />
|
||||
@@ -673,6 +674,7 @@
|
||||
<ClInclude Include="..\..\Core\Util\GameManager.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PPGeDraw.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PSARUnpack.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PkgUnpack.h" />
|
||||
<ClInclude Include="..\..\Core\WaveFile.h" />
|
||||
<ClInclude Include="..\..\ext\cityhash\city.h" />
|
||||
<ClInclude Include="..\..\ext\cityhash\citycrc.h" />
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
<ClInclude Include="..\..\UI\ImDebugger\ImJitViewer.h" />
|
||||
<ClInclude Include="..\..\UI\ImDebugger\ImMemView.h" />
|
||||
<ClInclude Include="..\..\UI\ImDebugger\ImStructViewer.h" />
|
||||
<ClInclude Include="..\..\UI\InstallPkgScreen.h" />
|
||||
<ClInclude Include="..\..\UI\InstallUpdateScreen.h" />
|
||||
<ClInclude Include="..\..\UI\InstallZipScreen.h" />
|
||||
<ClInclude Include="..\..\UI\JitCompareScreen.h" />
|
||||
@@ -173,6 +174,7 @@
|
||||
<ClCompile Include="..\..\UI\ImDebugger\ImJitViewer.cpp" />
|
||||
<ClCompile Include="..\..\UI\ImDebugger\ImMemView.cpp" />
|
||||
<ClCompile Include="..\..\UI\ImDebugger\ImStructViewer.cpp" />
|
||||
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp" />
|
||||
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp" />
|
||||
<ClCompile Include="..\..\UI\InstallZipScreen.cpp" />
|
||||
<ClCompile Include="..\..\UI\JitCompareScreen.cpp" />
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
<ClCompile Include="..\..\UI\IAPScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp">
|
||||
<Filter>Screens</Filter>
|
||||
</ClCompile>
|
||||
@@ -219,6 +222,9 @@
|
||||
<ClInclude Include="..\..\UI\IAPScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\..\UI\InstallPkgScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\..\UI\InstallUpdateScreen.h">
|
||||
<Filter>Screens</Filter>
|
||||
</ClInclude>
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# The PKG package format (PSP game updates)
|
||||
|
||||
A `.pkg` is the NPDRM container Sony distributed downloadable content in - full PSN games, DLC,
|
||||
themes, and the thing these notes are about: **game updates**. An update package holds a patched
|
||||
EBOOT (`PBOOT.PBP`) plus whatever data files the patch replaces, and installing it drops them in
|
||||
`ms0:/PSP/GAME/<DISC_ID>/`. The patched EBOOT then runs with the original UMD (or the original
|
||||
PSN game) still supplying everything it doesn't override.
|
||||
|
||||
This document is what was learned decoding some update packages on 2026-08-21. `Tools/pkg.py` is a
|
||||
working parser/extractor built from it; PPSSPP reads and installs them itself now - see "How PPSSPP
|
||||
handles them" and the verification sections at the end.
|
||||
|
||||
Format reference: <https://www.psdevwiki.com/ps3/PKG_files>, cross-checked against
|
||||
[pkg2zip](https://github.com/mmozeiko/pkg2zip).
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
+0x000 header (0xC0 bytes, plaintext)
|
||||
+0x0C0 extended header (0x40 bytes, plaintext, PSP/Vita only)
|
||||
+0x100 hashes/signatures
|
||||
+0x280 metadata (plaintext, offset and count are in the header)
|
||||
...
|
||||
+data_offset encrypted area: item table, then filenames, then file contents
|
||||
```
|
||||
|
||||
### Header
|
||||
|
||||
```
|
||||
+0x00 u32 "\x7FPKG" magic (0x7F504B47)
|
||||
+0x04 u16 revision. 0x8000 retail, 0x0000 debug
|
||||
+0x06 u16 type. 1 = PS3, 2 = PSP/Vita
|
||||
+0x08 u32 metadata offset (0x280 in everything seen)
|
||||
+0x0C u32 metadata entry count
|
||||
+0x10 u32 metadata size
|
||||
+0x14 u32 item count
|
||||
+0x18 u64 total package size
|
||||
+0x20 u64 data offset - start of the encrypted area
|
||||
+0x28 u64 data size
|
||||
+0x30 char[0x30] content id, e.g. "JP0177-ULJM05681_00-PJD2UPDATEVR0101"
|
||||
+0x60 u8[0x10] QA digest
|
||||
+0x70 u8[0x10] riv - the AES counter block (see below)
|
||||
+0x80 u8[0x40] header CMAC and signatures
|
||||
```
|
||||
|
||||
### Extended header
|
||||
|
||||
Present on PSP and Vita packages, magic `"\x7Fext"` (0x7F657874) at +0xC0. The only field that
|
||||
matters for reading the package is the **key index**, a u32 at +0xE4 - or equivalently
|
||||
`header[0xE7] & 7`, which is how pkg2zip reads it. Every PSP package seen uses key index 1.
|
||||
|
||||
### Metadata
|
||||
|
||||
A flat sequence of `u32 id, u32 size, u8 value[size]` records. The ones worth reading:
|
||||
|
||||
| id | Meaning |
|
||||
| --- | --- |
|
||||
| 2 | content type. **7 = PSP** (also 0xE/0xF/0x10 for PC Engine / Minis / NeoGeo) |
|
||||
| 4 | package size |
|
||||
| 6 | title id |
|
||||
| 13 | offset and size of the item table, inside the encrypted area |
|
||||
| 14 | offset and size of `PARAM.SFO`, inside the encrypted area |
|
||||
|
||||
Everything else (DRM type, SDK revision, QA digest, install dir) is informational.
|
||||
|
||||
## Encryption
|
||||
|
||||
The whole area from `data_offset` on is AES-128-CTR, with the counter block starting at `riv`
|
||||
(header +0x70) and incrementing once per 16 bytes: the block at byte offset *n* of the encrypted
|
||||
area uses counter `riv + n/16`. Offsets in the item table are relative to `data_offset`, so
|
||||
that division is straightforward - no separate bookkeeping.
|
||||
|
||||
Two different keys are used *within the same package*, and each item says which one applies via
|
||||
its own `psp_type` byte (see the item table below):
|
||||
|
||||
| Package / item | Key |
|
||||
| --- | --- |
|
||||
| PS3 package, or PSP item with `psp_type != 0x90` | `2e7b71d7c9c9a14ea3221f188828b8f8` |
|
||||
| PSP item with `psp_type == 0x90` | `07f2c68290b50d2c33818d709b60e62b` |
|
||||
| Vita, key index 2/3/4 | `AES-ECB(vita_key_N, riv)`, key by index |
|
||||
|
||||
That per-item split is the one thing that isn't obvious from the wiki page and will make a reader
|
||||
produce garbage filenames for most of a package while a couple of entries decode perfectly. In a
|
||||
game update it's the `PBOOT.PBP` and the patch data files that carry `0x90`; the icons,
|
||||
`PARAM.SFO`, `PS3LOGO.DAT`, the directory entries and `ISO.BIN.EDAT` use the PS3 key.
|
||||
|
||||
## Item table
|
||||
|
||||
`item_count` records of 0x20 bytes, at the item table offset from metadata id 13 (0 in every
|
||||
update package seen, i.e. right at the start of the encrypted area):
|
||||
|
||||
```
|
||||
+0x00 u32 filename offset (relative to data_offset, always 16-byte aligned)
|
||||
+0x04 u32 filename length
|
||||
+0x08 u64 data offset (relative to data_offset, always 16-byte aligned)
|
||||
+0x10 u64 data size
|
||||
+0x18 u8 psp_type - 0x90 selects the PSP key, see above
|
||||
+0x19 u8[2] padding
|
||||
+0x1B u8 flags
|
||||
+0x1C u32 padding
|
||||
```
|
||||
|
||||
Filenames are stored in the encrypted area too, and are decrypted with **the item's own key**, not
|
||||
the package's main key.
|
||||
|
||||
`flags` is a content type, and in these packages it maps 1:1 onto how the file contents are
|
||||
encrypted - a decoder can tell what it is holding before looking at it:
|
||||
|
||||
| flags | Meaning | Contents start with |
|
||||
| --- | --- | --- |
|
||||
| 2 | NPDRM EDAT | `NPD\0` - only ever `ISO.BIN.EDAT` |
|
||||
| 3 | plain file | whatever it is (PNG, `\0PSF`, ...) |
|
||||
| 4 | directory | - |
|
||||
| 5 | PSP EDAT | `\0PSPEDAT` |
|
||||
| 8 | PSP EDAT (`.sprx` modules) | `\0PSPEDAT` |
|
||||
| 11 | PBP | `\0PBP` |
|
||||
|
||||
Both flags 5 and 8 give you a `\0PSPEDAT`, but they are not the same thing inside. A flags-5 data
|
||||
file wraps a PGD, which PPSSPP decrypts at runtime through `sceNpDrmEdataSetupKey()` in
|
||||
`Core/HLE/scePspNpDrm_user.cpp`. A flags-8 `.sprx` wraps an encrypted PRX instead, and goes through
|
||||
the module loader - see "NPDRM `.sprx` modules" below. The byte at 0x0E of the header tells them apart
|
||||
(3 for the PGD kind, 1 for the PRX kind).
|
||||
|
||||
## What an update package contains
|
||||
|
||||
Always this shape:
|
||||
|
||||
```
|
||||
PARAM.SFO CATEGORY=PP, TITLE_ID, VERSION
|
||||
PS3LOGO.DAT a PNG, despite the name
|
||||
ICON0.PNG / PIC0.PNG / PIC2.PNG sometimes
|
||||
USRDIR/ directory entry
|
||||
USRDIR/CONTENT/ directory entry
|
||||
USRDIR/CONTENT/PBOOT.PBP the patched EBOOT
|
||||
USRDIR/CONTENT/... the patch data files
|
||||
USRDIR/ISO.BIN.EDAT 272 bytes, NPD header
|
||||
```
|
||||
|
||||
The outer `PARAM.SFO` is the *package's* - `CATEGORY=PP` (game patch), and its `VERSION` is the
|
||||
package version, not the patch version. The interesting SFO is the one **inside** `PBOOT.PBP`:
|
||||
that one has `CATEGORY=PG`, `DISC_ID`, `DISC_VERSION`, `APP_VER` (the patch version) and
|
||||
`PSP_SYSTEM_VER` (the firmware the patch needs), which is what a real PSP matches against the
|
||||
disc before deciding to boot the patch.
|
||||
|
||||
## What the packages measured
|
||||
|
||||
- All are content type 7, `CATEGORY=PP`. Some are for digital NP\* titles, others for UMD UL\*/UC\*
|
||||
titles. `PSP_SYSTEM_VER` ranges 6.10 to 6.60.
|
||||
- **Every single `PBOOT.PBP` uses PRX tag `0x2E5E10F0`** in its `DATA.PSP`. PPSSPP already has
|
||||
that key - `Core/ELF/PrxDecrypter.cpp`, in the `TAG_INFO2` table, commented
|
||||
"5.00 PSP-2000 (Game PSN Update 2 LBP)". So **no new crypto is needed to run these**.
|
||||
- Verified end to end rather than assumed: extract Hatsune Miku Project DIVA 2nd's update, rename
|
||||
`PBOOT.PBP` to `EBOOT.PBP`, and boot it headless with the UMD mounted -
|
||||
|
||||
```
|
||||
./build/PPSSPPHeadless -i --graphics=software --memstick=<ms> \
|
||||
--mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \
|
||||
<ms>/PSP/GAME/ULJM05681/EBOOT.PBP
|
||||
```
|
||||
|
||||
It logs `Decrypting tag 2E5E10F0`, loads the ELF (`tag=ELF/PdvApp`), resolves its imports, and
|
||||
reads `Diva2Data.cpk` / `Diva2Script.cpk` / `Diva2Sound.cpk` off `disc0:`. Runs without error.
|
||||
(`--mount` is what makes that work: `Load_PSP_ELF_PBP` in `Core/PSPLoaders.cpp` mounts the ISO
|
||||
on `disc0:`, `umd:` and `umd1:` when booting an ELF or PBP.)
|
||||
|
||||
**pkg2zip cannot extract update packages**, so it is not an alternative here. Its PSP path only
|
||||
recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an ISO),
|
||||
`PSP-KEY.EDAT` and `CONTENT.DAT`, and `continue`s past everything else - `PBOOT.PBP` and every
|
||||
patch file are silently dropped, with no warning that anything was skipped.
|
||||
|
||||
## How PPSSPP handles them
|
||||
|
||||
Four pieces:
|
||||
|
||||
- **`Core/Util/PkgUnpack.cpp`** reads a package: header, item table, both PARAM.SFOs, and the
|
||||
decryption. `PkgReader::Open()` gives you a `PkgInfo` with the disc ID, disc version and patch
|
||||
version; `InstallPkg()` writes the payload out. Sits next to `PSARUnpack.cpp`, and like it needs
|
||||
nothing but the AES already in `ext/libkirk`.
|
||||
- **`UI/InstallPkgScreen.cpp`** is what opening a `.pkg` gets you, the same way a `.zip` gets
|
||||
`InstallZipScreen` - it shows what the update patches, what it'll take up on disk, and where it's
|
||||
going. The size is exact rather than an estimate: package contents aren't compressed, so summing
|
||||
the item table is the answer. `GameManager::InstallPkgOnThread()` does the work.
|
||||
- **`GameScreen`** shows an installed update in the info pane, and offers "Delete Game Update" in
|
||||
its context menu. Deleting takes the whole `PSP/GAME/<DISC_ID>` folder when the update is all
|
||||
that's in it, and only `PBOOT.PBP` when a digital game shares the folder - nothing records what
|
||||
an install wrote, so the executable is the only part still identifiable afterwards.
|
||||
- **`FindGameUpdatePBOOT()` in `Core/PSPLoaders.cpp`** is the boot-time half. Starting a disc looks
|
||||
for `ms0:/PSP/GAME/<DISC_ID>/PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN`
|
||||
if it's there, leaving the disc mounted.
|
||||
|
||||
`PPSSPPHeadless --install-pkg=DIR <file.pkg>` does an install without the UI, which is how the
|
||||
above got tested. It prints what the package is and installs into DIR exactly (the app picks
|
||||
`PSP/GAME/<DISC_ID>` itself).
|
||||
|
||||
### Install layout
|
||||
|
||||
The package's own PS3-style wrapping is stripped: `USRDIR/CONTENT/<x>` and `USRDIR/<x>` both become
|
||||
`<x>` in the game folder, and the `USRDIR` and `USRDIR/CONTENT` directory entries are dropped
|
||||
rather than created. The root-level files - `PARAM.SFO`, `PS3LOGO.DAT`, `ICON0.PNG`, `PIC0.PNG`,
|
||||
`PIC2.PNG` - are store metadata and are **not** installed. Writing that `PARAM.SFO` in particular
|
||||
would be actively wrong: a folder holding a `PARAM.SFO` and no `EBOOT.PBP` is what PPSSPP
|
||||
identifies as *save data*, so the update would show up in the savedata list.
|
||||
|
||||
### Which updates get used
|
||||
|
||||
The update's `DISC_ID` has to match the disc's, or it's ignored with a warning - that's what keeps
|
||||
an update from being applied to the wrong game.
|
||||
|
||||
`DISC_VERSION` is advisory. An update is built against one specific disc revision, and PPSSPP logs
|
||||
a warning when they differ, but still boots it: refusing outright is a worse failure mode than
|
||||
letting the user find out, since they installed it deliberately. This is not what a real PSP does.
|
||||
It comes up in practice - the LittleBigPlanet v2.05 update targets disc version 1.00, and the
|
||||
common European dump is 1.01, and it works.
|
||||
|
||||
There's no setting to turn this off. Anyone who has a `PBOOT.PBP` sitting in a game folder either
|
||||
installed it here or copied it off a real memory stick, and in both cases booting it is what they
|
||||
were after.
|
||||
|
||||
### Verified
|
||||
|
||||
- All packages parse and install through the C++ path, byte-identical to `Tools/pkg.py` - each
|
||||
one reports its disc ID and versions and writes its payload without an error.
|
||||
- Hatsune Miku Project DIVA 2nd (ULJM05681): install the v1.01 update, boot the UMD, PPSSPP boots
|
||||
`PBOOT.PBP` and the game reads its CPKs off `disc0:`.
|
||||
- LittleBigPlanet (UCES01264): install the v2.05 update, boot the v1.01 UMD, and the patched game
|
||||
opens `ms0:/PSP/GAME/UCES01264/PATCH.ARC` alongside the disc's own `lbp_archive.arc` - the update
|
||||
is actually in use, not just booted.
|
||||
- `python3 test.py -g --graphics=software`: no failures, so restructuring the disc boot
|
||||
path didn't disturb anything.
|
||||
- Truncated and item-table-corrupted packages are refused with a message rather than crashing.
|
||||
Corruption *inside* file data still installs - nothing here verifies the package CMAC, same as
|
||||
every other PKG tool.
|
||||
- The browser listing, install screen and install itself were checked by hand in the app.
|
||||
|
||||
## Digital (NP\*) titles
|
||||
|
||||
About half of the packages patch a digital title rather than a UMD, and that half is tested too.
|
||||
|
||||
The one that settles the question is **Super Robot Taisen Operation Extend (NPJH50521)**, because
|
||||
it's a real NPUMDIMG `EBOOT.PBP` rather than a decrypted ISO dump - `NPDRM: PSAR ID: 4d55504e`,
|
||||
mounted on `disc0:` by the NPDRM block device, with the game's own 560 MB EBOOT sitting in the same
|
||||
folder as the update. All eight of its update revisions were installed and booted in turn, and each
|
||||
one loads a distinguishably different executable:
|
||||
|
||||
```
|
||||
disc executable .text 0x419b1c
|
||||
v1.01 0x41fb8c v1.02 0x4233dc v1.03 0x42952c v1.04 0x42a53c
|
||||
v1.05 0x42a61c v1.06 0x42a59c v1.07 0x42b48c v1.08 0x42b7dc
|
||||
```
|
||||
|
||||
So **`ISO.BIN.EDAT` does not re-key the PBOOT**, which was the open worry: a digital title's patched
|
||||
executable is encrypted exactly like a UMD one, and needs nothing PPSSPP doesn't already have.
|
||||
|
||||
**`DISC_VERSION` being advisory matters far more than expected.** Most of the pairs hit a mismatch,
|
||||
because the dumps in circulation are later disc revisions than the updates were built against.
|
||||
Refusing outright would make most of them unusable. Elminage Original was the one clean
|
||||
exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning.
|
||||
|
||||
### NPDRM `.sprx` modules
|
||||
|
||||
Package payloads are full of `\0PSPEDAT` files. That's fine for
|
||||
*data*: `sceNpDrmEdataSetupKey()` in `Core/HLE/scePspNpDrm_user.cpp` wraps an open file descriptor
|
||||
with the `0x04100002`/`0x04100001` ioctl pair, and the game reads plaintext.
|
||||
|
||||
A few packages wrap **executables** that way - `.sprx` modules the game loads with
|
||||
`sceKernelLoadModuleNpDrm`. Those need more than the data path does. Until they were handled, God
|
||||
Eater 2 (NPJH50832) installed cleanly, booted its `PBOOT.PBP`, and then looped forever failing to
|
||||
load `system.sprx`; Shiren 4 Plus (NPJH50698), which keeps the whole game in one `.sprx` behind a
|
||||
small loader, failed the same way.
|
||||
|
||||
An NPDRM module is two layers, and the loader originally saw only the outer one:
|
||||
|
||||
```
|
||||
+0x00 "\0PSPEDAT" header, 0x90 bytes
|
||||
+0x08 u32 key mode; low byte is what sceNpDrmGetFixedKey takes (3 in all of these)
|
||||
+0x0C u16 payload offset (0x90 in everything seen)
|
||||
+0x0F u8 flag bits: 1 = XOR in the licensee key, 2 = XOR in the 16 bytes at 0x40
|
||||
+0x10 char[0x30] content ID, "JP0365-NPJH50698_00-SIREN4PLUS2012MA"
|
||||
+0x90 a normal "~PSP" PRX: tag 0x407810F0 at 0xD0, decrypt_mode 23 at 0x7C
|
||||
```
|
||||
|
||||
`sceKernelLoadModule()` steps over the EDAT header, and the PRX inside then decrypts. Two keys go
|
||||
into that, both worked out from JPCSP (`ModuleMgrForUser.sceKernelLoadModuleNpDrm`, `crypto/DRM.java`,
|
||||
`crypto/PRX.java`, `crypto/KeyVault.java`), and both feed `pspDecryptType5()`, which already had a
|
||||
slot for each.
|
||||
|
||||
**xor2, the per-content key** - `NpDrmDeriveModuleKey()` in `Core/HLE/scePspNpDrm_user.cpp`, four
|
||||
steps in order:
|
||||
|
||||
1. `sceNpDrmGetFixedKey(kirk, key, edat+0x10, 0x01000000 | edat[0x08])` - already in
|
||||
`ext/libkirk/amctrl.c`, and identical to JPCSP's `hleNpDrmGetFixedKey`: our `key_363C` is its
|
||||
`drmFixedKey`, our `key_357C[0/1/2]` are its `drmEncKey1/2/3`, and its AES-CBC under an all-zero
|
||||
IV over one block is our `AES_encrypt`. Nothing had called this function before.
|
||||
2. If `edat[0x0F] & 1`: XOR the licensee key the game passed to `sceNpDrmSetLicenseeKey()`, which
|
||||
this file already kept but never used. The game sets it before it loads the module.
|
||||
3. If `edat[0x0F] & 2`: XOR the 16 bytes at `edat+0x40`. None seen here use it.
|
||||
4. AES-128 decrypt the result under `drmModuleKey`, the one constant that had to be added here.
|
||||
JPCSP does CBC with a zero IV; over a single block that is a plain `AES_decrypt`.
|
||||
|
||||
**xor1, a static key picked by `decrypt_mode`** - the PRX header byte at 0x7C being 23
|
||||
(`DECRYPT_MODE_SPRX`), which is what these payloads are. JPCSP keys this on the mode rather than on
|
||||
the tag, and so do we: tag 0x407810F0's table entry has no seed of its own in JPCSP's tables either,
|
||||
so our table was never wrong, it just had nothing to say about a case selected somewhere else.
|
||||
`pspDecryptType5()` takes the mode-derived XOR when the mode calls for it and falls back to the tag
|
||||
table's otherwise - the same precedence JPCSP uses, which leaves every tag that does carry a seed
|
||||
(the `pauth` ones) exactly as it was.
|
||||
|
||||
No new decryption logic was needed. `pspDecryptType5()` is structurally identical to JPCSP's "new
|
||||
method" for type 5: `expandSeed(pti->key, pti->code, seed)` XORs xor2 over the 0x90-byte scrambled
|
||||
key buffer as `RoundXOR(buf2, 0, 0x90, xor2, null)` does; `PRXType5::decrypt()` XORs both over the
|
||||
0x50-byte kirk header and SHA1 as `RoundXOR(buf2, 0x14, 0x50, xor1, xor2)` does, and then xor1 alone
|
||||
over the 0x60 bytes at `id` as `RoundXOR(buf4, 0x14, 0x60, xor1, null)` does. JPCSP's `RoundXOR` is
|
||||
`buf[i] ^= key[i & 0xF]`, the same repeating XOR as our `xor[i % 0x10]`.
|
||||
|
||||
### The other half of it: KL4E
|
||||
|
||||
Decrypting is only half the job. Shiren 4 Plus's `f5psp.sprx` decrypts to bytes that start with
|
||||
`KL4E`: every one of these modules has `comp_attribute = 0x0201`, i.e. compressed, and
|
||||
`(comp_attribute & 0xF00) != 0` means KL4E/KL3E rather than gzip. So decryption alone would just
|
||||
move the failure from "unsupported PRX type" to "decompression failed".
|
||||
|
||||
`Core/Util/KL4E.cpp` already handles that - it went in for firmware modules that use the same
|
||||
compression, and these get it for free. It's worth knowing the two halves are independent, because
|
||||
each one on its own leaves the module unloadable and the error doesn't say which is missing.
|
||||
|
||||
Note that none of this can be checked offline against the hand-decrypted `.sprx` files that
|
||||
circulate for God Eater 2: their flag byte says the licensee key is part of the derivation, and that
|
||||
key only exists while the game is running. Those files are still useful as a cross-check of the
|
||||
installer itself - the `PBOOT.PBP` in such a folder is byte-for-byte what `InstallPkg()` writes, and
|
||||
the only difference is that the `.sprx` files beside it were decrypted by hand.
|
||||
|
||||
### Where that leaves the three module titles
|
||||
|
||||
- **God Eater 2 (NPJH50832)** installs, boots, loads its modules and plays. Decryption alone wasn't
|
||||
enough for it: it also needed the type-B relocation fix in `ElfReader::LoadRelocations2` (issue
|
||||
#8075), where two `lui`s sharing one `addiu` got different high halves, so a callback pointer
|
||||
landed 0x48 bytes inside a function.
|
||||
- **Shiren 4 Plus (NPJH50698)** loads `f5psp.sprx` - the log says `'FDS3PSP' is KL4E-compressed,
|
||||
decompressing` - and runs.
|
||||
- **Tales of the World Radiant Mythology 3 (NPJH50353)** still doesn't reach its modules inside a
|
||||
headless boot, so it remains untested rather than known-good.
|
||||
|
||||
### Still not tested
|
||||
|
||||
- Only God Eater 2 has been played past a title screen. For the rest the runs are short headless
|
||||
boots, so "the update is in use" means the patched executable is what loaded and ran - not that a
|
||||
patched *asset* was read. LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no
|
||||
equivalent observation for a digital one yet.
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
// kirk4()/kirk7() below take a size_t, and nothing here provided it - the header only ever
|
||||
// compiled because whatever included it had pulled in a definition first. Core/HLE/scePspNpDrm_user.cpp
|
||||
// includes libkirk before anything else, so it doesn't.
|
||||
#include <stddef.h>
|
||||
|
||||
#include "kirk_common.h"
|
||||
#include "SHA1.h"
|
||||
#include "AES.h"
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
#include "Core/MIPS/MIPSTables.h"
|
||||
#include "Core/System.h"
|
||||
#include "Core/Util/PSARUnpack.h"
|
||||
#include "Core/Util/PkgUnpack.h"
|
||||
#include "Core/WebServer.h"
|
||||
#include "Core/HLE/sceUtility.h"
|
||||
#include "Core/SaveState.h"
|
||||
@@ -671,6 +672,38 @@ int main(int argc, const char* argv[]) {
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
// Same deal for installing a game update package.
|
||||
if (cmdLineOptions.installPkg.has_value()) {
|
||||
if (cmdLineOptions.bootFilenames.size() != 1) {
|
||||
fprintf(stderr, "--install-pkg takes exactly one .pkg file\n");
|
||||
return 1;
|
||||
}
|
||||
std::unique_ptr<FileLoader> loader(ConstructFileLoader(Path(cmdLineOptions.bootFilenames[0])));
|
||||
PkgReader reader;
|
||||
std::string pkgError;
|
||||
if (!loader || !reader.Open(loader.get(), &pkgError)) {
|
||||
fprintf(stderr, "Not a usable PKG: %s\n", pkgError.c_str());
|
||||
return 1;
|
||||
}
|
||||
const PkgInfo &info = reader.Info();
|
||||
printf("%s (%s)\n", info.title.c_str(), info.contentId.c_str());
|
||||
printf("Category %s, %d items, %lld bytes installed\n", info.category.c_str(),
|
||||
(int)info.items.size(), (long long)PkgInstalledSize(info));
|
||||
if (info.isGameUpdate) {
|
||||
printf("Game update for %s v%s -> app version %s (firmware %s)\n", info.discId.c_str(),
|
||||
info.discVersion.c_str(), info.appVer.c_str(), info.systemVer.c_str());
|
||||
} else {
|
||||
fprintf(stderr, "This PKG isn't a game update - nothing we know how to install\n");
|
||||
return 1;
|
||||
}
|
||||
if (!InstallPkg(reader, Path(cmdLineOptions.installPkg.value()), nullptr, &pkgError)) {
|
||||
fprintf(stderr, "Install failed: %s\n", pkgError.c_str());
|
||||
return 1;
|
||||
}
|
||||
printf("Installed into %s\n", cmdLineOptions.installPkg.value().c_str());
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_Config.RestoreDefaults(RestoreSettingsBits::SETTINGS | RestoreSettingsBits::CONTROLS | RestoreSettingsBits::RECENT, false);
|
||||
|
||||
Core_RegisterDebugOutputListeners(&SendDebugOutput, &SendDebugScreenshot);
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user