From 4b761f07157e487f1578704f1d3ea4b100b62a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 24 Aug 2026 12:09:17 +0200 Subject: [PATCH] Offer to install PSP firmware updaters instead of running them Opening an official updater (a PSP/GAME/UPDATE EBOOT.PBP, identified by the MSTKUPDATE disc ID) from the main screen now brings up a confirmation dialog that unpacks the firmware into the NAND directory, where the emulated flash0/flash1 live. Running the updater itself doesn't work, so there was nothing useful to do with one before. Unpacks the file list for the model we claim to be (iPSPModel), on a worker thread, with a progress bar - for which PSARUnpackOptions gets an optional progress callback. AGENTS.md: translate UI strings last, in a separate commit The English string is what all ~47 languages get derived from, so rewording it after the sweep means redoing the sweep. Check the wording first. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 5 + Core/Util/PSARUnpack.cpp | 7 ++ Core/Util/PSARUnpack.h | 4 + UI/CMakeLists.txt | 2 + UI/InstallUpdateScreen.cpp | 200 ++++++++++++++++++++++++++++++ UI/InstallUpdateScreen.h | 80 ++++++++++++ UI/MainScreen.cpp | 19 ++- UI/UI.vcxproj | 2 + UI/UI.vcxproj.filters | 6 + UWP/UI_UWP/UI_UWP.vcxproj | 2 + UWP/UI_UWP/UI_UWP.vcxproj.filters | 6 + android/jni/Android.mk | 1 + 12 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 UI/InstallUpdateScreen.cpp create mode 100644 UI/InstallUpdateScreen.h diff --git a/AGENTS.md b/AGENTS.md index 98d13d5de4..4a761f7892 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,11 @@ For string sanitation, we already have SanitizeString in StringUtils.cpp - add n ## Translated UI strings (assets/lang) +**When implementing new UI, translations come last, in their own commit after everything else is +done.** Write the English strings, get the feature built and working, commit that - then stop and ask +the user to check the English wording before translating anything. The English string is what all ~47 +languages get derived from, so rewording it afterwards means redoing the whole sweep. + One .ini file per language, keyed by section and key against `assets/lang/en_US.ini`. **Don't hand-edit the ~47 files**, and don't run the AI commands in `Tools/langtool` either - you can read the call site, which its fixed prompt can't. Do the translating yourself and let the tool do the file surgery. Run it diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index c0cf887409..ca3b27299d 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -489,6 +489,9 @@ public: PSARCompression entryCompression() const { return entryCompression_; } // Empty for a directory, or for an entry we couldn't decompress. const std::vector &entryData() const { return entryData_; } + // How far into the archive the next record starts, and where the records stop - i.e. progress. + u32 position() const { return pos_; } + size_t limit() const { return limit_; } private: // Decrypts one record into 'out'. Returns the decrypted size, or <= 0 on failure. @@ -726,6 +729,10 @@ bool UnpackPSAR(const u8 *psar, size_t psarSize, const Path &outputDir, const PS stats->entries++; stats->compressionCounts[(int)reader.entryCompression()]++; + if (options.progress && reader.limit() > 0) { + options.progress(std::min(1.0f, (float)reader.position() / (float)reader.limit())); + } + if (options.verbose) { INFO_LOG(Log::Loader, "PSAR entry '%s' (%s, %d bytes)", reader.entryName().c_str(), PSARCompressionToString(reader.entryCompression()), (int)reader.entryData().size()); diff --git a/Core/Util/PSARUnpack.h b/Core/Util/PSARUnpack.h index f604298170..620932c5d9 100644 --- a/Core/Util/PSARUnpack.h +++ b/Core/Util/PSARUnpack.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include @@ -81,6 +82,9 @@ struct PSARUnpackOptions { bool listOnly = false; // Log a line per entry. Off by default - an updater holds well over a thousand of them. bool verbose = false; + // Called on the unpacking thread after every entry, with how far through the archive we are + // (0.0 to 1.0). Unpacking a full firmware takes a while, so a UI wants this. + std::function progress; }; struct PSARUnpackStats { diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index 6ad1a9ac52..660e29392d 100644 --- a/UI/CMakeLists.txt +++ b/UI/CMakeLists.txt @@ -99,6 +99,8 @@ list(APPEND UISource UploadScreen.cpp CwCheatScreen.h CwCheatScreen.cpp + InstallUpdateScreen.h + InstallUpdateScreen.cpp InstallZipScreen.h InstallZipScreen.cpp JitCompareScreen.h diff --git a/UI/InstallUpdateScreen.cpp b/UI/InstallUpdateScreen.cpp new file mode 100644 index 0000000000..46c20454cd --- /dev/null +++ b/UI/InstallUpdateScreen.cpp @@ -0,0 +1,200 @@ +// 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 "Common/Data/Text/I18n.h" +#include "Common/Data/Text/Parsers.h" +#include "Common/File/DirListing.h" +#include "Common/File/FileUtil.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/Thread/Promise.h" +#include "Common/Thread/ThreadManager.h" +#include "Common/UI/UI.h" +#include "Common/UI/View.h" +#include "Common/UI/ViewGroup.h" + +#include "Core/Config.h" +#include "Core/ConfigValues.h" +#include "Core/Util/PathUtil.h" + +#include "UI/InstallUpdateScreen.h" +#include "UI/MiscViews.h" + +// An updater carries one file list per hardware revision, and anything the chosen model's list +// doesn't name isn't part of its firmware. Unpacking the model we claim to be keeps flash0 +// consistent with what the emulator reports to games. +static PSPModelGeneration EmulatedModelGeneration() { + return g_Config.iPSPModel == PSP_MODEL_FAT ? PSPModelGeneration::PSP_1000 : PSPModelGeneration::PSP_2000; +} + +InstallUpdateScreen::InstallUpdateScreen(const Path &path, std::string_view title) + : UISimpleBaseDialogScreen(Path(), SimpleDialogFlags::ContentsCanScroll), path_(path), title_(title) { + destination_ = GetSysDirectory(DIRECTORY_NAND); + + File::FileInfo fileInfo; + if (File::GetFileInfo(path_, &fileInfo)) { + fileSize_ = fileInfo.size; + } + // There's no practical way to merge two firmwares, so an install replaces whatever is there. + overwrites_ = File::Exists(destination_ / "flash0"); +} + +std::string_view InstallUpdateScreen::GetTitle() const { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + return iz->T("PSP firmware update"); +} + +void InstallUpdateScreen::CreateDialogViews(UI::ViewGroup *parent) { + using namespace UI; + + auto di = GetI18NCategory(I18NCat::DIALOG); + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + auto st = GetI18NCategory(I18NCat::STORE); // Borrow "Size" from here, like GameScreen does. + + LinearLayout *container = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(600, WRAP_CONTENT, 0.0f, UI::Gravity::G_HCENTER, Margins(10)))); + + container->Add(new TextView(iz->T("Install PSP firmware update?"), ALIGN_LEFT, false))->SetBig(true); + container->Add(new Spacer(8.0f)); + + if (!title_.empty()) { + // The updater's own title, which spells out the firmware version. + container->Add(new TextWithImage(ImageID("I_INFO"), title_)); + } + container->Add(new TextWithImage(ImageID("I_FILE"), GetFriendlyPath(path_))); + if (fileSize_ > 0) { + container->Add(new TextView(StringFromFormat("%s: %s", st->T_cstr("Size"), NiceSizeFormat(fileSize_).c_str()))); + } + + container->Add(new Spacer(12.0f)); + container->Add(new TextView(iz->T("Install into folder"))); + container->Add(new TextView(GetFriendlyPath(destination_)))->SetAlign(FLAG_WRAP_TEXT); + + if (overwrites_) { + container->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), "")); + } + + container->Add(new Spacer(12.0f)); + + installChoice_ = container->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD"))); + installChoice_->OnClick.Add([this](UI::EventParams &e) { + StartInstall(); + }); + + progressBar_ = container->Add(new ProgressBar()); + progressBar_->SetVisibility(V_GONE); + + resultView_ = container->Add(new NoticeView(NoticeLevel::SUCCESS, "", "")); + resultView_->SetVisibility(V_GONE); + + // The screen can get recreated mid-install (a rotation, say), so pick the status back up. + RefreshStatus(); +} + +void InstallUpdateScreen::StartInstall() { + if (state_ && !state_->done) { + // Already running. A previous attempt that failed can be retried, though. + return; + } + + state_ = std::make_shared(); + reportedDone_ = false; + + PSARUnpackOptions options; + options.model = EmulatedModelGeneration(); + + INFO_LOG(Log::Loader, "Unpacking the updater %s into %s (model %s)", path_.c_str(), + destination_.c_str(), PSPModelGenerationToString(options.model)); + + g_threadManager.EnqueueTask(new IndependentTask(TaskType::IO_BLOCKING, TaskPriority::NORMAL, + [state = state_, path = path_, destination = destination_, options]() mutable { + options.progress = [state](float progress) { + state->progress = progress; + }; + state->success = UnpackUpdater(path, destination, options, &state->stats, &state->error); + if (state->success && state->stats.written == 0) { + // Nothing came out, so the archive had no file list for the model we asked for - + // old firmwares predate the later models. Not something to call a success. + state->success = false; + if (state->error.empty()) { + state->error = "The updater has no firmware for this PSP model"; + } + } + // Everything above is published by this store - see the atomic in InstallState. + state->done = true; + })); + + RefreshStatus(); +} + +void InstallUpdateScreen::RefreshStatus() { + using namespace UI; + + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + + const bool installing = state_ && !state_->done; + const bool succeeded = state_ && state_->done && state_->success; + + if (installChoice_) { + // There's no point installing the same firmware twice, but a failure can be retried. + installChoice_->SetEnabled(!installing && !succeeded); + } + if (progressBar_) { + progressBar_->SetVisibility(installing ? V_VISIBLE : V_GONE); + if (installing) { + progressBar_->SetProgress(state_->progress); + } + } + if (resultView_) { + if (!state_ || !state_->done) { + resultView_->SetVisibility(V_GONE); + } else if (state_->success) { + resultView_->SetLevelAndText(NoticeLevel::SUCCESS, iz->T("Installed!")); + resultView_->SetDetailsText(StringFromFormat("%s - %d files", state_->stats.firmwareVersion.c_str(), state_->stats.written)); + resultView_->SetVisibility(V_VISIBLE); + } else { + resultView_->SetLevelAndText(NoticeLevel::ERROR, iz->T("Installation failed")); + resultView_->SetDetailsText(state_->error); + resultView_->SetVisibility(V_VISIBLE); + } + } +} + +bool InstallUpdateScreen::key(const KeyInput &key) { + // Ignore key presses while installing, so the user can't escape out mid-write. + if (state_ && !state_->done) { + return false; + } + return UISimpleBaseDialogScreen::key(key); +} + +void InstallUpdateScreen::update() { + UISimpleBaseDialogScreen::update(); + + if (!state_) { + return; + } + if (state_->done && !reportedDone_) { + reportedDone_ = true; + if (state_->success) { + INFO_LOG(Log::Loader, "Installed firmware %s: %d files, %d failed", state_->stats.firmwareVersion.c_str(), + state_->stats.written, state_->stats.failed); + } else { + ERROR_LOG(Log::Loader, "Failed to install the updater: %s", state_->error.c_str()); + } + } + RefreshStatus(); +} diff --git a/UI/InstallUpdateScreen.h b/UI/InstallUpdateScreen.h new file mode 100644 index 0000000000..e4fe1c5b5a --- /dev/null +++ b/UI/InstallUpdateScreen.h @@ -0,0 +1,80 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include +#include +#include + +#include "Common/File/Path.h" +#include "Common/UI/Notice.h" +#include "Common/UI/UIScreen.h" +#include "Common/UI/View.h" +#include "Common/UI/ViewGroup.h" + +#include "Core/Util/PSARUnpack.h" + +#include "UI/BaseScreens.h" +#include "UI/SimpleDialogScreen.h" + +// An official PSP firmware updater (a PSP/GAME/UPDATE/EBOOT.PBP, or the folder holding one). +// Running it isn't going to get anyone anywhere, but the firmware inside it is exactly what the +// emulated flash0/flash1 want, so offer to unpack it into the NAND directory instead. +class InstallUpdateScreen : public UISimpleBaseDialogScreen { +public: + // title is the updater's SFO title, which already carries the version ("PSP Update ver 6.61"). + InstallUpdateScreen(const Path &path, std::string_view title); + + void CreateDialogViews(UI::ViewGroup *parent) override; + void update() override; + bool key(const KeyInput &key) override; + + const char *tag() const override { return "InstallUpdate"; } + +protected: + std::string_view GetTitle() const override; + +private: + // The unpack runs on a worker thread, and the user can leave the screen while it's going, so + // the state it writes is shared with the task rather than owned by the screen. + struct InstallState { + std::atomic progress{}; + std::atomic done{}; + // Only valid to read once done is set. + bool success = false; + PSARUnpackStats stats; + std::string error; + }; + + void StartInstall(); + void RefreshStatus(); + + Path path_; + Path destination_; + std::string title_; + u64 fileSize_ = 0; + bool overwrites_ = false; + + std::shared_ptr state_; + bool reportedDone_ = false; + + UI::Choice *installChoice_ = nullptr; + UI::ProgressBar *progressBar_ = nullptr; + NoticeView *resultView_ = nullptr; +}; diff --git a/UI/MainScreen.cpp b/UI/MainScreen.cpp index a034919bd8..550d33c7df 100644 --- a/UI/MainScreen.cpp +++ b/UI/MainScreen.cpp @@ -49,6 +49,7 @@ #include "UI/RemoteISOScreen.h" #include "UI/DisplayLayoutScreen.h" #include "UI/SavedataScreen.h" +#include "UI/InstallUpdateScreen.h" #include "UI/InstallZipScreen.h" #include "UI/Background.h" #include "UI/GameBrowser.h" @@ -68,7 +69,9 @@ static void LaunchFile(ScreenManager *screenManager, Screen *currentScreen, cons screenManager->push(new InstallZipScreen(path)); } else { // Check if we already know that this game isn't playable. - auto info = g_gameInfoCache->GetInfo(nullptr, path, GameInfoFlags::FILE_TYPE); + // If coming from the main screen, the info will already be computed here since the icon is displayed etc. + // Otherwise, we probably technically should wait for it... + auto info = g_gameInfoCache->GetInfo(nullptr, path, GameInfoFlags::FILE_TYPE | GameInfoFlags::PARAM_SFO); switch (info->fileType) { case IdentifiedFileType::PSP_UMD_VIDEO_ISO: @@ -82,6 +85,20 @@ static void LaunchFile(ScreenManager *screenManager, Screen *currentScreen, cons screenManager->push(new SavedataPopupScreen(Path(), path, title)); return; } + case IdentifiedFileType::PSP_PBP: + case IdentifiedFileType::PSP_PBP_DIRECTORY: + { + // Check if it's an update file. If so, we'll offer to install it directly, + // instead of running it (which currently will not work). + if (info->id == "MSTKUPDATE") { + std::string title = info->GetTitle(); // includes the version. + // The unpacker wants the PBP itself, not the folder it happens to sit in. + const Path pbpPath = info->fileType == IdentifiedFileType::PSP_PBP ? path : path / "EBOOT.PBP"; + screenManager->push(new InstallUpdateScreen(pbpPath, title)); + return; + } + break; + } default: break; } diff --git a/UI/UI.vcxproj b/UI/UI.vcxproj index 5db23dac28..1c89ea0cd5 100644 --- a/UI/UI.vcxproj +++ b/UI/UI.vcxproj @@ -79,6 +79,7 @@ + @@ -136,6 +137,7 @@ + diff --git a/UI/UI.vcxproj.filters b/UI/UI.vcxproj.filters index 72f784c375..d48a5f6306 100644 --- a/UI/UI.vcxproj.filters +++ b/UI/UI.vcxproj.filters @@ -40,6 +40,9 @@ Screens + + Screens + Screens @@ -191,6 +194,9 @@ Screens + + Screens + Screens diff --git a/UWP/UI_UWP/UI_UWP.vcxproj b/UWP/UI_UWP/UI_UWP.vcxproj index 6d1e967e2b..32b48346fa 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj +++ b/UWP/UI_UWP/UI_UWP.vcxproj @@ -114,6 +114,7 @@ + @@ -172,6 +173,7 @@ + diff --git a/UWP/UI_UWP/UI_UWP.vcxproj.filters b/UWP/UI_UWP/UI_UWP.vcxproj.filters index 53b77646ca..4d0d433135 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj.filters +++ b/UWP/UI_UWP/UI_UWP.vcxproj.filters @@ -73,6 +73,9 @@ Screens + + Screens + Screens @@ -216,6 +219,9 @@ Screens + + Screens + Screens diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 02f45dcd91..be75a70f5f 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -984,6 +984,7 @@ LOCAL_SRC_FILES := \ $(SRC)/UI/BaseScreens.cpp \ $(SRC)/UI/Background.cpp \ $(SRC)/UI/CwCheatScreen.cpp \ + $(SRC)/UI/InstallUpdateScreen.cpp \ $(SRC)/UI/InstallZipScreen.cpp \ $(SRC)/UI/JitCompareScreen.cpp \ $(SRC)/UI/OnScreenDisplay.cpp \