#include "vcs_texture_replacement.hpp" #include "vcs_config.hpp" #include "vcs_runtime_log.hpp" #include #include #include #include #include #include #include #include #include extern "C" { #include #include } namespace vcs { namespace { // 'tex\0'. Every texture container in the game's .IMG archives starts with it. constexpr std::uint32_t kTexIdent = 0x00746578u; // Archive entries are laid out on 2 KiB sector boundaries, so a container can // only ever begin at one. Scanning by sector rather than byte keeps a false // positive from a random run of bytes inside model or collision data. constexpr std::uint64_t kSectorSize = 0x800u; struct DecodedImage { std::uint32_t width{}; std::uint32_t height{}; std::vector rgba; bool has_transparency{}; }; struct State { std::mutex mutex; bool initialized{}; bool enabled{}; std::filesystem::path directory; // whole-raster content key -> index entry std::unordered_map by_full_content; // upper-cased internal name -> .dds path supplied by the user std::unordered_map overrides; // Decoded .dds keyed by upper-cased name. unordered_map keeps element // addresses stable across rehash, so a pointer handed to the renderer stays // valid for the rest of the process. std::unordered_map decoded; std::unordered_set failed_decodes; std::unordered_set indexed_archives; std::uint64_t textures_indexed{}; std::uint64_t containers_indexed{}; std::uint64_t substituted_textures{}; }; State &state() { static State instance; return instance; } std::string upper_copy(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return static_cast(std::toupper(ch)); }); return value; } std::uint32_t read_u32(const std::uint8_t *bytes) noexcept { return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8u) | (static_cast(bytes[2]) << 16u) | (static_cast(bytes[3]) << 24u); } std::int16_t read_s16(const std::uint8_t *bytes) noexcept { return static_cast(static_cast(bytes[0]) | (static_cast(bytes[1]) << 8u)); } std::string read_ascii(const std::uint8_t *bytes, std::size_t capacity) { std::string value; for (std::size_t index = 0; index < capacity; ++index) { const unsigned char ch = bytes[index]; if (ch == 0u) break; // Names are plain ASCII identifiers; anything else means this is not a // name field and the container should not be trusted. if (ch < 0x20u || ch > 0x7Eu) return {}; value.push_back(static_cast(ch)); } return value; } // --------------------------------------------------------------------------- // DDS decoding // // Only what a texture author actually exports: the three classic block formats // and uncompressed 24/32-bit. Everything lands as RGBA8, which is the form the // GE renderer already hands the backend for every texture, so a replacement // joins the normal upload path instead of needing one of its own. // --------------------------------------------------------------------------- constexpr std::uint32_t kDdsMagic = 0x20534444u; // 'DDS ' constexpr std::uint32_t four_cc(char a, char b, char c, char d) { return static_cast(static_cast(a)) | (static_cast(static_cast(b)) << 8u) | (static_cast(static_cast(c)) << 16u) | (static_cast(static_cast(d)) << 24u); } void write_pixel(std::vector &rgba, std::uint32_t width, std::uint32_t height, std::uint32_t x, std::uint32_t y, std::uint8_t r, std::uint8_t g, std::uint8_t b, std::uint8_t a) { if (x >= width || y >= height) return; const std::size_t at = (static_cast(y) * width + x) * 4u; rgba[at + 0u] = static_cast(r); rgba[at + 1u] = static_cast(g); rgba[at + 2u] = static_cast(b); rgba[at + 3u] = static_cast(a); } // Shared 565 colour half of BC1/BC2/BC3. punchthrough enables BC1's one-bit // alpha, which BC2 and BC3 must not use because they carry their own alpha. void decode_color_block(const std::uint8_t *block, bool punchthrough, std::array &r, std::array &g, std::array &b, std::array &a) { const std::uint16_t c0 = static_cast(block[0] | (block[1] << 8)); const std::uint16_t c1 = static_cast(block[2] | (block[3] << 8)); const auto expand = [](std::uint16_t value, std::uint8_t &er, std::uint8_t &eg, std::uint8_t &eb) { const std::uint32_t r5 = (value >> 11u) & 0x1Fu; const std::uint32_t g6 = (value >> 5u) & 0x3Fu; const std::uint32_t b5 = value & 0x1Fu; er = static_cast((r5 * 255u + 15u) / 31u); eg = static_cast((g6 * 255u + 31u) / 63u); eb = static_cast((b5 * 255u + 15u) / 31u); }; std::array pr{}, pg{}, pb{}, pa{255u, 255u, 255u, 255u}; expand(c0, pr[0], pg[0], pb[0]); expand(c1, pr[1], pg[1], pb[1]); if (c0 > c1 || !punchthrough) { pr[2] = static_cast((2u * pr[0] + pr[1]) / 3u); pg[2] = static_cast((2u * pg[0] + pg[1]) / 3u); pb[2] = static_cast((2u * pb[0] + pb[1]) / 3u); pr[3] = static_cast((pr[0] + 2u * pr[1]) / 3u); pg[3] = static_cast((pg[0] + 2u * pg[1]) / 3u); pb[3] = static_cast((pb[0] + 2u * pb[1]) / 3u); } else { pr[2] = static_cast((pr[0] + pr[1]) / 2u); pg[2] = static_cast((pg[0] + pg[1]) / 2u); pb[2] = static_cast((pb[0] + pb[1]) / 2u); pr[3] = pg[3] = pb[3] = 0u; pa[3] = 0u; } for (std::uint32_t index = 0; index < 16u; ++index) { const std::uint32_t selector = (block[4u + (index >> 2u)] >> ((index & 3u) * 2u)) & 3u; r[index] = pr[selector]; g[index] = pg[selector]; b[index] = pb[selector]; a[index] = pa[selector]; } } void decode_bc_alpha(const std::uint8_t *block, std::array &a) { std::array values{}; values[0] = block[0]; values[1] = block[1]; if (values[0] > values[1]) { for (std::uint32_t i = 1; i < 7u; ++i) values[i + 1u] = static_cast( ((7u - i) * values[0] + i * values[1]) / 7u); } else { for (std::uint32_t i = 1; i < 5u; ++i) values[i + 1u] = static_cast( ((5u - i) * values[0] + i * values[1]) / 5u); values[6] = 0u; values[7] = 255u; } std::uint64_t bits = 0u; for (int i = 0; i < 6; ++i) bits |= static_cast(block[2 + i]) << (8 * i); for (std::uint32_t index = 0; index < 16u; ++index) a[index] = values[(bits >> (3u * index)) & 7u]; } bool decode_dxt(const std::uint8_t *data, std::size_t available, DecodedImage &out, int variant) { // 1 = BC1, 3 = BC2, 5 = BC3 const std::uint32_t block_bytes = variant == 1 ? 8u : 16u; const std::uint32_t blocks_x = (out.width + 3u) / 4u; const std::uint32_t blocks_y = (out.height + 3u) / 4u; if (static_cast(blocks_x) * blocks_y * block_bytes > available) return false; std::size_t at = 0u; for (std::uint32_t by = 0; by < blocks_y; ++by) { for (std::uint32_t bx = 0; bx < blocks_x; ++bx, at += block_bytes) { std::array r{}, g{}, b{}, a{}; const std::uint8_t *color = data + at + (variant == 1 ? 0u : 8u); decode_color_block(color, variant == 1, r, g, b, a); if (variant == 3) { for (std::uint32_t index = 0; index < 16u; ++index) { const std::uint8_t nibble = (data[at + (index >> 1u)] >> ((index & 1u) * 4u)) & 0x0Fu; a[index] = static_cast(nibble * 17u); } } else if (variant == 5) { decode_bc_alpha(data + at, a); } for (std::uint32_t index = 0; index < 16u; ++index) { write_pixel(out.rgba, out.width, out.height, bx * 4u + (index & 3u), by * 4u + (index >> 2u), r[index], g[index], b[index], a[index]); } } } return true; } std::uint32_t mask_shift(std::uint32_t mask) { if (mask == 0u) return 0u; std::uint32_t shift = 0u; while ((mask & 1u) == 0u) { mask >>= 1u; ++shift; } return shift; } std::uint8_t extract_channel(std::uint32_t pixel, std::uint32_t mask) { if (mask == 0u) return 255u; const std::uint32_t value = (pixel & mask) >> mask_shift(mask); const std::uint32_t range = mask >> mask_shift(mask); if (range == 0u) return 255u; return static_cast((value * 255u + range / 2u) / range); } bool decode_dds_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 < 128 || length > 256 * 1024 * 1024) return false; input.seekg(0, std::ios::beg); std::vector bytes(static_cast(length)); input.read(reinterpret_cast(bytes.data()), length); if (!input) return false; if (read_u32(bytes.data()) != kDdsMagic) return false; const std::uint32_t height = read_u32(bytes.data() + 12u); const std::uint32_t width = read_u32(bytes.data() + 16u); const std::uint32_t pf_flags = read_u32(bytes.data() + 80u); const std::uint32_t fourcc = read_u32(bytes.data() + 84u); const std::uint32_t bit_count = read_u32(bytes.data() + 88u); const std::uint32_t r_mask = read_u32(bytes.data() + 92u); const std::uint32_t g_mask = read_u32(bytes.data() + 96u); const std::uint32_t b_mask = read_u32(bytes.data() + 100u); const std::uint32_t a_mask = read_u32(bytes.data() + 104u); if (width == 0u || height == 0u || width > 8192u || height > 8192u) return false; std::size_t data_at = 128u; // DX10 extension header. Not decoded: it carries DXGI formats this reader // does not claim to handle, and guessing would corrupt the image silently. if ((pf_flags & 0x4u) != 0u && fourcc == four_cc('D', 'X', '1', '0')) return false; if (data_at >= bytes.size()) return false; out.width = width; out.height = height; try { out.rgba.assign(static_cast(width) * height * 4u, std::byte{0}); } catch (...) { return false; } const std::uint8_t *data = bytes.data() + data_at; const std::size_t available = bytes.size() - data_at; if ((pf_flags & 0x4u) != 0u) { // DDPF_FOURCC if (fourcc == four_cc('D', 'X', 'T', '1')) return decode_dxt(data, available, out, 1); if (fourcc == four_cc('D', 'X', 'T', '3')) return decode_dxt(data, available, out, 3); if (fourcc == four_cc('D', 'X', 'T', '5')) return decode_dxt(data, available, out, 5); return false; } if ((pf_flags & 0x40u) == 0u) return false; // DDPF_RGB if (bit_count != 32u && bit_count != 24u) return false; const std::size_t stride = static_cast(width) * (bit_count / 8u); if (stride * height > available) return false; for (std::uint32_t y = 0; y < height; ++y) { for (std::uint32_t x = 0; x < width; ++x) { const std::uint8_t *pixel = data + y * stride + x * (bit_count / 8u); const std::uint32_t value = bit_count == 32u ? read_u32(pixel) : (static_cast(pixel[0]) | (static_cast(pixel[1]) << 8u) | (static_cast(pixel[2]) << 16u)); write_pixel(out.rgba, width, height, x, y, extract_channel(value, r_mask), extract_channel(value, g_mask), extract_channel(value, b_mask), bit_count == 32u && a_mask != 0u ? extract_channel(value, a_mask) : 255u); } } 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; std::filesystem::recursive_directory_iterator walk( s.directory, std::filesystem::directory_options::skip_permission_denied, error); if (error) return; for (const auto &entry : walk) { std::error_code file_error; if (!entry.is_regular_file(file_error) || file_error) 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 /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()); } } void ensure_initialized(State &s) { if (s.initialized) return; s.initialized = true; const VcsConfiguration &config = vcs_configuration(); if (!config.initialized || !config.textures.enabled) return; s.enabled = true; std::filesystem::path directory(config.textures.directory); if (directory.is_relative() && !config.executable_directory.empty()) directory = config.executable_directory / directory; s.directory = directory; scan_override_directory(s); std::ostringstream message; message << "texture replacement enabled directory=\"" << s.directory.string() << "\" dds_files=" << s.overrides.size(); runtime_log_line(message.str()); } } // namespace std::uint64_t texture_replacement_content_key(const std::uint8_t *bytes, std::size_t size, std::uint32_t width, std::uint32_t height, std::uint32_t depth, std::size_t hash_bytes) noexcept { if (bytes == nullptr || size == 0u) return 0u; // Leading bytes only, and deliberately *not* mixed with the total size. The // guest may pad a texture's row pitch when it uploads, and folding the size // in would turn that padding into a mismatch and hide a content match that // is otherwise perfect. Size is compared separately and reported, which is // what stage 1 exists to measure. // // Shape is mixed in, though, because it is known identically on both sides // and costs nothing. Hashing content alone put 235 of this game's 2734 // textures into shared buckets -- flat and near-flat art collides easily // once only its first kilobyte is considered. const std::size_t length = hash_bytes == 0u ? size : std::min(size, hash_bytes); std::uint64_t hash = 0xCBF29CE484222325ull; const auto mix = [&hash](std::uint64_t value) { hash ^= value; hash *= 0x100000001B3ull; }; // Eight bytes per iteration. A byte at a time makes the loop a chain of // dependent multiplies -- the same trap ge_renderer's own signature hash // documents avoiding, and hashing a whole raster that way is thousands of // them per texture. std::size_t index = 0u; for (; index + 8u <= length; index += 8u) { std::uint64_t word{}; std::memcpy(&word, bytes + index, sizeof(word)); mix(word); } if (index < length) { std::uint64_t tail = 0u; std::memcpy(&tail, bytes + index, length - index); mix(tail ^ (static_cast(length - index) << 56u)); } mix(width); mix(height); mix(depth); return hash != 0u ? hash : 1u; } std::vector texture_replacement_parse_tex_chunk( const std::uint8_t *chunk, std::size_t size) noexcept { std::vector out; if (chunk == nullptr || size < 0x40u || read_u32(chunk) != kTexIdent) return out; std::uint32_t file_size = read_u32(chunk + 8u); const std::uint32_t reloc = read_u32(chunk + 16u); if (file_size > size) file_size = static_cast(size); constexpr std::uint32_t head = 0x28u; if (reloc > file_size || head >= reloc) return out; // The container threads its texture records on a circular linked list whose // head sits at 0x28. Each link points at the record's *successor* field, so // the record itself begins eight bytes earlier. struct Record { std::uint32_t object{}; std::uint32_t raster{}; std::uint32_t data{}; }; std::vector records; std::vector allocations; std::uint32_t next = read_u32(chunk + head); while (next != head && next >= 8u && next + 72u < reloc && records.size() < 4096u) { const std::uint32_t object = next - 8u; const std::uint32_t raster = read_u32(chunk + object); if (raster + 16u > reloc) break; records.push_back(Record{object, raster, read_u32(chunk + raster + 4u)}); allocations.push_back(object); allocations.push_back(raster); allocations.push_back(records.back().data); const std::uint32_t following = read_u32(chunk + next); if (following == 0u) break; next = following; } if (records.empty()) return out; allocations.push_back(reloc); // A record's raster blob runs until whatever the container allocated next, // so its length is the distance to the nearest higher allocation. const auto blob_length = [&](std::uint32_t at) -> std::uint64_t { std::uint32_t best = reloc; for (const std::uint32_t offset : allocations) if (offset > at && offset < best) best = offset; return best > at ? static_cast(best - at) : 0u; }; for (const Record &record : records) { if (record.data >= reloc) continue; const std::uint64_t length = blob_length(record.data); if (length == 0u || record.data + length > reloc) continue; TextureIndexEntry entry{}; entry.name = upper_copy(read_ascii(chunk + record.object + 16u, 32u)); if (entry.name.empty()) continue; const std::int16_t min_width = read_s16(chunk + record.raster + 8u); const std::uint32_t log_w = chunk[record.raster + 10u]; const std::uint32_t log_h = chunk[record.raster + 11u]; entry.depth = chunk[record.raster + 12u]; entry.mipmaps = chunk[record.raster + 13u]; if ((entry.depth != 4u && entry.depth != 8u) || log_w > 12u || log_h > 12u) continue; entry.width = 1u << log_w; entry.height = 1u << log_h; const std::uint32_t row_pixels = std::max( entry.width, min_width > 0 ? static_cast(min_width) : 0u); const std::uint64_t row_bytes = static_cast(row_pixels) * entry.depth / 8u; const std::uint64_t base_bytes = row_bytes * entry.height; const std::uint64_t palette_bytes = (entry.depth == 4u ? 16u : 256u) * 4u; if (base_bytes == 0u || length < base_bytes + palette_bytes) continue; entry.archive_offset = record.data; entry.raster_size = base_bytes; entry.full_key = texture_replacement_content_key( chunk + record.data, static_cast(base_bytes), entry.width, entry.height, entry.depth, 0u); out.push_back(std::move(entry)); } return out; } void texture_replacement_index_archive(const std::filesystem::path &path) noexcept { State &s = state(); try { std::lock_guard guard(s.mutex); ensure_initialized(s); if (!s.enabled) 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); if (!input) return; const std::streamoff length = input.tellg(); if (length <= 0) return; input.seekg(0, std::ios::beg); std::vector bytes(static_cast(length)); input.read(reinterpret_cast(bytes.data()), length); if (!input) return; std::uint64_t containers = 0u; std::uint64_t textures = 0u; for (std::uint64_t offset = 0u; offset + 0x40u <= bytes.size(); offset += kSectorSize) { if (read_u32(bytes.data() + offset) != kTexIdent) continue; const std::size_t available = bytes.size() - static_cast(offset); auto entries = texture_replacement_parse_tex_chunk( bytes.data() + offset, available); if (entries.empty()) continue; ++containers; for (TextureIndexEntry &entry : entries) { entry.archive_offset += offset; ++textures; // A duplicate key usually means two textures are byte-identical // -- the game ships the same art under several names. Keep the // first; they cannot be told apart by content anyway. s.by_full_content.emplace(entry.full_key, std::move(entry)); } } s.containers_indexed += containers; s.textures_indexed += textures; std::ostringstream message; message << "texture index archive=\"" << path.filename().string() << "\" containers=" << containers << " textures=" << textures; runtime_log_line(message.str()); } catch (...) { // Indexing is an optional convenience; a malformed archive must never // take the game down with it. } } bool texture_replacement_lookup(const std::uint8_t *pixels, std::size_t size, std::uint32_t width, std::uint32_t height, std::uint32_t depth, TextureReplacement &out) noexcept { State &s = state(); try { std::lock_guard guard(s.mutex); if (!s.initialized || !s.enabled || s.overrides.empty() || pixels == nullptr || size == 0u) return false; // Whole-raster key. Stage 1 measured every one of this game's textures // reaching VRAM byte-identical to its archive copy, so the strong key is // usable -- and it has to be, because the leading-bytes key puts several // radar tiles in shared buckets and would swap the wrong one. const std::uint64_t key = texture_replacement_content_key(pixels, size, width, height, depth, 0u); const auto found = s.by_full_content.find(key); if (found == s.by_full_content.end()) return false; const std::string &name = found->second.name; const auto override_path = s.overrides.find(name); if (override_path == s.overrides.end()) return false; if (s.failed_decodes.count(name) != 0u) return false; auto decoded = s.decoded.find(name); if (decoded == s.decoded.end()) { DecodedImage 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: .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 << (image.has_transparency ? " alpha=forced" : " alpha=opaque"); runtime_log_line(message.str()); decoded = s.decoded.emplace(name, std::move(image)).first; } ++s.substituted_textures; out.rgba = decoded->second.rgba.data(); 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; } } void texture_replacement_log_summary() noexcept { State &s = state(); try { std::lock_guard guard(s.mutex); if (!s.initialized || !s.enabled) return; std::size_t matched_overrides = 0u; for (const auto &[name, path] : s.overrides) { (void)path; for (const auto &[key, entry] : s.by_full_content) { (void)key; if (entry.name == name) { ++matched_overrides; break; } } } std::ostringstream message; message << "texture replacement summary containers=" << s.containers_indexed << " indexed=" << s.textures_indexed << " substituted=" << s.substituted_textures << " decoded_dds=" << s.decoded.size() << " failed_dds=" << s.failed_decodes.size() << " dds_files=" << s.overrides.size() << " dds_names_found_in_game=" << matched_overrides; runtime_log_line(message.str()); } catch (...) { } } } // namespace vcs