Install PKG game updates, and boot them

Opening a .pkg now offers to install it, the way a .zip does - see the new
InstallPkgScreen, which shows what the update patches, what it'll take up
on disk (exact, since package contents aren't compressed) and where it's
going. The package's PS3-style USRDIR/CONTENT wrapping is stripped so the
files land where the PSP expects them, in PSP/GAME/<DISC_ID>.

Booting a disc then looks for PSP/GAME/<DISC_ID>/PBOOT.PBP and 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 the rest. The update's
DISC_ID has to match; a DISC_VERSION mismatch only warns, since updates do
get used with slightly different dumps in practice.

Verified against the whole corpus: every one installs, and the
digital NP* update/base-image pairs that could be assembled all boot the
patch rather than the disc's executable. That includes Super Robot Taisen
Operation Extend from a real NPUMDIMG EBOOT.PBP, which settles that
ISO.BIN.EDAT does not re-key the PBOOT - a digital title's patched EBOOT is
encrypted exactly like a UMD one. On the UMD side, the patched
LittleBigPlanet reads PATCH.ARC out of the install alongside the disc's own
archive.

The DISC_VERSION warning turns out to be load-bearing: many of the pairs
mismatch, because the dumps in circulation are later disc revisions than the
updates were built against. docs/pkg_notes.md has the numbers, and the one
thing that doesn't work - PGD-wrapped .sprx modules, which
sceKernelLoadModuleNpDrm can't load.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-09-08 11:15:15 -06:00
1 parent 5089caf2a0
commit 3a70c6b149
18 files changed
+568 -17

No files matched your search

+7
View File
@@ -219,6 +219,8 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin
return IdentifiedFileType::ARCHIVE_RAR;
} else if (!memcmp(&id, "\x37\x7A\xBC\xAF", 4)) {
return IdentifiedFileType::ARCHIVE_7Z;
} else if (!memcmp(&id, "\x7F""PKG", 4)) {
return IdentifiedFileType::PSP_PKG;
}
// "~PSP" is an encrypted PRX. The module loader decrypts those on the way in, so as far as
@@ -279,6 +281,10 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin
return IdentifiedFileType::ARCHIVE_RAR;
} else if (extension == ".7z") {
return IdentifiedFileType::ARCHIVE_7Z;
} else if (extension == ".pkg") {
// Magic didn't match, but the name says what it was meant to be - report it as a PKG so
// the install screen can explain what's wrong with it.
return IdentifiedFileType::PSP_PKG;
}
return IdentifiedFileType::UNKNOWN;
}
@@ -673,6 +679,7 @@ const char *IdentifiedFileTypeToString(IdentifiedFileType type) {
case IdentifiedFileType::UNKNOWN_ISO: return "UNKNOWN_ISO";
case IdentifiedFileType::ARCHIVE_RAR: return "ARCHIVE_RAR";
case IdentifiedFileType::ARCHIVE_ZIP: return "ARCHIVE_ZIP";
case IdentifiedFileType::PSP_PKG: return "PSP_PKG";
case IdentifiedFileType::ARCHIVE_7Z: return "ARCHIVE_7Z";
case IdentifiedFileType::PSP_PS1_PBP: return "PSP_PS1_PBP";
case IdentifiedFileType::PSX_ISO: return "PSX_ISO";
+2
View File
@@ -40,6 +40,8 @@ enum class IdentifiedFileType {
ARCHIVE_RAR,
ARCHIVE_ZIP,
ARCHIVE_7Z,
// A .pkg game update - a container we install from rather than boot. See Core/Util/PkgUnpack.h.
PSP_PKG,
PSP_PS1_PBP,
PSX_ISO,
PS2_ISO,
+76 -2
View File
@@ -260,8 +260,82 @@ static const char * const altBootNames[] = {
//"disc0:/PSP_GAME/SYSDIR/ss.RAW",//Code Geass: Lost Colors chinese version
};
// 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.
static bool ReadPBPParamSFO(const std::string &path, ParamSFOData *sfo) {
const int fd = pspFileSystem.OpenFile(path, FILEACCESS_READ);
if (fd < 0) {
return false;
}
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);
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();
}
// 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) {
std::string bootpath("disc0:/PSP_GAME/SYSDIR/EBOOT.BIN");
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++) {
@@ -273,7 +347,6 @@ bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) {
}
// 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";
}
@@ -299,6 +372,7 @@ bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string) {
// try unencrypted Boot.BIN
bootpath = "disc0:/PSP_GAME/SYSDIR/BOOT.BIN";
}
}
// Fail early with a clearer message for some types of ISOs.
if (!pspFileSystem.GetFileInfo(bootpath).exists) {
+1
View File
@@ -255,6 +255,7 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) {
break;
case IdentifiedFileType::ARCHIVE_7Z: *errorString = "7z file detected (Require 7-Zip)"; break;
case IdentifiedFileType::PSP_PKG: *errorString = "PKG game updates need to be installed, not booted."; break;
case IdentifiedFileType::PSX_ISO: *errorString = "PSX game image detected."; break;
case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break;
case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break;
+65
View File
@@ -52,6 +52,7 @@
#include "Core/FileSystems/ISOFileSystem.h"
#include "Core/Util/GameManager.h"
#include "Core/Util/PathUtil.h"
#include "Core/Util/PkgUnpack.h"
#include "Core/Util/RecentFiles.h"
#include "Common/Data/Text/I18n.h"
@@ -902,6 +903,70 @@ bool GameManager::InstallZipOnThread(ZipFileTask task) {
return true;
}
// Installing a game update from a .pkg. Unlike a zip there's nothing to guess about - the
// package says which disc it patches, and that decides the destination folder.
void GameManager::InstallPkgContents(Path pkgPath, bool deleteAfter) {
SetCurrentThreadName("InstallPkgContents");
AndroidJNIThreadContext context; // Destructor detaches.
auto di = GetI18NCategory(I18NCat::DIALOG);
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, 0.0f, 0.1f);
bool success = false;
std::string error;
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pkgPath));
PkgReader reader;
if (!loader || !reader.Open(loader.get(), &error)) {
ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str());
SetInstallError(iz->T("This PKG file isn't a PSP game update"));
} else if (!reader.Info().isGameUpdate) {
SetInstallError(iz->T("This PKG file isn't a PSP game update"));
} else {
const Path destination = GetSysDirectory(DIRECTORY_GAME) / reader.Info().discId;
success = InstallPkg(reader, destination, [this](float progress) {
installProgress_ = progress;
auto di = GetI18NCategory(I18NCat::DIALOG);
g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f);
}, &error);
if (!success) {
ERROR_LOG(Log::HLE, "PKG install failed: %s", error.c_str());
SetInstallError(iz->T("Failed to install the game update"));
}
}
// Close the package before anything tries to delete it.
loader.reset();
if (deleteAfter && success) {
if (System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN)) {
System_MoveToTrash(pkgPath);
} else {
File::Delete(pkgPath);
}
}
g_OSD.RemoveProgressBar("install", success, 0.5f);
installProgress_ = 1.0f;
InstallDone();
if (success) {
ResetInstallError();
}
}
bool GameManager::InstallPkgOnThread(const Path &pkgPath, bool deleteAfter) {
if (InstallInProgress() || installDonePending_) {
return false;
}
installThread_ = std::thread([this, pkgPath, deleteAfter]() {
InstallPkgContents(pkgPath, deleteAfter);
});
return true;
}
bool GameManager::UninstallGameOnThread(const std::string &name) {
if (name.empty()) {
ERROR_LOG(Log::HLE, "Cannot uninstall an empty-named game");
+5
View File
@@ -85,6 +85,10 @@ public:
// Only returns false if there's already an installation in progress.
bool InstallZipOnThread(ZipFileTask task);
// Installs a game update from a .pkg into PSP/GAME/<DISC_ID>. Same contract as the above -
// only returns false if something else is already installing. See Core/Util/PkgUnpack.h.
bool InstallPkgOnThread(const Path &pkgPath, bool deleteAfter);
// Separate kind of functionality from InstallZipOnThread, so doesn't re-use the task struct.
bool UninstallGameOnThread(const std::string &name);
@@ -94,6 +98,7 @@ public:
private:
void InstallZipContents(ZipFileTask task);
void InstallPkgContents(Path pkgPath, bool deleteAfter);
bool InstallMemstickZip(const Path &zipFile, const Path &dest, const ZipFileInfo &info);
bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir);
void UninstallGame(const std::string &name);
+2
View File
@@ -103,6 +103,8 @@ list(APPEND UISource
InstallUpdateScreen.cpp
InstallZipScreen.h
InstallZipScreen.cpp
InstallPkgScreen.h
InstallPkgScreen.cpp
JitCompareScreen.h
JitCompareScreen.cpp
MemStickScreen.h
+4 -2
View File
@@ -239,6 +239,7 @@ void GameButton::Draw(UIContext &dc) {
case IdentifiedFileType::ARCHIVE_ZIP: imageIcon = ImageID("I_ARCHIVE_ZIP"); drawBackground = false; break;
case IdentifiedFileType::ARCHIVE_7Z: imageIcon = ImageID("I_ARCHIVE_7Z"); drawBackground = false; break;
case IdentifiedFileType::ARCHIVE_RAR: imageIcon = ImageID("I_ARCHIVE_RAR"); drawBackground = false; break;
case IdentifiedFileType::PSP_PKG: imageIcon = ImageID("I_FOLDER_UPLOAD"); drawBackground = false; break;
default: break;
}
@@ -880,11 +881,12 @@ void GameBrowser::Refresh() {
}
}
// Put RAR/ZIP files at the end to get them out of the way.
// Put RAR/ZIP files at the end to get them out of the way. Game update packages go here
// too - they're not something to boot, they're something to install.
// We do support unpacking some of them automatically.
if (browseFlags_ & BrowseFlags::ARCHIVES) {
fileInfo.clear();
path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:");
path_.GetListing(fileInfo, "zip:rar:r00:r01:7z:pkg:");
if (!fileInfo.empty()) {
for (size_t i = 0; i < fileInfo.size(); i++) {
if (!fileInfo[i].isDirectory) {
+2
View File
@@ -153,6 +153,7 @@ bool GameInfo::Delete() {
case IdentifiedFileType::ARCHIVE_RAR:
case IdentifiedFileType::ARCHIVE_ZIP:
case IdentifiedFileType::ARCHIVE_7Z:
case IdentifiedFileType::PSP_PKG:
case IdentifiedFileType::UNKNOWN:
case IdentifiedFileType::PSP_UMD_VIDEO_ISO:
case IdentifiedFileType::PPSSPP_GE_DUMP:
@@ -918,6 +919,7 @@ handleELF:
}
case IdentifiedFileType::ARCHIVE_ZIP:
case IdentifiedFileType::PSP_PKG:
info_->SetTitle(info_->GetFilePath().GetFilename());
info_->icon.dataLoaded = true;
break;
+193
View File
@@ -0,0 +1,193 @@
// Copyright (c) 2026- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <memory>
#include "Common/Data/Text/I18n.h"
#include "Common/Data/Text/Parsers.h"
#include "Common/File/DiskFree.h"
#include "Common/File/FileUtil.h"
#include "Common/StringUtils.h"
#include "Common/System/Request.h"
#include "Common/System/System.h"
#include "Common/UI/Context.h"
#include "Common/UI/UI.h"
#include "Common/UI/View.h"
#include "Common/UI/ViewGroup.h"
#include "Core/Loaders.h"
#include "Core/System.h"
#include "Core/Util/GameManager.h"
#include "Core/Util/PathUtil.h"
#include "UI/InstallPkgScreen.h"
InstallPkgScreen::InstallPkgScreen(const Path &pkgPath)
: UITwoPaneBaseDialogScreen(Path(), TwoPaneFlags::SettingsToTheRight | TwoPaneFlags::ContentsCanScroll), pkgPath_(pkgPath) {
g_GameManager.ResetInstallError();
// Reading the package is cheap - the item table and two small PARAM.SFOs. We only keep the
// info; the install re-opens the file on its own thread.
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pkgPath_));
PkgReader reader;
if (!loader || !reader.Open(loader.get(), &pkgError_)) {
return;
}
pkgInfo_ = reader.Info();
if (!pkgInfo_.isGameUpdate) {
// Everything we can read is a game update; anything else got rejected above with a better
// message than this.
pkgError_ = "This PKG file isn't a PSP game update";
return;
}
canInstall_ = true;
installSize_ = PkgInstalledSize(pkgInfo_);
destination_ = GetSysDirectory(DIRECTORY_GAME) / pkgInfo_.discId;
alreadyInstalled_ = File::Exists(destination_ / "PBOOT.PBP");
int64_t space = 0;
if (free_disk_space(GetSysDirectory(DIRECTORY_GAME), space)) {
freeSpace_ = space;
}
}
std::string_view InstallPkgScreen::GetTitle() const {
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
return iz->T("Game update");
}
void InstallPkgScreen::CreateSettingsViews(UI::ViewGroup *parent) {
using namespace UI;
auto di = GetI18NCategory(I18NCat::DIALOG);
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
installChoice_ = nullptr;
if (canInstall_) {
installChoice_ = parent->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD")));
installChoice_->OnClick.Handle(this, &InstallPkgScreen::OnInstall);
}
if (System_GetPropertyBool(SYSPROP_CAN_SHOW_FILE)) {
parent->Add(new Spacer(12.0f));
parent->Add(new Choice(di->T("Show in folder")))->OnClick.Add([this](UI::EventParams &) {
System_ShowFileInFolder(pkgPath_);
});
}
if (canInstall_) {
parent->Add(new Spacer(12.0f));
parent->Add(new CheckBox(&deletePkgFile_, iz->T("Delete PKG file")));
}
}
void InstallPkgScreen::CreateContentViews(UI::ViewGroup *parent) {
using namespace UI;
auto di = GetI18NCategory(I18NCat::DIALOG);
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
auto er = GetI18NCategory(I18NCat::ERRORS);
LinearLayout *leftColumn = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8))));
if (!canInstall_) {
leftColumn->Add(new TextView(GetFriendlyPath(pkgPath_)));
leftColumn->Add(new NoticeView(NoticeLevel::ERROR, iz->T(pkgError_.empty() ? "This PKG file isn't a PSP game update" : pkgError_), ""));
doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", ""));
doneView_->SetVisibility(Visibility::V_GONE);
return;
}
leftColumn->Add(new TextView(iz->T("Install game update?")))->SetBig(true);
leftColumn->Add(new TextView(pkgPath_.GetFilename()));
if (!pkgInfo_.title.empty()) {
leftColumn->Add(new TextView(pkgInfo_.title));
}
if (!pkgInfo_.pbootTitle.empty()) {
leftColumn->Add(new TextView(pkgInfo_.pbootTitle));
}
leftColumn->Add(new Spacer(8.0f));
// What it patches, and to what. The disc version is the one the update was built against.
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2 (%3)", iz->T("Game"), pkgInfo_.discId, pkgInfo_.discVersion)));
if (!pkgInfo_.appVer.empty()) {
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Update version"), pkgInfo_.appVer)));
}
if (!pkgInfo_.systemVer.empty()) {
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Requires firmware"), pkgInfo_.systemVer)));
}
// Package contents aren't compressed, so this is what it'll actually take up.
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Space needed"), NiceSizeFormat(installSize_))));
if (freeSpace_ >= 0) {
leftColumn->Add(new TextView(ApplySafeSubstitutions("%1: %2", iz->T("Free space"), NiceSizeFormat((u64)freeSpace_))));
}
leftColumn->Add(new Spacer(8.0f));
leftColumn->Add(new TextView(iz->T("Install into folder")));
leftColumn->Add(new TextView(GetFriendlyPath(destination_)))->SetAlign(FLAG_WRAP_TEXT);
doneView_ = leftColumn->Add(new NoticeView(NoticeLevel::SUCCESS, "", ""));
doneView_->SetVisibility(Visibility::V_GONE);
if (freeSpace_ >= 0 && (u64)freeSpace_ < installSize_) {
leftColumn->Add(new NoticeView(NoticeLevel::ERROR, er->T("Not enough free space"), ""));
}
if (alreadyInstalled_) {
leftColumn->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), iz->T("An update for this game is already installed")));
}
}
bool InstallPkgScreen::key(const KeyInput &key) {
// Ignore key presses while installing, so the user can't escape mid-write.
if (g_GameManager.GetState() == GameManagerState::IDLE) {
return UIDialogScreen::key(key);
}
return false;
}
void InstallPkgScreen::OnInstall(UI::EventParams &params) {
if (!canInstall_) {
return;
}
if (g_GameManager.InstallPkgOnThread(pkgPath_, deletePkgFile_)) {
installStarted_ = true;
if (installChoice_) {
installChoice_->SetEnabled(false);
}
}
}
void InstallPkgScreen::update() {
auto iz = GetI18NCategory(I18NCat::INSTALLZIP);
using namespace UI;
if (g_GameManager.GetState() == GameManagerState::IDLE && doneView_) {
const std::string err = g_GameManager.GetInstallError();
if (!err.empty()) {
doneView_->SetLevelAndText(NoticeLevel::ERROR, iz->T(err));
doneView_->SetVisibility(Visibility::V_VISIBLE);
} else if (installStarted_) {
doneView_->SetLevelAndText(NoticeLevel::SUCCESS, iz->T("Installed!"));
doneView_->SetVisibility(Visibility::V_VISIBLE);
}
}
UIBaseDialogScreen::update();
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#pragma once
#include "Common/File/Path.h"
#include "Common/UI/Notice.h"
#include "Common/UI/UIScreen.h"
#include "Common/UI/View.h"
#include "Core/Util/PkgUnpack.h"
#include "UI/BaseScreens.h"
#include "UI/SimpleDialogScreen.h"
// Offers to install a PSP game update from a .pkg file, the way InstallZipScreen does for zips.
// The package goes into PSP/GAME/<DISC_ID>, and from then on booting that disc runs the update -
// see FindGameUpdatePBOOT() in Core/PSPLoaders.cpp.
class InstallPkgScreen : public UITwoPaneBaseDialogScreen {
public:
InstallPkgScreen(const Path &pkgPath);
void update() override;
bool key(const KeyInput &key) override;
const char *tag() const override { return "InstallPkg"; }
protected:
void CreateSettingsViews(UI::ViewGroup *parent) override;
void CreateContentViews(UI::ViewGroup *parent) override;
std::string_view GetTitle() const override;
ViewLayoutMode LayoutMode() const override {
return ViewLayoutMode::ApplyInsets;
}
private:
void OnInstall(UI::EventParams &params);
Path pkgPath_;
Path destination_;
PkgInfo pkgInfo_;
std::string pkgError_; // Why the package can't be installed, if it can't.
bool canInstall_ = false;
u64 installSize_ = 0;
s64 freeSpace_ = -1; // Negative if we couldn't find out.
bool alreadyInstalled_ = false;
UI::Choice *installChoice_ = nullptr;
NoticeView *doneView_ = nullptr;
bool installStarted_ = false;
bool deletePkgFile_ = false;
};
+4
View File
@@ -51,6 +51,7 @@
#include "UI/SavedataScreen.h"
#include "UI/InstallUpdateScreen.h"
#include "UI/InstallZipScreen.h"
#include "UI/InstallPkgScreen.h"
#include "UI/Background.h"
#include "UI/GameBrowser.h"
#include "Core/Config.h"
@@ -67,6 +68,9 @@ static void LaunchFile(ScreenManager *screenManager, Screen *currentScreen, cons
if (extension == ".zip" || extension == ".7z") {
// If is a zip file, we have a screen for that.
screenManager->push(new InstallZipScreen(path));
} else if (extension == ".pkg") {
// A game update package - not something to boot, something to install.
screenManager->push(new InstallPkgScreen(path));
} else {
// Check if we already know that this game isn't playable.
// If coming from the main screen, the info will already be computed here since the icon is displayed etc.
+2
View File
@@ -79,6 +79,7 @@
<ClCompile Include="TiltAnalogSettingsScreen.cpp" />
<ClCompile Include="TouchControlLayoutScreen.cpp" />
<ClCompile Include="TouchControlVisibilityScreen.cpp" />
<ClCompile Include="InstallPkgScreen.cpp" />
<ClCompile Include="InstallUpdateScreen.cpp" />
<ClCompile Include="InstallZipScreen.cpp" />
<ClCompile Include="Theme.cpp" />
@@ -137,6 +138,7 @@
<ClInclude Include="TiltAnalogSettingsScreen.h" />
<ClInclude Include="TouchControlLayoutScreen.h" />
<ClInclude Include="TouchControlVisibilityScreen.h" />
<ClInclude Include="InstallPkgScreen.h" />
<ClInclude Include="InstallUpdateScreen.h" />
<ClInclude Include="InstallZipScreen.h" />
<ClInclude Include="Theme.h" />
+6
View File
@@ -40,6 +40,9 @@
<ClCompile Include="TiltAnalogSettingsScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="InstallPkgScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="InstallUpdateScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
@@ -194,6 +197,9 @@
<ClInclude Include="TiltAnalogSettingsScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="InstallPkgScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="InstallUpdateScreen.h">
<Filter>Screens</Filter>
</ClInclude>
+2
View File
@@ -114,6 +114,7 @@
<ClInclude Include="..\..\UI\ImDebugger\ImJitViewer.h" />
<ClInclude Include="..\..\UI\ImDebugger\ImMemView.h" />
<ClInclude Include="..\..\UI\ImDebugger\ImStructViewer.h" />
<ClInclude Include="..\..\UI\InstallPkgScreen.h" />
<ClInclude Include="..\..\UI\InstallUpdateScreen.h" />
<ClInclude Include="..\..\UI\InstallZipScreen.h" />
<ClInclude Include="..\..\UI\JitCompareScreen.h" />
@@ -173,6 +174,7 @@
<ClCompile Include="..\..\UI\ImDebugger\ImJitViewer.cpp" />
<ClCompile Include="..\..\UI\ImDebugger\ImMemView.cpp" />
<ClCompile Include="..\..\UI\ImDebugger\ImStructViewer.cpp" />
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp" />
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp" />
<ClCompile Include="..\..\UI\InstallZipScreen.cpp" />
<ClCompile Include="..\..\UI\JitCompareScreen.cpp" />
+6
View File
@@ -73,6 +73,9 @@
<ClCompile Include="..\..\UI\IAPScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="..\..\UI\InstallPkgScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
<ClCompile Include="..\..\UI\InstallUpdateScreen.cpp">
<Filter>Screens</Filter>
</ClCompile>
@@ -219,6 +222,9 @@
<ClInclude Include="..\..\UI\IAPScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="..\..\UI\InstallPkgScreen.h">
<Filter>Screens</Filter>
</ClInclude>
<ClInclude Include="..\..\UI\InstallUpdateScreen.h">
<Filter>Screens</Filter>
</ClInclude>
+1
View File
@@ -989,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 \
+124 -13
View File
@@ -6,8 +6,9 @@ EBOOT (`PBOOT.PBP`) plus whatever data files the patch replaces, and installing
`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.
`Tools/pkg.py` is a working parser/extractor. PPSSPP itself cannot yet read or use these - see
"What PPSSPP is missing" at the end.
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).
@@ -164,17 +165,127 @@ recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an
`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.
## What PPSSPP is missing
## How PPSSPP handles them
`grep -r PBOOT` over the tree returns nothing, so both halves are unwritten:
Three pieces, added 2026-08-21:
1. **Reading and installing a `.pkg`** - unpack it into `ms0:/PSP/GAME/<DISC_ID>/`, next to the
existing ZIP/ISO install paths in `Core/Util/GameManager.cpp`.
2. **Booting the patch** - when starting a game, notice
`ms0:/PSP/GAME/<DISC_ID>/PBOOT.PBP` whose inner SFO matches the disc's `DISC_ID` and
`DISC_VERSION`, and boot that instead with the ISO mounted on `disc0:`. The mounting already
exists (see `--mount` above); what's missing is the decision.
- **`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.
- **`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.
Only the UMD half of this is proven. The digital NP\* titles patch a PSN `EBOOT.PBP` /
NPUMDIMG rather than a UMD, and `ISO.BIN.EDAT` most likely re-keys it - that path has not been
tested.
`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.
### Known limitation: PGD-wrapped `.sprx` modules don't load
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 ship `\0PSPEDAT` **executables** - `.sprx` modules - and that path is not
implemented. God Eater 2 (NPJH50832, 45 of them) installs cleanly, boots its `PBOOT.PBP`, and then
loops forever on:
```
E Loader: SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE=sceKernelLoadModuleNpDrm(
ms0:/PSP/GAME/NPJH50832/system.sprx, 00000000, 00000000): failed to load
E sceModule: Wrong magic number 50535000
```
Shiren 4 Plus (NPJH50698, one `.sprx` holding the whole game behind a 62 KB loader) fails
identically. Tales of the World Radiant Mythology 3 (NPJH50353, two) doesn't reach its modules
within 100 seconds of headless boot, so it gets past startup - it presumably hits the same wall
whenever it does load them.
`sceKernelLoadModuleNpDrm()` in `Core/HLE/sceKernelModule.cpp` is a one-line forward to
`sceKernelLoadModule()`, which sees something that isn't ELF magic and gives up. Fixing it means
unwrapping the EDAT before handing the bytes to the ELF loader, using the licensee key the game has
already set via `sceNpDrmSetLicenseeKey` - the same material the data path uses.
This is a pre-existing emulator gap rather than something the installer gets wrong: until now
nothing put such a file on the memory stick, so it was unreachable. The unpatched God Eater 2 never
calls `sceKernelLoadModuleNpDrm` at all - the update is what introduces the modules - so for that
title the update is currently better not installed, since the unpatched game boots. It's also why a
"God Eater 2 DLC Update v1.40 *Decrypted for PPSSPP Emulator*" folder circulates: the `PBOOT.PBP` in
it is byte-for-byte what `InstallPkg()` writes, and the only difference is that its `.sprx` files
have been unwrapped to plain ELF by hand.
### Still not tested
- Nothing here plays past a title screen. The runs are 25-second 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.