From 3274d0c8f6466c38074dad45fea86b34acfd5f97 Mon Sep 17 00:00:00 2001 From: Jessica_Natalia Date: Sat, 15 Aug 2026 23:55:51 -0300 Subject: [PATCH] TEXTURE REPLACEMENT 3 TEXTURE REPLACEMENT 3 --- profiles/vcs/config/VCSNative.ini | 21 ++- profiles/vcs/host/ge_renderer.cpp | 8 ++ profiles/vcs/host/vcs_texture_replacement.cpp | 136 ++++++++++++++++-- profiles/vcs/host/vcs_texture_replacement.hpp | 16 ++- 4 files changed, 164 insertions(+), 17 deletions(-) diff --git a/profiles/vcs/config/VCSNative.ini b/profiles/vcs/config/VCSNative.ini index c03f689..91b18df 100644 --- a/profiles/vcs/config/VCSNative.ini +++ b/profiles/vcs/config/VCSNative.ini @@ -87,14 +87,23 @@ NPCs=1.50 MouseMenu=false [Textures] -; DDS texture replacement matched by the texture's internal name inside the -; game's TEX containers -- the same name VCS IMG Studio shows. Nothing is -; matched by VRAM address, so installing a mod that rebuilds the archives does -; not invalidate your files: the names are re-read from whatever is installed. +; Texture replacement matched by the texture's internal name inside the game's +; TEX containers -- the same name VCS IMG Studio shows. Nothing is matched by +; VRAM address, so installing a mod that rebuilds the archives does not +; invalidate your files: the names are re-read from whatever is installed. +; +; Use .dds exported as UNCOMPRESSED A8R8G8B8. That is lossless with a full +; 8-bit alpha channel, which is what interface art needs -- DXT quantizes in 4x4 +; blocks and ruins sharp edges, and DXT1 carries only one bit of alpha. Nothing +; is gained by compressing: every override is decoded to RGBA8 before upload. +; +; .png is read too, but only if the ffmpeg in this build has a PNG decoder, and +; the one shipped here does not (it is a minimal build for the PMF movies). A +; .png override is skipped with a line in the log saying so. Enabled=false ; Relative to the executable directory. Subfolders are scanned as well and are -; only for organisation: TexturesDDS/UI/HUD/radar.dds and TexturesDDS/radar.dds -; both replace the texture named "radar". +; only for organisation: /UI/HUD/radar.png and /radar.png both replace +; the texture named "radar". Directory=TexturesDDS [Controls] diff --git a/profiles/vcs/host/ge_renderer.cpp b/profiles/vcs/host/ge_renderer.cpp index 8f41647..da636f6 100644 --- a/profiles/vcs/host/ge_renderer.cpp +++ b/profiles/vcs/host/ge_renderer.cpp @@ -4319,6 +4319,14 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, replacement.rgba, replacement.rgba + replacement.size); replaced = ge_gpu_backend_upload_decoded_texture( gpu_draw, replacement.width, replacement.height, pixels); + // The guest's TEXFUNC decides whether the shader reads a + // texture's alpha at all. A palettized original the game + // declared opaque leaves that bit clear, and a replacement + // carrying real transparency would be flattened by it. Raise + // it only when the image actually has non-opaque pixels, so + // an opaque replacement changes nothing. + if (replaced && replacement.has_transparency) + gpu_draw.texture_use_alpha = true; } } } diff --git a/profiles/vcs/host/vcs_texture_replacement.cpp b/profiles/vcs/host/vcs_texture_replacement.cpp index 71ff5ac..7b7178a 100644 --- a/profiles/vcs/host/vcs_texture_replacement.cpp +++ b/profiles/vcs/host/vcs_texture_replacement.cpp @@ -13,6 +13,11 @@ #include #include +extern "C" { +#include +#include +} + namespace vcs { namespace { @@ -27,6 +32,7 @@ struct DecodedImage { std::uint32_t width{}; std::uint32_t height{}; std::vector rgba; + bool has_transparency{}; }; struct State { @@ -290,6 +296,96 @@ bool decode_dds_file(const std::filesystem::path &path, DecodedImage &out) { return true; } +// PNG is the better source for this pipeline and especially for interface art. +// Everything is decoded to RGBA8 before upload anyway, so a block-compressed DDS +// buys nothing here and only spends quality: DXT quantizes in 4x4 blocks, which +// is exactly what ruins sharp edges and text, and DXT1 carries a single bit of +// alpha. PNG is lossless and ffmpeg is already a dependency of this build. +bool decode_png_file(const std::filesystem::path &path, DecodedImage &out) { + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input) return false; + const std::streamoff length = input.tellg(); + if (length <= 0 || length > 64 * 1024 * 1024 || + length > static_cast(std::numeric_limits::max())) + return false; + input.seekg(0, std::ios::beg); + std::vector compressed(static_cast(length)); + input.read(reinterpret_cast(compressed.data()), length); + if (!input) return false; + + // The ffmpeg shipped with this profile is a minimal build carrying only what + // the PMF movies need, and a PNG decoder is not part of it. Say so once and + // plainly: the alternative is a user staring at a folder of .png files that + // silently do nothing. Uncompressed A8R8G8B8 .dds is the lossless route that + // works with this build. + const AVCodec *decoder = avcodec_find_decoder(AV_CODEC_ID_PNG); + if (decoder == nullptr) { + static bool reported = false; + if (!reported) { + reported = true; + runtime_log_line( + "texture replacement: this build's ffmpeg has no PNG decoder, so every " + ".png override is ignored -- export as uncompressed A8R8G8B8 .dds " + "instead, which is equally lossless"); + } + return false; + } + AVCodecContext *codec = avcodec_alloc_context3(decoder); + AVPacket *packet = av_packet_alloc(); + AVFrame *frame = av_frame_alloc(); + bool ok = false; + if (codec != nullptr && packet != nullptr && frame != nullptr && + avcodec_open2(codec, decoder, nullptr) >= 0 && + av_new_packet(packet, static_cast(compressed.size())) >= 0) { + std::memcpy(packet->data, compressed.data(), compressed.size()); + if (avcodec_send_packet(codec, packet) >= 0 && + avcodec_receive_frame(codec, frame) >= 0 && frame->width > 0 && + frame->height > 0 && frame->width <= 8192 && frame->height <= 8192) { + out.width = static_cast(frame->width); + out.height = static_cast(frame->height); + try { + out.rgba.assign(static_cast(out.width) * out.height * 4u, + std::byte{0}); + } catch (...) { + out.rgba.clear(); + } + if (!out.rgba.empty()) { + SwsContext *sws = sws_getContext( + frame->width, frame->height, static_cast(frame->format), + frame->width, frame->height, AV_PIX_FMT_RGBA, SWS_POINT, nullptr, + nullptr, nullptr); + if (sws != nullptr) { + std::uint8_t *dst[4]{reinterpret_cast(out.rgba.data()), + nullptr, nullptr, nullptr}; + int dst_stride[4]{frame->width * 4, 0, 0, 0}; + ok = sws_scale(sws, frame->data, frame->linesize, 0, frame->height, dst, + dst_stride) == frame->height; + sws_freeContext(sws); + } + } + } + } + if (frame != nullptr) av_frame_free(&frame); + if (packet != nullptr) av_packet_free(&packet); + if (codec != nullptr) avcodec_free_context(&codec); + return ok; +} + +// Whether this build can read PNG at all. Decides which file wins a name owned +// by both a .png and a .dds: preferring the lossless format is only right while +// it is actually readable, and preferring an unreadable one would hide a working +// .dds sitting right next to it. +bool png_decoder_available() { + static const bool available = avcodec_find_decoder(AV_CODEC_ID_PNG) != nullptr; + return available; +} + +bool decode_replacement_file(const std::filesystem::path &path, DecodedImage &out) { + const std::string extension = upper_copy(path.extension().string()); + if (extension == ".PNG") return decode_png_file(path, out); + return decode_dds_file(path, out); +} + void scan_override_directory(State &s) { std::error_code error; if (!std::filesystem::is_directory(s.directory, error) || error) return; @@ -299,12 +395,20 @@ void scan_override_directory(State &s) { for (const auto &entry : walk) { std::error_code file_error; if (!entry.is_regular_file(file_error) || file_error) continue; - if (upper_copy(entry.path().extension().string()) != ".DDS") continue; + const std::string extension = upper_copy(entry.path().extension().string()); + if (extension != ".PNG" && extension != ".DDS") continue; // Subdirectories exist purely so the user can organise; only the file - // stem takes part in matching, so TexturesDDS/UI/HUD/radar.dds and - // TexturesDDS/radar.dds mean the same texture. + // stem takes part in matching, so /UI/HUD/radar.png and + // /radar.png mean the same texture. const std::string key = upper_copy(entry.path().stem().string()); if (key.empty()) continue; + const std::string preferred = png_decoder_available() ? ".PNG" : ".DDS"; + const auto existing = s.overrides.find(key); + if (existing != s.overrides.end()) { + if (extension != preferred) continue; + existing->second = entry.path(); + continue; + } s.overrides.emplace(key, entry.path()); } } @@ -459,7 +563,15 @@ void texture_replacement_index_archive(const std::filesystem::path &path) noexce std::lock_guard guard(s.mutex); ensure_initialized(s); if (!s.enabled) return; - if (upper_copy(path.extension().string()) != ".IMG") return; + // .IMG is the streamed world/character archive. .XTX is a standalone + // container holding a single TEX chunk at offset zero, and it is where + // this game keeps most of its interface art: the empire HUD bars, the + // loading screens, the memory-card and splash screens, the per-language + // legal screens. None of that is reachable through the .IMG, so leaving + // .XTX out made interface replacement look broken for exactly the files + // a user is most likely to want to change. + const std::string extension = upper_copy(path.extension().string()); + if (extension != ".IMG" && extension != ".XTX") return; if (!s.indexed_archives.insert(path.generic_string()).second) return; std::ifstream input(path, std::ios::binary | std::ios::ate); @@ -529,20 +641,27 @@ bool texture_replacement_lookup(const std::uint8_t *pixels, std::size_t size, auto decoded = s.decoded.find(name); if (decoded == s.decoded.end()) { DecodedImage image; - if (!decode_dds_file(override_path->second, image)) { + if (!decode_replacement_file(override_path->second, image)) { s.failed_decodes.insert(name); std::ostringstream message; message << "texture replacement FAILED to decode \"" << override_path->second.filename().string() - << "\" -- supported: DXT1/DXT3/DXT5 and uncompressed 24/32-bit," - " no DX10 header"; + << "\" -- supported: .png, and .dds as DXT1/DXT3/DXT5 or" + " uncompressed 24/32-bit with no DX10 header"; runtime_log_line(message.str()); return false; } + for (std::size_t at = 3u; at < image.rgba.size(); at += 4u) { + if (image.rgba[at] != std::byte{255}) { + image.has_transparency = true; + break; + } + } std::ostringstream message; message << "texture replacement active name=\"" << name << "\" original=" << found->second.width << 'x' << found->second.height - << " replacement=" << image.width << 'x' << image.height; + << " replacement=" << image.width << 'x' << image.height + << (image.has_transparency ? " alpha=forced" : " alpha=opaque"); runtime_log_line(message.str()); decoded = s.decoded.emplace(name, std::move(image)).first; } @@ -552,6 +671,7 @@ bool texture_replacement_lookup(const std::uint8_t *pixels, std::size_t size, out.size = decoded->second.rgba.size(); out.width = decoded->second.width; out.height = decoded->second.height; + out.has_transparency = decoded->second.has_transparency; return true; } catch (...) { return false; diff --git a/profiles/vcs/host/vcs_texture_replacement.hpp b/profiles/vcs/host/vcs_texture_replacement.hpp index 96c57ea..4ca5601 100644 --- a/profiles/vcs/host/vcs_texture_replacement.hpp +++ b/profiles/vcs/host/vcs_texture_replacement.hpp @@ -38,9 +38,10 @@ struct TextureIndexEntry { std::uint64_t full_key{}; }; -// Indexes one of the game's .IMG archives, if it has not been seen yet. Called -// as the host registers archives the guest actually opens, which keeps user -// backup copies sitting next to the real files out of the index. +// Indexes one of the game's texture archives (.IMG or .XTX), if it has not been +// seen yet. Called as the host registers archives the guest actually opens, +// which keeps user backup copies sitting next to the real files out of the +// index. void texture_replacement_index_archive(const std::filesystem::path &path) noexcept; // A decoded replacement, owned by the module and stable for the process's life. @@ -49,6 +50,15 @@ struct TextureReplacement { std::size_t size{}; std::uint32_t width{}; std::uint32_t height{}; + // True when the decoded image actually contains non-opaque pixels. + // + // The shader honours a texture's alpha only when the guest's TEXFUNC says + // the texture has any (TCC=RGBA); otherwise it substitutes the vertex alpha + // and the image's own alpha is discarded. A replacement carrying real + // transparency therefore has to raise that bit, or it renders opaque. An + // image whose alpha is uniformly 255 leaves it alone, so nothing changes for + // opaque replacements. + bool has_transparency{}; }; // Looks up a replacement for the texture the GE is about to upload, decoding the