[VK] Add PNG texture dumping and replacement (texture packs)

Dump sampled guest textures to WYSIWYG PNGs and load same-resolution PNG
replacements back over them, on the Vulkan backend. Aimed at texture-pack
authoring/modding (e.g. team logos and names in APF 2K8).

Core:
- Shared hook in Common/texture_cache.h::upload_texture: FNV-1a hash of the
  guest texture bytes is the stable identity; on upload, apply a replacement
  if present, otherwise dump. Both are backend virtuals.
- VK impl (VKTextureCache.cpp): dump reads the host image back, decodes to
  RGBA8 and applies the view remap for WYSIWYG; replace decodes the PNG,
  inverts the remap, re-encodes to the host format and overwrites the image.
  Formats: RGBA8/BGRA8, BC1/2/3, R4G4B4A4, R5G6B5, R16, R16G16B16A16(+half).
- Replacement regenerates the full mip chain (plain box downsample) and writes
  every level, so edits don't ghost under trilinear filtering or vanish when
  drawn small.

Config / UI:
- video.texture_dump / texture_replace flags and texture_dump_path /
  texture_replace_path roots in system_config.h.
- Settings > GPU > Additional Settings: toggles plus folder line-edits with
  Browse buttons.
- Paths are roots; <title>/dump/ (dumps) and <title>/ (replacements,
  non-recursive) are appended, so a shared root can't form a dump->replace
  loop. Empty = <config dir>/textures/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
davidhbui 2026-06-23 10:08:15 -07:00
parent c7478d6dcc
commit 10a6809944
8 changed files with 768 additions and 0 deletions

View File

@ -500,6 +500,20 @@ namespace rsx
virtual void prepare_for_dma_transfers(commandbuffer_type&) = 0;
virtual void cleanup_after_dma_transfers(commandbuffer_type&) = 0;
// Dumps a freshly uploaded texture to a PNG on disk (texture_dump setting).
// Backends that support it (Vulkan) override this; default is a no-op so
// other backends keep building unchanged.
virtual void dump_texture_image(commandbuffer_type&, section_storage_type* /*section*/,
u16 /*width*/, u16 /*height*/, u32 /*gcm_format*/, u64 /*content_hash*/,
const texture_channel_remap_t& /*remap*/) {}
// Overwrites a freshly uploaded texture with a same-resolution PNG
// replacement if one exists on disk (texture_replace setting). Returns
// true if a replacement was applied. Default is a no-op.
virtual bool replace_texture_image(commandbuffer_type&, section_storage_type* /*section*/,
u16 /*width*/, u16 /*height*/, u32 /*gcm_format*/, u64 /*content_hash*/,
const texture_channel_remap_t& /*remap*/) { return false; }
public:
virtual void destroy() = 0;
virtual bool is_depth_texture(u32, u32) = 0;
@ -2520,6 +2534,30 @@ namespace rsx
auto uploaded = upload_image_from_cpu(cmd, tex_range, attributes.width, attributes.height, attributes.depth, tex.get_exact_mipmap_count(), attributes.pitch, attributes.gcm_format,
texture_upload_context::shader_read, subresources_layout, extended_dimension, attributes.swizzled);
if (uploaded && (g_cfg.video.texture_dump || g_cfg.video.texture_replace))
{
if (const u8* guest = vm::get_super_ptr<const u8>(attributes.address))
{
// FNV-1a hash of the guest texture bytes - stable across runs, used
// as both the dump filename and the replacement lookup key.
u64 content_hash = 0xcbf29ce484222325ull;
for (u32 i = 0; i < tex_size; ++i)
{
content_hash = (content_hash ^ guest[i]) * 0x100000001b3ull;
}
bool replaced = false;
if (g_cfg.video.texture_replace)
{
replaced = replace_texture_image(cmd, uploaded, attributes.width, attributes.height, attributes.gcm_format, content_hash, tex.decoded_remap());
}
if (!replaced && g_cfg.video.texture_dump)
{
dump_texture_image(cmd, uploaded, attributes.width, attributes.height, attributes.gcm_format, content_hash, tex.decoded_remap());
}
}
}
return{ uploaded->get_view(tex.decoded_remap()),
texture_upload_context::shader_read, format_class, scale, extended_dimension };
}

View File

@ -4,7 +4,15 @@
#include "VKCompute.h"
#include "VKAsyncScheduler.h"
#include "Emu/System.h"
#include "util/asm.hpp"
#include "Utilities/File.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>
#define STB_DXT_IMPLEMENTATION
#include <stb_dxt.h>
#include <stb_image.h>
namespace vk
{
@ -1454,6 +1462,614 @@ namespace vk
}
}
namespace
{
inline u8 expand5(u32 v) { return static_cast<u8>((v << 3) | (v >> 2)); }
inline u8 expand6(u32 v) { return static_cast<u8>((v << 2) | (v >> 4)); }
inline u32 pack_rgba(u8 r, u8 g, u8 b, u8 a)
{
// Little-endian byte order R,G,B,A - what stbi_write_png expects.
return u32(r) | (u32(g) << 8) | (u32(b) << 16) | (u32(a) << 24);
}
// Decodes the 8-byte RGB color portion of a BC1/BC2/BC3 block. opaque
// (BC2/BC3) disables BC1's 3-color+transparent mode.
void decode_bc1_colors(const u8* block, bool opaque, u32 out[16])
{
const u16 c0 = static_cast<u16>(block[0] | (block[1] << 8));
const u16 c1 = static_cast<u16>(block[2] | (block[3] << 8));
u8 r[4], g[4], b[4], a[4] = {255, 255, 255, 255};
r[0] = expand5((c0 >> 11) & 0x1f); g[0] = expand6((c0 >> 5) & 0x3f); b[0] = expand5(c0 & 0x1f);
r[1] = expand5((c1 >> 11) & 0x1f); g[1] = expand6((c1 >> 5) & 0x3f); b[1] = expand5(c1 & 0x1f);
if (c0 > c1 || opaque)
{
r[2] = u8((2 * r[0] + r[1]) / 3); g[2] = u8((2 * g[0] + g[1]) / 3); b[2] = u8((2 * b[0] + b[1]) / 3);
r[3] = u8((r[0] + 2 * r[1]) / 3); g[3] = u8((g[0] + 2 * g[1]) / 3); b[3] = u8((b[0] + 2 * b[1]) / 3);
}
else
{
r[2] = u8((r[0] + r[1]) / 2); g[2] = u8((g[0] + g[1]) / 2); b[2] = u8((b[0] + b[1]) / 2);
r[3] = g[3] = b[3] = 0; a[3] = 0;
}
const u32 idx = u32(block[4]) | (u32(block[5]) << 8) | (u32(block[6]) << 16) | (u32(block[7]) << 24);
for (u32 i = 0; i < 16; ++i)
{
const u32 s = (idx >> (i * 2)) & 3;
out[i] = pack_rgba(r[s], g[s], b[s], a[s]);
}
}
// Decodes an 8-byte BC3 interpolated-alpha block into 16 values.
void decode_bc3_alpha(const u8* block, u8 out[16])
{
u8 a[8];
a[0] = block[0]; a[1] = block[1];
if (a[0] > a[1])
{
for (u32 i = 1; i < 7; ++i) a[i + 1] = u8(((7 - i) * a[0] + i * a[1]) / 7);
}
else
{
for (u32 i = 1; i < 5; ++i) a[i + 1] = u8(((5 - i) * a[0] + i * a[1]) / 5);
a[6] = 0; a[7] = 255;
}
u64 bits = 0;
for (u32 i = 0; i < 6; ++i) bits |= u64(block[2 + i]) << (8 * i);
for (u32 i = 0; i < 16; ++i) out[i] = a[(bits >> (i * 3)) & 7];
}
void store_block(const u32 texels[16], u32 bx, u32 by, u32 width, u32 height, u8* out)
{
for (u32 y = 0; y < 4; ++y)
{
const u32 py = by * 4 + y;
if (py >= height) break;
for (u32 x = 0; x < 4; ++x)
{
const u32 px = bx * 4 + x;
if (px >= width) continue;
std::memcpy(out + (usz(py) * width + px) * 4, &texels[y * 4 + x], 4);
}
}
}
// Tightly-packed readback size for a VK format's base level.
u64 vk_readback_size(VkFormat format, u32 width, u32 height)
{
const u32 bx = (width + 3) / 4, by = (height + 3) / 4;
switch (format)
{
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK:
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK:
return u64(bx) * by * 8;
case VK_FORMAT_BC2_UNORM_BLOCK:
case VK_FORMAT_BC2_SRGB_BLOCK:
case VK_FORMAT_BC3_UNORM_BLOCK:
case VK_FORMAT_BC3_SRGB_BLOCK:
return u64(bx) * by * 16;
case VK_FORMAT_R4G4B4A4_UNORM_PACK16:
case VK_FORMAT_B4G4R4A4_UNORM_PACK16:
case VK_FORMAT_R5G6B5_UNORM_PACK16:
case VK_FORMAT_B5G6R5_UNORM_PACK16:
case VK_FORMAT_R16_UNORM:
return u64(width) * height * 2;
case VK_FORMAT_R16G16B16A16_UNORM:
case VK_FORMAT_R16G16B16A16_SFLOAT:
return u64(width) * height * 8;
default:
return u64(width) * height * 4;
}
}
// Converts a 16-bit half float to an 8-bit unorm (clamped to [0,1]).
inline u8 half_to_unorm8(u16 h)
{
const u32 sign = (h >> 15) & 1;
const u32 exp = (h >> 10) & 0x1f;
const u32 mant = h & 0x3ff;
u32 bits;
if (exp == 0)
{
if (mant == 0)
{
bits = sign << 31;
}
else
{
s32 e = -1; u32 m = mant;
do { e++; m <<= 1; } while ((m & 0x400) == 0);
bits = (sign << 31) | u32((127 - 15 - e) << 23) | ((m & 0x3ff) << 13);
}
}
else if (exp == 0x1f)
{
bits = (sign << 31) | (0xffu << 23) | (mant << 13);
}
else
{
bits = (sign << 31) | ((exp - 15 + 127) << 23) | (mant << 13);
}
float f;
std::memcpy(&f, &bits, 4);
if (f <= 0.0f) return 0;
if (f >= 1.0f) return 255;
return u8(f * 255.0f + 0.5f);
}
// Decodes a supported VK host format to tightly-packed RGBA8, or returns
// empty for unsupported formats.
std::vector<u8> decode_vk_to_rgba8(VkFormat format, const u8* src, u32 width, u32 height)
{
std::vector<u8> out(usz(width) * height * 4);
u8* dst = out.data();
const u32 bx_count = (width + 3) / 4, by_count = (height + 3) / 4;
switch (format)
{
case VK_FORMAT_R8G8B8A8_UNORM:
case VK_FORMAT_R8G8B8A8_SRGB:
std::memcpy(dst, src, out.size());
return out;
case VK_FORMAT_B8G8R8A8_UNORM:
case VK_FORMAT_B8G8R8A8_SRGB:
for (usz i = 0; i < usz(width) * height; ++i)
{
dst[i * 4 + 0] = src[i * 4 + 2];
dst[i * 4 + 1] = src[i * 4 + 1];
dst[i * 4 + 2] = src[i * 4 + 0];
dst[i * 4 + 3] = src[i * 4 + 3];
}
return out;
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK:
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK:
for (u32 by = 0; by < by_count; ++by)
for (u32 bx = 0; bx < bx_count; ++bx)
{
u32 texels[16];
decode_bc1_colors(src + (usz(by) * bx_count + bx) * 8, false, texels);
store_block(texels, bx, by, width, height, dst);
}
return out;
case VK_FORMAT_BC2_UNORM_BLOCK:
case VK_FORMAT_BC2_SRGB_BLOCK:
for (u32 by = 0; by < by_count; ++by)
for (u32 bx = 0; bx < bx_count; ++bx)
{
const u8* blk = src + (usz(by) * bx_count + bx) * 16;
u32 texels[16];
decode_bc1_colors(blk + 8, true, texels);
for (u32 i = 0; i < 16; ++i)
{
const u32 nib = (blk[i / 2] >> ((i & 1) * 4)) & 0xf;
texels[i] = (texels[i] & 0x00ffffffu) | (u32(nib * 17) << 24);
}
store_block(texels, bx, by, width, height, dst);
}
return out;
case VK_FORMAT_BC3_UNORM_BLOCK:
case VK_FORMAT_BC3_SRGB_BLOCK:
for (u32 by = 0; by < by_count; ++by)
for (u32 bx = 0; bx < bx_count; ++bx)
{
const u8* blk = src + (usz(by) * bx_count + bx) * 16;
u32 texels[16];
decode_bc1_colors(blk + 8, true, texels);
u8 alpha[16];
decode_bc3_alpha(blk, alpha);
for (u32 i = 0; i < 16; ++i)
texels[i] = (texels[i] & 0x00ffffffu) | (u32(alpha[i]) << 24);
store_block(texels, bx, by, width, height, dst);
}
return out;
case VK_FORMAT_R4G4B4A4_UNORM_PACK16:
for (usz i = 0; i < usz(width) * height; ++i)
{
const u16 v = u16(src[i * 2] | (src[i * 2 + 1] << 8));
// Vulkan PACK16: R in the high nibble, A in the low nibble.
dst[i * 4 + 0] = u8(((v >> 12) & 0xf) * 17);
dst[i * 4 + 1] = u8(((v >> 8) & 0xf) * 17);
dst[i * 4 + 2] = u8(((v >> 4) & 0xf) * 17);
dst[i * 4 + 3] = u8((v & 0xf) * 17);
}
return out;
case VK_FORMAT_R16_UNORM:
for (usz i = 0; i < usz(width) * height; ++i)
{
dst[i * 4 + 0] = src[i * 2 + 1]; // high byte of the 16-bit channel
dst[i * 4 + 1] = 0;
dst[i * 4 + 2] = 0;
dst[i * 4 + 3] = 255;
}
return out;
case VK_FORMAT_R16G16B16A16_SFLOAT:
for (usz i = 0; i < usz(width) * height; ++i)
{
const usz o = i * 8;
dst[i * 4 + 0] = half_to_unorm8(u16(src[o + 0] | (src[o + 1] << 8)));
dst[i * 4 + 1] = half_to_unorm8(u16(src[o + 2] | (src[o + 3] << 8)));
dst[i * 4 + 2] = half_to_unorm8(u16(src[o + 4] | (src[o + 5] << 8)));
dst[i * 4 + 3] = half_to_unorm8(u16(src[o + 6] | (src[o + 7] << 8)));
}
return out;
default:
return {};
}
}
// Applies the texture's view component mapping (the per-texture channel
// remap the game samples through) to decoded RGBA8, so the dump matches
// the in-game appearance instead of the raw host channel order.
void apply_component_mapping_rgba8(std::vector<u8>& rgba, const VkComponentMapping& m)
{
auto pick = [](VkComponentSwizzle s, u8 r, u8 g, u8 b, u8 a, u8 ident) -> u8
{
switch (s)
{
case VK_COMPONENT_SWIZZLE_ZERO: return 0;
case VK_COMPONENT_SWIZZLE_ONE: return 255;
case VK_COMPONENT_SWIZZLE_R: return r;
case VK_COMPONENT_SWIZZLE_G: return g;
case VK_COMPONENT_SWIZZLE_B: return b;
case VK_COMPONENT_SWIZZLE_A: return a;
default: return ident; // VK_COMPONENT_SWIZZLE_IDENTITY
}
};
for (usz i = 0; i + 4 <= rgba.size(); i += 4)
{
const u8 r = rgba[i], g = rgba[i + 1], b = rgba[i + 2], a = rgba[i + 3];
rgba[i + 0] = pick(m.r, r, g, b, a, r);
rgba[i + 1] = pick(m.g, r, g, b, a, g);
rgba[i + 2] = pick(m.b, r, g, b, a, b);
rgba[i + 3] = pick(m.a, r, g, b, a, a);
}
}
// Reverse of apply_component_mapping_rgba8: given the game's forward view
// mapping, transform canonical (PNG) RGBA into the host-logical channel
// order, so that sampling through the forward mapping reproduces the PNG.
void inverse_component_mapping_rgba8(std::vector<u8>& rgba, const VkComponentMapping& m)
{
auto resolve = [](VkComponentSwizzle s, int ident) -> int
{
switch (s)
{
case VK_COMPONENT_SWIZZLE_R: return 0;
case VK_COMPONENT_SWIZZLE_G: return 1;
case VK_COMPONENT_SWIZZLE_B: return 2;
case VK_COMPONENT_SWIZZLE_A: return 3;
case VK_COMPONENT_SWIZZLE_ZERO:
case VK_COMPONENT_SWIZZLE_ONE: return -1; // forced by the game; host value is don't-care
default: return ident; // VK_COMPONENT_SWIZZLE_IDENTITY
}
};
const int sel[4] = {resolve(m.r, 0), resolve(m.g, 1), resolve(m.b, 2), resolve(m.a, 3)};
for (usz i = 0; i + 4 <= rgba.size(); i += 4)
{
const u8 png[4] = {rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]};
u8 host[4] = {0, 0, 0, 0};
for (int o = 0; o < 4; ++o)
{
if (sel[o] >= 0) host[sel[o]] = png[o];
}
rgba[i + 0] = host[0]; rgba[i + 1] = host[1]; rgba[i + 2] = host[2]; rgba[i + 3] = host[3];
}
}
// Encodes host-logical RGBA8 into a host VK format, or returns empty for
// unsupported formats. BC uses the vendored stb_dxt compressor.
std::vector<u8> encode_rgba8_to_host(VkFormat format, const u8* rgba, u32 width, u32 height)
{
switch (format)
{
case VK_FORMAT_R8G8B8A8_UNORM:
case VK_FORMAT_R8G8B8A8_SRGB:
{
std::vector<u8> out(usz(width) * height * 4);
std::memcpy(out.data(), rgba, out.size());
return out;
}
case VK_FORMAT_B8G8R8A8_UNORM:
case VK_FORMAT_B8G8R8A8_SRGB:
{
std::vector<u8> out(usz(width) * height * 4);
for (usz i = 0; i < usz(width) * height; ++i)
{
out[i * 4 + 0] = rgba[i * 4 + 2];
out[i * 4 + 1] = rgba[i * 4 + 1];
out[i * 4 + 2] = rgba[i * 4 + 0];
out[i * 4 + 3] = rgba[i * 4 + 3];
}
return out;
}
case VK_FORMAT_R4G4B4A4_UNORM_PACK16:
{
std::vector<u8> out(usz(width) * height * 2);
for (usz i = 0; i < usz(width) * height; ++i)
{
const u16 v = u16(((rgba[i * 4 + 0] >> 4) << 12) | ((rgba[i * 4 + 1] >> 4) << 8) |
((rgba[i * 4 + 2] >> 4) << 4) | (rgba[i * 4 + 3] >> 4));
out[i * 2 + 0] = u8(v & 0xff);
out[i * 2 + 1] = u8(v >> 8);
}
return out;
}
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK:
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK:
case VK_FORMAT_BC3_UNORM_BLOCK:
case VK_FORMAT_BC3_SRGB_BLOCK:
{
const bool bc3 = (format == VK_FORMAT_BC3_UNORM_BLOCK || format == VK_FORMAT_BC3_SRGB_BLOCK);
const u32 block_bytes = bc3 ? 16 : 8;
const u32 bx = (width + 3) / 4, by = (height + 3) / 4;
std::vector<u8> out(usz(bx) * by * block_bytes);
for (u32 yb = 0; yb < by; ++yb)
{
for (u32 xb = 0; xb < bx; ++xb)
{
u8 block[64];
for (u32 y = 0; y < 4; ++y)
{
for (u32 x = 0; x < 4; ++x)
{
const u32 px = std::min<u32>(xb * 4 + x, width - 1);
const u32 py = std::min<u32>(yb * 4 + y, height - 1);
std::memcpy(&block[(y * 4 + x) * 4], rgba + (usz(py) * width + px) * 4, 4);
}
}
stb_compress_dxt_block(&out[(usz(yb) * bx + xb) * block_bytes], block, bc3 ? 1 : 0, STB_DXT_NORMAL);
}
}
return out;
}
default:
return {};
}
}
// Plain box halving of a tightly packed RGBA8 image, used to regenerate the
// mip chain for a replacement texture so every level reflects the edit (a
// base-only replacement ghosts under trilinear filtering / at small sizes).
// NOTE: deliberately a plain average (not alpha-weighted) - these textures
// commonly key transparency off a black RGB background rather than the alpha
// channel, so edge texels must average toward black, not toward logo colour.
std::vector<u8> downsample_rgba8(const std::vector<u8>& src, u32 sw, u32 sh, u32 dw, u32 dh)
{
std::vector<u8> dst(usz(dw) * dh * 4);
for (u32 y = 0; y < dh; ++y)
{
const u32 sy0 = std::min(y * 2, sh - 1);
const u32 sy1 = std::min(sy0 + 1, sh - 1);
for (u32 x = 0; x < dw; ++x)
{
const u32 sx0 = std::min(x * 2, sw - 1);
const u32 sx1 = std::min(sx0 + 1, sw - 1);
const u8* p00 = &src[(usz(sy0) * sw + sx0) * 4];
const u8* p01 = &src[(usz(sy0) * sw + sx1) * 4];
const u8* p10 = &src[(usz(sy1) * sw + sx0) * 4];
const u8* p11 = &src[(usz(sy1) * sw + sx1) * 4];
u8* d = &dst[(usz(y) * dw + x) * 4];
for (u32 c = 0; c < 4; ++c)
{
d[c] = u8((u32(p00[c]) + p01[c] + p10[c] + p11[c] + 2) / 4);
}
}
}
return dst;
}
}
void texture_cache::dump_texture_image(vk::command_buffer& cmd, cached_texture_section* section,
u16 width, u16 height, u32 gcm_format, u64 content_hash,
const rsx::texture_channel_remap_t& remap)
{
if (!section)
return;
if (!m_dumped_texture_hashes.insert(content_hash).second)
return; // Already dumped this texture this session.
vk::image* image = section->get_raw_texture();
if (!image)
return;
const VkFormat vk_format = image->format();
const u64 readback_size = vk_readback_size(vk_format, width, height);
// Configurable dump root (empty = config dir). The per-title and dump/
// subfolders are always appended: the dump/ split keeps dumps out of the
// replacement folder so a shared root can't form a dump->replace loop.
std::string root = g_cfg.video.texture_dump_path.to_string();
if (root.empty())
root = fs::get_config_dir() + "textures/";
else if (root.back() != '/' && root.back() != '\\')
root += '/';
const std::string dir = root + Emu.GetTitleID() + "/dump/";
if (!fs::create_path(dir))
return;
const std::string path = fmt::format("%s%016x_%02x.png", dir, content_hash, gcm_format);
if (fs::exists(path))
return;
// Read the uploaded image back into a host-visible buffer.
vk::buffer readback(*m_device, readback_size, m_memory_types.host_visible_coherent, 0,
VK_BUFFER_USAGE_TRANSFER_DST_BIT, 0, VMM_ALLOCATION_POOL_UNDEFINED);
VkBufferImageCopy region{};
region.bufferRowLength = 0; // tightly packed
region.bufferImageHeight = 0;
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.imageOffset = {0, 0, 0};
region.imageExtent = {width, height, 1};
image->push_layout(cmd, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
vk::copy_image_to_buffer(cmd, image, &readback, region);
image->pop_layout(cmd);
// Submit and wait so the copy completes, then restart the command buffer
// for the caller (matches the cache's flush idiom above).
cmd.end();
vk::fence submit_fence(*m_device);
vk::queue_submit_t submit_info{m_submit_queue, &submit_fence};
cmd.submit(submit_info, VK_TRUE);
vk::wait_for_fence(&submit_fence, GENERAL_WAIT_TIMEOUT);
cmd.reset();
cmd.begin();
if (void* mapped = readback.map(0, readback_size))
{
std::vector<u8> rgba = decode_vk_to_rgba8(vk_format, static_cast<const u8*>(mapped), width, height);
readback.unmap();
if (rgba.empty())
{
rsx_log.warning("texture_dump: unsupported host format %u, skipping", static_cast<u32>(vk_format));
}
else
{
// Reorder channels to match what the game samples (WYSIWYG).
const VkComponentMapping mapping = apply_component_mapping_flags(gcm_format, rsx::component_order::default_, remap);
apply_component_mapping_rgba8(rgba, mapping);
if (!stbi_write_png(path.c_str(), width, height, 4, rgba.data(), width * 4))
{
rsx_log.error("texture_dump: failed to write %s", path);
}
}
}
}
bool texture_cache::replace_texture_image(vk::command_buffer& cmd, cached_texture_section* section,
u16 width, u16 height, u32 gcm_format, u64 content_hash,
const rsx::texture_channel_remap_t& remap)
{
if (!section)
return false;
vk::image* image = section->get_raw_texture();
if (!image)
return false;
// Configurable replacement root (empty = config dir); the per-title subfolder
// is always appended. Reads only this folder (non-recursive), so dumps under
// <root>/<title>/dump/ are never picked up as replacements.
std::string root = g_cfg.video.texture_replace_path.to_string();
if (root.empty())
root = fs::get_config_dir() + "textures/";
else if (root.back() != '/' && root.back() != '\\')
root += '/';
const std::string path = root + Emu.GetTitleID() + "/" + fmt::format("%016x_%02x.png", content_hash, gcm_format);
if (!fs::exists(path))
{
return false;
}
fs::file f(path);
if (!f)
return false;
std::vector<u8> file_data(f.size());
f.read(file_data.data(), file_data.size());
int pw = 0, ph = 0, comp = 0;
stbi_uc* decoded = stbi_load_from_memory(file_data.data(), static_cast<int>(file_data.size()), &pw, &ph, &comp, 4);
if (!decoded)
{
rsx_log.error("texture_replace: failed to decode %s", path);
return false;
}
if (u32(pw) != width || u32(ph) != height)
{
rsx_log.warning("texture_replace: %s is %dx%d but the texture is %ux%u (same-resolution only), skipping", path, pw, ph, width, height);
stbi_image_free(decoded);
return false;
}
std::vector<u8> rgba(decoded, decoded + usz(pw) * ph * 4);
stbi_image_free(decoded);
// The inverse of the view remap is applied per mip level below so the game's
// forward remap reproduces the edited PNG (WYSIWYG round-trip).
const VkComponentMapping mapping = apply_component_mapping_flags(gcm_format, rsx::component_order::default_, remap);
const VkFormat vk_format = image->format();
const u32 mip_count = std::max<u32>(1, image->mipmaps());
// Overwrite every mip level, regenerating the chain from the edited base by
// box-downsampling. A base-only replacement leaves the original sub-mips, so
// the game's trilinear filtering blends old+new (ghosting when drawn large)
// and small draws sample only untouched mips (no replacement at all).
image->push_layout(cmd, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
std::vector<std::unique_ptr<vk::buffer>> upload_buffers;
upload_buffers.reserve(mip_count);
std::vector<u8> level = std::move(rgba); // logical RGBA base level
u32 lw = width, lh = height;
u32 uploaded_mips = 0;
for (u32 m = 0; m < mip_count; ++m)
{
if (m > 0)
{
const u32 nw = std::max<u32>(1, lw >> 1);
const u32 nh = std::max<u32>(1, lh >> 1);
level = downsample_rgba8(level, lw, lh, nw, nh);
lw = nw;
lh = nh;
}
// Apply the inverse view remap on a copy (downsampling stays in logical
// RGBA order for correct alpha weighting), then encode to host format.
std::vector<u8> enc = level;
inverse_component_mapping_rgba8(enc, mapping);
std::vector<u8> host_data = encode_rgba8_to_host(vk_format, enc.data(), lw, lh);
if (host_data.empty())
{
if (m == 0)
{
rsx_log.warning("texture_replace: unsupported host format %u for replacement, skipping", static_cast<u32>(vk_format));
image->pop_layout(cmd);
return false;
}
break; // can't encode this level; leave remaining mips as uploaded by the game
}
auto buf = std::make_unique<vk::buffer>(*m_device, host_data.size(), m_memory_types.host_visible_coherent, 0,
VK_BUFFER_USAGE_TRANSFER_SRC_BIT, 0, VMM_ALLOCATION_POOL_UNDEFINED);
void* dst = buf->map(0, host_data.size());
if (!dst)
{
image->pop_layout(cmd);
return false;
}
std::memcpy(dst, host_data.data(), host_data.size());
buf->unmap();
VkBufferImageCopy region{};
region.bufferRowLength = 0;
region.bufferImageHeight = 0;
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, m, 0, 1};
region.imageOffset = {0, 0, 0};
region.imageExtent = {lw, lh, 1};
vk::copy_buffer_to_image(cmd, buf.get(), image, region);
upload_buffers.push_back(std::move(buf));
++uploaded_mips;
}
image->pop_layout(cmd);
rsx_log.success("texture_replace: applied %s (%u/%u mips)", path, uploaded_mips, mip_count);
// Submit and wait so the copies complete before the upload buffers are freed,
// then restart the command buffer for the caller. upload_buffers stays in
// scope through the fence wait below.
cmd.end();
vk::fence submit_fence(*m_device);
vk::queue_submit_t submit_info{m_submit_queue, &submit_fence};
cmd.submit(submit_info, VK_TRUE);
vk::wait_for_fence(&submit_fence, GENERAL_WAIT_TIMEOUT);
cmd.reset();
cmd.begin();
return true;
}
void texture_cache::initialize(vk::render_device& device, VkQueue submit_queue, vk::data_heap& upload_heap)
{
m_device = &device;

View File

@ -497,6 +497,17 @@ namespace vk
void cleanup_after_dma_transfers(vk::command_buffer& cmd) override;
// PNG texture dump (texture_dump setting): reads the just-uploaded image
// back to the CPU, decodes it to RGBA8 and writes a PNG. Deduplicated per
// content hash so each texture is only dumped once per session.
void dump_texture_image(vk::command_buffer& cmd, cached_texture_section* section,
u16 width, u16 height, u32 gcm_format, u64 content_hash,
const rsx::texture_channel_remap_t& remap) override;
bool replace_texture_image(vk::command_buffer& cmd, cached_texture_section* section,
u16 width, u16 height, u32 gcm_format, u64 content_hash,
const rsx::texture_channel_remap_t& remap) override;
std::unordered_set<u64> m_dumped_texture_hashes;
public:
using baseclass::texture_cache;

View File

@ -155,6 +155,10 @@ struct cfg_root : cfg::node
cfg::_bool disable_vulkan_mem_allocator{ this, "Disable Vulkan Memory Allocator", false };
cfg::_bool full_rgb_range_output{ this, "Use full RGB output range", true, true }; // Video out dynamic range
cfg::_bool strict_texture_flushing{ this, "Strict Texture Flushing", false };
cfg::_bool texture_dump{ this, "Dump Textures to PNG", false, true };
cfg::_bool texture_replace{ this, "Replace Textures from PNG", false, true };
cfg::string texture_dump_path{ this, "Texture Dump Path", "", true }; // root; <title>/dump/ is appended. empty = <config dir>/textures/
cfg::string texture_replace_path{ this, "Texture Replacement Path", "", true }; // root; <title>/ is appended. empty = <config dir>/textures/
cfg::_bool multithreaded_rsx{ this, "Multithreaded RSX", false };
cfg::_bool relaxed_zcull_sync{ this, "Relaxed ZCULL Sync", false };
cfg::_bool force_hw_MSAA_resolve{ this, "Force Hardware MSAA Resolve", false, true };

View File

@ -77,6 +77,10 @@ const std::map<emu_settings_type, cfg_location> settings_location =
{ emu_settings_type::DebugOverlay, get_cfg_location(local_cfg.video.debug_overlay) },
{ emu_settings_type::RenderdocCompatibility, get_cfg_location(local_cfg.video.renderdoc_compatiblity) },
{ emu_settings_type::GPUTextureScaling, get_cfg_location(local_cfg.video.use_gpu_texture_scaling) },
{ emu_settings_type::TextureDumpToPNG, get_cfg_location(local_cfg.video.texture_dump) },
{ emu_settings_type::TextureReplaceFromPNG, get_cfg_location(local_cfg.video.texture_replace) },
{ emu_settings_type::TextureDumpPath, get_cfg_location(local_cfg.video.texture_dump_path) },
{ emu_settings_type::TextureReplacePath, get_cfg_location(local_cfg.video.texture_replace_path) },
{ emu_settings_type::StretchToDisplayArea, get_cfg_location(local_cfg.video.stretch_to_display_area) },
{ emu_settings_type::ForceHighpZ, get_cfg_location(local_cfg.video.force_high_precision_z_buffer) },
{ emu_settings_type::StrictRenderingMode, get_cfg_location(local_cfg.video.strict_rendering_mode) },

View File

@ -71,6 +71,10 @@ enum class emu_settings_type
DebugOverlay,
RenderdocCompatibility,
GPUTextureScaling,
TextureDumpToPNG,
TextureReplaceFromPNG,
TextureDumpPath,
TextureReplacePath,
StretchToDisplayArea,
VulkanAdapter,
ForceHighpZ,

View File

@ -5,6 +5,7 @@
#include <QFontMetrics>
#include <QPushButton>
#include <QMessageBox>
#include <QFileDialog>
#include <QInputDialog>
#include <QDesktopServices>
#include <QColorDialog>
@ -640,6 +641,32 @@ settings_dialog::settings_dialog(std::shared_ptr<gui_settings> gui_settings, std
m_emu_settings->EnhanceCheckBox(ui->asyncTextureStreaming, emu_settings_type::VulkanAsyncTextureUploads);
SubscribeTooltip(ui->asyncTextureStreaming, tooltips.settings.async_texture_streaming);
m_emu_settings->EnhanceCheckBox(ui->textureDumpToPNG, emu_settings_type::TextureDumpToPNG);
SubscribeTooltip(ui->textureDumpToPNG, tr("Dumps sampled textures to PNG under textures/<title-id>/dump/. Hurts performance; for texture-pack authoring only."));
m_emu_settings->EnhanceCheckBox(ui->textureReplaceFromPNG, emu_settings_type::TextureReplaceFromPNG);
SubscribeTooltip(ui->textureReplaceFromPNG, tr("Loads same-resolution PNG replacements from textures/<title-id>/ over matching textures. Hurts performance; for texture packs only."));
m_emu_settings->EnhanceLineEdit(ui->textureDumpPathEdit, emu_settings_type::TextureDumpPath);
SubscribeTooltip(ui->textureDumpPathEdit, tr("Root folder for texture dumps; a <title-id>/dump subfolder is created inside. Leave empty for the default (config dir / textures)."));
connect(ui->textureDumpPathBrowse, &QAbstractButton::clicked, this, [this]()
{
const QString dir = QFileDialog::getExistingDirectory(this, tr("Select Texture Dump Folder"),
ui->textureDumpPathEdit->text(), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty())
ui->textureDumpPathEdit->setText(dir);
});
m_emu_settings->EnhanceLineEdit(ui->textureReplacePathEdit, emu_settings_type::TextureReplacePath);
SubscribeTooltip(ui->textureReplacePathEdit, tr("Root folder for PNG replacements; loaded from the <title-id> subfolder inside. Leave empty for the default (config dir / textures)."));
connect(ui->textureReplacePathBrowse, &QAbstractButton::clicked, this, [this]()
{
const QString dir = QFileDialog::getExistingDirectory(this, tr("Select Texture Replacement Folder"),
ui->textureReplacePathEdit->text(), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty())
ui->textureReplacePathEdit->setText(dir);
});
// Radio buttons
SubscribeTooltip(ui->rb_legacy_recompiler, tooltips.settings.legacy_shader_recompiler);

View File

@ -987,6 +987,70 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="textureDumpToPNG">
<property name="text">
<string>Dump Textures to PNG</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="textureReplaceFromPNG">
<property name="text">
<string>Replace Textures from PNG</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="textureDumpPathLayout">
<item>
<widget class="QLabel" name="textureDumpPathLabel">
<property name="text">
<string>Dump folder:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="textureDumpPathEdit">
<property name="placeholderText">
<string>Default (config dir)</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="textureDumpPathBrowse">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="textureReplacePathLayout">
<item>
<widget class="QLabel" name="textureReplacePathLabel">
<property name="text">
<string>Replacement folder:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="textureReplacePathEdit">
<property name="placeholderText">
<string>Default (config dir)</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="textureReplacePathBrowse">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="additional_settings_spacer">
<property name="orientation">