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; }