From 5cc5a2d802c8199f1d29b0a73a2b3dbda01798e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:08:58 +0200 Subject: [PATCH] Replacement: Find pack files whose names differ in case - Fixes #18746 Packs are mostly made on Windows, where "portraits/x.png" in textures.ini finds Portraits/x.png. On a case sensitive file system (Steam Deck, Linux) it didn't, while zipped packs already ignored case. DirectoryReader gets an opt-in ignoreCase mode, used for texture packs. On the first miss it indexes the directory by lowercased path and retries there, so packs where every name matches never pay for it, and the walk happens on the IO thread that loads the texture. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/File/VFS/DirectoryReader.cpp | 69 +++++++++++++++++++++++++++-- Common/File/VFS/DirectoryReader.h | 20 ++++++++- GPU/Common/TextureReplacer.cpp | 2 +- unittest/TestTextureReplacer.cpp | 27 ++++++++++- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Common/File/VFS/DirectoryReader.cpp b/Common/File/VFS/DirectoryReader.cpp index b820a45196..386568d70b 100644 --- a/Common/File/VFS/DirectoryReader.cpp +++ b/Common/File/VFS/DirectoryReader.cpp @@ -1,16 +1,73 @@ +#include "ppsspp_config.h" + +#include #include #include "Common/Common.h" #include "Common/Log.h" +#include "Common/File/DirListing.h" #include "Common/File/VFS/DirectoryReader.h" -DirectoryReader::DirectoryReader(const Path &path) { +DirectoryReader::DirectoryReader(const Path &path, bool ignoreCase) { path_ = path; +#if !PPSSPP_PLATFORM(WINDOWS) + // Windows file systems already ignore case. + ignoreCase_ = ignoreCase; +#endif +} + +static std::string ToLowerASCII(std::string_view str) { + std::string lower(str); + for (char &c : lower) { + c = (char)tolower((unsigned char)c); + } + return lower; +} + +void DirectoryReader::IndexDirectory(const Path &dir, const std::string &prefix) { + std::vector files; + File::GetFilesInDir(dir, &files); + for (const File::FileInfo &file : files) { + const std::string relative = prefix + file.name; + if (file.isDirectory) { + IndexDirectory(file.fullName, relative + "/"); + } else { + lowerIndex_[ToLowerASCII(relative)] = relative; + } + } +} + +bool DirectoryReader::FindIgnoringCase(std::string_view path, std::string *actualPath) { + if (!ignoreCase_) { + return false; + } + std::lock_guard guard(indexLock_); + if (!indexBuilt_) { + // Only done on the first miss, so a directory where every name matches never pays for it. + IndexDirectory(path_, ""); + indexBuilt_ = true; + INFO_LOG(Log::IO, "Indexed %d files in %s to find names that differ in case", (int)lowerIndex_.size(), path_.c_str()); + } + auto it = lowerIndex_.find(ToLowerASCII(path)); + if (it == lowerIndex_.end()) { + return false; + } + *actualPath = it->second; + if (caseFixCount_++ < 5) { + WARN_LOG(Log::IO, "'%.*s' differs in case from the file '%s' in %s, which only works on some file systems", STR_VIEW(path), actualPath->c_str(), path_.c_str()); + } + return true; } uint8_t *DirectoryReader::ReadFile(std::string_view path, size_t *size) { - Path new_path = Path(path).StartsWith(path_) ? Path(path) : path_ / path; - return File::ReadLocalFile(new_path, size); + const bool absolute = Path(path).StartsWith(path_); + Path new_path = absolute ? Path(path) : path_ / path; + uint8_t *data = File::ReadLocalFile(new_path, size); + std::string actualPath; + if (!data && !absolute && FindIgnoringCase(path, &actualPath)) { + data = File::ReadLocalFile(path_ / actualPath, size); + } + return data; } bool DirectoryReader::GetFileListing(std::string_view path, std::vector *listing, const char *filter = nullptr) { @@ -53,7 +110,11 @@ public: VFSFileReference *DirectoryReader::GetFile(std::string_view path) { Path filePath = path_ / path; if (!File::Exists(filePath)) { - return nullptr; + std::string actualPath; + if (!FindIgnoringCase(path, &actualPath)) { + return nullptr; + } + filePath = path_ / actualPath; } DirectoryReaderFileReference *reference = new DirectoryReaderFileReference(); diff --git a/Common/File/VFS/DirectoryReader.h b/Common/File/VFS/DirectoryReader.h index 6bb078ae4a..b291e7610b 100644 --- a/Common/File/VFS/DirectoryReader.h +++ b/Common/File/VFS/DirectoryReader.h @@ -1,12 +1,19 @@ #pragma once +#include +#include +#include + #include "Common/File/VFS/VFS.h" #include "Common/File/FileUtil.h" #include "Common/File/Path.h" class DirectoryReader : public VFSBackend { public: - explicit DirectoryReader(const Path &path); + // With ignoreCase, GetFile and ReadFile also find files whose names only differ in case, like the + // zip reader does. Meant for directories that people fill by hand on Windows, like texture packs, + // which then break on case sensitive file systems. + explicit DirectoryReader(const Path &path, bool ignoreCase = false); // use delete[] on the returned value. uint8_t *ReadFile(std::string_view path, size_t *size) override; @@ -27,5 +34,16 @@ public: } private: + // Returns the path (relative to path_) of a file whose name matches path except for case. + bool FindIgnoringCase(std::string_view path, std::string *actualPath); + void IndexDirectory(const Path &dir, const std::string &prefix); + Path path_; + bool ignoreCase_ = false; + + // Lowercase relative path -> actual relative path of every file, built on the first miss. + std::mutex indexLock_; + bool indexBuilt_ = false; + std::unordered_map lowerIndex_; + int caseFixCount_ = 0; }; diff --git a/GPU/Common/TextureReplacer.cpp b/GPU/Common/TextureReplacer.cpp index b27d3dc647..650485285a 100644 --- a/GPU/Common/TextureReplacer.cpp +++ b/GPU/Common/TextureReplacer.cpp @@ -169,7 +169,7 @@ bool TextureReplacer::LoadIni(std::string *error, bool notify) { if (!dir) { INFO_LOG(Log::TexReplacement, "%s wasn't a zip file - opening the directory %s instead.", zipPath.c_str(), basePath_.c_str()); vfsIsZip_ = false; - dir = new DirectoryReader(basePath_); + dir = new DirectoryReader(basePath_, true); } else { if (!replaceEnabled_ && saveEnabled_) { WARN_LOG(Log::TexReplacement, "Found zip file even though only saving is enabled! This is weird."); diff --git a/unittest/TestTextureReplacer.cpp b/unittest/TestTextureReplacer.cpp index 21e4c6d1d4..15c454c97e 100644 --- a/unittest/TestTextureReplacer.cpp +++ b/unittest/TestTextureReplacer.cpp @@ -6,6 +6,7 @@ #include "Common/Data/Format/PNGLoad.h" #include "Common/File/FileUtil.h" #include "Common/File/Path.h" +#include "Common/File/VFS/DirectoryReader.h" #include "Common/Thread/ThreadManager.h" #include "GPU/Common/TextureReplacer.h" @@ -711,6 +712,30 @@ static bool TestSaveIndexedOption(const Path &packDir) { return true; } +// Packs made on Windows often name their folders differently in textures.ini (#18746). On a case +// insensitive file system this passes without the index, Linux CI is where it counts. +static bool TestDirectoryIgnoreCase(const Path &packDir) { + if (!File::CreateDir(packDir / "Portraits")) { + return false; + } + if (!CreateTestPNG(packDir / "Portraits" / "Face.png", 4, 4, 0xFF0000FF)) { + return false; + } + + DirectoryReader reader(packDir, true); + VFSFileReference *ref = reader.GetFile("portraits/face.png"); + EXPECT_TRUE(ref != nullptr); + if (ref) { + reader.ReleaseFile(ref); + } + size_t size = 0; + uint8_t *data = reader.ReadFile("PORTRAITS/FACE.PNG", &size); + EXPECT_TRUE(data != nullptr && size != 0); + delete[] data; + EXPECT_TRUE(reader.GetFile("portraits/missing.png") == nullptr); + return true; +} + bool TestTextureReplacer() { Path packDir = UnitTestTempPath("unittest_texture_pack"); if (!CreateTestPack(packDir)) { @@ -741,7 +766,7 @@ bool TestTextureReplacer() { } if (!TestIndexedPNG(packDir) || !TestIndexedReplacement(packDir) || !TestIndexedKTX2(packDir) || - !TestSaveIndexedPNG(packDir) || !TestSaveIndexedOption(packDir)) { + !TestSaveIndexedPNG(packDir) || !TestSaveIndexedOption(packDir) || !TestDirectoryIgnoreCase(packDir)) { File::DeleteDirRecursively(packDir); return false; }