From b18792e40eee74d15b598e981c2695b88b95156d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 09:38:02 +0200 Subject: [PATCH 01/10] Replacement: Treat a fake mipmap level as level 0 of its own texture - Fixes #19623 With a fake mipmap change (Japanese Tactics Ogre), each PSP level is cached and hashed as its own 2D texture, but since 73c06bb776 (v1.14) the replacement and dump code used the PSP level as the replacement level. Loading a replacement for a level-2 font sheet asked for level 2 of a one-level replacement and hit the "Invalid miplevel" assert, and dumps were named _2, which the ini then treats as an ignored texture. Use the created texture's level for both again, as 1.13.2 did. Also don't force mip filtering from a mipmapped replacement when only one level was uploaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/TextureCacheCommon.cpp | 19 ++++++++++++------- GPU/Common/TextureCacheCommon.h | 2 +- GPU/GLES/TextureCacheGLES.cpp | 2 +- GPU/Metal/TextureCacheMetal.mm | 2 +- GPU/Vulkan/TextureCacheVulkan.cpp | 8 ++++---- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/GPU/Common/TextureCacheCommon.cpp b/GPU/Common/TextureCacheCommon.cpp index fa59ee3e44..1191ad1e5c 100644 --- a/GPU/Common/TextureCacheCommon.cpp +++ b/GPU/Common/TextureCacheCommon.cpp @@ -265,8 +265,9 @@ SamplerCacheKey TextureCacheCommon::GetSamplingParams(int maxLevel, const TexCac bool useReplacerFiltering = false; // (Not for indices that the shader colors, filtering them would mix up the palette entries.) if (entry && replacer_.Enabled() && entry->replacedTexture && !(entry->status & TexStatus::CLUT8_INDEXED)) { - // If replacement textures have multiple mip levels, enforce mip filtering. - if (entry->replacedTexture->State() == ReplacementState::ACTIVE && entry->replacedTexture->NumLevels() > 1) { + // If replacement textures have multiple mip levels, enforce mip filtering. Not if only one got + // uploaded, as with a fake mipmap change. + if (entry->replacedTexture->State() == ReplacementState::ACTIVE && entry->replacedTexture->NumLevels() > 1 && !(entry->status & TexStatus::NO_MIPS)) { key.mipEnable = true; key.mipFilt = 1; key.maxLevel = 9 * 256; @@ -3212,16 +3213,20 @@ void TextureCacheCommon::LoadTextureLevel(TexCacheEntry &entry, uint8_t *data, s PROFILE_THIS_SCOPE("decodetex"); + // Replacements and dumps go by the level of the texture we create. With a fake mipmap change, + // its level 0 comes from PSP level baseLevelSrc. + const int dstLevel = srcLevel - plan.baseLevelSrc; + if (plan.doReplace) { - plan.replaced->GetSize(srcLevel, &w, &h); + plan.replaced->GetSize(dstLevel, &w, &h); double replaceStart = time_now_d(); if (plan.decodeToClut8) { // Only an indexed replacement for the GPU gets here. The indices go up as they are, as an R8 image. - plan.replaced->CopyIndexLevelToPitched(srcLevel, data, dataSize, stride); + plan.replaced->CopyIndexLevelToPitched(dstLevel, data, dataSize, stride); } else if (plan.replaced->IsIndexed()) { - CopyIndexedReplacementLevel(plan.replaced, srcLevel, data, dataSize, stride); + CopyIndexedReplacementLevel(plan.replaced, dstLevel, data, dataSize, stride); } else { - plan.replaced->CopyLevelTo(srcLevel, data, dataSize, stride); + plan.replaced->CopyLevelTo(dstLevel, data, dataSize, stride); } replacementTimeThisFrame_ += time_now_d() - replaceStart; } else { @@ -3281,7 +3286,7 @@ void TextureCacheCommon::LoadTextureLevel(TexCacheEntry &entry, uint8_t *data, s replacedInfo.fmt = dstFmt; // NOTE: Reading the decoded texture here may be very slow, if we just wrote it to write-combined memory. - replacer_.NotifyTextureDecoded(plan.replaced, replacedInfo, pixelData, decPitch, srcLevel, w, h, scaledW, scaledH); + replacer_.NotifyTextureDecoded(plan.replaced, replacedInfo, pixelData, decPitch, dstLevel, w, h, scaledW, scaledH); } } } diff --git a/GPU/Common/TextureCacheCommon.h b/GPU/Common/TextureCacheCommon.h index d626d1744b..e999fddf4e 100644 --- a/GPU/Common/TextureCacheCommon.h +++ b/GPU/Common/TextureCacheCommon.h @@ -286,7 +286,7 @@ struct BuildTexturePlan { int maxPossibleLevels; // Load the 0-mip from this PSP texture level instead of 0. - // If non-zero, we are only loading one level. + // If non-zero, we are only loading one level. Replacements and dumps still treat it as level 0. int baseLevelSrc; // The scale factor of the final texture. diff --git a/GPU/GLES/TextureCacheGLES.cpp b/GPU/GLES/TextureCacheGLES.cpp index 457b24717f..3050cf55dd 100644 --- a/GPU/GLES/TextureCacheGLES.cpp +++ b/GPU/GLES/TextureCacheGLES.cpp @@ -220,7 +220,7 @@ void TextureCacheGLES::BuildTexture(TexCacheEntry *const entry) { Draw::DataFormat dstFmt = GetDestFormat(GETextureFormat(entry->format), gstate.getClutPaletteFormat()); if (plan.doReplace) { - plan.replaced->GetSize(plan.baseLevelSrc, &tw, &th); + plan.replaced->GetSize(0, &tw, &th); // Indices for the GPU are uploaded as the R8 format of the CLUT8 textures. dstFmt = plan.decodeToClut8 ? Draw::DataFormat::R8_UNORM : plan.replaced->Format(); } else if (plan.scaleFactor > 1 || plan.saveTexture) { diff --git a/GPU/Metal/TextureCacheMetal.mm b/GPU/Metal/TextureCacheMetal.mm index caacf4e4ab..923f05043a 100644 --- a/GPU/Metal/TextureCacheMetal.mm +++ b/GPU/Metal/TextureCacheMetal.mm @@ -434,7 +434,7 @@ void TextureCacheMetal::BuildTexture(TexCacheEntry *const entry) { } size_t size = (size_t)stride * rows; if (plan.doReplace && compressed) { - size = std::max(size, (size_t)plan.replaced->GetLevelDataSizeAfterCopy(plan.baseLevelSrc + i)); + size = std::max(size, (size_t)plan.replaced->GetLevelDataSizeAfterCopy(i)); } if (computeUpload) { const int srcWidth = gstate.getTextureWidth(i); diff --git a/GPU/Vulkan/TextureCacheVulkan.cpp b/GPU/Vulkan/TextureCacheVulkan.cpp index 5e6b776a44..1b07e163ae 100644 --- a/GPU/Vulkan/TextureCacheVulkan.cpp +++ b/GPU/Vulkan/TextureCacheVulkan.cpp @@ -754,15 +754,15 @@ void TextureCacheVulkan::BuildTexture(TexCacheEntry *const entry) { int rowLength = pixelStride; if (bcFormat) { // For block compressed formats, we just set the upload size to the data size.. - uploadSize = plan.replaced->GetLevelDataSizeAfterCopy(plan.baseLevelSrc + i); + uploadSize = plan.replaced->GetLevelDataSizeAfterCopy(i); rowLength = (mipWidth + 3) & ~3; } // Directly load the replaced image. data = pushBuffer->Allocate(uploadSize, pushAlignment, &texBuf, &bufferOffset); double replaceStart = time_now_d(); const bool copied = plan.decodeToClut8 ? - plan.replaced->CopyIndexLevelToPitched(plan.baseLevelSrc + i, (uint8_t *)data, uploadSize, byteStride) : - plan.replaced->CopyLevelTo(plan.baseLevelSrc + i, (uint8_t *)data, uploadSize, byteStride); + plan.replaced->CopyIndexLevelToPitched(i, (uint8_t *)data, uploadSize, byteStride) : + plan.replaced->CopyLevelTo(i, (uint8_t *)data, uploadSize, byteStride); if (!copied) { // If plan.doReplace, this shouldn't fail. WARN_LOG(Log::G3D, "Failed to copy replaced texture level"); // TODO: Fill with some pattern? @@ -815,7 +815,7 @@ void TextureCacheVulkan::BuildTexture(TexCacheEntry *const entry) { replacedInfo.isVideo = IsVideo(entry->addr); replacedInfo.isFinal = (entry->status & TexStatus::TO_SCALE) == 0; replacedInfo.fmt = FromVulkanFormat(actualFmt); - replacer_.NotifyTextureDecoded(plan.replaced, replacedInfo, data, stride, plan.baseLevelSrc + i, mipUnscaledWidth, mipUnscaledHeight, w, h); + replacer_.NotifyTextureDecoded(plan.replaced, replacedInfo, data, stride, i, mipUnscaledWidth, mipUnscaledHeight, w, h); } } } From f74111835f44db6c0683fecd172cddc496b72b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 09:38:03 +0200 Subject: [PATCH 02/10] Replacement: Dump all mip levels with ignoreMipmap = false Saving level 0 marked the whole texture as saved, so the other levels were skipped. Track it per level. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/TextureReplacer.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/GPU/Common/TextureReplacer.cpp b/GPU/Common/TextureReplacer.cpp index a947410a06..b27d3dc647 100644 --- a/GPU/Common/TextureReplacer.cpp +++ b/GPU/Common/TextureReplacer.cpp @@ -904,9 +904,12 @@ void TextureReplacer::NotifyTextureDecoded(ReplacedTexture *texture, const Repla hashfile = HashName(replacementKey, level) + ".png"; } + if (level >= (int)ARRAY_SIZE(SavedTextureCacheData::levelSaved)) { + return; + } auto it = savedCache_.find(replacementKey); - if (it != savedCache_.end()) { - // We've already saved this texture. Ignore it. + if (it != savedCache_.end() && it->second.levelSaved[level]) { + // We've already saved this level. Ignore it. // We don't really care about changing the scale factor during runtime, only confusing. return; } From 7b9e1a1c939fa9da05b607201f40dbcd99a1ca4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:01:20 +0200 Subject: [PATCH 03/10] ZipFileReader: Look up the exact name before the case insensitive one The case insensitive lookup compares against every name in the zip, while an exact one uses libzip's hash table. Texture packs can have tens of thousands of files. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/File/VFS/ZipFileReader.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Common/File/VFS/ZipFileReader.cpp b/Common/File/VFS/ZipFileReader.cpp index 83655bc989..d9dcf4a84c 100644 --- a/Common/File/VFS/ZipFileReader.cpp +++ b/Common/File/VFS/ZipFileReader.cpp @@ -351,7 +351,11 @@ public: VFSFileReference *ZipFileReader::GetFile(std::string_view path) { std::string p(path); - int zi = zip_name_locate(zip_file_, p.c_str(), ZIP_FL_NOCASE); // this is EXPENSIVE + // An exact match uses libzip's hash table, the case insensitive one compares every name in the zip. + int zi = zip_name_locate(zip_file_, p.c_str(), 0); + if (zi < 0) { + zi = zip_name_locate(zip_file_, p.c_str(), ZIP_FL_NOCASE); + } if (zi < 0) { // Not found. return nullptr; From 034901ee872bb9fef6ece02ab33ae607962b5f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:01:20 +0200 Subject: [PATCH 04/10] Replacement: Copy levels on the calling thread, and end loading at a bad filename The copies went through the thread pool, where they could wait behind other tasks like pipeline compiles, and one core already saturates memory bandwidth. A filename with a parent dir component left the texture in CANCEL_INIT, which the texture cache keeps polling forever. Treat it like a missing file instead. Also remove an unused lastUsed field. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/ReplacedTexture.cpp | 41 +++++++++------------------------- GPU/Common/ReplacedTexture.h | 1 - 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/GPU/Common/ReplacedTexture.cpp b/GPU/Common/ReplacedTexture.cpp index cf7d3187d2..7d4737496b 100644 --- a/GPU/Common/ReplacedTexture.cpp +++ b/GPU/Common/ReplacedTexture.cpp @@ -32,7 +32,6 @@ #include "Common/Data/Format/DDSLoad.h" #include "Common/Data/Format/ZIMLoad.h" #include "Common/Data/Format/PNGLoad.h" -#include "Common/Thread/ParallelLoop.h" #include "Common/Thread/Waitable.h" #include "Common/Thread/ThreadManager.h" #include "Common/Log.h" @@ -220,7 +219,6 @@ bool ReplacedTexture::Poll(double budget) { // Successfully waited! Can get rid of it. threadWaitable_->WaitAndRelease(); threadWaitable_ = nullptr; - lastUsed = now; } lastUsed_ = now; return true; @@ -289,10 +287,12 @@ void ReplacedTexture::Prepare(VFSBackend *vfs) { } std::string path(desc_.filenames[i]); - // Defense in depth: skip filenames that could escape the pack dir. + // Defense in depth: skip filenames that could escape the pack dir. Like a missing file, this ends + // the levels (without any, the texture is NOT_FOUND). if (HasParentDirComponent(path)) { - SetState(ReplacementState::CANCEL_INIT); - return; + ERROR_LOG(Log::TexReplacement, "Ignoring texture filename with parent dir component: %s", path.c_str()); + result = LoadLevelResult::DONE; + break; } VFSFileReference *fileRef = vfs_->GetFile(path.c_str()); if (!fileRef) { @@ -1007,9 +1007,6 @@ bool ReplacedTexture::CopyLevelTo(int level, uint8_t *out, size_t outDataSize, i return false; } - // Dubious if this is worth it, sometimes seems to help, sometimes not. -#define PARALLEL_COPY - int blockSize; if (!Draw::DataFormatIsBlockCompressed(fmt, &blockSize)) { if (fmt != Draw::DataFormat::R8G8B8A8_UNORM) { @@ -1024,46 +1021,28 @@ bool ReplacedTexture::CopyLevelTo(int level, uint8_t *out, size_t outDataSize, i _assert_msg_(data.size() == info.w * info.h * 4, "Data has wrong size"); + // Plain copies: one core already saturates memory bandwidth, and pool tasks can wait behind other + // work queued there (like pipeline compiles). if (rowPitch == info.w * 4) { -#ifdef PARALLEL_COPY - ParallelMemcpy(&g_threadManager, out, data.data(), info.w * 4 * info.h); -#else memcpy(out, data.data(), info.w * 4 * info.h); -#endif } else { -#ifdef PARALLEL_COPY - const int MIN_LINES_PER_THREAD = 4; - const int MAX_THREADS = 6; - ParallelRangeLoop(&g_threadManager, [&](int l, int h) { - int extraPixels = outW - info.w; - for (int y = l; y < h; ++y) { - memcpy((uint8_t *)out + rowPitch * y, data.data() + info.w * 4 * y, info.w * 4); - // Fill the rest of the line with black. - memset((uint8_t *)out + rowPitch * y + info.w * 4, 0, extraPixels * 4); - } - }, 0, info.h, MIN_LINES_PER_THREAD, MAX_THREADS); -#else int extraPixels = outW - info.w; for (int y = 0; y < info.h; ++y) { memcpy((uint8_t *)out + rowPitch * y, data.data() + info.w * 4 * y, info.w * 4); + // Fill the rest of the line with black. memset((uint8_t *)out + rowPitch * y + info.w * 4, 0, extraPixels * 4); } -#endif - // Memset the rest of the padding to avoid leaky edge pixels. Guess we could parallelize this too, but meh. + // Memset the rest of the padding to avoid leaky edge pixels. for (int y = info.h; y < outH; y++) { uint8_t *dest = (uint8_t *)out + rowPitch * y; memset(dest, 0, outW * 4); } } } else { -#ifdef PARALLEL_COPY - // Only parallel copy in the simple case for now. if (info.w == outW && info.h == outH) { - // TODO: Add sanity checks here for other formats? - ParallelMemcpy(&g_threadManager, out, data.data(), data.size()); + memcpy(out, data.data(), data.size()); return true; } -#endif // Alright, so careful copying of blocks it is, padding with zero-blocks as needed. int inBlocksW = (info.w + 3) / 4; int inBlocksH = (info.h + 3) / 4; diff --git a/GPU/Common/ReplacedTexture.h b/GPU/Common/ReplacedTexture.h index 11d545eb18..02bb7459e5 100644 --- a/GPU/Common/ReplacedTexture.h +++ b/GPU/Common/ReplacedTexture.h @@ -259,7 +259,6 @@ private: std::mutex lock_; Draw::DataFormat fmt = Draw::DataFormat::UNDEFINED; TextureAlpha alphaStatus_ = TextureAlpha::Any; - double lastUsed = 0.0; std::atomic state_ = ReplacementState::UNLOADED; 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 05/10] 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; } From 149d8786bff0b80730afaf1a84d590883f85545a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:42:53 +0200 Subject: [PATCH 06/10] TextureCache: Count replaced and upscaled textures at their real size The cache decides when to start evicting textures by their estimated memory use, which only counted the PSP size. A 4x replacement takes 16 times that and texture packs often go higher, so with a big pack the cache could hold far more than it meant to before evicting anything. Record the size when the texture is built. Unscaled textures without replacements get the same estimate as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/TextureCacheCommon.cpp | 15 +++++++++++++-- GPU/Common/TextureCacheCommon.h | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/GPU/Common/TextureCacheCommon.cpp b/GPU/Common/TextureCacheCommon.cpp index 1191ad1e5c..b53e5678b5 100644 --- a/GPU/Common/TextureCacheCommon.cpp +++ b/GPU/Common/TextureCacheCommon.cpp @@ -1726,9 +1726,12 @@ bool TextureCacheCommon::GetCurrentClutBuffer(GPUDebugBuffer &buffer) { return true; } -// Host memory usage, not PSP memory usage. +// Host memory usage, not PSP memory usage. Level 0 only. u32 TexCacheEntry::EstimateTexMemoryUsage() const { - // TODO: This does not take into account the HD remaster's larger textures. + return hostMemorySize != 0 ? hostMemorySize : EstimatePSPSizeMemoryUsage(); +} + +u32 TexCacheEntry::EstimatePSPSizeMemoryUsage() const { const u8 dimW = ((dim >> 0) & 0xf); const u8 dimH = ((dim >> 8) & 0xf); @@ -3107,6 +3110,14 @@ bool TextureCacheCommon::PrepareBuildTexture(BuildTexturePlan &plan, TexCacheEnt SaveIndexedTexture(entry, plan); } + // A replacement or upscaled texture takes many times the PSP size, and the decimation in + // Decimate() goes by this. + if (plan.doReplace) { + entry->hostMemorySize = plan.replaced->GetLevelDataSizeAfterCopy(0); + } else { + entry->hostMemorySize = entry->EstimatePSPSizeMemoryUsage() * plan.scaleFactor * plan.scaleFactor * plan.depth; + } + // Will be filled in again during decode. entry->SetAlphaStatus(TextureAlpha::Any); return true; diff --git a/GPU/Common/TextureCacheCommon.h b/GPU/Common/TextureCacheCommon.h index e999fddf4e..9724c4bb37 100644 --- a/GPU/Common/TextureCacheCommon.h +++ b/GPU/Common/TextureCacheCommon.h @@ -194,6 +194,8 @@ struct TexCacheEntry { #endif int lastFrame; int lastSyncDomain; + // Bytes of host memory the texture takes, set when it's built. See EstimateTexMemoryUsage. + u32 hostMemorySize = 0; TextureAlpha GetAlphaStatus() const { return (status & TexStatus::ALPHA_SOLID) ? TextureAlpha::Solid : TextureAlpha::Any; @@ -222,6 +224,8 @@ struct TexCacheEntry { u64 CacheKey() const; static u64 CacheKey(u32 addr, u8 format, u16 dim, u32 cluthash); u32 EstimateTexMemoryUsage() const; + // The size of the texture's level 0 at the PSP's size, in 32 bits for the CLUT formats. + u32 EstimatePSPSizeMemoryUsage() const; }; // TODO: Work on shrinking it further. From 0e396a99cb8e063623068cb296f5a998bf69adf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:42:53 +0200 Subject: [PATCH 07/10] TextureCache: Keep polling pending replacements when over the frame budget A texture waiting for its replacement was only polled while the frame's replacement budget lasted, so if something used the budget up early in every frame, the textures drawn after it kept their originals until the cache was cleared (like when minimizing on Android). Poll every time, and only let the rebuild wait for the budget, keeping TO_REPLACE set so it's retried. Possibly related to #21586. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/TextureCacheCommon.cpp | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/GPU/Common/TextureCacheCommon.cpp b/GPU/Common/TextureCacheCommon.cpp index b53e5678b5..11683f328e 100644 --- a/GPU/Common/TextureCacheCommon.cpp +++ b/GPU/Common/TextureCacheCommon.cpp @@ -647,11 +647,15 @@ TextureApplyResult TextureCacheCommon::ApplyTexture(bool doBind) { reason = "scaling"; } - if (match && (entry->status & TexStatus::TO_REPLACE) && replacementTimeThisFrame_ < replacementFrameBudgetSeconds_) { + if (match && (entry->status & TexStatus::TO_REPLACE)) { int w0 = gstate.getTextureWidth(0); int h0 = gstate.getTextureHeight(0); int d0 = 1; if (entry->replacedTexture) { + // Polling is cheap, so it's done every time. Only rebuilding the texture waits for the frame's + // budget, otherwise whatever used up the budget early in each frame could keep the textures + // drawn after it from ever getting their replacements. + const bool canRebuild = replacementTimeThisFrame_ < replacementFrameBudgetSeconds_; PollReplacement(entry, &w0, &h0, &d0); // This texture is pending a replacement load. // So check the replacer if it's reached a conclusion. @@ -661,16 +665,24 @@ TextureApplyResult TextureCacheCommon::ApplyTexture(bool doBind) { DEBUG_LOG(Log::TexReplacement, "No replacement for texture %dx%d", w0, h0); entry->status &= ~TexStatus::TO_REPLACE; if (g_Config.bSaveNewTextures) { - // Load it once more to actually save it. Since we don't set STATUS_TO_REPLACE, we won't end up looping. - match = false; - reason = "replacing"; + if (canRebuild) { + // Load it once more to actually save it. Since we don't set STATUS_TO_REPLACE, we won't end up looping. + match = false; + reason = "replacing"; + } else { + entry->status |= TexStatus::TO_REPLACE; + } } break; case ReplacementState::ACTIVE: // There is now replacement data available! - // Just reload the texture to process the replacement. - match = false; - reason = "replacing"; + // Just reload the texture to process the replacement, or try again next time. + if (canRebuild) { + match = false; + reason = "replacing"; + } else { + entry->status |= TexStatus::TO_REPLACE; + } break; default: // We'll just wait for a result. From df40740ae033b014ebc2a080d5e3fc21326ce117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 10:55:13 +0200 Subject: [PATCH 08/10] ZipFileReader: Lock the archive when looking up a file Texture replacement looks up files from several IO threads at once, while another thread may be reading from the same archive. libzip isn't thread safe, and a name lookup writes to the archive: it caches each name's guessed encoding, and allocates a converted copy of CP437 names. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/File/VFS/ZipFileReader.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Common/File/VFS/ZipFileReader.cpp b/Common/File/VFS/ZipFileReader.cpp index d9dcf4a84c..595083f0f3 100644 --- a/Common/File/VFS/ZipFileReader.cpp +++ b/Common/File/VFS/ZipFileReader.cpp @@ -351,6 +351,9 @@ public: VFSFileReference *ZipFileReader::GetFile(std::string_view path) { std::string p(path); + // Texture replacement looks up files from several threads. libzip isn't thread safe, and the lookup + // writes to the archive (it caches each name's encoding, and a converted copy for CP437 names). + std::lock_guard guard(lock_); // An exact match uses libzip's hash table, the case insensitive one compares every name in the zip. int zi = zip_name_locate(zip_file_, p.c_str(), 0); if (zi < 0) { From 52afb9a677c0dd937fdf4f40b02be1611ea0bbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 11:01:47 +0200 Subject: [PATCH 09/10] Headless: Count --timeout-emulated from when --state has loaded The game boots until the queued state is read, decompressed and applied, which takes a varying amount of host time, and those boot frames counted towards the timeout. So the same state ended on different frames depending on the host, for example while texture replacements competed for the IO threads. After the load the emulation is identical: syscall traces of Fate/Extra CCC with no texture pack, and with one loading slowly or instantly, are byte for byte the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/debugging.md | 4 +++- headless/Headless.cpp | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/debugging.md b/docs/debugging.md index 67af0b05fe..c9c6f20fea 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -55,7 +55,9 @@ A working invocation, and the traps around it: - **The timeouts apply to the whole session**, not per test. `--timeout-wall=N` is real seconds and `--timeout-emulated=N` is emulated ones; both default to infinity, both can be set at once, and whichever - is reached first ends the run (`TIMEOUT` or `TIMEOUT (emulated)`). `--timeout` is the old name for + is reached first ends the run (`TIMEOUT` or `TIMEOUT (emulated)`). With `--state=`, emulated time counts + from when the state has loaded: until then the game boots for however long the load takes on the host, so + counting from boot made runs end on different frames. `--timeout` is the old name for `--timeout-wall`. As soon as you pass one it applies to your whole interactive debugging session too, so pass something huge (`--timeout-wall=100000`) or the process exits out from under you mid-session. The wall-clock check is skipped while `IsDebuggerPresent()`; the emulated one doesn't need that escape hatch, diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 85c028e454..46bee0dd8b 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -547,7 +547,9 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar const double emulatedNow = CoreTiming::GetGlobalTimeUs() / 1000000.0; const double emulatedStep = emulatedNow - lastEmulatedTime; lastEmulatedTime = emulatedNow; - if (emulatedStep > 0.0 && emulatedStep < emulatedStepLimit) { + // With --state, count from when the state lands. Until then the game boots, for however long + // reading and decompressing the state takes on the host, which made runs end on different frames. + if (emulatedStep > 0.0 && emulatedStep < emulatedStepLimit && !g_stateLoadPending) { emulatedElapsed += emulatedStep; } From cffb12fd36d69902f5f7d89e2947e7b3db4092e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 20 Oct 2026 11:57:57 +0200 Subject: [PATCH 10/10] TextureCache: Raise the eviction thresholds to go with the real sizes Nothing is evicted until the cache estimate reaches these, and they were tuned when the estimate counted every texture at its PSP size. Counted at real size, upscaled textures crossed 16MB scale^2 times sooner (and re-scaling an evicted one is slow), and a single 2048x2048 replacement filled the 4MB secondary cache. 128MB covers what upscaling up to about 3x effectively had before. Also remove TEXTURE_KILL_AGE_LOWMEM, unused since the low memory mode went away. Co-Authored-By: Claude Opus 5.5 (1M context) --- GPU/Common/TextureCacheCommon.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GPU/Common/TextureCacheCommon.cpp b/GPU/Common/TextureCacheCommon.cpp index 11683f328e..1241518dea 100644 --- a/GPU/Common/TextureCacheCommon.cpp +++ b/GPU/Common/TextureCacheCommon.cpp @@ -51,8 +51,6 @@ // If a texture hasn't been seen for this many frames, get rid of it. #define TEXTURE_KILL_AGE 200 -#define TEXTURE_KILL_AGE_LOWMEM 60 -// Not used in lowmem mode. #define TEXTURE_SECOND_KILL_AGE 100 // Used when there are multiple CLUT variants of a texture, to avoid blowing up memory. See #10418 // We should change this to do shader-based palette expansion. @@ -63,8 +61,10 @@ // Try to be prime to other decimation intervals. #define TEXCACHE_DECIMATION_INTERVAL 13 -#define TEXCACHE_MIN_PRESSURE 16 * 1024 * 1024 // Total in VRAM -#define TEXCACHE_SECOND_MIN_PRESSURE 4 * 1024 * 1024 +// Below these sizes (in host memory, see EstimateTexMemoryUsage) nothing is evicted. Above them, textures +// that haven't been used for the kill age are. A single 2048x2048 replacement is 16MB. +#define TEXCACHE_MIN_PRESSURE 128 * 1024 * 1024 +#define TEXCACHE_SECOND_MIN_PRESSURE 32 * 1024 * 1024 static bool MatchFramebuffer(const TextureDefinition &entry, VirtualFramebuffer *framebuffer, u32 texaddrOffset, RasterChannel channel, bool useBufferedRendering, FramebufferMatchInfo *matchInfo); static bool GetBestFramebufferCandidate(FramebufferManagerCommon *fbManager, const TextureDefinition &entry, u32 texAddrOffset, AttachCandidate *bestCandidate, const char *context);