From f0dafdee10c4f8527cc403f664a14e66b85a5d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 21 Aug 2026 21:18:38 +0200 Subject: [PATCH] Show and remove installed game updates from the game info screen An installed update silently replaces what the game boots, so the info pane now says when there is one - version, size and where it lives - and the context menu offers to remove it again. Removing takes the whole PSP/GAME/ folder when the update is all that's in it. When a digital game shares the folder, only PBOOT.PBP goes, since deleting the folder would take the game with it and nothing records what the install wrote. The confirmation names the exact path either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018izZ1mGTWhz2RqeudqsDQR --- Core/Util/PkgUnpack.cpp | 53 +++++++++++++++++++++++++++++++++++ Core/Util/PkgUnpack.h | 24 +++++++++++++++- UI/GameScreen.cpp | 62 +++++++++++++++++++++++++++++++++++++++++ UI/GameScreen.h | 10 +++++++ docs/pkg_notes.md | 6 +++- 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/Core/Util/PkgUnpack.cpp b/Core/Util/PkgUnpack.cpp index 6c1d4b5738..105d52f4e6 100644 --- a/Core/Util/PkgUnpack.cpp +++ b/Core/Util/PkgUnpack.cpp @@ -17,13 +17,18 @@ #include #include +#include #include "Common/File/FileUtil.h" #include "Common/File/Path.h" #include "Common/Log.h" #include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" #include "Core/ELF/ParamSFO.h" +#include "Core/ELF/PBPReader.h" #include "Core/Loaders.h" +#include "Core/System.h" #include "Core/Util/PkgUnpack.h" extern "C" { @@ -496,3 +501,51 @@ bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function loader(ConstructFileLoader(pbootPath)); + if (!loader) { + return false; + } + PBPReader pbp(loader.get()); + std::vector sfoData; + ParamSFOData sfo; + if (!pbp.IsValid() || !pbp.GetSubFile(PBP_PARAM_SFO, &sfoData) || !sfo.ReadSFO(sfoData)) { + WARN_LOG(Log::Loader, "'%s' doesn't look like a game update", pbootPath.c_str()); + return false; + } + + update->folder = folder; + update->pbootPath = pbootPath; + update->appVer = sfo.GetValueString("APP_VER"); + update->discVersion = sfo.GetValueString("DISC_VERSION"); + update->title = sfo.GetValueString("PBOOT_TITLE"); + update->sharesFolderWithGame = File::Exists(folder / "EBOOT.PBP"); + update->sizeOnDisk = update->sharesFolderWithGame + ? (u64)std::max(0, File::GetFileSize(pbootPath)) + : File::ComputeRecursiveDirectorySize(folder); + return true; +} + +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update) { + const bool useTrash = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + // Only the PBOOT when the folder is a game in its own right - see the struct's comment. + const Path target = update.sharesFolderWithGame ? update.pbootPath : update.folder; + INFO_LOG(Log::Loader, "Removing game update '%s'", target.c_str()); + if (useTrash) { + // TODO: No way to tell whether this succeeded. + System_MoveToTrash(target); + return true; + } + return update.sharesFolderWithGame ? File::Delete(target) : File::DeleteDirRecursively(target); +} diff --git a/Core/Util/PkgUnpack.h b/Core/Util/PkgUnpack.h index a13dc6df59..8b8b2637f8 100644 --- a/Core/Util/PkgUnpack.h +++ b/Core/Util/PkgUnpack.h @@ -22,9 +22,9 @@ #include #include "Common/CommonTypes.h" +#include "Common/File/Path.h" class FileLoader; -class Path; // 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 @@ -106,3 +106,25 @@ u64 PkgInstalledSize(const PkgInfo &info); // Unpacks the installable items into destDir, which should be the game folder itself // (PSP/GAME/). progress is called with 0..1 as it goes, and may be null. bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function &progress, std::string *error); + +// An update that has been installed, i.e. what's left over in PSP/GAME/ afterwards. +struct InstalledGameUpdate { + Path folder; + Path pbootPath; + std::string appVer; // The update's version, from PBOOT.PBP's own PARAM.SFO. + std::string discVersion; // The disc version it was built against. + std::string title; // PBOOT_TITLE, when the update names itself ("Update 2.01"). + u64 sizeOnDisk = 0; // How much deleting it would actually free. + // True when a game shares the folder - a digital title, whose EBOOT.PBP sits right next to the + // update. Deleting the whole folder would take the game with it. + bool sharesFolderWithGame = false; +}; + +// Looks for an update installed for discId. Cheap enough to call while building a UI - a stat and +// a small read, plus a directory walk for the size. +bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update); + +// Removes an installed update, to the trash where there is one. Takes the whole folder when the +// update is all that's in it, and only PBOOT.PBP when a game shares the folder - that stops the +// update from being used, and it's the only part we can still identify after the fact. +bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update); diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index 28fe1705ab..66f071efb0 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -262,6 +262,15 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { } } +void GameScreen::RefreshInstalledUpdate() { + hasInstalledUpdate_ = false; + // Homebrew reuses real disc IDs often enough that we'd get false positives. + if (isHomebrew_ || !(knownFlags_ & GameInfoFlags::PARAM_SFO)) { + return; + } + hasInstalledUpdate_ = FindInstalledGameUpdate(info_->id, &installedUpdate_); +} + void GameScreen::CreateContentViews(UI::ViewGroup *parent) { if (!info_) { // Shouldn't happen @@ -505,6 +514,24 @@ void GameScreen::CreateContentViews(UI::ViewGroup *parent) { } } + // An installed game update replaces the disc's executable, so it's worth saying so here - + // otherwise there's nothing in the UI to explain why a patched game is running. + RefreshInstalledUpdate(); + if (hasInstalledUpdate_) { + infoLayout->Add(new ItemHeader(ga->T("Game update"))); + std::string updateLine = installedUpdate_.title; + if (!installedUpdate_.appVer.empty()) { + const std::string version = ApplySafeSubstitutions(ga->T("Version %1"), installedUpdate_.appVer); + updateLine = updateLine.empty() ? version : updateLine + " - " + version; + } + if (updateLine.empty()) { + updateLine = ga->T("Installed"); + } + updateLine += " - " + NiceSizeFormat(installedUpdate_.sizeOnDisk); + infoLayout->Add(new TextView(updateLine, ALIGN_LEFT, true))->SetBullet(true); + infoLayout->Add(new TextView(GetFriendlyPath(installedUpdate_.folder), ALIGN_LEFT | FLAG_WRAP_TEXT, true))->SetBullet(true); + } + // Show plugin info_, if any. Later might add checkboxes. auto plugins = HLEPlugins::FindPlugins(info_->id, g_Config.sLanguageIni); if (!plugins.empty()) { @@ -625,6 +652,12 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { }); } + RefreshInstalledUpdate(); + if (!inGame_ && hasInstalledUpdate_) { + Choice *btnDeleteUpdate = parent->Add(new Choice(ga->T("Delete Game Update"), ImageID("I_TRASHCAN"))); + btnDeleteUpdate->OnClick.Handle(this, &GameScreen::OnDeleteGameUpdate); + } + // Don't want to be able to delete the game while it's running. if (!inGame_) { Choice *deleteChoice = parent->Add(new Choice(ga->T("Delete Game"), ImageID("I_WARNING"))); @@ -632,6 +665,35 @@ void GameScreen::CreateContextMenu(UI::ViewGroup *parent) { } } +void GameScreen::OnDeleteGameUpdate(UI::EventParams &e) { + if (!hasInstalledUpdate_) { + return; + } + auto di = GetI18NCategory(I18NCat::DIALOG); + auto ga = GetI18NCategory(I18NCat::GAME); + + std::string prompt(ga->T("DeleteConfirmGameUpdate", "Do you really want to remove the installed update?\nThe game will go back to running the version on the disc.")); + prompt += "\n\n"; + // Say exactly what disappears - for a digital game the folder holds the game itself, so only + // the update's executable goes. + prompt += GetFriendlyPath(installedUpdate_.sharesFolderWithGame ? installedUpdate_.pbootPath : installedUpdate_.folder); + + const bool trashAvailable = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN); + const InstalledGameUpdate update = installedUpdate_; + screenManager()->push( + new UI::MessagePopupScreen(ga->T("Delete Game Update"), prompt, trashAvailable ? di->T("Move to trash") : di->T("Delete"), di->T("Cancel"), + [this, update](bool yes) { + if (!yes) { + return; + } + if (!DeleteInstalledGameUpdate(update)) { + auto er = GetI18NCategory(I18NCat::ERRORS); + g_OSD.Show(OSDType::MESSAGE_ERROR, er->T("Failed to delete the game update")); + } + RecreateViews(); + })); +} + void GameScreen::OnCreateConfig(UI::EventParams &e) { if (!info_->Ready(GameInfoFlags::PARAM_SFO)) { return; diff --git a/UI/GameScreen.h b/UI/GameScreen.h index 398a2a8c0c..7b1e5c8e3b 100644 --- a/UI/GameScreen.h +++ b/UI/GameScreen.h @@ -23,6 +23,7 @@ #include "Common/UI/UIScreen.h" #include "Common/File/Path.h" #include "UI/GameInfoCache.h" +#include "Core/Util/PkgUnpack.h" #include "UI/SimpleDialogScreen.h" @@ -58,6 +59,11 @@ private: void OnCreateConfig(UI::EventParams &e); void OnDeleteConfig(UI::EventParams &e); void OnSetBackground(UI::EventParams &e); + void OnDeleteGameUpdate(UI::EventParams &e); + + // Checks whether a game update is installed for this game. Both the info pane and the context + // menu need the answer, and either can be built first. + void RefreshInstalledUpdate(); std::string CRC32string; @@ -69,6 +75,10 @@ private: bool knownHasCRC_ = false; + // A game update installed in PSP/GAME/, which is what actually runs when there is one. + InstalledGameUpdate installedUpdate_; + bool hasInstalledUpdate_ = false; + std::shared_ptr info_; mutable std::string titleCache_; }; diff --git a/docs/pkg_notes.md b/docs/pkg_notes.md index 0b34aea32e..c41a2837d7 100644 --- a/docs/pkg_notes.md +++ b/docs/pkg_notes.md @@ -167,7 +167,7 @@ patch file are silently dropped, with no warning that anything was skipped. ## How PPSSPP handles them -Three pieces, added 2026-08-21: +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 @@ -177,6 +177,10 @@ Three pieces, added 2026-08-21: `InstallZipScreen` - it shows what the update patches, what it'll take up on disk, and where it's going. The size is exact rather than an estimate: package contents aren't compressed, so summing the item table is the answer. `GameManager::InstallPkgOnThread()` does the work. +- **`GameScreen`** shows an installed update in the info pane, and offers "Delete Game Update" in + its context menu. Deleting takes the whole `PSP/GAME/` folder when the update is all + that's in it, and only `PBOOT.PBP` when a digital game shares the folder - nothing records what + an install wrote, so the executable is the only part still identifiable afterwards. - **`FindGameUpdatePBOOT()` in `Core/PSPLoaders.cpp`** is the boot-time half. Starting a disc looks for `ms0:/PSP/GAME//PBOOT.PBP`, and boots that instead of `disc0:/PSP_GAME/SYSDIR/EBOOT.BIN` if it's there, leaving the disc mounted.