From 5a25cc171dd4ff6da59a09763342a55986624d54 Mon Sep 17 00:00:00 2001 From: Connor McLaughlin Date: Sun, 20 Feb 2022 00:20:18 +1000 Subject: [PATCH] GS: Add texture dumping and replacement system --- pcsx2-qt/Settings/GraphicsSettingsWidget.cpp | 12 +- pcsx2-qt/Settings/GraphicsSettingsWidget.ui | 105 ++- pcsx2/CMakeLists.txt | 3 + pcsx2/Config.h | 9 +- pcsx2/GS/GS.cpp | 18 + pcsx2/GS/Renderers/HW/GSRendererHW.cpp | 8 + pcsx2/GS/Renderers/HW/GSTextureCache.cpp | 198 +++++- pcsx2/GS/Renderers/HW/GSTextureCache.h | 8 + .../HW/GSTextureReplacementLoaders.cpp | 642 +++++++++++++++++ .../GS/Renderers/HW/GSTextureReplacements.cpp | 670 ++++++++++++++++++ pcsx2/GS/Renderers/HW/GSTextureReplacements.h | 60 ++ pcsx2/GS/Window/GSwxDialog.cpp | 12 + pcsx2/PathDefs.h | 1 + pcsx2/Pcsx2Config.cpp | 19 + pcsx2/gui/AppConfig.cpp | 25 + pcsx2/gui/AppConfig.h | 6 +- pcsx2/gui/AppMain.cpp | 1 + pcsx2/pcsx2.vcxproj | 3 + pcsx2/pcsx2.vcxproj.filters | 9 + pcsx2/pcsx2core.vcxproj | 3 + pcsx2/pcsx2core.vcxproj.filters | 9 + tools/texture_dump_alpha_scaler.py | 163 +++++ 22 files changed, 1923 insertions(+), 61 deletions(-) create mode 100644 pcsx2/GS/Renderers/HW/GSTextureReplacementLoaders.cpp create mode 100644 pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp create mode 100644 pcsx2/GS/Renderers/HW/GSTextureReplacements.h create mode 100755 tools/texture_dump_alpha_scaler.py diff --git a/pcsx2-qt/Settings/GraphicsSettingsWidget.cpp b/pcsx2-qt/Settings/GraphicsSettingsWidget.cpp index 6575745209..f8da272708 100644 --- a/pcsx2-qt/Settings/GraphicsSettingsWidget.cpp +++ b/pcsx2-qt/Settings/GraphicsSettingsWidget.cpp @@ -163,6 +163,16 @@ GraphicsSettingsWidget::GraphicsSettingsWidget(SettingsDialog* dialog, QWidget* SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.mergeSprite, "EmuCore/GS", "UserHacks_merge_pp_sprite", false); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.wildHack, "EmuCore/GS", "UserHacks_WildHack", false); + ////////////////////////////////////////////////////////////////////////// + // Texture Replacements + ////////////////////////////////////////////////////////////////////////// + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.dumpReplaceableTextures, "EmuCore/GS", "DumpReplaceableTextures", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.dumpReplaceableMipmaps, "EmuCore/GS", "DumpReplaceableMipmaps", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.dumpTexturesWithFMVActive, "EmuCore/GS", "DumpTexturesWithFMVActive", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.loadTextureReplacements, "EmuCore/GS", "LoadTextureReplacements", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.loadTextureReplacementsAsync, "EmuCore/GS", "LoadTextureReplacementsAsync", true); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.precacheTextureReplacements, "EmuCore/GS", "PrecacheTextureReplacements", false); + ////////////////////////////////////////////////////////////////////////// // Advanced Settings ////////////////////////////////////////////////////////////////////////// @@ -339,7 +349,7 @@ void GraphicsSettingsWidget::updateRendererDependentOptions() { // software has no hacks tabs m_ui.verticalLayout->insertWidget(1, m_ui.softwareRendererGroup); - m_ui.softwareRendererGroup->setCurrentIndex((current_tab >= 4) ? (current_tab - 2) : (current_tab >= 2 ? 1 : current_tab)); + m_ui.softwareRendererGroup->setCurrentIndex((current_tab >= 5) ? (current_tab - 3) : (current_tab >= 2 ? 1 : current_tab)); } m_software_renderer_visible = is_software; diff --git a/pcsx2-qt/Settings/GraphicsSettingsWidget.ui b/pcsx2-qt/Settings/GraphicsSettingsWidget.ui index 70ac5b865f..784119deb8 100644 --- a/pcsx2-qt/Settings/GraphicsSettingsWidget.ui +++ b/pcsx2-qt/Settings/GraphicsSettingsWidget.ui @@ -934,24 +934,93 @@ Advanced - - - - - - - Use Blit Swap Chain - - - - - - - Use Debug Device - - - - + + + + + Debug Options + + + + + + Use Blit Swap Chain + + + + + + + Use Debug Device + + + + + + + + + + Texture Replacement + + + + + + Dump Textures + + + + + + + Dump Mipmaps + + + + + + + Async Texture Loading + + + + + + + Precache Textures + + + + + + + Load Textures + + + + + + + Dump FMV Textures + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + diff --git a/pcsx2/CMakeLists.txt b/pcsx2/CMakeLists.txt index 7d8647128f..bceb28044b 100644 --- a/pcsx2/CMakeLists.txt +++ b/pcsx2/CMakeLists.txt @@ -657,6 +657,8 @@ set(pcsx2GSSources GS/Renderers/HW/GSRendererHW.cpp GS/Renderers/HW/GSRendererNew.cpp GS/Renderers/HW/GSTextureCache.cpp + GS/Renderers/HW/GSTextureReplacementLoaders.cpp + GS/Renderers/HW/GSTextureReplacements.cpp GS/Renderers/SW/GSDrawScanline.cpp GS/Renderers/SW/GSDrawScanlineCodeGenerator.cpp GS/Renderers/SW/GSDrawScanlineCodeGenerator.all.cpp @@ -721,6 +723,7 @@ set(pcsx2GSHeaders GS/Renderers/HW/GSRendererHW.h GS/Renderers/HW/GSRendererNew.h GS/Renderers/HW/GSTextureCache.h + GS/Renderers/HW/GSTextureReplacements.h GS/Renderers/HW/GSVertexHW.h GS/Renderers/SW/GSDrawScanlineCodeGenerator.h GS/Renderers/SW/GSDrawScanlineCodeGenerator.all.h diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 92faba8e57..64b97de5a4 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -458,7 +458,13 @@ struct Pcsx2Config SaveRT : 1, SaveFrame : 1, SaveTexture : 1, - SaveDepth : 1; + SaveDepth : 1, + DumpReplaceableTextures : 1, + DumpReplaceableMipmaps : 1, + DumpTexturesWithFMVActive : 1, + LoadTextureReplacements : 1, + LoadTextureReplacementsAsync : 1, + PrecacheTextureReplacements : 1; }; }; @@ -886,6 +892,7 @@ namespace EmuFolders extern wxDirName Cache; extern wxDirName Covers; extern wxDirName GameSettings; + extern wxDirName Textures; // Assumes that AppRoot and DataRoot have been initialized. void SetDefaults(); diff --git a/pcsx2/GS/GS.cpp b/pcsx2/GS/GS.cpp index 6fd59f336e..d5e4665dbc 100644 --- a/pcsx2/GS/GS.cpp +++ b/pcsx2/GS/GS.cpp @@ -27,6 +27,7 @@ #include "Renderers/Null/GSDeviceNull.h" #include "Renderers/OpenGL/GSDeviceOGL.h" #include "Renderers/HW/GSRendererNew.h" +#include "Renderers/HW/GSTextureReplacements.h" #include "GSLzma.h" #include "common/pxStreams.h" @@ -824,6 +825,17 @@ void GSUpdateConfig(const Pcsx2Config::GSOptions& new_config) // clear out the sampler cache when AF options change, since the anisotropy gets baked into them if (GSConfig.MaxAnisotropy != old_config.MaxAnisotropy) g_gs_device->ClearSamplerCache(); + + // texture dumping/replacement options + GSTextureReplacements::UpdateConfig(old_config); + + // clear the hash texture cache since we might have replacements now + // also clear it when dumping changes, since we want to dump everything being used + if (GSConfig.LoadTextureReplacements != old_config.LoadTextureReplacements || + GSConfig.DumpReplaceableTextures != old_config.DumpReplaceableTextures) + { + s_gs->PurgeTextureCache(); + } } void GSSwitchRenderer(GSRendererType new_renderer) @@ -1304,6 +1316,9 @@ void GSApp::Init() m_default_configuration["disable_shader_cache"] = "0"; m_default_configuration["dithering_ps2"] = "2"; m_default_configuration["dump"] = "0"; + m_default_configuration["DumpReplaceableTextures"] = "0"; + m_default_configuration["DumpReplaceableMipmaps"] = "0"; + m_default_configuration["DumpTexturesWithFMVActive"] = "0"; m_default_configuration["extrathreads"] = "2"; m_default_configuration["extrathreads_height"] = "4"; m_default_configuration["filter"] = std::to_string(static_cast(BiFiltering::PS2)); @@ -1314,6 +1329,8 @@ void GSApp::Init() m_default_configuration["interlace"] = "7"; m_default_configuration["conservative_framebuffer"] = "1"; m_default_configuration["linear_present"] = "1"; + m_default_configuration["LoadTextureReplacements"] = "0"; + m_default_configuration["LoadTextureReplacementsAsync"] = "1"; m_default_configuration["MaxAnisotropy"] = "0"; m_default_configuration["mipmap"] = "1"; m_default_configuration["mipmap_hw"] = std::to_string(static_cast(HWMipmapLevel::Automatic)); @@ -1341,6 +1358,7 @@ void GSApp::Init() m_default_configuration["override_GL_ARB_texture_barrier"] = "-1"; m_default_configuration["paltex"] = "0"; m_default_configuration["png_compression_level"] = std::to_string(Z_BEST_SPEED); + m_default_configuration["PrecacheTextureReplacements"] = "0"; m_default_configuration["preload_frame_with_gs_data"] = "0"; m_default_configuration["Renderer"] = std::to_string(static_cast(GSRendererType::Auto)); m_default_configuration["resx"] = "1024"; diff --git a/pcsx2/GS/Renderers/HW/GSRendererHW.cpp b/pcsx2/GS/Renderers/HW/GSRendererHW.cpp index 78a142505a..7273a52dcd 100644 --- a/pcsx2/GS/Renderers/HW/GSRendererHW.cpp +++ b/pcsx2/GS/Renderers/HW/GSRendererHW.cpp @@ -15,6 +15,7 @@ #include "PrecompiledHeader.h" #include "GSRendererHW.h" +#include "GSTextureReplacements.h" #include "GS/GSGL.h" #include "Host.h" @@ -74,6 +75,7 @@ GSRendererHW::GSRendererHW() } m_dump_root = root_hw; + GSTextureReplacements::Initialize(m_tc); } void GSRendererHW::SetScaling() @@ -189,6 +191,7 @@ GSRendererHW::~GSRendererHW() void GSRendererHW::Destroy() { m_tc->RemoveAll(); + GSTextureReplacements::Shutdown(); GSRenderer::Destroy(); } @@ -260,6 +263,8 @@ void GSRendererHW::SetGameCRC(u32 crc, int options) break; } } + + GSTextureReplacements::GameChanged(); } bool GSRendererHW::CanUpscale() @@ -306,6 +311,9 @@ void GSRendererHW::VSync(u32 field, bool registers_written) m_reset = false; } + if (GSConfig.LoadTextureReplacements) + GSTextureReplacements::ProcessAsyncLoadedTextures(); + //Check if the frame buffer width or display width has changed SetScaling(); diff --git a/pcsx2/GS/Renderers/HW/GSTextureCache.cpp b/pcsx2/GS/Renderers/HW/GSTextureCache.cpp index 93a4139742..297bd06c11 100644 --- a/pcsx2/GS/Renderers/HW/GSTextureCache.cpp +++ b/pcsx2/GS/Renderers/HW/GSTextureCache.cpp @@ -15,6 +15,7 @@ #include "PrecompiledHeader.h" #include "GSTextureCache.h" +#include "GSTextureReplacements.h" #include "GSRendererHW.h" #include "GS/GSGL.h" #include "GS/GSIntrin.h" @@ -65,6 +66,8 @@ GSTextureCache::GSTextureCache(GSRenderer* r) GSTextureCache::~GSTextureCache() { + GSTextureReplacements::Shutdown(); + RemoveAll(); m_surface_offset_cache.clear(); @@ -1452,50 +1455,18 @@ GSTextureCache::Source* GSTextureCache::CreateSource(const GIFRegTEX0& TEX0, con } else { + // maintain the clut even when paltex is on for the dump/replacement texture lookup + bool paltex = (GSConfig.GPUPaletteConversion && psm.pal > 0); + const u32* clut = (psm.pal > 0) ? static_cast(m_renderer->m_mem.m_clut) : nullptr; + // try the hash cache - if (CanCacheTextureSize(TEX0.TW, TEX0.TH)) + if ((src->m_from_hash_cache = LookupHashCache(TEX0, TEXA, paltex, clut, lod)) != nullptr) { - const bool paltex = (GSConfig.GPUPaletteConversion && psm.pal > 0); - const u32* clut = (!paltex && psm.pal > 0) ? static_cast(m_renderer->m_mem.m_clut) : nullptr; - const HashCacheKey key{ HashCacheKey::Create(TEX0, TEXA, m_renderer, clut, lod) }; - - auto it = m_hash_cache.find(key); - if (it == m_hash_cache.end()) - { - // hash and upload texture - src->m_texture = g_gs_device->CreateTexture(tw, th, paltex ? false : (lod != nullptr), paltex ? GSTexture::Format::UNorm8 : GSTexture::Format::Color); - PreloadTexture(TEX0, TEXA, m_renderer->m_mem, paltex, src->m_texture, 0); - - // upload mips if present - if (lod) - { - const int basemip = lod->x; - const int nmips = lod->y - lod->x + 1; - for (int mip = 1; mip < nmips; mip++) - { - const GIFRegTEX0 MIP_TEX0{m_renderer->GetTex0Layer(basemip + mip)}; - PreloadTexture(MIP_TEX0, TEXA, m_renderer->m_mem, paltex, src->m_texture, mip); - } - } - - // insert it into the hash cache - HashCacheEntry entry{ src->m_texture, 1, 0 }; - it = m_hash_cache.emplace(key, entry).first; - m_hash_cache_memory_usage += src->m_texture->GetMemUsage(); - } - else - { - // use existing texture - src->m_texture = it->second.texture; - it->second.refcount++; - } - - src->m_from_hash_cache = &it->second; - + src->m_texture = src->m_from_hash_cache->texture; if (psm.pal > 0) AttachPaletteToSource(src, psm.pal, paltex); } - else if (GSConfig.GPUPaletteConversion && psm.pal > 0) + else if (paltex) { src->m_texture = g_gs_device->CreateTexture(tw, th, false, GSTexture::Format::UNorm8); AttachPaletteToSource(src, psm.pal, true); @@ -1517,6 +1488,120 @@ GSTextureCache::Source* GSTextureCache::CreateSource(const GIFRegTEX0& TEX0, con return src; } +// This really needs a better home... +extern bool FMVstarted; + +GSTextureCache::HashCacheEntry* GSTextureCache::LookupHashCache(const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, bool& paltex, const u32* clut, const GSVector2i* lod) +{ + // don't bother hashing if we're not dumping or replacing. + const bool dump = GSConfig.DumpReplaceableTextures && (!FMVstarted || GSConfig.DumpTexturesWithFMVActive); + const bool replace = GSConfig.LoadTextureReplacements; + const bool can_cache = CanCacheTextureSize(TEX0.TW, TEX0.TH); + if (!dump && !replace && !can_cache) + return nullptr; + + // need the hash either for replacing, dumping or caching. + // if dumping/replacing is on, we compute the clut hash regardless, since replacements aren't indexed + HashCacheKey key{HashCacheKey::Create(TEX0, TEXA, m_renderer, (dump || replace || !paltex) ? clut : nullptr, lod)}; + + // handle dumping first, this is mostly isolated. + if (dump) + { + // dump base level + GSTextureReplacements::DumpTexture(key, TEX0, TEXA, m_renderer->m_mem, 0); + + // and the mips + if (lod && GSConfig.DumpReplaceableMipmaps) + { + const int basemip = lod->x; + const int nmips = lod->y - lod->x + 1; + for (int mip = 1; mip < nmips; mip++) + { + const GIFRegTEX0 MIP_TEX0{m_renderer->GetTex0Layer(basemip + mip)}; + GSTextureReplacements::DumpTexture(key, MIP_TEX0, TEXA, m_renderer->m_mem, mip); + } + } + } + + // check with the full key + auto it = m_hash_cache.find(key); + + // if this fails, and paltex is on, try indexed texture + const bool needs_second_lookup = paltex && (dump || replace); + if (needs_second_lookup && it == m_hash_cache.end()) + it = m_hash_cache.find(key.WithRemovedCLUTHash()); + + // did we find either a replacement, cached/indexed texture? + if (it != m_hash_cache.end()) + { + // super easy, cache hit. remove paltex if it's a replacement texture. + HashCacheEntry* entry = &it->second; + paltex &= (entry->texture->GetFormat() == GSTexture::Format::UNorm8); + entry->refcount++; + return entry; + } + + // cache miss. + // check for a replacement texture with the full clut key + if (replace) + { + bool replacement_texture_pending = false; + GSTexture* replacement_tex = GSTextureReplacements::LookupReplacementTexture(key, lod != nullptr, &replacement_texture_pending); + if (replacement_tex) + { + // found a replacement texture! insert it into the hash cache, and clear paltex (since it's not indexed) + const HashCacheEntry entry{replacement_tex, 1u, 0u}; + m_hash_cache_memory_usage += replacement_tex->GetMemUsage(); + paltex = false; + return &m_hash_cache.emplace(key, entry).first->second; + } + else if (replacement_texture_pending) + { + // we didn't have a texture immediately, but there is a replacement available (and being loaded). + // so clear paltex, since when it gets injected back, it's not going to be indexed + paltex = false; + } + } + + // if this texture isn't cacheable, bail out now since we don't want to waste time preloading it + if (!can_cache) + return nullptr; + + // expand/upload texture + const int tw = 1 << TEX0.TW; + const int th = 1 << TEX0.TH; + GSTexture* tex = g_gs_device->CreateTexture(tw, th, paltex ? false : (lod != nullptr), paltex ? GSTexture::Format::UNorm8 : GSTexture::Format::Color); + if (!tex) + { + // out of video memory if we hit here + return nullptr; + } + + // upload base level + PreloadTexture(TEX0, TEXA, m_renderer->m_mem, paltex, tex, 0); + + // upload mips if present + if (lod) + { + const int basemip = lod->x; + const int nmips = lod->y - lod->x + 1; + for (int mip = 1; mip < nmips; mip++) + { + const GIFRegTEX0 MIP_TEX0{m_renderer->GetTex0Layer(basemip + mip)}; + PreloadTexture(MIP_TEX0, TEXA, m_renderer->m_mem, paltex, tex, mip); + } + } + + // remove the palette hash when using paltex/indexed + if (paltex) + key.RemoveCLUTHash(); + + // insert into the cache cache, and we're done + const HashCacheEntry entry{tex, 1u, 0u}; + m_hash_cache_memory_usage += tex->GetMemUsage(); + return &m_hash_cache.emplace(key, entry).first->second; +} + GSTextureCache::Target* GSTextureCache::CreateTarget(const GIFRegTEX0& TEX0, int w, int h, int type, const bool clear) { ASSERT(type == RenderTarget || type == DepthStencil); @@ -2388,6 +2473,29 @@ GSTextureCache::SurfaceOffset GSTextureCache::ComputeSurfaceOffset(const Surface return so; } +void GSTextureCache::InjectHashCacheTexture(const HashCacheKey& key, GSTexture* tex) +{ + auto it = m_hash_cache.find(key); + if (it == m_hash_cache.end()) + { + // We must've got evicted before we finished loading. No matter, add it in there anyway; + // if it's not used again, it'll get tossed out later. + const HashCacheEntry entry{ tex, 1u, 0u }; + m_hash_cache_memory_usage += tex->GetMemUsage(); + m_hash_cache.emplace(key, entry).first->second; + return; + } + + // Reset age so we don't get thrown out too early. + it->second.age = 0; + + // Update memory usage, swap the textures, and recycle the old one for reuse. + m_hash_cache_memory_usage -= it->second.texture->GetMemUsage(); + m_hash_cache_memory_usage += tex->GetMemUsage(); + it->second.texture->Swap(tex); + g_gs_device->Recycle(tex); +} + // GSTextureCache::Palette GSTextureCache::Palette::Palette(const GSRenderer* renderer, u16 pal, bool need_gs_texture) @@ -2752,6 +2860,18 @@ GSTextureCache::HashCacheKey GSTextureCache::HashCacheKey::Create(const GIFRegTE return ret; } +GSTextureCache::HashCacheKey GSTextureCache::HashCacheKey::WithRemovedCLUTHash() const +{ + HashCacheKey ret{*this}; + ret.CLUTHash = 0; + return ret; +} + +void GSTextureCache::HashCacheKey::RemoveCLUTHash() +{ + CLUTHash = 0; +} + u64 GSTextureCache::HashCacheKeyHash::operator()(const HashCacheKey& key) const { std::size_t h = 0; diff --git a/pcsx2/GS/Renderers/HW/GSTextureCache.h b/pcsx2/GS/Renderers/HW/GSTextureCache.h index c43b9dd351..4f502b70ee 100644 --- a/pcsx2/GS/Renderers/HW/GSTextureCache.h +++ b/pcsx2/GS/Renderers/HW/GSTextureCache.h @@ -53,6 +53,9 @@ public: static HashCacheKey Create(const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, GSRenderer* renderer, const u32* clut, const GSVector2i* lod); + HashCacheKey WithRemovedCLUTHash() const; + void RemoveCLUTHash(); + __fi bool operator==(const HashCacheKey& e) const { return std::memcmp(this, &e, sizeof(*this)) == 0; } __fi bool operator!=(const HashCacheKey& e) const { return std::memcmp(this, &e, sizeof(*this)) != 0; } __fi bool operator<(const HashCacheKey& e) const { return std::memcmp(this, &e, sizeof(*this)) < 0; } @@ -291,6 +294,8 @@ protected: Source* CreateSource(const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, Target* t = NULL, bool half_right = false, int x_offset = 0, int y_offset = 0, const GSVector2i* lod = nullptr); Target* CreateTarget(const GIFRegTEX0& TEX0, int w, int h, int type, const bool clear); + HashCacheEntry* LookupHashCache(const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, bool& paltex, const u32* clut, const GSVector2i* lod); + static void PreloadTexture(const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, GSLocalMemory& mem, bool paltex, GSTexture* tex, u32 level); static HashType HashTexture(GSRenderer* renderer, const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA); @@ -335,4 +340,7 @@ public: SurfaceOffset ComputeSurfaceOffset(const GSOffset& off, const GSVector4i& r, const Target* t); SurfaceOffset ComputeSurfaceOffset(const uint32_t bp, const uint32_t bw, const uint32_t psm, const GSVector4i& r, const Target* t); SurfaceOffset ComputeSurfaceOffset(const SurfaceOffsetKey& sok); + + /// Injects a texture into the hash cache, by using GSTexture::Swap(), transitively applying to all sources. Ownership of tex is transferred. + void InjectHashCacheTexture(const HashCacheKey& key, GSTexture* tex); }; diff --git a/pcsx2/GS/Renderers/HW/GSTextureReplacementLoaders.cpp b/pcsx2/GS/Renderers/HW/GSTextureReplacementLoaders.cpp new file mode 100644 index 0000000000..3fc9625dcf --- /dev/null +++ b/pcsx2/GS/Renderers/HW/GSTextureReplacementLoaders.cpp @@ -0,0 +1,642 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "common/Align.h" +#include "common/FileSystem.h" +#include "common/StringUtil.h" +#include "common/ScopedGuard.h" + +#include "GS/Renderers/HW/GSTextureReplacements.h" + +#include +#include + +struct LoaderDefinition +{ + const char* extension; + GSTextureReplacements::ReplacementTextureLoader loader; +}; + +static bool PNGLoader(const std::string& filename, GSTextureReplacements::ReplacementTexture* tex, bool only_base_image); +static bool DDSLoader(const std::string& filename, GSTextureReplacements::ReplacementTexture* tex, bool only_base_image); + +static constexpr LoaderDefinition s_loaders[] = { + {"png", PNGLoader}, + {"dds", DDSLoader}, +}; + + +GSTextureReplacements::ReplacementTextureLoader GSTextureReplacements::GetLoader(const std::string_view& filename) +{ + const std::string_view extension(FileSystem::GetExtension(filename)); + if (extension.empty()) + return nullptr; + + for (const LoaderDefinition& defn : s_loaders) + { + if (StringUtil::Strncasecmp(extension.data(), defn.extension, extension.size()) == 0) + return defn.loader; + } + + return nullptr; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Helper routines +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +static u32 GetBlockCount(u32 extent, u32 block_size) +{ + return std::max(Common::AlignUp(extent, block_size) / block_size, 1u); +} + +static void CalcBlockMipmapSize(u32 block_size, u32 bytes_per_block, u32 base_width, u32 base_height, u32 mip, u32& width, u32& height, u32& pitch, u32& size) +{ + width = std::max(base_width >> mip, 1u); + height = std::max(base_width >> mip, 1u); + + const u32 blocks_wide = GetBlockCount(width, block_size); + const u32 blocks_high = GetBlockCount(height, block_size); + + // Pitch can't be specified with each mip level, so we have to calculate it ourselves. + pitch = blocks_wide * bytes_per_block; + size = blocks_high * pitch; +} + +static void ConvertTexture_X8B8G8R8(u32 width, u32 height, std::vector& data, u32& pitch) +{ + for (u32 row = 0; row < height; row++) + { + u8* data_ptr = data.data() + row * pitch; + + for (u32 x = 0; x < width; x++) + { + // Set alpha channel to full intensity. + data_ptr[3] = 0x80; + data_ptr += sizeof(u32); + } + } +} + +static void ConvertTexture_A8R8G8B8(u32 width, u32 height, std::vector& data, u32& pitch) +{ + for (u32 row = 0; row < height; row++) + { + u8* data_ptr = data.data() + row * pitch; + + for (u32 x = 0; x < width; x++) + { + // Byte swap ABGR -> RGBA + u32 val; + std::memcpy(&val, data_ptr, sizeof(val)); + val = ((val & 0xFF00FF00) | ((val >> 16) & 0xFF) | ((val << 16) & 0xFF0000)); + std::memcpy(data_ptr, &val, sizeof(u32)); + data_ptr += sizeof(u32); + } + } +} + +static void ConvertTexture_X8R8G8B8(u32 width, u32 height, std::vector& data, u32& pitch) +{ + for (u32 row = 0; row < height; row++) + { + u8* data_ptr = data.data() + row * pitch; + + for (u32 x = 0; x < width; x++) + { + // Byte swap XBGR -> RGBX, and set alpha to full intensity. + u32 val; + std::memcpy(&val, data_ptr, sizeof(val)); + val = ((val & 0x0000FF00) | ((val >> 16) & 0xFF) | ((val << 16) & 0xFF0000)) | 0xFF000000; + std::memcpy(data_ptr, &val, sizeof(u32)); + data_ptr += sizeof(u32); + } + } +} + +static void ConvertTexture_R8G8B8(u32 width, u32 height, std::vector& data, u32& pitch) +{ + const u32 new_pitch = width * sizeof(u32); + std::vector new_data(new_pitch * height); + + for (u32 row = 0; row < height; row++) + { + const u8* rgb_data_ptr = data.data() + row * pitch; + u8* data_ptr = new_data.data() + row * new_pitch; + + for (u32 x = 0; x < width; x++) + { + // This is BGR in memory. + u32 val; + std::memcpy(&val, rgb_data_ptr, sizeof(val)); + val = ((val & 0x0000FF00) | ((val >> 16) & 0xFF) | ((val << 16) & 0xFF0000)) | 0xFF000000; + std::memcpy(data_ptr, &val, sizeof(u32)); + data_ptr += sizeof(u32); + rgb_data_ptr += 3; + } + } + + data = std::move(new_data); + pitch = new_pitch; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// PNG Handlers +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +bool PNGLoader(const std::string& filename, GSTextureReplacements::ReplacementTexture* tex, bool only_base_image) +{ + png_structp png_ptr = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + if (!png_ptr) + return false; + + png_infop info_ptr = png_create_info_struct(png_ptr); + if (!info_ptr) + { + png_destroy_read_struct(&png_ptr, nullptr, nullptr); + return false; + } + + ScopedGuard cleanup([&png_ptr, &info_ptr]() { + png_destroy_read_struct(&png_ptr, &info_ptr, nullptr); + }); + + auto fp = FileSystem::OpenManagedCFile(filename.c_str(), "rb"); + if (!fp) + return false; + + if (setjmp(png_jmpbuf(png_ptr))) + return false; + + png_init_io(png_ptr, fp.get()); + png_read_info(png_ptr, info_ptr); + + png_uint_32 width = 0; + png_uint_32 height = 0; + int bitDepth = 0; + int colorType = -1; + if (png_get_IHDR(png_ptr, info_ptr, &width, &height, &bitDepth, &colorType, nullptr, nullptr, nullptr) != 1 || + width == 0 || height == 0) + { + return false; + } + + const u32 pitch = width * sizeof(u32); + tex->width = width; + tex->height = height; + tex->format = GSTexture::Format::Color; + tex->pitch = pitch; + tex->data.resize(pitch * height); + + const png_uint_32 row_bytes = png_get_rowbytes(png_ptr, info_ptr); + std::vector row_data(row_bytes); + + for (u32 y = 0; y < height; y++) + { + png_read_row(png_ptr, static_cast(row_data.data()), nullptr); + + const u8* row_ptr = row_data.data(); + u8* out_ptr = tex->data.data() + y * pitch; + if (colorType == PNG_COLOR_TYPE_RGB) + { + for (u32 x = 0; x < width; x++) + { + u32 pixel = static_cast(*(row_ptr)++); + pixel |= static_cast(*(row_ptr)++) << 8; + pixel |= static_cast(*(row_ptr)++) << 16; + pixel |= 0x80000000u; // make opaque + std::memcpy(out_ptr, &pixel, sizeof(pixel)); + out_ptr += sizeof(pixel); + } + } + else if (colorType == PNG_COLOR_TYPE_RGBA) + { + std::memcpy(out_ptr, row_ptr, pitch); + } + } + + return true; +} + +bool GSTextureReplacements::SavePNGImage(const std::string& filename, u32 width, u32 height, const u8* buffer, u32 pitch) +{ + const int compression = theApp.GetConfigI("png_compression_level"); + + png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + if (!png_ptr) + return false; + + png_infop info_ptr = png_create_info_struct(png_ptr); + if (info_ptr == nullptr) + { + png_destroy_write_struct(&png_ptr, nullptr); + return false; + } + + ScopedGuard cleanup([&png_ptr, &info_ptr]() { + png_destroy_write_struct(&png_ptr, &info_ptr); + }); + + if (setjmp(png_jmpbuf(png_ptr))) + return false; + + auto fp = FileSystem::OpenManagedCFile(filename.c_str(), "wb"); + if (!fp) + return false; + + png_init_io(png_ptr, fp.get()); + png_set_compression_level(png_ptr, compression); + png_set_IHDR(png_ptr, info_ptr, width, height, 8, PNG_COLOR_TYPE_RGBA, + PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_write_info(png_ptr, info_ptr); + png_set_swap(png_ptr); + + for (u32 y = 0; y < height; ++y) + { + // cast is needed here for mac builder + png_write_row(png_ptr, (png_bytep)(buffer + y * pitch)); + } + + png_write_end(png_ptr, nullptr); + return true; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// DDS Handler +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// From https://raw.githubusercontent.com/Microsoft/DirectXTex/master/DirectXTex/DDS.h +// +// This header defines constants and structures that are useful when parsing +// DDS files. DDS files were originally designed to use several structures +// and constants that are native to DirectDraw and are defined in ddraw.h, +// such as DDSURFACEDESC2 and DDSCAPS2. This file defines similar +// (compatible) constants and structures so that one can use DDS files +// without needing to include ddraw.h. +// +// THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF +// ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A +// PARTICULAR PURPOSE. +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// http://go.microsoft.com/fwlink/?LinkId=248926 + +#pragma pack(push, 1) + +static constexpr uint32_t DDS_MAGIC = 0x20534444; // "DDS " + +struct DDS_PIXELFORMAT +{ + uint32_t dwSize; + uint32_t dwFlags; + uint32_t dwFourCC; + uint32_t dwRGBBitCount; + uint32_t dwRBitMask; + uint32_t dwGBitMask; + uint32_t dwBBitMask; + uint32_t dwABitMask; +}; + +#define DDS_FOURCC 0x00000004 // DDPF_FOURCC +#define DDS_RGB 0x00000040 // DDPF_RGB +#define DDS_RGBA 0x00000041 // DDPF_RGB | DDPF_ALPHAPIXELS +#define DDS_LUMINANCE 0x00020000 // DDPF_LUMINANCE +#define DDS_LUMINANCEA 0x00020001 // DDPF_LUMINANCE | DDPF_ALPHAPIXELS +#define DDS_ALPHA 0x00000002 // DDPF_ALPHA +#define DDS_PAL8 0x00000020 // DDPF_PALETTEINDEXED8 +#define DDS_PAL8A 0x00000021 // DDPF_PALETTEINDEXED8 | DDPF_ALPHAPIXELS +#define DDS_BUMPDUDV 0x00080000 // DDPF_BUMPDUDV + +#ifndef MAKEFOURCC +#define MAKEFOURCC(ch0, ch1, ch2, ch3) \ + ((uint32_t)(uint8_t)(ch0) | ((uint32_t)(uint8_t)(ch1) << 8) | ((uint32_t)(uint8_t)(ch2) << 16) | \ + ((uint32_t)(uint8_t)(ch3) << 24)) +#endif /* defined(MAKEFOURCC) */ + +#define DDS_HEADER_FLAGS_TEXTURE \ + 0x00001007 // DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT +#define DDS_HEADER_FLAGS_MIPMAP 0x00020000 // DDSD_MIPMAPCOUNT +#define DDS_HEADER_FLAGS_VOLUME 0x00800000 // DDSD_DEPTH +#define DDS_HEADER_FLAGS_PITCH 0x00000008 // DDSD_PITCH +#define DDS_HEADER_FLAGS_LINEARSIZE 0x00080000 // DDSD_LINEARSIZE + +// Subset here matches D3D10_RESOURCE_DIMENSION and D3D11_RESOURCE_DIMENSION +enum DDS_RESOURCE_DIMENSION +{ + DDS_DIMENSION_TEXTURE1D = 2, + DDS_DIMENSION_TEXTURE2D = 3, + DDS_DIMENSION_TEXTURE3D = 4, +}; + +struct DDS_HEADER +{ + uint32_t dwSize; + uint32_t dwFlags; + uint32_t dwHeight; + uint32_t dwWidth; + uint32_t dwPitchOrLinearSize; + uint32_t dwDepth; // only if DDS_HEADER_FLAGS_VOLUME is set in dwFlags + uint32_t dwMipMapCount; + uint32_t dwReserved1[11]; + DDS_PIXELFORMAT ddspf; + uint32_t dwCaps; + uint32_t dwCaps2; + uint32_t dwCaps3; + uint32_t dwCaps4; + uint32_t dwReserved2; +}; + +struct DDS_HEADER_DXT10 +{ + uint32_t dxgiFormat; + uint32_t resourceDimension; + uint32_t miscFlag; // see DDS_RESOURCE_MISC_FLAG + uint32_t arraySize; + uint32_t miscFlags2; // see DDS_MISC_FLAGS2 +}; + +#pragma pack(pop) + +static_assert(sizeof(DDS_HEADER) == 124, "DDS Header size mismatch"); +static_assert(sizeof(DDS_HEADER_DXT10) == 20, "DDS DX10 Extended Header size mismatch"); + +constexpr DDS_PIXELFORMAT DDSPF_A8R8G8B8 = { + sizeof(DDS_PIXELFORMAT), DDS_RGBA, 0, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000}; +constexpr DDS_PIXELFORMAT DDSPF_X8R8G8B8 = { + sizeof(DDS_PIXELFORMAT), DDS_RGB, 0, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0x00000000}; +constexpr DDS_PIXELFORMAT DDSPF_A8B8G8R8 = { + sizeof(DDS_PIXELFORMAT), DDS_RGBA, 0, 32, 0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000}; +constexpr DDS_PIXELFORMAT DDSPF_X8B8G8R8 = { + sizeof(DDS_PIXELFORMAT), DDS_RGB, 0, 32, 0x000000ff, 0x0000ff00, 0x00ff0000, 0x00000000}; +constexpr DDS_PIXELFORMAT DDSPF_R8G8B8 = { + sizeof(DDS_PIXELFORMAT), DDS_RGB, 0, 24, 0x00ff0000, 0x0000ff00, 0x000000ff, 0x00000000}; + +// End of Microsoft code from DDS.h. + +static bool DDSPixelFormatMatches(const DDS_PIXELFORMAT& pf1, const DDS_PIXELFORMAT& pf2) +{ + return std::tie(pf1.dwSize, pf1.dwFlags, pf1.dwFourCC, pf1.dwRGBBitCount, pf1.dwRBitMask, + pf1.dwGBitMask, pf1.dwGBitMask, pf1.dwBBitMask, pf1.dwABitMask) == + std::tie(pf2.dwSize, pf2.dwFlags, pf2.dwFourCC, pf2.dwRGBBitCount, pf2.dwRBitMask, + pf2.dwGBitMask, pf2.dwGBitMask, pf2.dwBBitMask, pf2.dwABitMask); +} + +struct DDSLoadInfo +{ + u32 block_size = 1; + u32 bytes_per_block = 4; + u32 width = 0; + u32 height = 0; + u32 mip_count = 0; + GSTexture::Format format = GSTexture::Format::Color; + s64 base_image_offset = 0; + u32 base_image_size = 0; + u32 base_image_pitch = 0; + + std::function& data, u32& pitch)> conversion_function; +}; + +static bool ParseDDSHeader(std::FILE* fp, DDSLoadInfo* info) +{ + u32 magic; + if (std::fread(&magic, sizeof(magic), 1, fp) != 1 || magic != DDS_MAGIC) + return false; + + DDS_HEADER header; + u32 header_size = sizeof(header); + if (std::fread(&header, header_size, 1, fp) != 1 || header.dwSize < header_size) + return false; + + // Required fields. + if ((header.dwFlags & DDS_HEADER_FLAGS_TEXTURE) != DDS_HEADER_FLAGS_TEXTURE) + return false; + + // Image should be 2D. + if (header.dwFlags & DDS_HEADER_FLAGS_VOLUME) + return false; + + // Presence of width/height fields is already tested by DDS_HEADER_FLAGS_TEXTURE. + info->width = header.dwWidth; + info->height = header.dwHeight; + if (info->width == 0 || info->height == 0) + return false; + + // Check for mip levels. + if (header.dwFlags & DDS_HEADER_FLAGS_MIPMAP) + { + info->mip_count = header.dwMipMapCount; + if (header.dwMipMapCount != 0) + info->mip_count = header.dwMipMapCount; + else + info->mip_count = GSTextureReplacements::CalcMipmapLevelsForReplacement(info->width, info->height); + } + else + { + info->mip_count = 1; + } + + // Handle fourcc formats vs uncompressed formats. + const bool has_fourcc = (header.ddspf.dwFlags & DDS_FOURCC) != 0; + if (has_fourcc) + { + // Handle DX10 extension header. + u32 dxt10_format = 0; + if (header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', '1', '0')) + { + DDS_HEADER_DXT10 dxt10_header; + if (std::fread(&dxt10_header, sizeof(dxt10_header), 1, fp) != 1) + return false; + + // Can't handle array textures here. Doesn't make sense to use them, anyway. + if (dxt10_header.resourceDimension != DDS_DIMENSION_TEXTURE2D || dxt10_header.arraySize != 1) + return false; + + header_size += sizeof(dxt10_header); + dxt10_format = dxt10_header.dxgiFormat; + } + + const GSDevice::FeatureSupport features(g_gs_device->Features()); + if (header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', 'T', '1') || dxt10_format == 71) + { + info->format = GSTexture::Format::BC1; + info->block_size = 4; + info->bytes_per_block = 8; + if (!features.dxt_textures) + return false; + } + else if (header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', 'T', '2') || header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', 'T', '3') || dxt10_format == 74) + { + info->format = GSTexture::Format::BC2; + info->block_size = 4; + info->bytes_per_block = 16; + if (!features.dxt_textures) + return false; + } + else if (header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', 'T', '4') || header.ddspf.dwFourCC == MAKEFOURCC('D', 'X', 'T', '5') || dxt10_format == 77) + { + info->format = GSTexture::Format::BC3; + info->block_size = 4; + info->bytes_per_block = 16; + if (!features.dxt_textures) + return false; + } + else if (dxt10_format == 98) + { + info->format = GSTexture::Format::BC7; + info->block_size = 4; + info->bytes_per_block = 16; + if (!features.bptc_textures) + return false; + } + else + { + // Leave all remaining formats to SOIL. + return false; + } + } + else + { + if (DDSPixelFormatMatches(header.ddspf, DDSPF_A8R8G8B8)) + { + info->conversion_function = ConvertTexture_A8R8G8B8; + } + else if (DDSPixelFormatMatches(header.ddspf, DDSPF_X8R8G8B8)) + { + info->conversion_function = ConvertTexture_X8R8G8B8; + } + else if (DDSPixelFormatMatches(header.ddspf, DDSPF_X8B8G8R8)) + { + info->conversion_function = ConvertTexture_X8B8G8R8; + } + else if (DDSPixelFormatMatches(header.ddspf, DDSPF_R8G8B8)) + { + info->conversion_function = ConvertTexture_R8G8B8; + } + else if (DDSPixelFormatMatches(header.ddspf, DDSPF_A8B8G8R8)) + { + // This format is already in RGBA order, so no conversion necessary. + } + else + { + return false; + } + + // All these formats are RGBA, just with byte swapping. + info->format = GSTexture::Format::Color; + info->block_size = 1; + info->bytes_per_block = header.ddspf.dwRGBBitCount / 8; + } + + // Mip levels smaller than the block size are padded to multiples of the block size. + const u32 blocks_wide = GetBlockCount(info->width, info->block_size); + const u32 blocks_high = GetBlockCount(info->height, info->block_size); + + // Pitch can be specified in the header, otherwise we can derive it from the dimensions. For + // compressed formats, both DDS_HEADER_FLAGS_LINEARSIZE and DDS_HEADER_FLAGS_PITCH should be + // set. See https://msdn.microsoft.com/en-us/library/windows/desktop/bb943982(v=vs.85).aspx + if (header.dwFlags & DDS_HEADER_FLAGS_PITCH && header.dwFlags & DDS_HEADER_FLAGS_LINEARSIZE) + { + // Convert pitch (in bytes) to texels/row length. + if (header.dwPitchOrLinearSize < info->bytes_per_block) + { + // Likely a corrupted or invalid file. + return false; + } + + info->base_image_pitch = header.dwPitchOrLinearSize; + info->base_image_size = info->base_image_pitch * blocks_high; + } + else + { + // Assume no padding between rows of blocks. + info->base_image_pitch = blocks_wide * info->bytes_per_block; + info->base_image_size = info->base_image_pitch * blocks_high; + } + + // Check for truncated or corrupted files. + info->base_image_offset = sizeof(magic) + header_size; + if (info->base_image_offset >= FileSystem::FSize64(fp)) + return false; + + return true; +} + +static bool ReadDDSMipLevel(std::FILE* fp, const std::string& filename, u32 mip_level, const DDSLoadInfo& info, u32 width, u32 height, std::vector& data, u32& pitch, u32 size) +{ + // D3D11 cannot handle block compressed textures where the first mip level is + // not a multiple of the block size. + if (mip_level == 0 && info.block_size > 1 && + ((width % info.block_size) != 0 || (height % info.block_size) != 0)) + { + Console.Error( + "Invalid dimensions for DDS texture %s. For compressed textures of this format, " + "the width/height of the first mip level must be a multiple of %u.", + filename.c_str(), info.block_size); + return false; + } + + data.resize(size); + if (std::fread(data.data(), size, 1, fp) != 1) + return false; + + // Apply conversion function for uncompressed textures. + if (info.conversion_function) + info.conversion_function(width, height, data, pitch); + + return true; +} + +bool DDSLoader(const std::string& filename, GSTextureReplacements::ReplacementTexture* tex, bool only_base_image) +{ + auto fp = FileSystem::OpenManagedCFile(filename.c_str(), "rb"); + if (!fp) + return false; + + DDSLoadInfo info; + if (!ParseDDSHeader(fp.get(), &info)) + return false; + + // always load the base image + if (FileSystem::FSeek64(fp.get(), info.base_image_offset, SEEK_SET) != 0) + return false; + + tex->format = info.format; + tex->width = info.width; + tex->height = info.height; + tex->pitch = info.base_image_pitch; + if (!ReadDDSMipLevel(fp.get(), filename, 0, info, tex->width, tex->height, tex->data, tex->pitch, info.base_image_size)) + return false; + + // Read in any remaining mip levels in the file. + if (!only_base_image) + { + for (u32 level = 1; level <= info.mip_count; level++) + { + GSTextureReplacements::ReplacementTexture::MipData md; + u32 mip_width, mip_height, mip_size; + CalcBlockMipmapSize(info.block_size, info.bytes_per_block, info.width, info.height, level, mip_width, mip_height, md.pitch, mip_size); + if (!ReadDDSMipLevel(fp.get(), filename, level, info, mip_width, mip_height, md.data, md.pitch, mip_size)) + break; + + tex->mips.push_back(std::move(md)); + } + } + + return true; +} diff --git a/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp b/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp new file mode 100644 index 0000000000..abe5406efc --- /dev/null +++ b/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp @@ -0,0 +1,670 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "common/HashCombine.h" +#include "common/FileSystem.h" +#include "common/Path.h" +#include "common/StringUtil.h" +#include "common/ScopedGuard.h" + +#include "Config.h" +#include "GS/GSLocalMemory.h" +#include "GS/Renderers/HW/GSTextureReplacements.h" + +#ifndef PCSX2_CORE +#include "gui/AppCoreThread.h" +#else +#include "VMManager.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// this is a #define instead of a variable to avoid warnings from non-literal format strings +#define TEXTURE_FILENAME_FORMAT_STRING "%" PRIx64 "-%08x" +#define TEXTURE_FILENAME_CLUT_FORMAT_STRING "%" PRIx64 "-%" PRIx64 "-%08x" +#define TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME "replacements" +#define TEXTURE_DUMP_SUBDIRECTORY_NAME "dumps" + +namespace +{ + struct TextureName // 24 bytes + { + u64 TEX0Hash; + u64 CLUTHash; + + union + { + struct + { + u32 TEX0_PSM : 6; + u32 TEX0_TW : 4; + u32 TEX0_TH : 4; + u32 TEX0_TCC : 1; + u32 TEXA_TA0 : 8; + u32 TEXA_AEM : 1; + u32 TEXA_TA1 : 8; + }; + u32 bits; + }; + u32 miplevel; + + __fi u32 Width() const { return (1u << TEX0_TW); } + __fi u32 Height() const { return (1u << TEX0_TH); } + __fi bool HasPalette() const { return (GSLocalMemory::m_psm[TEX0_PSM].pal > 0); } + + __fi GSVector2 ReplacementScale(const GSTextureReplacements::ReplacementTexture& rtex) const + { + return ReplacementScale(rtex.width, rtex.height); + } + + __fi GSVector2 ReplacementScale(u32 rwidth, u32 rheight) const + { + return GSVector2(static_cast(rwidth) / static_cast(Width()), static_cast(rheight) / static_cast(Height())); + } + + __fi bool operator==(const TextureName& rhs) const { return std::tie(TEX0Hash, CLUTHash, bits) == std::tie(rhs.TEX0Hash, rhs.CLUTHash, rhs.bits); } + __fi bool operator!=(const TextureName& rhs) const { return std::tie(TEX0Hash, CLUTHash, bits) != std::tie(rhs.TEX0Hash, rhs.CLUTHash, rhs.bits); } + __fi bool operator<(const TextureName& rhs) const { return std::tie(TEX0Hash, CLUTHash, bits) < std::tie(rhs.TEX0Hash, rhs.CLUTHash, rhs.bits); } + }; + static_assert(sizeof(TextureName) == 24, "ReplacementTextureName is expected size"); +} // namespace + +namespace std +{ + template <> + struct hash + { + std::size_t operator()(const TextureName& val) const + { + std::size_t h = 0; + HashCombine(h, val.TEX0Hash, val.CLUTHash, val.bits, val.miplevel); + return h; + } + }; +} // namespace std + +namespace GSTextureReplacements +{ + static TextureName CreateTextureName(const GSTextureCache::HashCacheKey& hash, u32 miplevel); + static GSTextureCache::HashCacheKey HashCacheKeyFromTextureName(const TextureName& tn); + static std::optional ParseReplacementName(const std::string& filename); + static std::string GetGameTextureDirectory(); + static std::string GetDumpFilename(const TextureName& name, u32 level); + static std::string GetGameSerial(); + static std::optional LoadReplacementTexture(const TextureName& name, const std::string& filename, bool only_base_image); + static void QueueAsyncReplacementTextureLoad(const TextureName& name, const std::string& filename, bool mipmap); + static void PrecacheReplacementTextures(); + static void ClearReplacementTextures(); + + static void StartWorkerThread(); + static void StopWorkerThread(); + static void QueueWorkerThreadItem(std::function fn); + static void WorkerThreadEntryPoint(); + static void SyncWorkerThread(); + static void CancelPendingLoadsAndDumps(); + + static std::string s_current_serial; + + /// Backreference to the texture cache so we can inject replacements. + static GSTextureCache* s_tc; + + /// Textures that have been dumped, to save stat() calls. + static std::unordered_set s_dumped_textures; + + /// Lookup map of texture names to replacements, if they exist. + static std::unordered_map s_replacement_texture_filenames; + + /// Lookup map of texture names to replacement data which has been cached. + static std::unordered_map s_replacement_texture_cache; + static std::mutex s_replacement_texture_cache_mutex; + + /// List of textures that are pending asynchronous load. + static std::unordered_set s_pending_async_load_textures; + + /// List of textures that we have asynchronously loaded and can now be injected back into the TC. + /// Second element is whether the texture should be created with mipmaps. + static std::vector> s_async_loaded_textures; + + /// Loader/dumper thread. + static std::thread s_worker_thread; + static std::mutex s_worker_thread_mutex; + static std::condition_variable s_worker_thread_cv; + static std::queue> s_worker_thread_queue; + static bool s_worker_thread_running = false; +}; // namespace GSTextureReplacements + +TextureName GSTextureReplacements::CreateTextureName(const GSTextureCache::HashCacheKey& hash, u32 miplevel) +{ + TextureName name; + name.bits = 0; + name.TEX0_PSM = hash.TEX0.PSM; + name.TEX0_TW = hash.TEX0.TW; + name.TEX0_TH = hash.TEX0.TH; + name.TEX0_TCC = hash.TEX0.TCC; + name.TEXA_TA0 = hash.TEXA.TA0; + name.TEXA_AEM = hash.TEXA.AEM; + name.TEXA_TA1 = hash.TEXA.TA1; + name.TEX0Hash = hash.TEX0Hash; + name.CLUTHash = name.HasPalette() ? hash.CLUTHash : 0; + name.miplevel = miplevel; + return name; +} + +GSTextureCache::HashCacheKey GSTextureReplacements::HashCacheKeyFromTextureName(const TextureName& tn) +{ + GSTextureCache::HashCacheKey key = {}; + key.TEX0.PSM = tn.TEX0_PSM; + key.TEX0.TW = tn.TEX0_TW; + key.TEX0.TH = tn.TEX0_TH; + key.TEX0.TCC = tn.TEX0_TCC; + key.TEXA.TA0 = tn.TEXA_TA0; + key.TEXA.AEM = tn.TEXA_AEM; + key.TEXA.TA1 = tn.TEXA_TA1; + key.TEX0Hash = tn.TEX0Hash; + key.CLUTHash = tn.HasPalette() ? tn.CLUTHash : 0; + return key; +} + +std::optional GSTextureReplacements::ParseReplacementName(const std::string& filename) +{ + TextureName ret; + ret.miplevel = 0; + + // TODO(Stenzek): Make this better. + char extension_dot; + if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_CLUT_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.CLUTHash, &ret.bits, &extension_dot) != 4 || extension_dot != '.') + { + if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.bits, &extension_dot) != 3 || extension_dot != '.') + return std::nullopt; + } + + return ret; +} + +std::string GSTextureReplacements::GetGameTextureDirectory() +{ + return Path::CombineStdString(EmuFolders::Textures, s_current_serial); +} + +std::string GSTextureReplacements::GetDumpFilename(const TextureName& name, u32 level) +{ + std::string ret; + if (s_current_serial.empty()) + return ret; + + const std::string game_dir(GetGameTextureDirectory()); + if (!FileSystem::DirectoryExists(game_dir.c_str())) + { + // create both dumps and replacements + if (!FileSystem::CreateDirectoryPath(game_dir.c_str(), false) || + !FileSystem::EnsureDirectoryExists(Path::CombineStdString(game_dir, "dumps").c_str(), false) || + !FileSystem::EnsureDirectoryExists(Path::CombineStdString(game_dir, "replacements").c_str(), false)) + { + // if it fails to create, we're not going to be able to use it anyway + return ret; + } + } + + const std::string game_subdir(Path::CombineStdString(game_dir, TEXTURE_DUMP_SUBDIRECTORY_NAME)); + + if (name.HasPalette()) + { + const std::string filename( + (level > 0) ? + StringUtil::StdStringFromFormat(TEXTURE_FILENAME_CLUT_FORMAT_STRING "-mip%u.png", name.TEX0Hash, name.CLUTHash, name.bits, level) : + StringUtil::StdStringFromFormat(TEXTURE_FILENAME_CLUT_FORMAT_STRING ".png", name.TEX0Hash, name.CLUTHash, name.bits)); + ret = Path::CombineStdString(game_subdir, filename); + } + else + { + const std::string filename( + (level > 0) ? + StringUtil::StdStringFromFormat(TEXTURE_FILENAME_FORMAT_STRING "-mip%u.png", name.TEX0Hash, name.bits, level) : + StringUtil::StdStringFromFormat(TEXTURE_FILENAME_FORMAT_STRING ".png", name.TEX0Hash, name.bits)); + ret = Path::CombineStdString(game_subdir, filename); + } + + return ret; +} + +std::string GSTextureReplacements::GetGameSerial() +{ +#ifndef PCSX2_CORE + return StringUtil::wxStringToUTF8String(GameInfo::gameSerial); +#else + return VMManager::GetGameSerial(); +#endif +} + +void GSTextureReplacements::Initialize(GSTextureCache* tc) +{ + s_tc = tc; + s_current_serial = GetGameSerial(); + + if (GSConfig.DumpReplaceableTextures || GSConfig.LoadTextureReplacements) + StartWorkerThread(); + + ReloadReplacementMap(); +} + +void GSTextureReplacements::GameChanged() +{ + if (!s_tc) + return; + + std::string new_serial(GetGameSerial()); + if (s_current_serial == new_serial) + return; + + s_current_serial = std::move(new_serial); + ReloadReplacementMap(); + ClearDumpedTextureList(); +} + +void GSTextureReplacements::ReloadReplacementMap() +{ + SyncWorkerThread(); + + // clear out the caches + { + std::unique_lock lock(s_replacement_texture_cache_mutex); + s_replacement_texture_cache.clear(); + s_replacement_texture_filenames.clear(); + s_pending_async_load_textures.clear(); + s_async_loaded_textures.clear(); + } + + // can't replace bios textures. + if (s_current_serial.empty() || !GSConfig.LoadTextureReplacements) + return; + + const std::string replacement_dir(Path::CombineStdString(GetGameTextureDirectory(), TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME)); + + FileSystem::FindResultsArray files; + if (!FileSystem::FindFiles(replacement_dir.c_str(), "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES | FILESYSTEM_FIND_RECURSIVE, &files)) + return; + + std::string filename; + for (FILESYSTEM_FIND_DATA& fd : files) + { + // file format we can handle? + filename = FileSystem::GetFileNameFromPath(fd.FileName); + if (!GetLoader(filename)) + continue; + + // parse the name if it's valid + std::optional name = ParseReplacementName(filename); + if (!name.has_value()) + continue; + + DevCon.WriteLn("Found %ux%u replacement '%*s'", name->Width(), name->Height(), static_cast(filename.size()), filename.data()); + s_replacement_texture_filenames.emplace(std::move(name.value()), std::move(fd.FileName)); + } + + if (GSConfig.PrecacheTextureReplacements) + PrecacheReplacementTextures(); +} + +void GSTextureReplacements::UpdateConfig(Pcsx2Config::GSOptions& old_config) +{ + // get rid of worker thread if it's no longer needed + if (s_worker_thread_running && !GSConfig.DumpReplaceableTextures && !GSConfig.LoadTextureReplacements) + StopWorkerThread(); + if (!s_worker_thread_running && (GSConfig.DumpReplaceableTextures || GSConfig.LoadTextureReplacements)) + StartWorkerThread(); + + if ((!GSConfig.DumpReplaceableTextures && old_config.DumpReplaceableTextures) || + (!GSConfig.LoadTextureReplacements && old_config.LoadTextureReplacements)) + { + CancelPendingLoadsAndDumps(); + } + + if (GSConfig.LoadTextureReplacements && !old_config.LoadTextureReplacements) + ReloadReplacementMap(); + else if (!GSConfig.LoadTextureReplacements && old_config.LoadTextureReplacements) + ClearReplacementTextures(); + + if (!GSConfig.DumpReplaceableTextures && old_config.DumpReplaceableTextures) + ClearDumpedTextureList(); + + if (GSConfig.LoadTextureReplacements && GSConfig.PrecacheTextureReplacements && !old_config.PrecacheTextureReplacements) + PrecacheReplacementTextures(); +} + +void GSTextureReplacements::Shutdown() +{ + StopWorkerThread(); + + std::string().swap(s_current_serial); + ClearReplacementTextures(); + ClearDumpedTextureList(); + s_tc = nullptr; +} + +u32 GSTextureReplacements::CalcMipmapLevelsForReplacement(u32 width, u32 height) +{ + return static_cast(std::log2(std::max(width, height))) + 1u; +} + +GSTexture* GSTextureReplacements::LookupReplacementTexture(const GSTextureCache::HashCacheKey& hash, bool mipmap, bool* pending) +{ + const TextureName name(CreateTextureName(hash, 0)); + *pending = false; + + // replacement for this name exists? + auto fnit = s_replacement_texture_filenames.find(name); + if (fnit == s_replacement_texture_filenames.end()) + return nullptr; + + // try the full cache first, to avoid reloading from disk + { + std::unique_lock lock(s_replacement_texture_cache_mutex); + auto it = s_replacement_texture_cache.find(name); + if (it != s_replacement_texture_cache.end()) + { + // replacement is cached, can immediately upload to host GPU + return CreateReplacementTexture(it->second, name.ReplacementScale(it->second), mipmap); + } + } + + // load asynchronously? + if (GSConfig.LoadTextureReplacementsAsync) + { + // replacement will be injected into the TC later on + std::unique_lock lock(s_replacement_texture_cache_mutex); + QueueAsyncReplacementTextureLoad(name, fnit->second, mipmap); + + *pending = true; + return nullptr; + } + else + { + // synchronous load + std::optional replacement(LoadReplacementTexture(name, fnit->second, !mipmap)); + if (!replacement.has_value()) + return nullptr; + + // insert into cache + std::unique_lock lock(s_replacement_texture_cache_mutex); + const ReplacementTexture& rtex = s_replacement_texture_cache.emplace(name, std::move(replacement.value())).first->second; + + // and upload to gpu + return CreateReplacementTexture(rtex, name.ReplacementScale(rtex), mipmap); + } +} + +std::optional GSTextureReplacements::LoadReplacementTexture(const TextureName& name, const std::string& filename, bool only_base_image) +{ + ReplacementTextureLoader loader = GetLoader(filename); + if (!loader) + return std::nullopt; + + ReplacementTexture rtex; + if (!loader(filename.c_str(), &rtex, only_base_image)) + return std::nullopt; + + return rtex; +} + +void GSTextureReplacements::QueueAsyncReplacementTextureLoad(const TextureName& name, const std::string& filename, bool mipmap) +{ + // check the pending list, so we don't queue it up multiple times + if (s_pending_async_load_textures.find(name) != s_pending_async_load_textures.end()) + return; + + s_pending_async_load_textures.insert(name); + QueueWorkerThreadItem([name, filename, mipmap]() { + // actually load the file, this is what will take the time + std::optional replacement(LoadReplacementTexture(name, filename, !mipmap)); + + // check the pending set, there's a race here if we disable replacements while loading otherwise + std::unique_lock lock(s_replacement_texture_cache_mutex); + if (s_pending_async_load_textures.find(name) == s_pending_async_load_textures.end()) + return; + + // insert into the cache and queue for later injection + if (replacement.has_value()) + { + s_replacement_texture_cache.emplace(name, std::move(replacement.value())); + s_async_loaded_textures.emplace_back(name, mipmap); + } + else + { + // loading failed, so clear it from the pending list + s_pending_async_load_textures.erase(name); + } + }); +} + +void GSTextureReplacements::PrecacheReplacementTextures() +{ + std::unique_lock lock(s_replacement_texture_cache_mutex); + + // predict whether the requests will come with mipmaps + // TODO: This will be wrong for hw mipmap games like Jak. + const bool mipmap = GSConfig.HWMipmap >= HWMipmapLevel::Basic || + GSConfig.UserHacks_TriFilter == TriFiltering::Forced; + + // pretty simple, just go through the filenames and if any aren't cached, cache them + for (const auto& it : s_replacement_texture_filenames) + { + if (s_replacement_texture_cache.find(it.first) != s_replacement_texture_cache.end()) + continue; + + // precaching always goes async.. for now + QueueAsyncReplacementTextureLoad(it.first, it.second, mipmap); + } +} + +void GSTextureReplacements::ClearReplacementTextures() +{ + s_replacement_texture_filenames.clear(); + + std::unique_lock lock(s_replacement_texture_cache_mutex); + s_replacement_texture_cache.clear(); + s_pending_async_load_textures.clear(); + s_async_loaded_textures.clear(); +} + +GSTexture* GSTextureReplacements::CreateReplacementTexture(const ReplacementTexture& rtex, const GSVector2& scale, bool mipmap) +{ + GSTexture* tex = g_gs_device->CreateTexture(rtex.width, rtex.height, mipmap, rtex.format); + if (!tex) + return nullptr; + + // upload base level + tex->Update(GSVector4i(0, 0, rtex.width, rtex.height), rtex.data.data(), rtex.pitch); + + // and the mips if they're present in the replacement texture + if (!rtex.mips.empty()) + { + for (u32 i = 0; i < static_cast(rtex.mips.size()); i++) + { + const u32 mip = i + 1; + const u32 mipw = std::max(rtex.width >> mip, 1u); + const u32 miph = std::max(rtex.height >> mip, 1u); + tex->Update(GSVector4i(0, 0, mipw, miph), rtex.mips[i].data.data(), rtex.mips[i].pitch, mip); + } + } + + tex->SetScale(scale); + return tex; +} + +void GSTextureReplacements::ProcessAsyncLoadedTextures() +{ + // this holds the lock while doing the upload, but it should be reasonably quick + std::unique_lock lock(s_replacement_texture_cache_mutex); + for (const auto& [name, mipmap] : s_async_loaded_textures) + { + // no longer pending! + s_pending_async_load_textures.erase(name); + + // we should be in the cache now, lock and loaded + auto it = s_replacement_texture_cache.find(name); + if (it == s_replacement_texture_cache.end()) + continue; + + // upload and inject into TC + GSTexture* tex = CreateReplacementTexture(it->second, name.ReplacementScale(it->second), mipmap); + if (tex) + s_tc->InjectHashCacheTexture(HashCacheKeyFromTextureName(name), tex); + } + s_async_loaded_textures.clear(); +} + +void GSTextureReplacements::DumpTexture(const GSTextureCache::HashCacheKey& hash, const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, GSLocalMemory& mem, u32 level) +{ + // check if it's been dumped already + const TextureName name(CreateTextureName(hash, level)); + if (s_dumped_textures.find(name) != s_dumped_textures.end()) + return; + + s_dumped_textures.insert(name); + + // already exists on disk? + std::string filename(GetDumpFilename(name, level)); + if (filename.empty() || FileSystem::FileExists(filename.c_str())) + return; + + const std::string_view title(FileSystem::GetFileTitleFromPath(filename)); + DevCon.WriteLn("Dumping %ux%u texture '%*s'.", name.Width(), name.Height(), static_cast(title.size()), title.data()); + + // compute width/height + const GSLocalMemory::psm_t& psm = GSLocalMemory::m_psm[TEX0.PSM]; + const GSVector2i& bs = psm.bs; + const int tw = 1 << TEX0.TW; + const int th = 1 << TEX0.TH; + const GSVector4i rect(0, 0, tw, th); + const GSVector4i block_rect(rect.ralign(bs)); + const int read_width = std::max(tw, psm.bs.x); + const int read_height = std::max(th, psm.bs.y); + const u32 pitch = static_cast(read_width) * sizeof(u32); + + // use per-texture buffer so we can compress the texture asynchronously and not block the GS thread + std::vector buffer(pitch * static_cast(read_height)); + (mem.*psm.rtx)(mem.GetOffset(TEX0.TBP0, TEX0.TBW, TEX0.PSM), block_rect, buffer.data(), pitch, TEXA); + + // okay, now we can actually dump it + QueueWorkerThreadItem([filename = std::move(filename), tw, th, pitch, buffer = std::move(buffer)]() { + if (!SavePNGImage(filename.c_str(), tw, th, buffer.data(), pitch)) + Console.Error("Failed to dump texture to '%s'.", filename.c_str()); + }); +} + +void GSTextureReplacements::ClearDumpedTextureList() +{ + s_dumped_textures.clear(); +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Worker Thread +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +void GSTextureReplacements::StartWorkerThread() +{ + std::unique_lock lock(s_worker_thread_mutex); + + if (s_worker_thread.joinable()) + return; + + s_worker_thread_running = true; + s_worker_thread = std::thread(WorkerThreadEntryPoint); +} + +void GSTextureReplacements::StopWorkerThread() +{ + { + std::unique_lock lock(s_worker_thread_mutex); + if (!s_worker_thread.joinable()) + return; + + s_worker_thread_running = false; + s_worker_thread_cv.notify_one(); + } + + s_worker_thread.join(); + + // clear out workery-things too + CancelPendingLoadsAndDumps(); +} + +void GSTextureReplacements::QueueWorkerThreadItem(std::function fn) +{ + pxAssert(s_worker_thread.joinable()); + + std::unique_lock lock(s_worker_thread_mutex); + s_worker_thread_queue.push(std::move(fn)); + s_worker_thread_cv.notify_one(); +} + +void GSTextureReplacements::WorkerThreadEntryPoint() +{ + std::unique_lock lock(s_worker_thread_mutex); + while (s_worker_thread_running) + { + if (s_worker_thread_queue.empty()) + { + s_worker_thread_cv.wait(lock); + continue; + } + + std::function fn = std::move(s_worker_thread_queue.front()); + s_worker_thread_queue.pop(); + lock.unlock(); + fn(); + lock.lock(); + } +} + +void GSTextureReplacements::SyncWorkerThread() +{ + std::unique_lock lock(s_worker_thread_mutex); + if (!s_worker_thread.joinable()) + return; + + // not the most efficient by far, but it only gets called on config changes, so whatever + for (;;) + { + if (s_worker_thread_queue.empty()) + break; + + lock.unlock(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + lock.lock(); + } +} + +void GSTextureReplacements::CancelPendingLoadsAndDumps() +{ + std::unique_lock lock(s_worker_thread_mutex); + if (!s_worker_thread.joinable()) + return; + + while (!s_worker_thread_queue.empty()) + s_worker_thread_queue.pop(); + s_async_loaded_textures.clear(); + s_pending_async_load_textures.clear(); +} diff --git a/pcsx2/GS/Renderers/HW/GSTextureReplacements.h b/pcsx2/GS/Renderers/HW/GSTextureReplacements.h new file mode 100644 index 0000000000..4759336976 --- /dev/null +++ b/pcsx2/GS/Renderers/HW/GSTextureReplacements.h @@ -0,0 +1,60 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once + +#include "GS/Renderers/HW/GSTextureCache.h" + +namespace GSTextureReplacements +{ + struct ReplacementTexture + { + u32 width; + u32 height; + GSTexture::Format format; + + u32 pitch; + std::vector data; + + struct MipData + { + u32 pitch; + std::vector data; + }; + std::vector mips; + }; + + void Initialize(GSTextureCache* tc); + void GameChanged(); + void ReloadReplacementMap(); + void UpdateConfig(Pcsx2Config::GSOptions& old_config); + void Shutdown(); + + u32 CalcMipmapLevelsForReplacement(u32 width, u32 height); + + GSTexture* LookupReplacementTexture(const GSTextureCache::HashCacheKey& hash, bool mipmap, bool* pending); + GSTexture* CreateReplacementTexture(const ReplacementTexture& rtex, const GSVector2& scale, bool mipmap); + void ProcessAsyncLoadedTextures(); + + void DumpTexture(const GSTextureCache::HashCacheKey& hash, const GIFRegTEX0& TEX0, const GIFRegTEXA& TEXA, GSLocalMemory& mem, u32 level); + void ClearDumpedTextureList(); + + /// Loader will take a filename and interpret the format (e.g. DDS, PNG, etc). + using ReplacementTextureLoader = bool (*)(const std::string& filename, GSTextureReplacements::ReplacementTexture* tex, bool only_base_image); + ReplacementTextureLoader GetLoader(const std::string_view& filename); + + /// Saves an image buffer to a PNG file (for dumping). + bool SavePNGImage(const std::string& filename, u32 width, u32 height, const u8* buffer, u32 pitch); +} // namespace GSTextureReplacements diff --git a/pcsx2/GS/Window/GSwxDialog.cpp b/pcsx2/GS/Window/GSwxDialog.cpp index cc329b3e45..ebf17bf3a5 100644 --- a/pcsx2/GS/Window/GSwxDialog.cpp +++ b/pcsx2/GS/Window/GSwxDialog.cpp @@ -574,6 +574,18 @@ DebugTab::DebugTab(wxWindow* parent) tab_box->Add(ogl_box.outer, wxSizerFlags().Expand()); + PaddedBoxSizer tex_box(wxVERTICAL, this, "Texture Replacements"); + auto* tex_grid = new wxFlexGridSizer(2, space, space); + m_ui.addCheckBox(tex_grid, "Dump Textures", "DumpReplaceableTextures", -1); + m_ui.addCheckBox(tex_grid, "Dump Mipmaps", "DumpReplaceableMipmaps", -1); + m_ui.addCheckBox(tex_grid, "Dump FMV Textures", "DumpTexturesWithFMVActive", -1); + m_ui.addCheckBox(tex_grid, "Async Texture Loading", "LoadTextureReplacementsAsync", -1); + m_ui.addCheckBox(tex_grid, "Load Textures", "LoadTextureReplacements", -1); + m_ui.addCheckBox(tex_grid, "Precache Textures", "PrecacheTextureReplacements", -1); + tex_box->Add(tex_grid); + + tab_box->Add(tex_box.outer, wxSizerFlags().Expand()); + SetSizerAndFit(tab_box.outer); } diff --git a/pcsx2/PathDefs.h b/pcsx2/PathDefs.h index dbe3a20a8a..00d8ad09e0 100644 --- a/pcsx2/PathDefs.h +++ b/pcsx2/PathDefs.h @@ -36,6 +36,7 @@ enum FoldersEnum_t FolderId_Cheats, FolderId_CheatsWS, FolderId_Cache, + FolderId_Textures, FolderId_COUNT }; diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index 418abe9c99..8431bf760a 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -48,6 +48,7 @@ namespace EmuFolders wxDirName Cache; wxDirName Covers; wxDirName GameSettings; + wxDirName Textures; } // namespace EmuFolders void TraceLogFilters::LoadSave(SettingsWrapper& wrap) @@ -311,6 +312,13 @@ Pcsx2Config::GSOptions::GSOptions() UserHacks_MergePPSprite = false; UserHacks_WildHack = false; + DumpReplaceableTextures = false; + DumpReplaceableMipmaps = false; + DumpTexturesWithFMVActive = false; + LoadTextureReplacements = false; + LoadTextureReplacementsAsync = true; + PrecacheTextureReplacements = false; + ShaderFX_Conf = "shaders/GS_FX_Settings.ini"; ShaderFX_GLSL = "shaders/GS.fx"; } @@ -513,6 +521,12 @@ void Pcsx2Config::GSOptions::ReloadIniSettings() GSSettingBoolEx(SaveFrame, "savef"); GSSettingBoolEx(SaveTexture, "savet"); GSSettingBoolEx(SaveDepth, "savez"); + GSSettingBoolEx(DumpReplaceableTextures, "DumpReplaceableTextures"); + GSSettingBoolEx(DumpReplaceableMipmaps, "DumpReplaceableMipmaps"); + GSSettingBoolEx(DumpTexturesWithFMVActive, "DumpTexturesWithFMVActive"); + GSSettingBoolEx(LoadTextureReplacements, "LoadTextureReplacements"); + GSSettingBoolEx(LoadTextureReplacementsAsync, "LoadTextureReplacementsAsync"); + GSSettingBoolEx(PrecacheTextureReplacements, "PrecacheTextureReplacements"); GSSettingIntEnumEx(InterlaceMode, "interlace"); @@ -1083,6 +1097,7 @@ void EmuFolders::SetDefaults() GameSettings = DataRoot.Combine(wxDirName("gamesettings")); Cache = DataRoot.Combine(wxDirName("cache")); Resources = AppRoot.Combine(wxDirName("resources")); + Textures = AppRoot.Combine(wxDirName("textures")); } static wxDirName LoadPathFromSettings(SettingsInterface& si, const wxDirName& root, const char* name, const char* def) @@ -1106,6 +1121,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si) Covers = LoadPathFromSettings(si, DataRoot, "Covers", "covers"); GameSettings = LoadPathFromSettings(si, DataRoot, "GameSettings", "gamesettings"); Cache = LoadPathFromSettings(si, DataRoot, "Cache", "cache"); + Textures = LoadPathFromSettings(si, DataRoot, "Textures", "textures"); Console.WriteLn("BIOS Directory: %s", Bios.ToString().c_str().AsChar()); Console.WriteLn("Snapshots Directory: %s", Snapshots.ToString().c_str().AsChar()); @@ -1117,6 +1133,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si) Console.WriteLn("Covers Directory: %s", Covers.ToString().c_str().AsChar()); Console.WriteLn("Game Settings Directory: %s", GameSettings.ToString().c_str().AsChar()); Console.WriteLn("Cache Directory: %s", Cache.ToString().c_str().AsChar()); + Console.WriteLn("Textures Directory: %s", Textures.ToString().c_str().AsChar()); } void EmuFolders::Save(SettingsInterface& si) @@ -1131,6 +1148,7 @@ void EmuFolders::Save(SettingsInterface& si) si.SetStringValue("Folders", "Cheats", wxDirName::MakeAutoRelativeTo(Cheats, datarel).c_str()); si.SetStringValue("Folders", "CheatsWS", wxDirName::MakeAutoRelativeTo(CheatsWS, datarel).c_str()); si.SetStringValue("Folders", "Cache", wxDirName::MakeAutoRelativeTo(Cache, datarel).c_str()); + si.SetStringValue("Folders", "Textures", wxDirName::MakeAutoRelativeTo(Textures, datarel).c_str()); } bool EmuFolders::EnsureFoldersExist() @@ -1146,5 +1164,6 @@ bool EmuFolders::EnsureFoldersExist() result = Covers.Mkdir() && result; result = GameSettings.Mkdir() && result; result = Cache.Mkdir() && result; + result = Textures.Mkdir() && result; return result; } diff --git a/pcsx2/gui/AppConfig.cpp b/pcsx2/gui/AppConfig.cpp index db789f2587..7198da5980 100644 --- a/pcsx2/gui/AppConfig.cpp +++ b/pcsx2/gui/AppConfig.cpp @@ -114,6 +114,12 @@ namespace PathDefs static const wxDirName retval(L"cache"); return retval; } + + const wxDirName& Textures() + { + static const wxDirName retval(L"textures"); + return retval; + } }; // Specifies the root folder for the application install. @@ -263,6 +269,11 @@ namespace PathDefs return GetDocuments() + Base::Cache(); } + wxDirName GetTextures() + { + return GetDocuments() + Base::Textures(); + } + wxDirName Get(FoldersEnum_t folderidx) { switch (folderidx) @@ -287,6 +298,8 @@ namespace PathDefs return GetCheatsWS(); case FolderId_Cache: return GetCache(); + case FolderId_Textures: + return GetTextures(); case FolderId_Documents: return CustomDocumentsFolder; @@ -402,6 +415,8 @@ wxDirName& AppConfig::FolderOptions::operator[](FoldersEnum_t folderidx) return CheatsWS; case FolderId_Cache: return Cache; + case FolderId_Textures: + return Textures; case FolderId_Documents: return CustomDocumentsFolder; @@ -440,6 +455,8 @@ bool AppConfig::FolderOptions::IsDefault(FoldersEnum_t folderidx) const return UseDefaultCheatsWS; case FolderId_Cache: return UseDefaultCache; + case FolderId_Textures: + return UseDefaultTextures; case FolderId_Documents: return false; @@ -518,6 +535,13 @@ void AppConfig::FolderOptions::Set(FoldersEnum_t folderidx, const wxString& src, EmuFolders::Cache.Mkdir(); break; + case FolderId_Textures: + Cache = src; + UseDefaultCache = useDefault; + EmuFolders::Textures = GetResolvedFolder(FolderId_Textures); + EmuFolders::Textures.Mkdir(); + break; + jNO_DEFAULT } } @@ -794,6 +818,7 @@ void AppSetEmuFolders() EmuFolders::CheatsWS = GetResolvedFolder(FolderId_CheatsWS); EmuFolders::Resources = g_Conf->Folders.Resources; EmuFolders::Cache = GetResolvedFolder(FolderId_Cache); + EmuFolders::Textures = GetResolvedFolder(FolderId_Textures); // Ensure cache directory exists, since we're going to write to it (e.g. game database) EmuFolders::Cache.Mkdir(); diff --git a/pcsx2/gui/AppConfig.h b/pcsx2/gui/AppConfig.h index 27be8d7f0f..b54fb192c9 100644 --- a/pcsx2/gui/AppConfig.h +++ b/pcsx2/gui/AppConfig.h @@ -128,7 +128,8 @@ public: UseDefaultLangs:1, UseDefaultCheats:1, UseDefaultCheatsWS:1, - UseDefaultCache:1; + UseDefaultCache:1, + UseDefaultTextures:1; BITFIELD_END wxDirName @@ -141,7 +142,8 @@ public: Cheats, CheatsWS, Resources, - Cache; + Cache, + Textures; wxDirName RunIso; // last used location for Iso loading. wxDirName RunELF; // last used location for ELF loading. diff --git a/pcsx2/gui/AppMain.cpp b/pcsx2/gui/AppMain.cpp index 5ea5a43840..e3e167d1c0 100644 --- a/pcsx2/gui/AppMain.cpp +++ b/pcsx2/gui/AppMain.cpp @@ -600,6 +600,7 @@ void AppApplySettings( const AppConfig* oldconf ) g_Conf->Folders.Snapshots.Mkdir(); g_Conf->Folders.Cheats.Mkdir(); g_Conf->Folders.CheatsWS.Mkdir(); + g_Conf->Folders.Textures.Mkdir(); RelocateLogfile(); diff --git a/pcsx2/pcsx2.vcxproj b/pcsx2/pcsx2.vcxproj index 2352a68431..4fe4fb953a 100644 --- a/pcsx2/pcsx2.vcxproj +++ b/pcsx2/pcsx2.vcxproj @@ -316,6 +316,8 @@ + + @@ -760,6 +762,7 @@ + diff --git a/pcsx2/pcsx2.vcxproj.filters b/pcsx2/pcsx2.vcxproj.filters index 67b93ab306..f88df260a9 100644 --- a/pcsx2/pcsx2.vcxproj.filters +++ b/pcsx2/pcsx2.vcxproj.filters @@ -1703,6 +1703,12 @@ System\Ps2\GS\Renderers\Vulkan + + System\Ps2\GS\Renderers\Hardware + + + System\Ps2\GS\Renderers\Hardware + @@ -2828,6 +2834,9 @@ System\Ps2\GS\Renderers\Vulkan + + System\Ps2\GS\Renderers\Hardware + diff --git a/pcsx2/pcsx2core.vcxproj b/pcsx2/pcsx2core.vcxproj index cc74316647..fe145d4d00 100644 --- a/pcsx2/pcsx2core.vcxproj +++ b/pcsx2/pcsx2core.vcxproj @@ -188,6 +188,8 @@ + + @@ -487,6 +489,7 @@ + diff --git a/pcsx2/pcsx2core.vcxproj.filters b/pcsx2/pcsx2core.vcxproj.filters index a04ae4ba5f..3f71df7b38 100644 --- a/pcsx2/pcsx2core.vcxproj.filters +++ b/pcsx2/pcsx2core.vcxproj.filters @@ -1196,6 +1196,12 @@ Host + + System\Ps2\GS\Renderers\Hardware + + + System\Ps2\GS\Renderers\Hardware + @@ -1973,6 +1979,9 @@ System\Ps2\PAD + + System\Ps2\GS\Renderers\Hardware + diff --git a/tools/texture_dump_alpha_scaler.py b/tools/texture_dump_alpha_scaler.py new file mode 100755 index 0000000000..19fd76f2e6 --- /dev/null +++ b/tools/texture_dump_alpha_scaler.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 + +import sys +import glob +import os +import argparse +from PIL import Image + +# PCSX2 - PS2 Emulator for PCs +# Copyright (C) 2002-2022 PCSX2 Dev Team +# +# PCSX2 is free software: you can redistribute it and/or modify it under the terms +# of the GNU Lesser General Public License as published by the Free Software Found- +# ation, either version 3 of the License, or (at your option) any later version. +# +# PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with PCSX2. +# If not, see . + + +DESCRIPTION = """Quick script to scale alpha values commonly seen in PCSX2 texture dumps. +This script will scale textures with a maximum alpha intensity of 128 to 255, and then back +again with the unscale command, suitable for use as replacements. Not unscaling after editing +may result in broken rendering! + +Example usage: + python3 texture_dump_alpha_scaler.py scale path/to/serial/dumps + + + + python3 texture_dump_alpha_scaler.py unscale path/to/serial/replacements +""" + +# pylint: disable=bare-except, disable=missing-function-docstring + + +def get_index_path(idir): + return os.path.join(idir, "__scaled_images__.txt") + + +def scale_image(path, relpath): + try: + img = Image.open(path, "r") + except: + return False + + print("Processing '%s'" % relpath) + if img.mode != "RGBA": + print(" Skipping because it's not RGBA (%s)" % img.mode) + return False + + data = img.getdata() + max_alpha = max(map(lambda p: p[3], data)) + print(" max alpha %u" % max_alpha) + if max_alpha > 128: + print(" skipping because of large alpha value") + return False + + new_pixels = list(map(lambda p: (p[0], p[1], p[2], min(p[3] * 2 - 1, 255)), data)) + img.putdata(new_pixels) + img.save(path) + print(" scaled!") + return True + + +def unscale_image(path, relpath): + try: + img = Image.open(path, "r") + except: + return False + + print("Processing '%s'" % relpath) + if img.mode != "RGBA": + print(" Skipping because it's not RGBA (%s)" % img.mode) + return False + + data = img.getdata() + new_pixels = list(map(lambda p: (p[0], p[1], p[2], max((p[3] + 1) // 2, 0)), data)) + img.putdata(new_pixels) + img.save(path) + print(" unscaled!") + return True + + +def get_scaled_images(idir): + try: + scaled_images = set() + with open(get_index_path(idir), "r") as ifile: + for line in ifile.readlines(): + line = line.strip() + if len(line) == 0: + continue + scaled_images.add(line) + return scaled_images + except: + return set() + + +def put_scaled_images(idir, scaled_images): + if len(scaled_images) > 0: + with open(get_index_path(idir), "w") as ifile: + ifile.writelines(map(lambda s: s + "\n", scaled_images)) + elif os.path.exists(get_index_path(idir)): + os.remove(get_index_path(idir)) + + +def scale_images(idir, force): + scaled_images = get_scaled_images(idir) + + for path in glob.glob(idir + "/**", recursive=True): + relpath = os.path.relpath(path, idir) + if not path.endswith(".png"): + continue + + if relpath in scaled_images and not force: + continue + + if not scale_image(path, relpath): + continue + + scaled_images.add(relpath) + + put_scaled_images(idir, scaled_images) + + +def unscale_images(idir, force): + scaled_images = get_scaled_images(idir) + if force: + for path in glob.glob(idir + "/**", recursive=True): + relpath = os.path.relpath(path, idir) + if not path.endswith(".png"): + continue + scaled_images.add(relpath) + + for relpath in list(scaled_images): + if unscale_image(os.path.join(idir, relpath), relpath): + scaled_images.remove(relpath) + put_scaled_images(idir, scaled_images) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=DESCRIPTION, + formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument("command", type=str, + help="Command, should be scale or unscale") + parser.add_argument("directory", type=str, + help="Directory containing images, searched recursively") + parser.add_argument("--force", + help="Scale images regardless of whether it's in the index", + action="store_true", required=False) + args = parser.parse_args() + if args.command == "scale": + scale_images(args.directory, args.force) + sys.exit(0) + elif args.command == "unscale": + unscale_images(args.directory, args.force) + sys.exit(0) + else: + print("Unknown command, should be scale or unscale") + sys.exit(1)