#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 #include "png_io.hpp" #include "../fs_helper.hpp" 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 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.hasTlut, key.format); } }; struct TlutMetadata { uint32_t size = 0; uint32_t format = 0; uint16_t entries = 0; bool valid = false; // Object the palette was registered from. GXLoadTlut consumes the registration, so this is what // lets the same object be loaded into more than one hardware slot. const GXTlutObj* source = nullptr; ByteBuffer data; }; struct CachedReplacement { gfx::TextureHandle handle; uint64_t bytes = 0; std::list::iterator lruIt; }; struct ReplacementIndexEntry { std::filesystem::path path; }; 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? constexpr uint64_t kReplacementWildcardTextureHash = 0xFFFFFFFFFFFFFFFFull; constexpr uint64_t kReplacementWildcardTlutHash = 0xFFFFFFFFFFFFFFFEull; 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(fs_path_to_string(*pathIt), fs_path_to_string(*rootIt))) { return false; } } return true; } bool is_sidecar_mip(std::string_view stem) noexcept { constexpr std::string_view tag = "_mip"; size_t i = stem.size(); while (i > 0 && stem[i - 1] >= '0' && stem[i - 1] <= '9') { --i; } if (i == stem.size() || i < tag.size()) { return false; } return stem.substr(i - tag.size(), tag.size()) == tag; } 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; } 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(), .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.textureHash, key.tlutHash, key.format); } return fmt::format("tex1_{}x{}_{:016x}_{}.dds", key.width, key.height, 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) { return std::nullopt; } if (!iequals_ascii(filename.substr(dot), ".dds") && !iequals_ascii(filename.substr(dot), ".png")) { 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; if (parts[index] == "m") { ++index; } size_t remaining = partCount - index; if (remaining != 2 && remaining != 3) { return std::nullopt; } uint64_t textureHash = 0; if (parts[index] == "$") { textureHash = kReplacementWildcardTextureHash; } else { const auto parsedTex = parse_hex(parts[index]); if (!parsedTex.has_value()) { return std::nullopt; } textureHash = *parsedTex; } auto formatPart = parts[partCount - 1]; if (formatPart == "arb") { formatPart = parts[partCount - 2]; remaining -= 1; } const auto format = parse_u32(formatPart); if (!format.has_value()) { return std::nullopt; } uint64_t tlutHash = 0; const bool hasTlut = remaining == 3; if (hasTlut) { const std::string_view tlutPart = parts[index + 1]; if (tlutPart == "$") { tlutHash = kReplacementWildcardTlutHash; } else { const auto parsedTlutHash = parse_hex(tlutPart); if (!parsedTlutHash.has_value()) { return std::nullopt; } tlutHash = *parsedTlutHash; } } return RuntimeTextureKey{ .textureHash = textureHash, .tlutHash = tlutHash, .width = dimensions->first, .height = dimensions->second, .hasTlut = hasTlut, .format = *format, }; } static std::optional load_texture_file(const std::filesystem::path& path) { if (path.extension() == ".png") { return png::load_png_file(path); } else { return dds::load_dds_file(path); } } constexpr bool isUnsupportedTextureFormat(const ConvertedTexture& texture) { switch (texture.format) { case wgpu::TextureFormat::BC1RGBAUnorm: case wgpu::TextureFormat::BC1RGBAUnormSrgb: case wgpu::TextureFormat::BC2RGBAUnorm: case wgpu::TextureFormat::BC2RGBAUnormSrgb: case wgpu::TextureFormat::BC3RGBAUnorm: case wgpu::TextureFormat::BC3RGBAUnormSrgb: case wgpu::TextureFormat::BC4RUnorm: case wgpu::TextureFormat::BC4RSnorm: case wgpu::TextureFormat::BC5RGUnorm: case wgpu::TextureFormat::BC5RGSnorm: case wgpu::TextureFormat::BC6HRGBUfloat: case wgpu::TextureFormat::BC6HRGBFloat: case wgpu::TextureFormat::BC7RGBAUnorm: case wgpu::TextureFormat::BC7RGBAUnormSrgb: return !webgpu::g_bcTexturesSupported; default: return false; } } std::optional load_replacement(const ReplacementIndexEntry& entry) noexcept { auto base = load_texture_file(entry.path); if (!base.has_value()) { Log.warn("texture_replacement: failed to load texture {}", fs_path_to_string(entry.path)); return std::nullopt; } if (isUnsupportedTextureFormat(base.value())) { Log.warn( "texture_replacement: failed to load texture {} due to unsupported format: {}", fs_path_to_string(entry.path), static_cast(base->format)); return std::nullopt; } std::vector more; std::error_code ec; for (uint32_t mipLevel = 1;; ++mipLevel) { const auto mipPath = entry.path.parent_path() / fmt::format("{}_mip{}{}", fs_path_to_string(entry.path.stem()), mipLevel, fs_path_to_string(entry.path.extension())); if (!std::filesystem::is_regular_file(mipPath, ec)) { break; } auto lvl = load_texture_file(mipPath); const uint32_t ew = std::max(base->width >> mipLevel, 1u); const uint32_t eh = std::max(base->height >> mipLevel, 1u); const bool ok = lvl.has_value() && lvl->format == base->format && lvl->width == ew && lvl->height == eh; if (!ok) { if (!lvl.has_value()) { Log.warn("texture_replacement: could not load mip {}", fs_path_to_string(mipPath)); } else { Log.warn("texture_replacement: expected {}x{} for mip {}, got {}x{}", ew, eh, fs_path_to_string(mipPath), lvl->width, lvl->height); } break; } more.push_back(std::move(*lvl)); } if (more.empty()) { return base; } const uint32_t mips = 1u + static_cast(more.size()); const uint64_t n = calc_texture_size(base->format, base->width, base->height, mips); if (n == 0) { return std::nullopt; } ByteBuffer blob{static_cast(n)}; uint8_t* const dst = blob.data(); uint64_t o = 0; const auto append = [&](const ByteBuffer& d) noexcept -> bool { if (o + d.size() > n) { return false; } std::memcpy(dst + o, d.data(), d.size()); o += d.size(); return true; }; if (!append(base->data)) { return std::nullopt; } for (const auto& mip : more) { if (!append(mip.data)) { return std::nullopt; } } if (o != n) { return std::nullopt; } return ConvertedTexture{ .format = base->format, .width = base->width, .height = base->height, .mips = mips, .data = std::move(blob), }; } 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; } auto userPath = std::filesystem::path{reinterpret_cast(g_config.userPath)}; auto cachePath = std::filesystem::path{reinterpret_cast(g_config.cachePath)}; s_replacementRoot = userPath / "texture_replacements"; s_dumpRoot = cachePath / "texture_dumps"; if (!ensure_directory(s_replacementRoot)) { return; } if (g_config.allowTextureDumps && !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 | std::filesystem::directory_options::follow_directory_symlink, 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(fs_path_to_string(path.extension()), ".dds") && !iequals_ascii(fs_path_to_string(path.extension()), ".png")) { continue; } if (is_sidecar_mip(fs_path_to_string(path.stem()))) { continue; } const auto parsed = parse_replacement_filename(fs_path_to_string(path.filename())); if (!parsed.has_value()) { continue; } s_replacementIndex.try_emplace(*parsed, path); } Log.info("Indexed {} texture replacements", s_replacementIndex.size()); } const ReplacementIndexEntry* find_replacement_path(const RuntimeTextureKey& key) noexcept { if (const auto it = s_replacementIndex.find(key); it != s_replacementIndex.end()) { return &it->second; } if (key.hasTlut) { RuntimeTextureKey tlutWildcardKey = key; tlutWildcardKey.tlutHash = kReplacementWildcardTlutHash; if (const auto it = s_replacementIndex.find(tlutWildcardKey); it != s_replacementIndex.end()) { return &it->second; } } RuntimeTextureKey textureWildcardKey = key; textureWildcardKey.textureHash = kReplacementWildcardTextureHash; if (const auto it = s_replacementIndex.find(textureWildcardKey); it != s_replacementIndex.end()) { return &it->second; } return 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 ReplacementIndexEntry& entry) noexcept { const auto replacement = load_replacement(entry); 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); handle->isReplacement = true; 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(); } bool dump_editable_texture_dds(const RuntimeTextureKey& key, const GXTexObj_& obj) noexcept { const ArrayRef texData{static_cast(obj.data), UINT32_MAX}; const uint32_t texWidth = obj.width(); const uint32_t texHeight = obj.height(); ConvertedTexture pixels; if (is_palette_format(obj.format())) { const TlutMetadata* tlut = get_loaded_tlut(obj); if (tlut == nullptr) { return false; } pixels = convert_texture_palette(obj.format(), texWidth, texHeight, 1, texData, static_cast(tlut->format), tlut->entries, {tlut->data.data(), tlut->data.size()}); } else { pixels = convert_texture(obj.format(), texWidth, texHeight, 1, texData); } const uint64_t rgbaBytes = calc_texture_size(wgpu::TextureFormat::RGBA8Unorm, texWidth, texHeight, 1); if (pixels.data.empty() || pixels.format != wgpu::TextureFormat::RGBA8Unorm || pixels.data.size() != rgbaBytes) { return false; } const auto path = s_dumpRoot / format_replacement_filename(key); return dds::write_rgba8_dds(path, texWidth, texHeight, 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(); } // Mirrors of the guest palettes, kept only so find_replacement can hash and the DDS dump decode a // CI palette. The renderer uploads from the guest pointer, so skip this work when replacement is off. void register_tlut(const GXTlutObj* obj, const void* data, GXTlutFmt format, uint16_t entries) noexcept { if (!g_config.allowTextureReplacements) { return; } if (obj == nullptr || data == nullptr) { return; } // A registration is only consumed by a matching load_tlut, and the map is keyed by a guest address // the game may free and reuse, so a table this large means stale entries: drop them wholesale. constexpr size_t kMaxPendingTluts = 4096; if (s_pendingTluts.size() >= kMaxPendingTluts && !s_pendingTluts.contains(obj)) { s_pendingTluts.clear(); } const size_t sz = static_cast(entries) * 2; ByteBuffer buffer{sz}; std::memcpy(buffer.data(), static_cast(data), sz); s_pendingTluts[obj] = { .size = static_cast(entries) * 2, .format = static_cast(format), .entries = entries, .valid = true, .source = obj, .data = std::move(buffer), }; } void load_tlut(const GXTlutObj* obj, uint32_t idx) noexcept { if (!g_config.allowTextureReplacements) { return; } if (idx >= s_loadedTluts.size()) { return; } // Consume the pending registration. The map is keyed by a guest object address that can be freed // or reused at any time, and entries used to live until shutdown. if (const auto it = s_pendingTluts.find(obj); it != s_pendingTluts.end()) { s_loadedTluts[idx] = std::move(it->second); s_pendingTluts.erase(it); return; } // The same object may legally be loaded into several hardware slots; only the first load finds the // pending entry, so recover the palette from whichever slot already holds it. for (const auto& loaded : s_loadedTluts) { if (!loaded.valid || loaded.source != obj) { continue; } s_loadedTluts[idx] = { .size = loaded.size, .format = loaded.format, .entries = loaded.entries, .valid = loaded.valid, .source = loaded.source, .data = loaded.data.clone(), }; return; } s_loadedTluts[idx] = {}; } 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; } std::string build_texture_replacement_name(const GXTexObj_& obj) noexcept { const RuntimeTextureKey key = build_runtime_key(obj); return format_replacement_filename(key); } } // namespace aurora::gfx::texture_replacement