From de7989ae5675869f28916a93876f732930c8b329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 1 Aug 2026 13:45:32 +0200 Subject: [PATCH] Add TextureReplacer unit test; make replacer testable standalone Add unittest/TestTextureReplacer which creates a fictive texture pack (textures.ini with readable invented hashes plus real PNG files), loads it via the replacer, and verifies lookups, filtering, hashranges, mip levels, and missing/ignored entries. To make the replacer runnable outside the emulator: - The constructor now accepts a null DrawContext (formats just default to unsupported). - FindReplacement/FindFiltering use the replaceEnabled_ member instead of the global config. - Added TextureReplacer::LoadPackForTesting() to load an ini from a path directly. --- CMakeLists.txt | 1 + GPU/Common/TextureReplacer.cpp | 24 +++-- GPU/Common/TextureReplacer.h | 4 + unittest/TestTextureReplacer.cpp | 165 +++++++++++++++++++++++++++++ unittest/UnitTest.cpp | 2 + unittest/UnitTests.vcxproj | 1 + unittest/UnitTests.vcxproj.filters | 1 + 7 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 unittest/TestTextureReplacer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d29f8c406c..4c59690f90 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1391,6 +1391,7 @@ if(UNITTEST) unittest/TestVFS.cpp unittest/TestZipSlip.cpp unittest/TestLzrc.cpp + unittest/TestTextureReplacer.cpp unittest/TestRiscVEmitter.cpp unittest/TestLoongArch64Emitter.cpp unittest/TestSoftwareGPUJit.cpp diff --git a/GPU/Common/TextureReplacer.cpp b/GPU/Common/TextureReplacer.cpp index 683156ffb9..213a171caa 100644 --- a/GPU/Common/TextureReplacer.cpp +++ b/GPU/Common/TextureReplacer.cpp @@ -63,10 +63,13 @@ TextureReplacer::TextureReplacer(Draw::DrawContext *draw) { } // We don't want to keep the draw object around, so extract the info we need. - if (draw->GetDataFormatSupport(Draw::DataFormat::BC3_UNORM_BLOCK)) formatSupport_.bc123 = true; - if (draw->GetDataFormatSupport(Draw::DataFormat::ASTC_4x4_UNORM_BLOCK)) formatSupport_.astc = true; - if (draw->GetDataFormatSupport(Draw::DataFormat::BC7_UNORM_BLOCK)) formatSupport_.bc7 = true; - if (draw->GetDataFormatSupport(Draw::DataFormat::ETC2_R8G8B8_UNORM_BLOCK)) formatSupport_.etc2 = true; + // In tests, draw may be null; formats then just default to unsupported. + if (draw) { + if (draw->GetDataFormatSupport(Draw::DataFormat::BC3_UNORM_BLOCK)) formatSupport_.bc123 = true; + if (draw->GetDataFormatSupport(Draw::DataFormat::ASTC_4x4_UNORM_BLOCK)) formatSupport_.astc = true; + if (draw->GetDataFormatSupport(Draw::DataFormat::BC7_UNORM_BLOCK)) formatSupport_.bc7 = true; + if (draw->GetDataFormatSupport(Draw::DataFormat::ETC2_R8G8B8_UNORM_BLOCK)) formatSupport_.etc2 = true; + } } TextureReplacer::~TextureReplacer() { @@ -76,6 +79,15 @@ TextureReplacer::~TextureReplacer() { delete vfs_; } +bool TextureReplacer::LoadPackForTesting(const Path &basePath, std::string *error) { + basePath_ = basePath; + gameID_ = ""; + replaceEnabled_ = true; + saveEnabled_ = false; + replaceEnabled_ = LoadIni(error, false); + return replaceEnabled_; +} + void TextureReplacer::NotifyConfigChanged() { gameID_ = g_paramSFO.GetDiscID(); @@ -610,7 +622,7 @@ u32 TextureReplacer::ComputeHash(u32 addr, int bufw, int w, int h, bool swizzled ReplacedTexture *TextureReplacer::FindReplacement(ReplacementCacheKey replacementKey, int w, int h) { // Only actually replace if we're replacing. We might just be saving. - if (!Enabled() || !g_Config.bReplaceTextures) { + if (!replaceEnabled_) { return nullptr; } @@ -952,7 +964,7 @@ static typename std::unordered_map::const_iterator L } bool TextureReplacer::FindFiltering(ReplacementCacheKey replacementKey, TextureFiltering *forceFiltering) { - if (!Enabled() || !g_Config.bReplaceTextures) { + if (!replaceEnabled_) { return false; } diff --git a/GPU/Common/TextureReplacer.h b/GPU/Common/TextureReplacer.h index 0986a62832..31f377a7da 100644 --- a/GPU/Common/TextureReplacer.h +++ b/GPU/Common/TextureReplacer.h @@ -93,6 +93,10 @@ public: // Returns nullptr if not found. ReplacedTexture *FindReplacement(ReplacementCacheKey key, int w, int h); + // For testing: point the replacer at a texture pack directory and load its + // ini, without touching the global config. Returns true on success. + bool LoadPackForTesting(const Path &basePath, std::string *error); + // Check if a NotifyTextureDecoded for this texture is desired (used to avoid reads from write-combined memory.) bool WillSave(const ReplacedTextureDecodeInfo &replacedInfo) const; diff --git a/unittest/TestTextureReplacer.cpp b/unittest/TestTextureReplacer.cpp new file mode 100644 index 0000000000..3f31ad0f56 --- /dev/null +++ b/unittest/TestTextureReplacer.cpp @@ -0,0 +1,165 @@ +#include +#include +#include + +#include "Common/Data/Format/PNGLoad.h" +#include "Common/File/FileUtil.h" +#include "Common/File/Path.h" +#include "Common/Thread/ThreadManager.h" +#include "GPU/Common/TextureReplacer.h" + +#include "UnitTest.h" + +// The fake hashes in textures.ini are designed to be readable: the address +// part starts with A, the CLUT hash with C, and the contents hash with 1. +static const u64 KEY_A = 0xA0000001C0000001ULL; // addr 0xA0000001, clut 0xC0000001 +static const u32 HASH_A = 0x10000001; +static const u64 KEY_B = 0xA0000002C0000002ULL; // two mip levels +static const u32 HASH_B = 0x10000002; +static const u64 KEY_C = 0xA0000003C0000003ULL; // hashrange +static const u32 HASH_C = 0x10000003; +static const u64 KEY_D = 0xA0000004C0000004ULL; // ignored (empty filename) +static const u32 HASH_D = 0x10000004; +static const u64 KEY_E = 0xA0000005C0000005ULL; // file missing +static const u32 HASH_E = 0x10000005; + +static bool CreateTestPNG(const Path &filename, int w, int h, u32 color) { + std::vector buf((size_t)w * h * 4); + for (int i = 0; i < w * h; i++) { + buf[i * 4] = (color >> 24) & 0xFF; + buf[i * 4 + 1] = (color >> 16) & 0xFF; + buf[i * 4 + 2] = (color >> 8) & 0xFF; + buf[i * 4 + 3] = color & 0xFF; + } + return pngSave(filename, buf.data(), w, h, 4); +} + +static bool CreateTestPack(const Path &packDir) { + File::DeleteDirRecursively(packDir); + if (!File::CreateDir(packDir)) { + return false; + } + + static const char *iniContent = + "[options]\n" + "hash = quick\n" + "version = 1\n" + "\n" + "[hashes]\n" + "A0000001C000000110000001 = tex_a.png\n" + "A0000002C000000210000002 = tex_b0.png\n" + "A0000002C000000210000002_1 = tex_b1.png\n" + "A0000003C000000310000003 = tex_c.png\n" + "A0000004C000000410000004 =\n" + "A0000005C000000510000005 = missing.png\n" + "\n" + "[hashranges]\n" + "A0000003,512,512 = 256,256\n" + "\n" + "[filtering]\n" + "A0000001C000000110000001 = nearest\n"; + + FILE *f = File::OpenCFile(packDir / "textures.ini", "w"); + if (!f) { + return false; + } + fwrite(iniContent, 1, strlen(iniContent), f); + fclose(f); + + if (!CreateTestPNG(packDir / "tex_a.png", 64, 64, 0xFF0000FF)) return false; + if (!CreateTestPNG(packDir / "tex_b0.png", 64, 64, 0x00FF00FF)) return false; + if (!CreateTestPNG(packDir / "tex_b1.png", 32, 32, 0x00FF00FF)) return false; + if (!CreateTestPNG(packDir / "tex_c.png", 256, 256, 0x0000FFFF)) return false; + + return true; +} + +static bool TestLookups(TextureReplacer *replacer) { + // Key A: single mip, found. + ReplacedTexture *texA = replacer->FindReplacement(ReplacementCacheKey(KEY_A, HASH_A), 64, 64); + EXPECT_TRUE(texA != nullptr); + + // Key B: two mip levels, found. + ReplacedTexture *texB = replacer->FindReplacement(ReplacementCacheKey(KEY_B, HASH_B), 64, 64); + EXPECT_TRUE(texB != nullptr); + + // Key C: found via hashrange. + ReplacedTexture *texC = replacer->FindReplacement(ReplacementCacheKey(KEY_C, HASH_C), 512, 512); + EXPECT_TRUE(texC != nullptr); + + // Key D: explicitly ignored (empty filename). + EXPECT_TRUE(replacer->FindReplacement(ReplacementCacheKey(KEY_D, HASH_D), 16, 16) == nullptr); + + // Key E: file missing, still creates a texture object (loads as NOT_FOUND). + ReplacedTexture *texE = replacer->FindReplacement(ReplacementCacheKey(KEY_E, HASH_E), 16, 16); + EXPECT_TRUE(texE != nullptr); + + // Unknown key: not in the ini at all. + ReplacementCacheKey unknownKey(0x2000000020000000ULL, 0x20000000); + EXPECT_TRUE(replacer->FindReplacement(unknownKey, 16, 16) == nullptr); + + if (!texA || !texB || !texC || !texE) { + return false; + } + + // Load the actual textures using the thread manager. + g_threadManager.Init(1, 1); + + // Key A: 64x64 single level, nearest filtering. + EXPECT_TRUE(texA->Poll(1.0)); + EXPECT_EQ_INT(texA->NumLevels(), 1); + int w = 0, h = 0; + texA->GetSize(0, &w, &h); + EXPECT_EQ_INT(w, 64); + EXPECT_EQ_INT(h, 64); + TextureFiltering filtering = TEX_FILTER_AUTO; + EXPECT_TRUE(texA->ForceFiltering(&filtering)); + EXPECT_TRUE(filtering == TEX_FILTER_FORCE_NEAREST); + + // Key B: two mip levels, 64x64 and 32x32. + EXPECT_TRUE(texB->Poll(1.0)); + EXPECT_EQ_INT(texB->NumLevels(), 2); + texB->GetSize(0, &w, &h); + EXPECT_EQ_INT(w, 64); + EXPECT_EQ_INT(h, 64); + texB->GetSize(1, &w, &h); + EXPECT_EQ_INT(w, 32); + EXPECT_EQ_INT(h, 32); + + // Key C: hashrange maps 512x512 -> 256x256, upscaled back to 512x512. + EXPECT_TRUE(texC->Poll(1.0)); + EXPECT_EQ_INT(texC->NumLevels(), 1); + texC->GetSize(0, &w, &h); + EXPECT_EQ_INT(w, 512); + EXPECT_EQ_INT(h, 512); + + // Key E: missing file should end up NOT_FOUND. + EXPECT_TRUE(texE->Poll(1.0)); + EXPECT_TRUE(texE->State() == ReplacementState::NOT_FOUND); + + g_threadManager.Teardown(); + return true; +} + +bool TestTextureReplacer() { + Path packDir = Path("unittest_texture_pack"); + if (!CreateTestPack(packDir)) { + return false; + } + + TextureReplacer replacer(nullptr); + std::string error; + if (!replacer.LoadPackForTesting(packDir, &error)) { + ERROR_LOG(Log::G3D, "Failed to load test texture pack: %s", error.c_str()); + File::DeleteDirRecursively(packDir); + return false; + } + + if (!TestLookups(&replacer)) { + File::DeleteDirRecursively(packDir); + return false; + } + + File::DeleteDirRecursively(packDir); + return true; +} diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index 9882c37ec6..fc61fdd6f1 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -1392,6 +1392,7 @@ bool TestThreadManager(); bool TestVFS(); bool TestZipSlip(); bool TestLzrc(); +bool TestTextureReplacer(); TestItem availableTests[] = { #if PPSSPP_ARCH(ARM64) || PPSSPP_ARCH(AMD64) || PPSSPP_ARCH(X86) @@ -1449,6 +1450,7 @@ TestItem availableTests[] = { TEST_ITEM(CmdLine), TEST_ITEM(ZipSlip), TEST_ITEM(Lzrc), + TEST_ITEM(TextureReplacer), }; int main(int argc, const char *argv[]) { diff --git a/unittest/UnitTests.vcxproj b/unittest/UnitTests.vcxproj index e3fa6e17c8..0489cb539a 100644 --- a/unittest/UnitTests.vcxproj +++ b/unittest/UnitTests.vcxproj @@ -294,6 +294,7 @@ + diff --git a/unittest/UnitTests.vcxproj.filters b/unittest/UnitTests.vcxproj.filters index 745603ecd7..87eebf3b2d 100644 --- a/unittest/UnitTests.vcxproj.filters +++ b/unittest/UnitTests.vcxproj.filters @@ -16,6 +16,7 @@ +