#include "texture_replacement.hpp" #include "../internal.hpp" #include "../gx/gx.hpp" #include "../webgpu/gpu.hpp" #include "dds_io.hpp" #include "texture_convert.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace aurora::gx; using aurora::webgpu::g_device; namespace aurora::gfx::texture_replacement { Module Log("aurora::gfx::texture_replacement"); struct RuntimeTextureKey { uint64_t textureHash = 0; uint64_t tlutHash = 0; uint32_t width = 0; uint32_t height = 0; bool hasMips = false; bool hasTlut = false; uint32_t format = 0; bool operator==(const RuntimeTextureKey& rhs) const = default; template friend H AbslHashValue(H h, const RuntimeTextureKey& key) { return H::combine(std::move(h), key.textureHash, key.tlutHash, key.width, key.height, key.hasMips, key.hasTlut, key.format); } }; struct TlutMetadata { uint32_t size = 0; uint32_t format = 0; uint16_t entries = 0; bool valid = false; ByteBuffer data; }; struct CachedReplacement { gfx::TextureHandle handle; uint64_t bytes = 0; std::list::iterator lruIt; }; absl::flat_hash_map s_replacementIndex; absl::flat_hash_map s_replacementCache; absl::flat_hash_set s_failedKeys; absl::flat_hash_set s_reportedMisses; absl::flat_hash_map s_pendingTluts; std::array s_loadedTluts{}; std::list s_replacementLru; std::filesystem::path s_replacementRoot; std::filesystem::path s_dumpRoot; uint64_t s_replacementCacheBytes = 0; constexpr uint64_t kReplacementCacheBudgetBytes = 4294967296; // 4GB, reasonable for modern hardware? bool iequals_ascii(std::string_view lhs, std::string_view rhs) noexcept { if (lhs.size() != rhs.size()) { return false; } for (size_t i = 0; i < lhs.size(); ++i) { if (std::tolower(static_cast(lhs[i])) != std::tolower(static_cast(rhs[i]))) { return false; } } return true; } bool is_relative_to(const std::filesystem::path& path, const std::filesystem::path& root) noexcept { if (root.empty()) { return false; } auto pathIt = path.begin(); auto rootIt = root.begin(); for (; rootIt != root.end(); ++rootIt, ++pathIt) { if (pathIt == path.end() || !iequals_ascii(pathIt->string(), rootIt->string())) { return false; } } return true; } std::optional parse_hex(std::string_view text) noexcept { if (text.empty()) { return std::nullopt; } uint64_t value = 0; for (const char ch : text) { value <<= 4; if (ch >= '0' && ch <= '9') { value |= static_cast(ch - '0'); } else if (ch >= 'a' && ch <= 'f') { value |= static_cast(ch - 'a' + 10); } else if (ch >= 'A' && ch <= 'F') { value |= static_cast(ch - 'A' + 10); } else { return std::nullopt; } } return value; } std::optional parse_u32(std::string_view text, int base = 10) noexcept { if (text.empty()) { return std::nullopt; } uint32_t value = 0; const auto* begin = text.data(); const auto* end = begin + text.size(); const auto [ptr, ec] = std::from_chars(begin, end, value, base); if (ec != std::errc{} || ptr != end) { return std::nullopt; } return value; } std::optional> parse_dimensions(std::string_view text) noexcept { const size_t sep = text.find('x'); if (sep == std::string_view::npos) { return std::nullopt; } const auto width = parse_u32(text.substr(0, sep)); const auto height = parse_u32(text.substr(sep + 1)); if (!width.has_value() || !height.has_value()) { return std::nullopt; } return std::pair{*width, *height}; } uint32_t texture_base_level_size(const GXTexObj_& obj) noexcept { switch (obj.format()) { case GX_TF_R8_PC: return obj.width() * obj.height(); case GX_TF_RGBA8_PC: return obj.width() * obj.height() * 4; default: return GXGetTexBufferSize(obj.width(), obj.height(), obj.format(), false, 0); } } std::optional compute_referenced_tlut_hash(const GXTexObj_& obj) noexcept { if (!is_palette_format(obj.format()) || obj.tlut >= s_loadedTluts.size()) { return std::nullopt; } const auto& tlut = s_loadedTluts[obj.tlut]; const uint32_t textureSize = texture_base_level_size(obj); const auto* textureData = static_cast(obj.data); if (!tlut.valid || textureData == nullptr || textureSize == 0) { return std::nullopt; } uint32_t minIndex = 0xffff; uint32_t maxIndex = 0; switch (obj.format()) { case GX_TF_C4: for (uint32_t i = 0; i < textureSize; ++i) { const uint32_t lowNibble = textureData[i] & 0xf; const uint32_t highNibble = textureData[i] >> 4; minIndex = std::min({minIndex, lowNibble, highNibble}); maxIndex = std::max({maxIndex, lowNibble, highNibble}); } break; case GX_TF_C8: for (uint32_t i = 0; i < textureSize; ++i) { const uint32_t index = textureData[i]; minIndex = std::min(minIndex, index); maxIndex = std::max(maxIndex, index); } break; case GX_TF_C14X2: for (uint32_t i = 0; i + sizeof(uint16_t) <= textureSize; i += sizeof(uint16_t)) { uint16_t value = 0; std::memcpy(&value, textureData + i, sizeof(value)); const uint32_t index = bswap(value) & 0x3fff; minIndex = std::min(minIndex, index); maxIndex = std::max(maxIndex, index); } break; default: return std::nullopt; } size_t tlutSize = 2 * (static_cast(maxIndex) + 1 - minIndex); const size_t tlutOffset = 2 * static_cast(minIndex); if (tlutOffset + tlutSize > tlut.data.size()) { return std::nullopt; } return XXH64(tlut.data.data() + tlutOffset, tlutSize, 0); } const TlutMetadata* get_loaded_tlut(const GXTexObj_& obj) noexcept { if (!is_palette_format(obj.format()) || obj.tlut >= s_loadedTluts.size()) { return nullptr; } const auto& tlut = s_loadedTluts[obj.tlut]; return tlut.valid ? &tlut : nullptr; } std::optional tlut_to_texture_format(uint32_t tlutFormat) noexcept { switch (tlutFormat) { case GX_TL_IA8: return GX_TF_IA8; case GX_TL_RGB565: return GX_TF_RGB565; case GX_TL_RGB5A3: return GX_TF_RGB5A3; default: return std::nullopt; } } bool ensure_directory(const std::filesystem::path& dir) noexcept { std::error_code ec; std::filesystem::create_directories(dir, ec); return !ec; } RuntimeTextureKey build_runtime_key(const GXTexObj_& obj) noexcept { RuntimeTextureKey key{ .width = obj.width(), .height = obj.height(), .hasMips = obj.has_mips(), .hasTlut = is_palette_format(obj.format()), .format = obj.format(), }; const uint32_t textureSize = texture_base_level_size(obj); if (obj.data != nullptr && textureSize != 0) { key.textureHash = XXH64(obj.data, textureSize, 0); } if (key.hasTlut) { key.tlutHash = compute_referenced_tlut_hash(obj).value_or(0); } return key; } std::string format_replacement_filename(const RuntimeTextureKey& key) { if (key.hasTlut) { return fmt::format("tex1_{}x{}{}_{:016x}_{:016x}_{}.dds", key.width, key.height, key.hasMips ? "_m" : "", key.textureHash, key.tlutHash, key.format); } return fmt::format("tex1_{}x{}{}_{:016x}_{}.dds", key.width, key.height, key.hasMips ? "_m" : "", key.textureHash, key.format); } std::optional parse_replacement_filename(std::string_view filename) noexcept { const size_t dot = filename.rfind('.'); if (dot == std::string_view::npos || !iequals_ascii(filename.substr(dot), ".dds")) { return std::nullopt; } const std::string_view stem = filename.substr(0, dot); constexpr std::string_view prefix = "tex1_"; if (!stem.starts_with(prefix)) { return std::nullopt; } std::array parts{}; size_t partCount = 0; size_t offset = 0; bool consumedAll = false; while (offset <= stem.size() && partCount < parts.size()) { const size_t next = stem.find('_', offset); parts[partCount++] = stem.substr(offset, next == std::string_view::npos ? stem.size() - offset : next - offset); if (next == std::string_view::npos) { consumedAll = true; break; } offset = next + 1; } if (!consumedAll || partCount < 4 || partCount > 6 || parts[0] != "tex1") { return std::nullopt; } const auto dimensions = parse_dimensions(parts[1]); if (!dimensions.has_value()) { return std::nullopt; } size_t index = 2; bool hasMips = false; if (parts[index] == "m") { hasMips = true; ++index; } const size_t remaining = partCount - index; if (remaining != 2 && remaining != 3) { return std::nullopt; } const auto texHash = parse_hex(parts[index]); const auto format = parse_u32(parts[partCount - 1]); if (!texHash.has_value() || !format.has_value()) { return std::nullopt; } uint64_t tlutHash = 0; const bool hasTlut = remaining == 3; if (hasTlut) { const auto parsedTlutHash = parse_hex(parts[index + 1]); if (!parsedTlutHash.has_value()) { return std::nullopt; } tlutHash = *parsedTlutHash; } return RuntimeTextureKey{ .textureHash = *texHash, .tlutHash = tlutHash, .width = dimensions->first, .height = dimensions->second, .hasMips = hasMips, .hasTlut = hasTlut, .format = *format, }; } void touch_cached_replacement(decltype(s_replacementCache)::iterator it) noexcept { if (it->second.lruIt != s_replacementLru.begin()) { s_replacementLru.splice(s_replacementLru.begin(), s_replacementLru, it->second.lruIt); it->second.lruIt = s_replacementLru.begin(); } } void evict_replacement_cache_if_needed() noexcept { while (s_replacementCacheBytes > kReplacementCacheBudgetBytes && !s_replacementLru.empty()) { const RuntimeTextureKey key = s_replacementLru.back(); s_replacementLru.pop_back(); const auto it = s_replacementCache.find(key); if (it == s_replacementCache.end()) { continue; } const uint64_t entryBytes = it->second.bytes; s_replacementCache.erase(it); s_replacementCacheBytes -= std::min(s_replacementCacheBytes, entryBytes); } } void build_index() noexcept { if (!g_config.allowTextureReplacements) { return; } s_replacementRoot = std::filesystem::path{g_config.configPath} / "texture_replacements"; s_dumpRoot = std::filesystem::path{g_config.configPath} / "texture_dumps"; if (!ensure_directory(s_replacementRoot) || !ensure_directory(s_dumpRoot)) { return; } std::error_code ec; for (std::filesystem::recursive_directory_iterator it(s_replacementRoot, std::filesystem::directory_options::skip_permission_denied, ec); it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { if (ec) { break; } if (!it->is_regular_file()) { continue; } const auto& path = it->path(); if (is_relative_to(path, s_dumpRoot)) { continue; } if (!iequals_ascii(path.extension().string(), ".dds")) { continue; } const auto parsed = parse_replacement_filename(path.filename().string()); if (!parsed.has_value()) { continue; } s_replacementIndex.try_emplace(*parsed, path); } } const std::filesystem::path* find_replacement_path(const RuntimeTextureKey& key) noexcept { const auto indexed = s_replacementIndex.find(key); return indexed != s_replacementIndex.end() ? &indexed->second : nullptr; } const gfx::TextureHandle* find_cached_replacement(const RuntimeTextureKey& key) noexcept { const auto cached = s_replacementCache.find(key); if (cached == s_replacementCache.end()) { return nullptr; } touch_cached_replacement(cached); return &cached->second.handle; } gfx::TextureHandle load_replacement_texture(const RuntimeTextureKey& key, const std::filesystem::path& path) noexcept { const auto replacement = dds::load_dds_file(path); if (!replacement.has_value()) { s_failedKeys.insert(key); return {}; } const auto label = fmt::format("TextureReplacement {}", format_replacement_filename(key)); const wgpu::Extent3D size{ .width = replacement->width, .height = replacement->height, .depthOrArrayLayers = 1, }; const wgpu::TextureDescriptor textureDescriptor{ .label = label.c_str(), .usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst, .dimension = wgpu::TextureDimension::e2D, .size = size, .format = replacement->format, .mipLevelCount = replacement->mips, .sampleCount = 1, }; auto texture = g_device.CreateTexture(&textureDescriptor); const auto viewLabel = fmt::format("{} view", label); const wgpu::TextureViewDescriptor textureViewDescriptor{ .label = viewLabel.c_str(), .format = replacement->format, .dimension = wgpu::TextureViewDimension::e2D, .mipLevelCount = replacement->mips, }; auto textureView = texture.CreateView(&textureViewDescriptor); auto handle = std::make_shared(std::move(texture), std::move(textureView), wgpu::TextureView{}, size, replacement->format, replacement->mips, gfx::InvalidTextureFormat); gfx::write_texture(*handle, replacement->data); return handle; } void cache_replacement(const RuntimeTextureKey& key, const gfx::TextureHandle& handle) noexcept { const uint64_t replacementBytes = calc_texture_size(handle->format, handle->size.width, handle->size.height, handle->mipCount); s_replacementLru.push_front(key); s_replacementCache.emplace( key, CachedReplacement{.handle = handle, .bytes = replacementBytes, .lruIt = s_replacementLru.begin()}); s_replacementCacheBytes += replacementBytes; evict_replacement_cache_if_needed(); } void bind_replacement(GXTexObj_& obj, GXTexMapID id, const gfx::TextureHandle& handle) noexcept { GXTexObj_ out = obj; out.mWidth = handle->size.width; out.mHeight = handle->size.height; out.mFormat = GX_TF_RGBA8_PC; g_gxState.textures[id] = gfx::TextureBind(out, handle); g_gxState.stateDirty = true; } bool dump_editable_texture_dds(const RuntimeTextureKey& key, const GXTexObj_& obj) noexcept { const auto pixels = convert_texture(obj.format(), obj.width(), obj.height(), obj.mip_count(), {static_cast(obj.data), UINT32_MAX}); if (pixels.data.empty()) { return false; } const auto path = s_dumpRoot / format_replacement_filename(key); return dds::write_rgba8_dds(path, obj.width(), obj.height(), pixels.data); } bool report_missing_key(const RuntimeTextureKey& key, const GXTexObj_& obj) noexcept { if (!s_reportedMisses.insert(key).second) { return false; } if (g_config.allowTextureDumps) { dump_editable_texture_dds(key, obj); } return true; } void initialize() noexcept { build_index(); } void shutdown() noexcept { s_replacementIndex.clear(); s_replacementCache.clear(); s_failedKeys.clear(); s_reportedMisses.clear(); s_pendingTluts.clear(); for (auto& tlut : s_loadedTluts) { tlut = {}; } s_replacementLru.clear(); s_replacementCacheBytes = 0; s_replacementRoot.clear(); s_dumpRoot.clear(); } void register_tlut(const GXTlutObj* obj, const void* data, GXTlutFmt format, uint16_t entries) noexcept { if (obj == nullptr || data == nullptr) { return; } const size_t sz = static_cast(entries) * 2; ByteBuffer buffer{sz}; std::memcpy(buffer.data(), buffer.data(), sz); s_pendingTluts[obj] = { .size = static_cast(entries) * 2, .format = static_cast(format), .entries = entries, .valid = true, .data = std::move(buffer), }; } void load_tlut(const GXTlutObj* obj, uint32_t idx) noexcept { if (idx >= s_loadedTluts.size()) { return; } const auto it = s_pendingTluts.find(obj); if (it == s_pendingTluts.end()) { s_loadedTluts[idx] = {}; return; } const auto& pending = it->second; s_loadedTluts[idx] = { .size = pending.size, .format = pending.format, .entries = pending.entries, .valid = pending.valid, .data = pending.data.clone(), }; } bool try_bind_replacement(GXTexObj_& obj, GXTexMapID id) noexcept { if (!g_config.allowTextureReplacements) { return false; } const auto handle = find_replacement(obj); if (!handle.has_value()) { return false; } bind_replacement(obj, id, *handle); return true; } std::optional find_replacement(const GXTexObj_& obj) noexcept { ZoneScoped; if (!g_config.allowTextureReplacements) { return std::nullopt; } const RuntimeTextureKey key = build_runtime_key(obj); const auto* path = find_replacement_path(key); if (path == nullptr) { report_missing_key(key, obj); return std::nullopt; } if (const auto* cached = find_cached_replacement(key); cached != nullptr) { return *cached; } if (s_failedKeys.contains(key)) { return std::nullopt; } auto handle = load_replacement_texture(key, *path); if (!handle) { return std::nullopt; } cache_replacement(key, handle); return handle; } } // namespace aurora::gfx::texture_replacement