From 396f9e802d00cfcd160e31852dd6fa815c0aade2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 23 Aug 2026 16:32:12 +0200 Subject: [PATCH] GameInfoCache: report the firmware updater bundled on a game disc Adds GameInfoFlags::BUNDLED_UPDATE_INFO, holding the version, title, size and timestamp of the updater in PSP_GAME/SYSDIR/UPDATE. It comes from the PARAM.SFO and the directory entry next to the archive, so it's a couple of small reads on the ISOFileSystem the worker already has open - no decryption, and DATA.BIN itself is only sniffed for its magic. Only computed for ISOs; everything else is marked complete with an empty struct. ISOFileSystem now parses the date out of the ISO9660 directory record, stored as Unix UTC seconds and reported as the PSP's atime/ctime/mtime. Those used to always read back as zero, so games calling sceIoGetstat on a UMD file saw 1900 where hardware gives the mastering date. Shown on GameScreen as e.g. "Firmware update on disc: 6.60 (2011-10-05), 25.6 MB". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149QcTVgZEXKXbgHyvXF4ZY --- Core/FileSystems/ISOFileSystem.cpp | 39 +++++++++++++++ Core/FileSystems/ISOFileSystem.h | 4 ++ Core/Util/PSARUnpack.cpp | 76 +++++++++++++++++++++++++++++- Core/Util/PSARUnpack.h | 24 ++++++++++ UI/GameInfoCache.cpp | 9 ++++ UI/GameInfoCache.h | 6 +++ UI/GameScreen.cpp | 11 ++++- assets/lang/en_US.ini | 1 + 8 files changed, 167 insertions(+), 3 deletions(-) diff --git a/Core/FileSystems/ISOFileSystem.cpp b/Core/FileSystems/ISOFileSystem.cpp index d168a54cbb..20994af8b4 100644 --- a/Core/FileSystems/ISOFileSystem.cpp +++ b/Core/FileSystems/ISOFileSystem.cpp @@ -18,8 +18,10 @@ #include #include #include +#include #include "Common/CommonTypes.h" +#include "Common/File/FileUtil.h" // for localtime_r on Windows #include "Common/Serialize/Serializer.h" #include "Common/Serialize/SerializeFuncs.h" #include "Common/StringUtils.h" @@ -138,6 +140,39 @@ struct VolDescriptor { char zeroos[653]; }; +// From http://howardhinnant.github.io/date_algorithms.html - same one sceRtc.cpp uses. Beats +// timegm(), which isn't portable, and mktime(), which would drag the host's timezone in. +static s64 DaysFromCivil(s64 y, u32 m, u32 d) { + y -= m <= 2; + const s64 era = (y >= 0 ? y : y - 399) / 400; + const u32 yoe = (u32)(y - era * 400); // [0, 399] + const u32 doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1; // [0, 365] + const u32 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + return era * 146097 + (s64)doe - 719468; +} + +// The 7-byte date in a directory record, as Unix UTC seconds. Returns 0 if there isn't a real +// date in there - plenty of ISOs leave it zeroed, and "1900-00-00" isn't worth propagating. +static s64 UnixTimeFromDirectoryEntry(const DirectoryEntry &dir) { + if (dir.month < 1 || dir.month > 12 || dir.day < 1 || dir.day > 31) { + return 0; + } + // years is an offset from 1900, and offsetFromGMT is a signed count of 15-minute steps. + const s64 days = DaysFromCivil(1900 + (s64)dir.years, dir.month, dir.day); + return days * 86400 + dir.hour * 3600 + dir.minute * 60 + dir.second - (s64)(s8)dir.offsetFromGMT * 15 * 60; +} + +// ISO9660 has one timestamp per file, so it goes into all three of the PSP's. Local time, to +// match what the other file systems report. +static void FillFileTimes(PSPFileInfo *info, s64 unixTime) { + if (unixTime == 0) { + return; + } + const time_t t = (time_t)unixTime; + localtime_r(&t, &info->mtime); + info->atime = info->ctime = info->mtime; +} + // Removes version numbers from filenames. static std::string_view CleanISOFileName(std::string_view name) { auto pos = name.find(';'); @@ -258,6 +293,7 @@ void ISOFileSystem::ReadDirectory(TreeEntry *root) const { entry->startsector = dir.firstDataSector; entry->dirsize = dir.dataLength; entry->valid = isFile; // Can pre-mark as valid if file, as we don't recurse into those. + entry->recordTime = UnixTimeFromDirectoryEntry(dir); VERBOSE_LOG(Log::FileSystem, "%s: %s %08x %08x %d", entry->isDirectory ? "D" : "F", entry->name.c_str(), (u32)dir.firstDataSector, entry->startingPosition, entry->startingPosition); // Round down to avoid any false reports. @@ -662,6 +698,7 @@ PSPFileInfo ISOFileSystem::GetFileInfo(std::string filename) { x.type = entry->isDirectory ? FILETYPE_DIRECTORY : FILETYPE_NORMAL; x.isOnSectorSystem = true; x.startSector = entry->startingPosition / 2048; + FillFileTimes(&x, entry->recordTime); } return x; } @@ -679,6 +716,7 @@ PSPFileInfo ISOFileSystem::GetFileInfoByHandle(u32 handle) { x.type = entry->isDirectory ? FILETYPE_DIRECTORY : FILETYPE_NORMAL; x.isOnSectorSystem = true; x.startSector = entry->startingPosition / 2048; + FillFileTimes(&x, entry->recordTime); } return x; } @@ -714,6 +752,7 @@ std::vector ISOFileSystem::GetDirListing(std::string_view path, boo x.type = e->isDirectory ? FILETYPE_DIRECTORY : FILETYPE_NORMAL; x.isOnSectorSystem = true; x.startSector = e->startingPosition/2048; + FillFileTimes(&x, e->recordTime); x.sectorSize = sectorSize; x.numSectors = (u32)((e->size + sectorSize - 1) / sectorSize); myVector.push_back(x); diff --git a/Core/FileSystems/ISOFileSystem.h b/Core/FileSystems/ISOFileSystem.h index c59c6d884a..66aa516fa4 100644 --- a/Core/FileSystems/ISOFileSystem.h +++ b/Core/FileSystems/ISOFileSystem.h @@ -72,6 +72,10 @@ private: u32 startingPosition = 0; s64 size = 0; bool isDirectory = false; + // The date/time from the ISO9660 directory record, as Unix UTC seconds (0 if there + // wasn't a usable one). ISO9660 only has the one timestamp per file, so it gets + // reported as all three of atime/ctime/mtime. + s64 recordTime = 0; u32 startsector = 0; u32 dirsize = 0; diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index 131859d658..c0cf887409 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -876,12 +876,20 @@ static std::string VersionFromUpdaterTitle(std::string_view title) { return std::string(title); } -static std::string VersionFromSFO(const std::vector &sfo) { +static std::string TitleFromSFO(const std::vector &sfo) { ParamSFOData paramSFO; if (sfo.empty() || !paramSFO.ReadSFO(sfo)) { return std::string(); } - return VersionFromUpdaterTitle(paramSFO.GetValueString("TITLE")); + return paramSFO.GetValueString("TITLE"); +} + +static std::string VersionFromSFO(const std::vector &sfo) { + const std::string title = TitleFromSFO(sfo); + if (title.empty()) { + return std::string(); + } + return VersionFromUpdaterTitle(title); } // Opens filename as a disc image, if it is one. Returns null otherwise, which is the normal @@ -919,6 +927,18 @@ static bool ReadWholeFile(IFileSystem *fs, const char *path, std::vector *ou return true; } +// Just the first few bytes, for a magic check. ReadWholeFile isn't an option when the file we're +// sniffing can be a hundred megabytes. +static bool ReadFileMagic(IFileSystem *fs, const std::string &path, u8 *out, size_t size) { + const int handle = fs->OpenFile(path, FILEACCESS_READ); + if (handle < 0) { + return false; + } + const size_t read = fs->ReadFile(handle, out, size); + fs->CloseFile(handle); + return read == size; +} + // Pulls the archive out of whatever this is - see the header for the shapes we accept. static bool ReadUpdaterPSAR(const Path &filename, std::vector *psar, std::string *sfoVersion, std::string *error) { FileLoader *loader = ConstructFileLoader(filename); @@ -990,6 +1010,58 @@ static bool ReadUpdaterPSAR(const Path &filename, std::vector *psar, std::st return false; } +std::string BundledUpdateInfo::Describe() const { + if (!present) { + return std::string(); + } + std::string desc = version.empty() ? "?" : version; + if (mtime != 0) { + const time_t t = (time_t)mtime; + tm local{}; + localtime_r(&t, &local); + desc += StringFromFormat(" (%04d-%02d-%02d)", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday); + } + return desc; +} + +bool ReadBundledUpdateInfo(IFileSystem *fs, std::string_view pathPrefix, BundledUpdateInfo *info) { + *info = BundledUpdateInfo{}; + + const std::string psarPath = std::string(pathPrefix) + UPDATE_PSAR_SUFFIX; + const PSPFileInfo psarFileInfo = fs->GetFileInfo(psarPath); + if (!psarFileInfo.exists || psarFileInfo.size == 0) { + return false; + } + + // Check that the file is actually an updater. + u8 magic[4]{}; + if (!ReadFileMagic(fs, psarPath, magic, sizeof(magic)) || ReadU32(magic) != PSAR_MAGIC) { + DEBUG_LOG(Log::Loader, "Disc has a %lld byte %s, but it isn't a PSAR - ignoring it", + (long long)psarFileInfo.size, UPDATE_PSAR_SUFFIX); + return false; + } + + info->present = true; + info->archiveSize = psarFileInfo.size; + // PSPFileInfo reports local time, since that's what the PSP wants. Back to UTC seconds - + // mktime is the exact inverse of the localtime_r the file system used. Not every kind of + // "disc" we can mount records a date at all, hence the check. + if (psarFileInfo.mtime.tm_mday != 0) { + tm local = psarFileInfo.mtime; + const time_t t = mktime(&local); + info->mtime = t == (time_t)-1 ? 0 : (s64)t; + } + + std::vector sfo; + if (ReadWholeFile(fs, (std::string(pathPrefix) + UPDATE_SFO_SUFFIX).c_str(), &sfo)) { + info->title = TitleFromSFO(sfo); + if (!info->title.empty()) { + info->version = VersionFromUpdaterTitle(info->title); + } + } + return true; +} + std::string ReadUpdaterVersion(const Path &filename) { std::vector psar; std::string version; diff --git a/Core/Util/PSARUnpack.h b/Core/Util/PSARUnpack.h index 42f3afe3b3..f604298170 100644 --- a/Core/Util/PSARUnpack.h +++ b/Core/Util/PSARUnpack.h @@ -17,12 +17,15 @@ #pragma once +#include #include +#include #include #include "Common/CommonTypes.h" class Path; +class IFileSystem; // Unpacks the firmware image inside an official PSP updater (PSP/GAME/UPDATE/EBOOT.PBP). // @@ -105,6 +108,27 @@ bool UnpackPSAR(const u8 *psar, size_t psarSize, const Path &outputDir, const PS // from whatever game the user already has rather than a separate download. bool UnpackUpdater(const Path &filename, const Path &outputDir, const PSARUnpackOptions &options, PSARUnpackStats *stats, std::string *error); +// What a game disc's bundled firmware updater says about itself. All of this comes from the +// PARAM.SFO and the directory entry next to the archive, so gathering it costs a couple of small +// reads - no decryption, and the archive itself is never touched. +struct BundledUpdateInfo { + bool present = false; + std::string version; // "6.61". Can be empty even when present, if the SFO is unreadable. + std::string title; // The updater's full SFO title, e.g. "PSP(tm) Update ver 6.61". + s64 archiveSize = 0; // Size of DATA.BIN, i.e. how much firmware is in there. + // When DATA.BIN was written, as Unix UTC seconds. 0 if the disc doesn't record one, which + // is normal for the shapes that aren't really an ISO. + s64 mtime = 0; + + // "6.61 (2011-01-25)", or just the version if there's no date. Empty if there's no updater. + std::string Describe() const; +}; + +// Reads the above out of a disc that's already open, whether that's an ISOFileSystem the caller +// mounted or the running game's disc0:. pathPrefix is what to stick in front of "PSP_GAME/..." - +// "/" for a freshly mounted image, "disc0:/" for the meta file system. +bool ReadBundledUpdateInfo(IFileSystem *fs, std::string_view pathPrefix, BundledUpdateInfo *info); + // The version string an updater advertises ("6.61"), read from the PARAM.SFO next to it - no // decryption needed, so it's cheap enough to check every disc with. Empty if there's no updater. std::string ReadUpdaterVersion(const Path &filename); diff --git a/UI/GameInfoCache.cpp b/UI/GameInfoCache.cpp index 480c966a09..c57e373f89 100644 --- a/UI/GameInfoCache.cpp +++ b/UI/GameInfoCache.cpp @@ -851,6 +851,15 @@ handleELF: } } + // Most UMDs carry a firmware updater, which is a source of things like the + // system fonts. Just note down what's there - unpacking it is a separate step. + if (flags_ & GameInfoFlags::BUNDLED_UPDATE_INFO) { + BundledUpdateInfo update; + ReadBundledUpdateInfo(&umd, "/", &update); + std::lock_guard lock(info_->lock); + info_->bundledUpdate = update; + } + if (flags_ & GameInfoFlags::PIC0) { info_->pic0.dataLoaded = ReadFileToString(&umd, join(gameRoot, "PIC0.PNG"), &info_->pic0.data, &info_->lock); } diff --git a/UI/GameInfoCache.h b/UI/GameInfoCache.h index 39cf389e17..51d9f7bf49 100644 --- a/UI/GameInfoCache.h +++ b/UI/GameInfoCache.h @@ -25,6 +25,7 @@ #include "Common/Thread/Event.h" #include "Core/ELF/ParamSFO.h" +#include "Core/Util/PSARUnpack.h" #include "Common/File/Path.h" namespace Draw { @@ -50,6 +51,7 @@ enum class GameInfoFlags { UNCOMPRESSED_SIZE = 0x80, SAVEDATA_SIZE = 0x100, ICON1_PMF = 0x200, + BUNDLED_UPDATE_INFO = 0x400, // The firmware updater that most game discs carry. ISO only. }; ENUM_CLASS_BITOPS(GameInfoFlags); @@ -170,6 +172,10 @@ public: u64 saveDataSize = 0; u64 installDataSize = 0; + // The firmware updater bundled on the disc, if any - see GameInfoFlags::BUNDLED_UPDATE_INFO. + // Always left empty for anything that isn't an ISO. + BundledUpdateInfo bundledUpdate; + std::string errorString; protected: diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index b654674330..28fe1705ab 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -66,7 +66,7 @@ void copyDeepLinkForPath(std::string_view filePath); void copyDeepLinkForPath(std::string_view) {} #endif -constexpr GameInfoFlags g_desiredFlags = GameInfoFlags::PARAM_SFO | GameInfoFlags::ICON | GameInfoFlags::PIC0 | GameInfoFlags::PIC1 | GameInfoFlags::ICON1_PMF | GameInfoFlags::UNCOMPRESSED_SIZE | GameInfoFlags::SIZE | GameInfoFlags::SAVEDATA_SIZE; +constexpr GameInfoFlags g_desiredFlags = GameInfoFlags::PARAM_SFO | GameInfoFlags::ICON | GameInfoFlags::PIC0 | GameInfoFlags::PIC1 | GameInfoFlags::ICON1_PMF | GameInfoFlags::UNCOMPRESSED_SIZE | GameInfoFlags::SIZE | GameInfoFlags::SAVEDATA_SIZE | GameInfoFlags::BUNDLED_UPDATE_INFO; class PMFView : public UI::InertView { public: @@ -387,6 +387,15 @@ void GameScreen::CreateContentViews(UI::ViewGroup *parent) { tvGameSize->SetShadow(true); } + // Most game discs carry a firmware updater, which holds files we'd like to have, like the fonts. + if ((knownFlags_ & GameInfoFlags::BUNDLED_UPDATE_INFO) && info_->bundledUpdate.present) { + char temp[256]; + snprintf(temp, sizeof(temp), "%s: %s, %s", ga->T_cstr("Firmware update on disc"), + info_->bundledUpdate.Describe().c_str(), NiceSizeFormat(info_->bundledUpdate.archiveSize).c_str()); + TextView *tvUpdate = mainGameInfo->Add(new TextView(temp, ALIGN_LEFT, true, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT))); + tvUpdate->SetShadow(true); + } + if ((knownFlags_ & GameInfoFlags::SAVEDATA_SIZE)) { char temp[256]; if (info_->saveDataSize > 0) { diff --git a/assets/lang/en_US.ini b/assets/lang/en_US.ini index 2f7d45b472..c1489a5521 100644 --- a/assets/lang/en_US.ini +++ b/assets/lang/en_US.ini @@ -624,6 +624,7 @@ Delete Save Data = Delete savedata Desktop shortcut created = Desktop shortcut created Europe = Europe File size incorrect, bad or modified ISO = File size incorrect, bad or modified ISO +Firmware update on disc = Firmware update on disc Game = Game Game ID unknown - not in the ReDump database = Game ID unknown - not in the ReDump database Game Settings = Game settings