mirror of
https://github.com/jessicanataliagta/PSPRecomp
synced 2026-09-26 16:49:34 -04:00
texture replacement
texture replacement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<std::uint32_t, 256> &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<std::uint64_t>(texture.height) + 7u) & ~7ull);
|
||||
}
|
||||
if (bytes == 0u || bytes > std::numeric_limits<std::size_t>::max()) return 0u;
|
||||
if (bytes > std::numeric_limits<std::size_t>::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<std::size_t>(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<std::size_t>::max()) {
|
||||
const auto span = static_cast<std::size_t>(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<TextureSetup, 8> 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<std::size_t>::max()) {
|
||||
const auto span = static_cast<std::size_t>(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<std::byte> 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<std::uint64_t>(mip_setups[level].width) *
|
||||
mip_setups[level].height * 4ull;
|
||||
@@ -4294,11 +4353,11 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory,
|
||||
total_bytes += static_cast<std::size_t>(bytes);
|
||||
}
|
||||
std::vector<std::byte> 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<std::size_t>(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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
#include "vcs_texture_replacement.hpp"
|
||||
|
||||
#include "vcs_config.hpp"
|
||||
#include "vcs_runtime_log.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
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<std::byte> 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<std::uint64_t, TextureIndexEntry> by_content;
|
||||
std::unordered_map<std::uint64_t, TextureIndexEntry> by_full_content;
|
||||
std::uint64_t matched_full{};
|
||||
// upper-cased internal name -> .dds path supplied by the user
|
||||
std::unordered_map<std::string, std::filesystem::path> 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<std::string, DecodedImage> decoded;
|
||||
std::unordered_set<std::string> failed_decodes;
|
||||
std::unordered_set<std::string> indexed_archives;
|
||||
std::unordered_set<std::uint64_t> 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<char>(std::toupper(ch));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
std::uint32_t read_u32(const std::uint8_t *bytes) noexcept {
|
||||
return static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8u) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16u) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24u);
|
||||
}
|
||||
|
||||
std::int16_t read_s16(const std::uint8_t *bytes) noexcept {
|
||||
return static_cast<std::int16_t>(static_cast<std::uint16_t>(bytes[0]) |
|
||||
(static_cast<std::uint16_t>(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<char>(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<std::uint32_t>(static_cast<unsigned char>(a)) |
|
||||
(static_cast<std::uint32_t>(static_cast<unsigned char>(b)) << 8u) |
|
||||
(static_cast<std::uint32_t>(static_cast<unsigned char>(c)) << 16u) |
|
||||
(static_cast<std::uint32_t>(static_cast<unsigned char>(d)) << 24u);
|
||||
}
|
||||
|
||||
void write_pixel(std::vector<std::byte> &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<std::size_t>(y) * width + x) * 4u;
|
||||
rgba[at + 0u] = static_cast<std::byte>(r);
|
||||
rgba[at + 1u] = static_cast<std::byte>(g);
|
||||
rgba[at + 2u] = static_cast<std::byte>(b);
|
||||
rgba[at + 3u] = static_cast<std::byte>(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<std::uint8_t, 16> &r, std::array<std::uint8_t, 16> &g,
|
||||
std::array<std::uint8_t, 16> &b, std::array<std::uint8_t, 16> &a) {
|
||||
const std::uint16_t c0 = static_cast<std::uint16_t>(block[0] | (block[1] << 8));
|
||||
const std::uint16_t c1 = static_cast<std::uint16_t>(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<std::uint8_t>((r5 * 255u + 15u) / 31u);
|
||||
eg = static_cast<std::uint8_t>((g6 * 255u + 31u) / 63u);
|
||||
eb = static_cast<std::uint8_t>((b5 * 255u + 15u) / 31u);
|
||||
};
|
||||
std::array<std::uint8_t, 4> 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<std::uint8_t>((2u * pr[0] + pr[1]) / 3u);
|
||||
pg[2] = static_cast<std::uint8_t>((2u * pg[0] + pg[1]) / 3u);
|
||||
pb[2] = static_cast<std::uint8_t>((2u * pb[0] + pb[1]) / 3u);
|
||||
pr[3] = static_cast<std::uint8_t>((pr[0] + 2u * pr[1]) / 3u);
|
||||
pg[3] = static_cast<std::uint8_t>((pg[0] + 2u * pg[1]) / 3u);
|
||||
pb[3] = static_cast<std::uint8_t>((pb[0] + 2u * pb[1]) / 3u);
|
||||
} else {
|
||||
pr[2] = static_cast<std::uint8_t>((pr[0] + pr[1]) / 2u);
|
||||
pg[2] = static_cast<std::uint8_t>((pg[0] + pg[1]) / 2u);
|
||||
pb[2] = static_cast<std::uint8_t>((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<std::uint8_t, 16> &a) {
|
||||
std::array<std::uint8_t, 8> 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<std::uint8_t>(
|
||||
((7u - i) * values[0] + i * values[1]) / 7u);
|
||||
} else {
|
||||
for (std::uint32_t i = 1; i < 5u; ++i)
|
||||
values[i + 1u] = static_cast<std::uint8_t>(
|
||||
((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<std::uint64_t>(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<std::uint64_t>(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<std::uint8_t, 16> 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<std::uint8_t>(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<std::uint8_t>((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<std::uint8_t> bytes(static_cast<std::size_t>(length));
|
||||
input.read(reinterpret_cast<char *>(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<std::size_t>(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<std::size_t>(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<std::uint32_t>(pixel[0]) |
|
||||
(static_cast<std::uint32_t>(pixel[1]) << 8u) |
|
||||
(static_cast<std::uint32_t>(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<TextureIndexEntry> texture_replacement_parse_tex_chunk(
|
||||
const std::uint8_t *chunk, std::size_t size) noexcept {
|
||||
std::vector<TextureIndexEntry> 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<std::uint32_t>(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<Record> records;
|
||||
std::vector<std::uint32_t> 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<std::uint64_t>(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<std::uint32_t>(
|
||||
entry.width, min_width > 0 ? static_cast<std::uint32_t>(min_width) : 0u);
|
||||
const std::uint64_t row_bytes =
|
||||
static_cast<std::uint64_t>(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<std::size_t>(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<std::mutex> 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<std::uint8_t> bytes(static_cast<std::size_t>(length));
|
||||
input.read(reinterpret_cast<char *>(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<std::size_t>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<TextureIndexEntry> 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user