mirror of
https://github.com/jessicanataliagta/PSPRecomp
synced 2026-09-27 08:55:30 -04:00
TEXTURE REPLACEMENT 3
TEXTURE REPLACEMENT 3
This commit is contained in:
@@ -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: <dir>/UI/HUD/radar.png and <dir>/radar.png both replace
|
||||
; the texture named "radar".
|
||||
Directory=TexturesDDS
|
||||
|
||||
[Controls]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
|
||||
namespace vcs {
|
||||
namespace {
|
||||
|
||||
@@ -27,6 +32,7 @@ struct DecodedImage {
|
||||
std::uint32_t width{};
|
||||
std::uint32_t height{};
|
||||
std::vector<std::byte> 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::streamoff>(std::numeric_limits<int>::max()))
|
||||
return false;
|
||||
input.seekg(0, std::ios::beg);
|
||||
std::vector<std::uint8_t> compressed(static_cast<std::size_t>(length));
|
||||
input.read(reinterpret_cast<char *>(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<int>(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<std::uint32_t>(frame->width);
|
||||
out.height = static_cast<std::uint32_t>(frame->height);
|
||||
try {
|
||||
out.rgba.assign(static_cast<std::size_t>(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<AVPixelFormat>(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<std::uint8_t *>(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 <dir>/UI/HUD/radar.png and
|
||||
// <dir>/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<std::mutex> 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user