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)