mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-11-01 20:18:15 +01:00
- Fixes #15145 A replacement listed in a new [indexed] section of textures.ini holds palette indices instead of colors: a palette PNG (the file's palette index) or an 8-bit grayscale PNG (the gray value), at any resolution. When the texture is built, the indices go through the game's current CLUT, including the CLUT index shift, mask and start position, and the result is uploaded as an ordinary RGBA texture. A game that swaps or customizes a palette keeps working with a high-resolution replacement. [indexed] keys use the [hashes] format and are matched with the same wildcards, so a key with a zero CLUT hash covers every palette of the texture. The contents hash only covers the indices, so one image is shared by all palettes (the loaded data is shared, each palette still has its own texture cache entry). Saving does not dump indexed textures yet. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
418 lines
13 KiB
C++
418 lines
13 KiB
C++
#include <cstdio>
|
|
#include <cstring>
|
|
#include <vector>
|
|
|
|
#include <png.h>
|
|
|
|
#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 const u64 KEY_F = 0xA0000006C0000006ULL; // same file as A, own hashrange
|
|
static const u32 HASH_F = 0x10000006;
|
|
static const u64 KEY_G = 0xA0000007C0000007ULL; // ignoreAddress pack, hashrange
|
|
static const u32 HASH_G = 0x10000007;
|
|
|
|
static bool CreateTestPNG(const Path &filename, int w, int h, u32 color) {
|
|
std::vector<u8> 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 WriteIni(const Path &packDir, const char *iniContent) {
|
|
FILE *f = File::OpenCFile(packDir / "textures.ini", "w");
|
|
if (!f) {
|
|
return false;
|
|
}
|
|
fwrite(iniContent, 1, strlen(iniContent), f);
|
|
fclose(f);
|
|
return true;
|
|
}
|
|
|
|
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"
|
|
"A0000006C000000610000006 = tex_a.png\n"
|
|
"\n"
|
|
"[hashranges]\n"
|
|
"A0000003,512,512 = 256,256\n"
|
|
"A0000006,64,64 = 32,32\n"
|
|
"\n"
|
|
"[filtering]\n"
|
|
"A0000001C000000110000001 = nearest\n";
|
|
|
|
if (!WriteIni(packDir, iniContent)) {
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
|
|
// Key F: the same file as A, but its own hashrange and no filtering, so it can't share A's texture.
|
|
ReplacedTexture *texF = replacer->FindReplacement(ReplacementCacheKey(KEY_F, HASH_F), 64, 64);
|
|
EXPECT_TRUE(texF != nullptr);
|
|
EXPECT_TRUE(texF != texA);
|
|
|
|
// 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 || !texF) {
|
|
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 F: hashrange maps 64x64 -> 32x32, so the 64x64 image covers 128x128.
|
|
EXPECT_TRUE(texF->Poll(1.0));
|
|
texF->GetSize(0, &w, &h);
|
|
EXPECT_EQ_INT(w, 128);
|
|
EXPECT_EQ_INT(h, 128);
|
|
EXPECT_FALSE(texF->ForceFiltering(&filtering));
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ignoreAddress matches files without the address, but hash ranges still apply per address.
|
|
static bool TestIgnoreAddress(const Path &packDir) {
|
|
static const char *iniContent =
|
|
"[options]\n"
|
|
"hash = xxh32\n"
|
|
"ignoreAddress = true\n"
|
|
"version = 1\n"
|
|
"\n"
|
|
"[hashes]\n"
|
|
"00000000C000000710000007 = tex_c.png\n"
|
|
"\n"
|
|
"[hashranges]\n"
|
|
"A0000007,512,512 = 256,256\n";
|
|
if (!WriteIni(packDir, iniContent)) {
|
|
return false;
|
|
}
|
|
|
|
TextureReplacer replacer(nullptr);
|
|
std::string error;
|
|
if (!replacer.LoadPackForTesting(packDir, &error)) {
|
|
return false;
|
|
}
|
|
|
|
ReplacementCacheKey key(KEY_G, HASH_G);
|
|
ReplacedTexture *texG = replacer.FindReplacement(key, 512, 512);
|
|
EXPECT_TRUE(texG != nullptr);
|
|
if (!texG) {
|
|
return false;
|
|
}
|
|
EXPECT_TRUE(replacer.FindReplacement(key, 512, 512) == texG);
|
|
|
|
g_threadManager.Init(1, 1);
|
|
EXPECT_TRUE(texG->Poll(1.0));
|
|
int w = 0, h = 0;
|
|
texG->GetSize(0, &w, &h);
|
|
EXPECT_EQ_INT(w, 512);
|
|
EXPECT_EQ_INT(h, 512);
|
|
g_threadManager.Teardown();
|
|
return true;
|
|
}
|
|
|
|
// Writes an 8-bit palette PNG (a palette in reverse gray order, so the index is not the color)
|
|
// or an 8-bit grayscale PNG, one byte per pixel.
|
|
static bool CreateIndexedPNG(const Path &filename, int w, int h, bool palette, const std::vector<u8> &indices) {
|
|
FILE *fp = File::OpenCFile(filename, "wb");
|
|
if (!fp) {
|
|
return false;
|
|
}
|
|
png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
|
|
png_infop info = png_create_info_struct(png);
|
|
if (setjmp(png_jmpbuf(png))) {
|
|
png_destroy_write_struct(&png, &info);
|
|
fclose(fp);
|
|
return false;
|
|
}
|
|
png_init_io(png, fp);
|
|
png_set_IHDR(png, info, w, h, 8, palette ? PNG_COLOR_TYPE_PALETTE : PNG_COLOR_TYPE_GRAY, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
|
|
if (palette) {
|
|
png_color entries[256];
|
|
for (int i = 0; i < 256; i++) {
|
|
entries[i].red = entries[i].green = entries[i].blue = 255 - i;
|
|
}
|
|
png_set_PLTE(png, info, entries, 256);
|
|
}
|
|
std::vector<png_bytep> rows(h);
|
|
for (int y = 0; y < h; y++) {
|
|
rows[y] = (png_bytep)indices.data() + y * w;
|
|
}
|
|
png_set_rows(png, info, rows.data());
|
|
png_write_png(png, info, PNG_TRANSFORM_IDENTITY, nullptr);
|
|
png_destroy_write_struct(&png, &info);
|
|
fclose(fp);
|
|
return true;
|
|
}
|
|
|
|
static bool ReadFileBytes(const Path &filename, std::vector<u8> *data) {
|
|
std::string str;
|
|
if (!File::ReadBinaryFileToString(filename, &str)) {
|
|
return false;
|
|
}
|
|
data->assign(str.begin(), str.end());
|
|
return true;
|
|
}
|
|
|
|
static bool TestIndexedPNG(const Path &packDir) {
|
|
std::vector<u8> indices(16 * 8);
|
|
for (size_t i = 0; i < indices.size(); i++) {
|
|
indices[i] = (u8)(i * 7 + 3);
|
|
}
|
|
|
|
for (int palette = 0; palette < 2; palette++) {
|
|
Path file = packDir / "indexed_test.png";
|
|
if (!CreateIndexedPNG(file, 16, 8, palette != 0, indices)) {
|
|
return false;
|
|
}
|
|
std::vector<u8> png;
|
|
if (!ReadFileBytes(file, &png)) {
|
|
return false;
|
|
}
|
|
int w = 0, h = 0;
|
|
unsigned char *out = nullptr;
|
|
EXPECT_TRUE(pngLoadIndexed(png.data(), png.size(), &w, &h, &out) == 1);
|
|
EXPECT_EQ_INT(w, 16);
|
|
EXPECT_EQ_INT(h, 8);
|
|
if (!out) {
|
|
return false;
|
|
}
|
|
EXPECT_TRUE(memcmp(out, indices.data(), indices.size()) == 0);
|
|
free(out);
|
|
|
|
// Too small a limit is refused.
|
|
EXPECT_TRUE(pngLoadIndexed(png.data(), png.size(), &w, &h, &out, 8, 8) == 0);
|
|
}
|
|
|
|
// Truecolor is not an index.
|
|
Path rgba = packDir / "indexed_rgba.png";
|
|
if (!CreateTestPNG(rgba, 4, 4, 0x11223344)) {
|
|
return false;
|
|
}
|
|
std::vector<u8> png;
|
|
if (!ReadFileBytes(rgba, &png)) {
|
|
return false;
|
|
}
|
|
int w = 0, h = 0;
|
|
unsigned char *out = nullptr;
|
|
EXPECT_TRUE(pngLoadIndexed(png.data(), png.size(), &w, &h, &out) == 0);
|
|
EXPECT_TRUE(out == nullptr);
|
|
return true;
|
|
}
|
|
|
|
// The [indexed] section marks an image as palette indices. It is looked up without the CLUT hash
|
|
// so that every palette of the texture shares the one image.
|
|
static bool TestIndexedReplacement(const Path &packDir) {
|
|
static const char *iniContent =
|
|
"[options]\n"
|
|
"hash = quick\n"
|
|
"version = 1\n"
|
|
"\n"
|
|
"[hashes]\n"
|
|
"A00000080000000010000008 = indexed.png\n"
|
|
"A00000090000000010000009 = indexed_rgba.png\n"
|
|
"A000000A000000001000000A = indexed.png\n"
|
|
"\n"
|
|
"[indexed]\n"
|
|
"A00000080000000010000008 = true\n"
|
|
"A00000090000000010000009 = true\n";
|
|
if (!WriteIni(packDir, iniContent)) {
|
|
return false;
|
|
}
|
|
|
|
std::vector<u8> indices(16 * 8);
|
|
for (size_t i = 0; i < indices.size(); i++) {
|
|
indices[i] = (u8)(255 - i * 2);
|
|
}
|
|
if (!CreateIndexedPNG(packDir / "indexed.png", 16, 8, true, indices)) {
|
|
return false;
|
|
}
|
|
if (!CreateTestPNG(packDir / "indexed_rgba.png", 16, 8, 0x11223344)) {
|
|
return false;
|
|
}
|
|
|
|
TextureReplacer replacer(nullptr);
|
|
std::string error;
|
|
if (!replacer.LoadPackForTesting(packDir, &error)) {
|
|
return false;
|
|
}
|
|
|
|
// Two palettes of the same texture.
|
|
ReplacedTexture *pal1 = replacer.FindReplacement(ReplacementCacheKey(0xA0000008C0000001ULL, 0x10000008), 16, 8);
|
|
ReplacedTexture *pal2 = replacer.FindReplacement(ReplacementCacheKey(0xA0000008C0000002ULL, 0x10000008), 16, 8);
|
|
EXPECT_TRUE(pal1 != nullptr);
|
|
EXPECT_TRUE(pal1 == pal2);
|
|
|
|
// The same image without the [indexed] entry is a regular replacement, which can't share the texture.
|
|
ReplacedTexture *plain = replacer.FindReplacement(ReplacementCacheKey(0xA000000AC0000001ULL, 0x1000000A), 16, 8);
|
|
EXPECT_TRUE(plain != nullptr);
|
|
EXPECT_TRUE(plain != pal1);
|
|
|
|
ReplacedTexture *bad = replacer.FindReplacement(ReplacementCacheKey(0xA0000009C0000001ULL, 0x10000009), 16, 8);
|
|
EXPECT_TRUE(bad != nullptr);
|
|
if (!pal1 || !plain || !bad) {
|
|
return false;
|
|
}
|
|
EXPECT_TRUE(pal1->IsIndexed());
|
|
EXPECT_FALSE(plain->IsIndexed());
|
|
|
|
g_threadManager.Init(1, 1);
|
|
EXPECT_TRUE(pal1->Poll(1.0));
|
|
EXPECT_TRUE(pal1->State() == ReplacementState::ACTIVE);
|
|
EXPECT_EQ_INT(pal1->NumLevels(), 1);
|
|
int w = 0, h = 0;
|
|
pal1->GetIndexDataSize(0, &w, &h);
|
|
EXPECT_EQ_INT(w, 16);
|
|
EXPECT_EQ_INT(h, 8);
|
|
pal1->GetSize(0, &w, &h);
|
|
EXPECT_EQ_INT(w, 16);
|
|
EXPECT_EQ_INT(h, 8);
|
|
EXPECT_TRUE(pal1->Format() == Draw::DataFormat::R8G8B8A8_UNORM);
|
|
|
|
std::vector<u8> copy(indices.size());
|
|
EXPECT_TRUE(pal1->CopyIndexLevelTo(0, copy.data(), copy.size()));
|
|
EXPECT_TRUE(copy == indices);
|
|
|
|
// A truecolor image can't serve as indices.
|
|
EXPECT_TRUE(bad->Poll(1.0));
|
|
EXPECT_TRUE(bad->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;
|
|
}
|
|
|
|
// Reloading the ini forgets what the old one said about each texture.
|
|
EXPECT_TRUE(replacer.GetNumTrackedTextures() != 0);
|
|
EXPECT_TRUE(replacer.LoadPackForTesting(packDir, &error));
|
|
EXPECT_EQ_INT(replacer.GetNumTrackedTextures(), 0);
|
|
|
|
if (!TestIgnoreAddress(packDir)) {
|
|
File::DeleteDirRecursively(packDir);
|
|
return false;
|
|
}
|
|
|
|
if (!TestIndexedPNG(packDir) || !TestIndexedReplacement(packDir)) {
|
|
File::DeleteDirRecursively(packDir);
|
|
return false;
|
|
}
|
|
|
|
File::DeleteDirRecursively(packDir);
|
|
return true;
|
|
}
|