From ad06cbe2c4f44b1965e7ff69660d2f8b95ec48dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 10 Sep 2026 13:21:46 -0600 Subject: [PATCH] Auto-install firmware from the game disc on boot Most UMDs carry a firmware updater, and having a real firmware in the NAND is what the LLE modules want. On boot, if the disc's updater is newer than what's installed - or nothing identifiable is installed, which is what a fonts-only NAND looks like - unpack it, with a progress bar on the OSD. Controlled by bAutoUpgradeFirmware, on by default, with a checkbox on the firmware screen. Off in headless, which shouldn't rewrite the NAND during a test run. The install itself is now shared with the install screen, and hardened, since it can run without anyone watching: - It stages into /install-staging and only erases the installed firmware once the new one is complete on disk, so a failure leaves what's there alone. - A single entry that didn't unpack fails the whole install. A firmware with holes still looks installed, so nothing would ever replace it. - A truncated archive is rejected instead of unpacking to half a firmware that every stat reports as a clean install. The PSAR header records the length; it was being clamped to the buffer rather than checked against it. Also falls back to the version in the archive's own header when the PARAM.SFO next to the updater is unreadable, which it is on a fair number of discs. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Config.cpp | 1 + Core/Config.h | 1 + Core/System.cpp | 6 + Core/Util/PSARUnpack.cpp | 271 +++++++++++++++++++++++++++++++++---- Core/Util/PSARUnpack.h | 26 ++++ UI/FirmwareScreen.cpp | 3 + UI/InstallUpdateScreen.cpp | 20 +-- headless/Headless.cpp | 3 + 8 files changed, 285 insertions(+), 46 deletions(-) diff --git a/Core/Config.cpp b/Core/Config.cpp index 2f7e0952f3..1523209d19 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -299,6 +299,7 @@ static const ConfigSetting generalSettings[] = { ConfigSetting("AutoSaveSymbolMap", SETTING(g_Config, bAutoSaveSymbolMap), false, CfgFlag::PER_GAME), ConfigSetting("CompressSymbols", SETTING(g_Config, bCompressSymbols), true, CfgFlag::DEFAULT), ConfigSetting("CacheFullIsoInRam", SETTING(g_Config, bCacheFullIsoInRam), false, CfgFlag::PER_GAME), + ConfigSetting("AutoUpgradeFirmware", SETTING(g_Config, bAutoUpgradeFirmware), true, CfgFlag::DEFAULT), ConfigSetting("RemoteISOPort", SETTING(g_Config, iRemoteISOPort), 0, CfgFlag::DEFAULT), ConfigSetting("LastRemoteISOServer", SETTING(g_Config, sLastRemoteISOServer), "", CfgFlag::DEFAULT), ConfigSetting("LastRemoteISOPort", SETTING(g_Config, iLastRemoteISOPort), 0, CfgFlag::DEFAULT), diff --git a/Core/Config.h b/Core/Config.h index cb4ebc95a6..1cb3f20bc1 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -247,6 +247,7 @@ public: bool bAutoSaveSymbolMap; bool bCompressSymbols; bool bCacheFullIsoInRam; + bool bAutoUpgradeFirmware; int iRemoteISOPort; // Also used for serving a local remote debugger. std::string sLastRemoteISOServer; int iLastRemoteISOPort; diff --git a/Core/System.cpp b/Core/System.cpp index f90d35f494..740d6ddbee 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -63,6 +63,7 @@ #include "Core/Config.h" #include "Core/Core.h" #include "Core/Util/PathUtil.h" +#include "Core/Util/PSARUnpack.h" #include "Core/CoreTiming.h" #include "Core/CoreParameter.h" #include "Core/FileLoaders/RamCachingFileLoader.h" @@ -513,6 +514,11 @@ static bool CPU_Init(FileLoader *fileLoader, IdentifiedFileType type, std::strin g_CoreParameter.mountIsoLoader = ConstructFileLoader(g_CoreParameter.mountIso); } + // Most game discs carry a firmware updater, so this is where a NAND with nothing (or only the + // fonts) in it gets filled in. Has to happen before the mount below: the install erases and + // rewrites the very directory flash0:/flash1: point at. + AutoInstallFirmwareFromDisc(); + MountFileSystems(); // Game-specific settings are load from for example Load_PSP_ISO (which calls g_Config.LoadGameConfig). diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index 3d6ec49089..6f7d1d1c5d 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -22,11 +22,13 @@ #include "zlib.h" +#include "Common/Data/Text/I18n.h" #include "Common/File/DirListing.h" #include "Common/File/FileUtil.h" #include "Common/File/Path.h" #include "Common/Log.h" #include "Common/StringUtils.h" +#include "Common/System/OSD.h" #include "Core/Config.h" #include "Core/ELF/ParamSFO.h" #include "Core/ELF/PBPReader.h" @@ -587,9 +589,17 @@ bool PSARReader::Init(std::string *error) { decrypted_ = ReadU32(psar_ + 0x20) == PSAR_DECRYPTED_MARKER; overhead_ = decrypted_ ? 0 : PSAR_HEADER_SIZE; + // The header records how long the run of records is. Trusting it both stops us decoding + // trailing padding as an entry and, more importantly, catches a short archive: without this + // check a truncated one just looks like a smaller complete one, and unpacks to half a + // firmware that every stat reports as a clean install. limit_ = size_; const u32 declaredSize = ReadU32(psar_ + 8); - if (declaredSize >= 0x40 && declaredSize <= size_) { + if (declaredSize > size_) { + *error = StringFromFormat("Truncated archive: it declares %u bytes but only %u are here", declaredSize, (u32)size_); + return false; + } + if (declaredSize >= 0x40) { limit_ = declaredSize; } @@ -1171,6 +1181,29 @@ bool EraseInstalledFirmware(const Path &nandRoot, std::string *error) { return success; } +int FirmwareVersionToInt(std::string_view version) { + const size_t dot = version.find('.'); + if (dot == std::string_view::npos || dot == 0 || dot + 1 >= version.size()) { + return 0; + } + int numeric = 0; + for (size_t i = 0; i < version.size(); i++) { + if (i == dot) { + continue; + } + if (version[i] < '0' || version[i] > '9') { + return 0; + } + numeric = numeric * 10 + (version[i] - '0'); + } + if (version.size() - dot == 2) { // One digit after the dot. + numeric *= 10; + } else if (version.size() - dot != 3) { + return 0; + } + return numeric; +} + bool FirmwareVersionSupportsVSH(std::string_view version) { // Every firmware boots to an interactive XMB, checked one release at a time against all 39 // versions that ship on a disc - 1.50 through 6.60 - plus the download-only 6.61. So the @@ -1179,29 +1212,7 @@ bool FirmwareVersionSupportsVSH(std::string_view version) { // // The lower bound is a sanity check rather than a real limit. 1.50 is the oldest firmware // there is, so anything below it isn't a version string we wrote. - // - // "6.61" -> 661. Sony always writes the minor part with two digits, but don't rely on it: - // a single-digit one is a tens value ("5.5" is 5.50, not 5.05). - const size_t dot = version.find('.'); - if (dot == std::string_view::npos || dot == 0 || dot + 1 >= version.size()) { - return false; - } - int numeric = 0; - for (size_t i = 0; i < version.size(); i++) { - if (i == dot) { - continue; - } - if (version[i] < '0' || version[i] > '9') { - return false; - } - numeric = numeric * 10 + (version[i] - '0'); - } - if (version.size() - dot == 2) { // One digit after the dot. - numeric *= 10; - } else if (version.size() - dot != 3) { - return false; - } - return numeric >= 150; + return FirmwareVersionToInt(version) >= 150; } std::string BundledUpdateInfo::Describe() const { @@ -1277,16 +1288,57 @@ static bool ReadFromMountedDisc(const char *suffix, std::vector *out) { return !out->empty(); } +// Just the front of a file on the mounted disc. DATA.BIN is around a hundred MB, and the version +// we're after lives in its first record. +static bool ReadHeadFromMountedDisc(const char *suffix, size_t bytes, std::vector *out) { + const int fd = pspFileSystem.OpenFile(std::string("disc0:/") + suffix, FILEACCESS_READ); + if (fd < 0) { + return false; + } + out->resize(bytes); + const size_t read = pspFileSystem.ReadFile(fd, out->data(), (s64)bytes); + pspFileSystem.CloseFile(fd); + out->resize(std::min(read, bytes)); + return !out->empty(); +} + +// The version the archive declares about itself, which is in its first record - so this needs the +// front of DATA.BIN and nothing else. Worth having as a fallback because the PARAM.SFO sitting +// next to the updater is zero bytes long on a fair number of discs. +static std::string ReadUpdaterVersionFromArchiveHeader() { + std::vector head; + if (!ReadHeadFromMountedDisc(UPDATE_PSAR_SUFFIX, 64 * 1024, &head)) { + return std::string(); + } + if (head.size() < 0x40 || ReadU32(head.data()) != PSAR_MAGIC) { + // Very common, and not a problem: a PSN release keeps the updater's directory entry but + // zero-fills the file, so there's nothing on the disc to install in the first place. + DEBUG_LOG(Log::Loader, "The disc's DATA.BIN isn't an archive - a placeholder, most likely"); + return std::string(); + } + PSARReader reader(head.data(), head.size()); + std::string error; + if (!reader.Init(&error)) { + WARN_LOG(Log::Loader, "Couldn't read the disc updater's header: %s", error.c_str()); + return std::string(); + } + return reader.firmwareVersion(); +} + bool MountedDiscHasUpdater() { return pspFileSystem.GetFileInfo(std::string("disc0:/") + UPDATE_PSAR_SUFFIX).exists; } std::string ReadMountedDiscUpdaterVersion() { std::vector sfo; - if (!ReadFromMountedDisc(UPDATE_SFO_SUFFIX, &sfo)) { - return std::string(); + if (ReadFromMountedDisc(UPDATE_SFO_SUFFIX, &sfo)) { + const std::string version = VersionFromSFO(sfo); + if (!version.empty()) { + return version; + } } - return VersionFromSFO(sfo); + // No SFO, or an empty one - ask the archive itself. + return ReadUpdaterVersionFromArchiveHeader(); } bool UnpackUpdaterFromMountedDisc(const Path &outputDir, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error) { @@ -1317,3 +1369,168 @@ bool UnpackUpdater(const Path &filename, const Path &outputDir, const PSARUnpack } return UnpackPSAR(psar.data(), psar.size(), outputDir, options, stats, error); } + +// An install is assembled here first and only swapped into place once it's known to be complete. +// A subdirectory of the NAND root rather than a temp dir elsewhere, so it's on the same volume and +// the swap at the end is a rename per top-level entry instead of a second copy of the firmware. +static const char *FIRMWARE_STAGING_DIR = "install-staging"; + +// Replaces what's in the NAND with what's in the staging directory. Everything the unpack produced +// moves, not just flash0/flash1/ipl: an entry no file list claimed is written out under its short +// name, which lands at the top level. +static bool SwapStagingIntoPlace(const Path &nandRoot, const Path &staging, std::string *error) { + std::vector entries; + if (!File::GetFilesInDir(staging, &entries) || entries.empty()) { + *error = "Nothing to install - the staging directory is empty"; + return false; + } + + // Past this point the NAND is being rewritten. Everything that can fail has already been + // done, so the window where an interruption leaves a half-built NAND is these few renames. + if (!EraseInstalledFirmware(nandRoot, error)) { + return false; + } + + for (const File::FileInfo &entry : entries) { + const Path destination = nandRoot / entry.name; + // EraseInstalledFirmware covers the three directories a firmware normally has; a loose + // top-level file wouldn't be one of them. + if (File::Exists(destination)) { + const bool removed = entry.isDirectory ? File::DeleteDirRecursively(destination) : File::Delete(destination); + if (!removed) { + *error = "Couldn't replace " + destination.ToString(); + return false; + } + } + // MoveIfFast rather than Rename: the two are in different directories, which plain + // Rename refuses for content URIs, and this is the same-name case MoveIfFast handles. + if (!File::MoveIfFast(entry.fullName, destination)) { + *error = "Couldn't move " + entry.fullName.ToString() + " into place"; + return false; + } + } + return true; +} + +bool InstallFirmware(const Path &updater, const Path &nandRoot, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error) { + PSARUnpackStats localStats; + if (!stats) { + stats = &localStats; + } + std::string localError; + if (!error) { + error = &localError; + } + + INFO_LOG(Log::Loader, "Installing the firmware from %s into %s (model %s)", + updater.empty() ? "the mounted disc" : updater.c_str(), nandRoot.c_str(), + PSPModelGenerationToString(options.model)); + + // Unpack somewhere harmless first, so that everything below can refuse the install without + // having touched the firmware that's already there. Anything left over from an install that + // was interrupted has nothing to do with this one. + const Path staging = nandRoot / FIRMWARE_STAGING_DIR; + if (File::Exists(staging) && !File::DeleteDirRecursively(staging)) { + *error = "Couldn't clear " + staging.ToString(); + return false; + } + + bool ok = updater.empty() + ? UnpackUpdaterFromMountedDisc(staging, options, stats, error) + : UnpackUpdater(updater, staging, options, stats, error); + + if (ok && 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. + if (error->empty()) { + *error = "The updater has no firmware for this PSP model"; + } + ok = false; + } + if (ok && stats->failed > 0) { + // Some entry didn't survive - a bad dump or a failing disc (every record is hash-checked + // on the way out, so corruption can't slip past), or the disk filled up. A firmware with + // holes in it is worse than the one already installed: it boots far enough to be believed + // and, since it looks like a complete install, nothing would ever replace it. + if (error->empty()) { + *error = StringFromFormat("%d of %d files couldn't be unpacked", stats->failed, stats->failed + stats->written); + } + ok = false; + } + + if (ok) { + ok = SwapStagingIntoPlace(nandRoot, staging, error); + } + + // Whether it worked or not, don't leave the staging copy behind. + if (File::Exists(staging) && !File::DeleteDirRecursively(staging)) { + WARN_LOG(Log::Loader, "Couldn't clean up %s", staging.c_str()); + } + return ok; +} + +// The OSD id for the automatic install, so the progress bar updates in place. +static const char *AUTO_INSTALL_OSD_ID = "firmware_install"; + +bool AutoInstallFirmwareFromDisc() { + if (!g_Config.bAutoUpgradeFirmware || !MountedDiscHasUpdater()) { + return false; + } + + const std::string discVersion = ReadMountedDiscUpdaterVersion(); + const int discVersionNumber = FirmwareVersionToInt(discVersion); + if (discVersionNumber == 0) { + // Neither the updater's PARAM.SFO nor the archive itself says which version this is - + // usually because there's no real archive there, just a zero-filled placeholder. Without + // a version there's no telling an upgrade from a downgrade, so leave the NAND alone. + INFO_LOG(Log::Loader, "The disc has an updater, but no version to compare against ('%s')", discVersion.c_str()); + return false; + } + + const Path nandRoot = GetSysDirectory(DIRECTORY_NAND); + InstalledFirmwareInfo installed; + ReadInstalledFirmwareInfo(nandRoot, &installed, false); + + const int installedVersion = FirmwareVersionToInt(installed.version); + if (installedVersion != 0) { + if (discVersionNumber <= installedVersion) { + return false; + } + } else if (installed.kernelModuleCount > 0) { + // A real firmware whose version.txt we couldn't read. Replacing it on every single boot, + // on the off chance that the disc's is newer, isn't worth it. + WARN_LOG(Log::Loader, "A firmware is installed but doesn't say which version it is. Leaving it alone."); + return false; + } + // Otherwise there's either nothing installed or only a partial install like the fonts, and + // the disc's firmware is an upgrade either way. + + INFO_LOG(Log::Loader, "Auto-installing firmware %s from the disc (installed: '%s')", + discVersion.c_str(), installed.version.c_str()); + + auto sy = GetI18NCategory(I18NCat::SYSTEM); + const std::string message = std::string(sy->T("Installing firmware")) + " " + discVersion; + g_OSD.SetProgressBar(AUTO_INSTALL_OSD_ID, message, 0.0f, 1.0f, 0.0f, 0.0f); + + PSARUnpackOptions options; + options.model = EmulatedModelGeneration(); + options.progress = [&message](float progress) { + g_OSD.SetProgressBar(AUTO_INSTALL_OSD_ID, message, 0.0f, 1.0f, progress, 0.0f); + }; + + PSARUnpackStats stats; + std::string error; + const bool success = InstallFirmware(Path(), nandRoot, options, &stats, &error); + g_OSD.RemoveProgressBar(AUTO_INSTALL_OSD_ID, success, 0.0f); + + if (!success) { + ERROR_LOG(Log::Loader, "Failed to install the firmware from the disc: %s", error.c_str()); + g_OSD.Show(OSDType::MESSAGE_ERROR, sy->T("Firmware installation failed"), error, 5.0f); + return false; + } + + INFO_LOG(Log::Loader, "Installed firmware %s from the disc: %d files, %d failed", + stats.firmwareVersion.c_str(), stats.written, stats.failed); + g_OSD.Show(OSDType::MESSAGE_SUCCESS, sy->T("Installed firmware"), stats.firmwareVersion, 3.0f); + return true; +} diff --git a/Core/Util/PSARUnpack.h b/Core/Util/PSARUnpack.h index 6b524a543d..b448938c32 100644 --- a/Core/Util/PSARUnpack.h +++ b/Core/Util/PSARUnpack.h @@ -168,12 +168,38 @@ void ReadInstalledFirmwareInfo(const Path &nandRoot, InstalledFirmwareInfo *info // Wipes what's in the NAND directory: flash0, flash1 and ipl. Two firmwares can't be merged - // files a newer one dropped would linger and still get loaded - so an install starts from empty. +// Deliberately leaves an install's staging directory alone: InstallFirmware() calls this with a +// finished firmware sitting in there, waiting to be moved into the space this just cleared. bool EraseInstalledFirmware(const Path &nandRoot, std::string *error); +// "6.61" -> 661. Sony writes the minor part with two digits, but a single-digit one is still a +// tens value, so "5.5" is 550 and not 505. Returns 0 if it isn't a version string at all, which +// makes the result safe to compare with: an unknown version is older than every real one. +int FirmwareVersionToInt(std::string_view version); + // The firmware versions we can actually boot the VSH (XMB) on. Every other version loads, but // the module patches it needs are version-specific, so it won't get anywhere. bool FirmwareVersionSupportsVSH(std::string_view version); +// Installs a firmware into the NAND directory. Two firmwares can't be merged - a file the new one +// doesn't have would linger and still get loaded - so this replaces rather than overlays. +// +// The unpack goes to a staging directory inside the NAND root first, and what's installed is only +// erased once the new firmware is complete on disk, so a failure of any kind leaves the existing +// one untouched. The cost is needing room for both at once. "Complete" is strict: a single entry +// that didn't unpack fails the whole install, because a firmware with holes in it still looks +// installed and would never be replaced. +// +// updater is an updater EBOOT.PBP, a disc image, or the folder holding one; pass an empty path to +// install from the disc mounted as disc0:, i.e. the running game's own disc. +bool InstallFirmware(const Path &updater, const Path &nandRoot, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error); + +// Called while a game boots, with its disc mounted as disc0:. Most UMDs carry a firmware updater, +// and having a real firmware is what the LLE modules want - so if the disc's is newer than what's +// installed, or nothing identifiable is installed at all, unpack it. Shows progress on the OSD. +// Does nothing unless g_Config.bAutoUpgradeFirmware is set. Returns true if it installed one. +bool AutoInstallFirmwareFromDisc(); + // The same three, for the disc mounted as disc0: - i.e. the game that's running. These read // through the mounted filesystem instead of opening the image a second time, which also means // they work for the shapes that aren't an image at all, like a folder-based "disc". diff --git a/UI/FirmwareScreen.cpp b/UI/FirmwareScreen.cpp index 6bd2e1251a..d7e2da5722 100644 --- a/UI/FirmwareScreen.cpp +++ b/UI/FirmwareScreen.cpp @@ -132,6 +132,9 @@ void FirmwareScreen::CreateSettingsViews(UI::ViewGroup *parent) { install->SetEnabled(!gameRunning); } + parent->Add(new CheckBox(&g_Config.bAutoUpgradeFirmware, sy->T("Install or upgrade firmware from game discs"), + sy->T("AutoUpgradeFirmwareDesc", "Most game discs carry a firmware updater. Install it when there's no firmware yet, or when the disc's is newer."))); + if (info_.anythingInstalled) { Choice *erase = parent->Add(new Choice(sy->T("Erase firmware"), ImageID("I_TRASHCAN"))); erase->OnClick.Add([this](UI::EventParams &) { diff --git a/UI/InstallUpdateScreen.cpp b/UI/InstallUpdateScreen.cpp index 215e6f8b56..581a9f3a81 100644 --- a/UI/InstallUpdateScreen.cpp +++ b/UI/InstallUpdateScreen.cpp @@ -143,30 +143,12 @@ void InstallUpdateScreen::StartInstall() { 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; }; - // Two firmwares can't be merged - a file the new one doesn't have would linger and still - // get loaded - so start from an empty NAND. - if (!EraseInstalledFirmware(destination, &state->error)) { - state->success = false; - state->done = true; - return; - } - 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"; - } - } + state->success = InstallFirmware(path, destination, options, &state->stats, &state->error); // Everything above is published by this store - see the atomic in InstallState. state->done = true; })); diff --git a/headless/Headless.cpp b/headless/Headless.cpp index f49f66d4ea..e659c4b242 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -768,6 +768,9 @@ int main(int argc, const char* argv[]) { g_Config.sMACAddress = "12:34:56:78:9A:BC"; g_Config.iFirmwareVersion = PSP_DEFAULT_FIRMWARE; g_Config.iPSPModel = PSP_MODEL_SLIM; + // Booting an ISO shouldn't rewrite the NAND out from under a test run - and the tests want + // whatever firmware is installed to stay put. Install one with --unpack-updater instead. + g_Config.bAutoUpgradeFirmware = false; g_Config.iGameVolume = VOLUMEHI_FULL; g_Config.iReverbVolume = VOLUMEHI_FULL; g_Config.internalDataDirectory.clear();