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.