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