From 6cca9622e71b60e4818a233aff101bd8e3162bc0 Mon Sep 17 00:00:00 2001 From: Jessica_Natalia Date: Sat, 15 Aug 2026 20:52:14 -0300 Subject: [PATCH] texture replacement texture replacement --- profiles/vcs/CMakeLists.txt | 1 + profiles/vcs/config/VCSNative.ini | 12 + profiles/vcs/host/ge_renderer.cpp | 75 ++- profiles/vcs/host/main.cpp | 2 + profiles/vcs/host/vcs_config.cpp | 20 + profiles/vcs/host/vcs_config.hpp | 10 + profiles/vcs/host/vcs_profile.cpp | 7 + profiles/vcs/host/vcs_texture_replacement.cpp | 628 ++++++++++++++++++ profiles/vcs/host/vcs_texture_replacement.hpp | 86 +++ profiles/vcs/tests/vcs_config_tests.cpp | 8 + 10 files changed, 842 insertions(+), 7 deletions(-) create mode 100644 profiles/vcs/host/vcs_texture_replacement.cpp create mode 100644 profiles/vcs/host/vcs_texture_replacement.hpp diff --git a/profiles/vcs/CMakeLists.txt b/profiles/vcs/CMakeLists.txt index 5b5f664..be6bf01 100644 --- a/profiles/vcs/CMakeLists.txt +++ b/profiles/vcs/CMakeLists.txt @@ -125,6 +125,7 @@ set(VCS_HOST_SOURCES host/framebuffer_capture.cpp host/display_window.cpp host/savedata_utility_ui.cpp + host/vcs_texture_replacement.cpp host/audio_output.cpp host/vcs_config.cpp host/vcs_camera_input.cpp diff --git a/profiles/vcs/config/VCSNative.ini b/profiles/vcs/config/VCSNative.ini index 6d14657..c03f689 100644 --- a/profiles/vcs/config/VCSNative.ini +++ b/profiles/vcs/config/VCSNative.ini @@ -85,6 +85,18 @@ NPCs=1.50 ; in-frame PSP Load Game slot picker instead. ; false = keyboard/gamepad only; no desktop cursor or mouse clicks in savedata. 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. +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". +Directory=TexturesDDS + [Controls] CameraStick=true MouseSensitivity=12 diff --git a/profiles/vcs/host/ge_renderer.cpp b/profiles/vcs/host/ge_renderer.cpp index 6ec8195..19b8bd5 100644 --- a/profiles/vcs/host/ge_renderer.cpp +++ b/profiles/vcs/host/ge_renderer.cpp @@ -4,6 +4,7 @@ #include "vcs_project2dfx.hpp" #include "vcs_fps_overlay.hpp" #include "savedata_utility_ui.hpp" +#include "vcs_texture_replacement.hpp" #include "psprecomp/common.hpp" @@ -2055,8 +2056,10 @@ TextureSetup make_texture_setup_for_level(const psprecomp::GuestMemory &memory, TextureSetup make_texture_setup(const psprecomp::GuestMemory &memory, const std::array &commands) noexcept { return make_texture_setup_for_level(memory,commands,selected_texture_level(commands)); } -std::uint64_t texture_source_signature(const psprecomp::GuestMemory &memory, - const TextureSetup &texture) noexcept { +// Size in bytes of a texture's stored image, padding included. Shared by the +// content signature and by the DDS replacement index, which has to hash exactly +// the same span the GE samples for its match to mean anything. +std::uint64_t texture_source_byte_size(const TextureSetup &texture) noexcept { if (texture.base == 0u || texture.width == 0u || texture.height == 0u) return 0u; std::uint64_t bytes = 0u; switch (texture.format) { @@ -2083,7 +2086,14 @@ std::uint64_t texture_source_signature(const psprecomp::GuestMemory &memory, row = (row + 15u) & ~15ull; bytes = row * ((static_cast(texture.height) + 7u) & ~7ull); } - if (bytes == 0u || bytes > std::numeric_limits::max()) return 0u; + if (bytes > std::numeric_limits::max()) return 0u; + return bytes; +} + +std::uint64_t texture_source_signature(const psprecomp::GuestMemory &memory, + const TextureSetup &texture) noexcept { + const std::uint64_t bytes = texture_source_byte_size(texture); + if (bytes == 0u) return 0u; const auto size = static_cast(bytes); const std::uint8_t *pixels = memory.raw_pointer(texture.base, size); if (pixels == nullptr) return 0u; @@ -4185,6 +4195,25 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, if (part == 0u) continue; any_signature = true; signature ^= part + 0x9E3779B97F4A7C15ull + (signature << 6u) + (signature >> 2u); + // Offer level 0 to the DDS replacement index. This site fires + // once per texture that is not already resident for the frame, + // so it identifies rather than running per draw. + if (level == 0u) { + const std::uint64_t source_bytes = texture_source_byte_size(source); + if (source_bytes != 0u && + source_bytes <= std::numeric_limits::max()) { + const auto span = static_cast(source_bytes); + // The archives store palettized art only, so formats 4 + // and 5 are the only ones that can ever match an index + // entry. Anything else reports depth 0 and simply misses. + const std::uint32_t depth = source.format == 4u ? 4u + : source.format == 5u ? 8u + : 0u; + texture_replacement_observe_texture( + memory.raw_pointer(source.base, span), span, + source.width, source.height, depth); + } + } } gpu_draw.texture_content_signature = any_signature ? signature : 0u; } @@ -4283,7 +4312,37 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, std::array mip_setups{}; std::size_t total_bytes = 0u; bool all = true; - for (std::uint32_t level = 0u; level < level_count; ++level) { + + // DDS replacement. The renderer already hands the backend plain RGBA8 + // for every texture, so a replacement is simply a different buffer on + // the same upload path -- no separate sampling route exists to go wrong. + // + // texture_width/height stay at the guest's values on purpose. The shader + // samples with normalized coordinates whose scale comes from the guest's + // texture matrix, not from the resident image, so leaving them alone lets + // a 512x512 replacement stand in for a 64x64 original with the UVs still + // landing where the game intends. + bool replaced = false; + if (!framebuffer_feedback) { + const TextureSetup base = make_texture_setup_for_level(memory, commands, 0u); + const std::uint64_t base_bytes = texture_source_byte_size(base); + if (base_bytes != 0u && base_bytes <= std::numeric_limits::max()) { + const auto span = static_cast(base_bytes); + const std::uint32_t depth = base.format == 4u ? 4u + : base.format == 5u ? 8u + : 0u; + TextureReplacement replacement{}; + if (texture_replacement_lookup(memory.raw_pointer(base.base, span), span, + base.width, base.height, depth, replacement)) { + std::vector pixels( + replacement.rgba, replacement.rgba + replacement.size); + replaced = ge_gpu_backend_upload_decoded_texture( + gpu_draw, replacement.width, replacement.height, pixels); + } + } + } + + for (std::uint32_t level = 0u; !replaced && level < level_count; ++level) { mip_setups[level] = make_texture_setup_for_level(memory, commands, level); const std::uint64_t bytes = static_cast(mip_setups[level].width) * mip_setups[level].height * 4ull; @@ -4294,11 +4353,11 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, total_bytes += static_cast(bytes); } std::vector decoded; - if (all) { + if (all && !replaced) { try { decoded.resize(total_bytes); } catch (...) { all = false; } } std::size_t offset = 0u; - for (std::uint32_t level = 0u; all && level < level_count; ++level) { + for (std::uint32_t level = 0u; all && !replaced && level < level_count; ++level) { const std::size_t bytes = static_cast(mip_setups[level].width) * mip_setups[level].height * 4u; if (!decode_texture_rgba_into(memory, mip_setups[level], @@ -4308,7 +4367,9 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, } offset += bytes; } - if (all) { + if (replaced) { + // Already uploaded above. + } else if (all) { (void)ge_gpu_backend_upload_decoded_texture_chain_packed( gpu_draw, mip_setups[0].width, mip_setups[0].height, level_count, std::move(decoded)); diff --git a/profiles/vcs/host/main.cpp b/profiles/vcs/host/main.cpp index 9c2d5b7..58d93dd 100644 --- a/profiles/vcs/host/main.cpp +++ b/profiles/vcs/host/main.cpp @@ -7,6 +7,7 @@ #include "ge_gpu_backend.hpp" #include "vcs_profile.hpp" #include "vcs_config.hpp" +#include "vcs_texture_replacement.hpp" #include "vcs_bootstrap_paths.hpp" #include "vcs_project2dfx.hpp" #include "vcs_hdr_post.hpp" @@ -444,6 +445,7 @@ int main(int argc, char **argv) { << gpu_final.last_texture_format << " texture_checksum=" << gpu_final.last_texture_checksum << "\n"; } + vcs::texture_replacement_log_summary(); if (shutdown_diag) std::cerr << "[shutdown] before-display-shutdown\n"; vcs::audio_output_shutdown(); vcs::display_window_shutdown(); diff --git a/profiles/vcs/host/vcs_config.cpp b/profiles/vcs/host/vcs_config.cpp index 00ac28d..7ad3f4d 100644 --- a/profiles/vcs/host/vcs_config.cpp +++ b/profiles/vcs/host/vcs_config.cpp @@ -471,6 +471,24 @@ void apply_frontend_key(VcsConfiguration &config, const std::string &key, warning(config, line, "unknown [Frontend] key '" + key + "'"); } +void apply_textures_key(VcsConfiguration &config, const std::string &key, + const std::string &value, std::size_t line) { + if (key == "enabled" || key == "ddsreplacement") { + if (!parse_bool(value, config.textures.enabled)) + warning(config, line, "Textures.Enabled expects true/false"); + return; + } + if (key == "directory" || key == "folder") { + const std::string trimmed = trim_copy(value); + if (trimmed.empty()) + warning(config, line, "Textures.Directory expects a path"); + else + config.textures.directory = trimmed; + return; + } + warning(config, line, "unknown [Textures] key '" + key + "'"); +} + void apply_controls_key(VcsConfiguration &config, const std::string &key, const std::string &value, std::size_t line) { if (key == "camerastick" || key == "mousecamera") { @@ -733,6 +751,8 @@ VcsConfiguration load_vcs_configuration(const std::filesystem::path &path) { apply_controls_key(config, key, value, line_number); else if (section == "frontend") apply_frontend_key(config, key, value, line_number); + else if (section == "textures" || section == "texturereplacement") + apply_textures_key(config, key, value, line_number); // These sections belong to the optional Project2DFX module, which // deliberately owns its parser so it can be compiled independently of // the core display/input configuration. They are nevertheless valid diff --git a/profiles/vcs/host/vcs_config.hpp b/profiles/vcs/host/vcs_config.hpp index c9afc89..122d448 100644 --- a/profiles/vcs/host/vcs_config.hpp +++ b/profiles/vcs/host/vcs_config.hpp @@ -221,6 +221,15 @@ struct FrontendConfiguration { bool mouse_menu{false}; }; +struct TexturesConfiguration { + // DDS texture replacement matched by the internal TEX name. Opt-in: with it + // off the host never opens the game's archives for indexing. + bool enabled{false}; + // Relative paths resolve against the executable directory. Subdirectories + // below it are scanned too and carry no meaning beyond organisation. + std::string directory{"TexturesDDS"}; +}; + struct ControlsConfiguration { // Mouse and right-stick camera. Needs the guest-side hook, which bypasses // the game's own camera conditions, so it is opt-in. @@ -249,6 +258,7 @@ struct ControlsConfiguration { struct VcsConfiguration { FrontendConfiguration frontend{}; + TexturesConfiguration textures{}; ControlsConfiguration controls{}; DisplayConfiguration display{}; RenderingConfiguration rendering{}; diff --git a/profiles/vcs/host/vcs_profile.cpp b/profiles/vcs/host/vcs_profile.cpp index aa3cfe9..15c55e7 100644 --- a/profiles/vcs/host/vcs_profile.cpp +++ b/profiles/vcs/host/vcs_profile.cpp @@ -12,6 +12,7 @@ #include "vcs_project2dfx.hpp" #include "vcs_draw_distance_patch.hpp" #include "savedata_utility_ui.hpp" +#include "vcs_texture_replacement.hpp" #include "psprecomp/common.hpp" #include "psprecomp/deflate.hpp" @@ -550,6 +551,12 @@ const VirtualDiscFile *register_virtual_disc_file(const std::filesystem::path &p return nullptr; } + // Indexing here, rather than by walking the asset directory, means only the + // archives the guest actually opens are indexed. Backup copies the user left + // sitting next to the real files are never picked up, and an asset mod that + // replaces an archive is indexed as it is opened. + texture_replacement_index_archive(path); + VirtualDiscFile item{}; item.native_path = path; item.start_sector = file_table.next_virtual_sector; diff --git a/profiles/vcs/host/vcs_texture_replacement.cpp b/profiles/vcs/host/vcs_texture_replacement.cpp new file mode 100644 index 0000000..d18579a --- /dev/null +++ b/profiles/vcs/host/vcs_texture_replacement.cpp @@ -0,0 +1,628 @@ +#include "vcs_texture_replacement.hpp" + +#include "vcs_config.hpp" +#include "vcs_runtime_log.hpp" + +#include +#include +#include +#include +#include +#include +#include +#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; +// Enough leading raster to identify a texture without hashing megabytes. The +// data is swizzled 4/8bpp indices, so the first block rows already differ +// between any two distinct textures in practice. +constexpr std::size_t kKeyBytes = 1024u; + +struct DecodedImage { + std::uint32_t width{}; + std::uint32_t height{}; + std::vector rgba; +}; + +struct State { + std::mutex mutex; + bool initialized{}; + bool enabled{}; + std::filesystem::path directory; + // content key -> index entry, one map per key strength + std::unordered_map by_content; + std::unordered_map by_full_content; + std::uint64_t matched_full{}; + // 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::unordered_set reported_hits; + std::uint64_t textures_indexed{}; + std::uint64_t containers_indexed{}; + std::uint64_t observed_textures{}; + std::uint64_t matched_textures{}; + 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; +} + +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; + if (upper_copy(entry.path().extension().string()) != ".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. + const std::string key = upper_copy(entry.path().stem().string()); + if (key.empty()) 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; + }; + for (std::size_t index = 0; index < length; ++index) mix(bytes[index]); + 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; + const auto raster = static_cast(base_bytes); + entry.content_key = texture_replacement_content_key( + chunk + record.data, raster, entry.width, entry.height, entry.depth, kKeyBytes); + entry.full_key = texture_replacement_content_key( + chunk + record.data, raster, 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; + if (upper_copy(path.extension().string()) != ".IMG") 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, entry); + s.by_content.emplace(entry.content_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. + } +} + +void texture_replacement_observe_texture(const std::uint8_t *pixels, + std::size_t size, + std::uint32_t width, + std::uint32_t height, + std::uint32_t depth) noexcept { + State &s = state(); + try { + std::lock_guard guard(s.mutex); + if (!s.initialized || !s.enabled || pixels == nullptr || size == 0u) return; + ++s.observed_textures; + const std::uint64_t key = texture_replacement_content_key( + pixels, size, width, height, depth, kKeyBytes); + const std::uint64_t full = texture_replacement_content_key( + pixels, size, width, height, depth, 0u); + const bool full_matched = s.by_full_content.count(full) != 0u; + if (full_matched) ++s.matched_full; + const auto found = s.by_content.find(key); + if (found == s.by_content.end()) return; + ++s.matched_textures; + // One line per distinct texture, not per draw. + if (!s.reported_hits.insert(key).second) return; + std::ostringstream message; + message << "texture match name=\"" << found->second.name << "\" " + << found->second.width << 'x' << found->second.height + << " depth=" << found->second.depth + << " archive_bytes=" << found->second.raster_size + << " vram_bytes=" << size + << (found->second.raster_size == size ? " size=exact" : " size=differs") + << (full_matched ? " key=full" : " key=leading_only") + << (s.overrides.count(found->second.name) != 0u ? " override=yes" + : " override=no"); + runtime_log_line(message.str()); + } catch (...) { + } +} + +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_dds_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"; + runtime_log_line(message.str()); + return false; + } + std::ostringstream message; + message << "texture replacement active name=\"" << name << "\" original=" + << found->second.width << 'x' << found->second.height + << " replacement=" << image.width << 'x' << image.height; + 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; + 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_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 + << " distinct_seen=" << s.reported_hits.size() + << " observed=" << s.observed_textures + << " matched=" << s.matched_textures + << " matched_full_key=" << s.matched_full + << " 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 diff --git a/profiles/vcs/host/vcs_texture_replacement.hpp b/profiles/vcs/host/vcs_texture_replacement.hpp new file mode 100644 index 0000000..42f75eb --- /dev/null +++ b/profiles/vcs/host/vcs_texture_replacement.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace vcs { + +// DDS texture replacement, matched by the *internal* texture name the game's +// own TEX containers carry. +// +// Why not the VRAM address: the address a texture happens to occupy is a +// transient property of one boot, and any mod that rebuilds the game's archives +// moves it. The name inside the TEX container is the stable, human-meaningful +// anchor, and it is re-read from whatever archives are actually installed, so an +// asset mod shifts the index with it instead of invalidating it. +// +// Nothing is precomputed or shipped. Both halves of the mapping -- the names and +// the content hashes that bridge them to what the GE samples -- are derived at +// runtime from the installed files. + +struct TextureIndexEntry { + std::string name; // internal TEX name, upper-cased for matching + std::uint32_t width{}; + std::uint32_t height{}; + std::uint32_t depth{}; // 4 or 8 bits per pixel, palettized + std::uint32_t mipmaps{}; + std::uint64_t archive_offset{}; + std::uint64_t raster_size{}; + // Two keys on purpose. The leading-bytes key tolerates the guest padding a + // texture's pitch on upload; the whole-raster key cannot, but discriminates + // far better. Measured on this game's main archive, the leading-bytes key + // puts three genuinely different textures into shared buckets -- all of them + // radar tiles, which share a uniform first kilobyte. Stage 1 carries both and + // reports which one the GE actually matches, so stage 2 can commit to the + // strongest key the guest's upload behaviour allows. + std::uint64_t content_key{}; + 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. +void texture_replacement_index_archive(const std::filesystem::path &path) noexcept; + +// Offers the raster bytes the GE is about to sample. Stage 1 only identifies and +// records; no substitution happens yet. Dimensions and bit depth participate in +// the key, so two textures now have to agree on shape *and* content to collide. +void texture_replacement_observe_texture(const std::uint8_t *pixels, + std::size_t size, + std::uint32_t width, + std::uint32_t height, + std::uint32_t depth) noexcept; + +// A decoded replacement, owned by the module and stable for the process's life. +struct TextureReplacement { + const std::byte *rgba{}; + std::size_t size{}; + std::uint32_t width{}; + std::uint32_t height{}; +}; + +// Looks up a replacement for the texture the GE is about to upload, decoding the +// .dds on first use. Returns false when there is no override for it, which is +// the overwhelmingly common case and costs one hash. +[[nodiscard]] 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; + +// Writes the index/override/match tally to the runtime log. Called at shutdown. +void texture_replacement_log_summary() noexcept; + +// Exposed for tests. +[[nodiscard]] std::vector texture_replacement_parse_tex_chunk( + const std::uint8_t *chunk, std::size_t size) noexcept; +// hash_bytes caps how much raster takes part; 0 means the whole thing. +[[nodiscard]] 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; + +} // namespace vcs diff --git a/profiles/vcs/tests/vcs_config_tests.cpp b/profiles/vcs/tests/vcs_config_tests.cpp index d8be43b..30f0eba 100644 --- a/profiles/vcs/tests/vcs_config_tests.cpp +++ b/profiles/vcs/tests/vcs_config_tests.cpp @@ -53,6 +53,9 @@ int main() { << "FlushEveryLine=false\n" << "[Frontend]\n" << "MouseMenu=false\n" + << "[Textures]\n" + << "Enabled=true\n" + << "Directory=MyTextures\n" << "[Controls]\n" << "CameraStick=true\n" << "MouseSensitivity=17\n" @@ -135,6 +138,9 @@ int main() { "Diagnostics.FlushEveryLine was not parsed"); require(!config.frontend.mouse_menu, "Frontend.MouseMenu was not parsed"); + require(config.textures.enabled, "Textures.Enabled was not parsed"); + require(config.textures.directory == "MyTextures", + "Textures.Directory was not parsed"); require(config.controls.camera_stick, "camera stick was not parsed"); require(config.controls.mouse_sensitivity == 17u, "mouse sensitivity was not parsed"); @@ -250,6 +256,8 @@ int main() { "missing INI did not preserve the stock on-foot camera upper limit"); require(!missing.frontend.mouse_menu, "missing INI did not preserve disabled pause-menu mouse default"); + require(!missing.textures.enabled && missing.textures.directory == "TexturesDDS", + "missing INI did not preserve the texture replacement defaults"); const vcs::InternalResolutionDimensions native = vcs::resolve_internal_resolution(missing.rendering); require(native.width == 480u && native.height == 272u,