From 34d8c0185e877020bb177aaee7e51d3b7161a4c9 Mon Sep 17 00:00:00 2001 From: salh Date: Wed, 22 Apr 2026 16:22:41 +0300 Subject: [PATCH] Improve texture swap modding workflow --- README.md | 5 + docs/TEXTURE_SWAPS.txt | 48 +- docs/TEXTURE_SWAP_MODDING_GUIDE.txt | 205 +++++++++ src/ac6_texture_overrides.cpp | 427 +++++++++++++++++- src/ac6_texture_overrides.h | 5 + .../src/graphics/d3d12/texture_cache.cpp | 18 + 6 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 docs/TEXTURE_SWAP_MODDING_GUIDE.txt diff --git a/README.md b/README.md index 2ca8a0a2..c6095454 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ cmake --build --preset win-amd64-relwithdebinfo The executable is placed at `out/build/win-amd64-relwithdebinfo/ac6recomp.exe`. +## Modding Docs + +- [Texture Swap Modding Guide](docs/TEXTURE_SWAP_MODDING_GUIDE.txt) +- [Texture Swap Reference](docs/TEXTURE_SWAPS.txt) + ## Runtime Defaults The default AC6 graphics configuration after this pivot is: diff --git a/docs/TEXTURE_SWAPS.txt b/docs/TEXTURE_SWAPS.txt index 97aef219..a934d593 100644 --- a/docs/TEXTURE_SWAPS.txt +++ b/docs/TEXTURE_SWAPS.txt @@ -10,6 +10,10 @@ Workflow 2. Dumps appear under: %USERPROFILE%\Documents\ac6recomp\texture_dumps\ or the directory set by the user_data_root runtime CVAR. + New dumps are also mirrored into: + %USERPROFILE%\Documents\ac6recomp\texture_dumps\sessions\\ + and the current session path is written to: + %USERPROFILE%\Documents\ac6recomp\texture_dumps\current_session.txt 3. Each dumped texture produces: .dds .json @@ -24,6 +28,40 @@ Workflow override/textures wins over mod folders. Within mods, lexicographically later folder names win. +Shared Replacements + +Exact .dds matches still work, but texture override folders now +also support an optional manifest: + + override/textures/manifest.toml + mods//textures/manifest.toml + +That lets one DDS serve multiple dumped textures, which is useful when the game +has several menu-specific versions of the same logical art. + +Example: + + [[swap]] + source = "shared/playstation_buttons.dds" + stable_keys = [ + "tex_1111111111111111_bp00000000_mp00000000_2d_512x256x1_m1_fmt18_e0_t1_p0_s0_r0", + "tex_2222222222222222_bp00000000_mp00000000_2d_512x256x1_m1_fmt18_e0_t1_p0_s0_r0", + ] + +You can also use wildcards: + + [[swap]] + source = "shared/common_ui_256.dds" + stable_key_globs = [ + "tex_*_2d_256x256x1_m1_fmt6_*", + ] + +Manifest notes: + - source paths are relative to the texture override folder that owns the manifest + - exact .dds files still win over manifest rules in the same folder + - if multiple manifest rules match, the later rule wins + - later mod folders still override earlier mod folders + Stable Keys Dump filenames are generated from the texture cache key, not guessed game @@ -50,7 +88,8 @@ Each JSON sidecar records: - signature tags from the AC6 backend classifier This is meant for filtering and later tooling, not for the core replacement -path. +path, but the stable_key and layout fields are handy when authoring manifest +rules. Current Scope @@ -66,6 +105,13 @@ First pass limitations: The fallback path is always the original guest texture load. A bad or missing replacement will not block rendering. +Practical Workflow Tip + +If you are targeting one menu or one HUD state, start the game fresh, open just +that screen, then inspect current_session.txt or the matching sessions folder. +That gives you a much smaller set of fresh dumps instead of the entire dump +history. + Xenos Texture Formats Values below come from rex::graphics::xenos::TextureFormat in diff --git a/docs/TEXTURE_SWAP_MODDING_GUIDE.txt b/docs/TEXTURE_SWAP_MODDING_GUIDE.txt new file mode 100644 index 00000000..d96b5c68 --- /dev/null +++ b/docs/TEXTURE_SWAP_MODDING_GUIDE.txt @@ -0,0 +1,205 @@ +Texture Swap Modding Guide + +This guide is for modders who want to replace in-game textures without digging +through the renderer code. + +What the system does + +The game can dump textures it sees at runtime as DDS files. You can edit those +DDS files and place replacements in the override folders. The game will load +your replacements the next time those textures are used. + +The texture swap system now supports: + - session-based dump browsing, so you can work from a small fresh folder + - exact one-file replacements with .dds + - manifest rules, so one DDS can replace multiple dumped textures + +Where dumps go + +Global dump history: + + %USERPROFILE%\Documents\ac6recomp\texture_dumps\ + +Current session pointer: + + %USERPROFILE%\Documents\ac6recomp\texture_dumps\current_session.txt + +Current session folder: + + %USERPROFILE%\Documents\ac6recomp\texture_dumps\sessions\\ + +The session folder is the main quality-of-life feature for modding. If you boot +the game fresh and only visit one menu, that session folder should stay much +smaller than the full dump history. + +Basic workflow + +1. Start with a clean target + Launch the game fresh if possible. + Go straight to the menu, HUD, or screen you want to mod. + +2. Open the current session folder + Read current_session.txt. + Open the session_path listed there. + +3. Identify the texture you want + Look through the DDS files in the current session folder. + Each DDS has a matching JSON file with the same stable key. + +4. Edit the DDS + Keep the same: + - format + - width and height + - array size or depth + - mip count + +5. Install the replacement + Put the edited DDS in one of these locations: + + override/textures/.dds + + or + + mods//textures/.dds + +6. Reload the texture + Restart the game or otherwise make the texture reload. + +Recommended folder layout + +For a loose override: + + Documents\ac6recomp\ + override\ + textures\ + tex_....dds + +For a mod: + + Documents\ac6recomp\ + mods\ + my_ui_mod\ + textures\ + tex_....dds + +Override priority + +Priority is: + - override/textures first + - then mods//textures + - among mods, later folder names win + +That means a loose override is best for testing, while a mod folder is better +for packaging and sharing. + +Using one DDS for many textures + +Some UI assets exist in several menu-specific variants. Instead of copying the +same replacement to many stable keys, you can use a manifest. + +Create: + + override/textures/manifest.toml + +or: + + mods//textures/manifest.toml + +Example with exact keys: + + [[swap]] + source = "shared/playstation_buttons.dds" + stable_keys = [ + "tex_1111111111111111_bp00000000_mp00000000_2d_512x256x1_m1_fmt18_e0_t1_p0_s0_r0", + "tex_2222222222222222_bp00000000_mp00000000_2d_512x256x1_m1_fmt18_e0_t1_p0_s0_r0", + ] + +Example with wildcard matching: + + [[swap]] + source = "shared/common_ui_256.dds" + stable_key_globs = [ + "tex_*_2d_256x256x1_m1_fmt6_*", + ] + +Manifest behavior + + - source is relative to the textures folder that contains manifest.toml + - exact .dds files still beat manifest rules in the same folder + - if more than one manifest rule matches, the later rule wins + - later mod folders still beat earlier mod folders + +Example mod layout with a manifest + + Documents\ac6recomp\ + mods\ + playstation_buttons\ + textures\ + manifest.toml + shared\ + playstation_buttons.dds + +How to use the JSON sidecars + +Each dumped texture has a matching JSON file. The JSON is useful when the DDS +preview alone is not enough. + +Useful fields include: + - stable_key + - width + - height + - guest_format + - dxgi_format + - signature_tags + - active_vertex_shader_hash + - active_pixel_shader_hash + +You do not need to edit the JSON. It is there to help you identify and group +textures. + +Good workflow for menu mods + +If you are replacing UI prompts, button art, or menu icons: + +1. Start a fresh session. +2. Open only the target menu. +3. Inspect the current session folder. +4. Find the relevant DDS files. +5. If several files are really the same logical art, create one shared DDS and + bind them together with manifest.toml. + +This is much faster than browsing the entire dump history every time. + +Troubleshooting + +Problem: The session folder exists but is empty. +Cause: The textures you hit may already exist in the global dump history, or +the relevant texture was never encountered. +What to do: Trigger the texture again. Existing global dumps should now also be +mirrored into the current session folder when they are encountered. + +Problem: My replacement does not load. +Cause: The DDS does not match the original texture layout, or the stable key is +wrong, or a higher-priority override is winning. +What to do: + - confirm the filename or manifest rule matches the dumped stable key + - keep the same format, size, array/depth, and mip count + - test with override/textures first + +Problem: The wrong texture is replaced. +Cause: A wildcard manifest rule is too broad. +What to do: Narrow the stable_key_globs rule or switch to exact stable_keys. + +Practical advice + + - Use the current session folder first, not the full dump folder. + - Use loose overrides for quick testing. + - Use mod folders plus a manifest for cleaner releases. + - Prefer one shared DDS plus manifest rules when the game has several + duplicates of the same art. + +Reference + +For the lower-level technical reference, see: + + docs/TEXTURE_SWAPS.txt diff --git a/src/ac6_texture_overrides.cpp b/src/ac6_texture_overrides.cpp index c4c6cc5a..749a0da2 100644 --- a/src/ac6_texture_overrides.cpp +++ b/src/ac6_texture_overrides.cpp @@ -2,14 +2,20 @@ #include #include +#include +#include #include #include +#include #include #include +#include #include #include #include +#include +#include REXCVAR_DECLARE(std::string, user_data_root); @@ -95,6 +101,24 @@ struct DxgiLayoutInfo { const char* name; }; +struct TextureSwapRule { + std::filesystem::path source_path; + std::vector stable_keys; + std::vector stable_key_globs; +}; + +struct TextureSwapManifestCacheEntry { + bool present = false; + bool parse_failed = false; + std::filesystem::file_time_type last_write_time{}; + std::vector rules; +}; + +std::mutex g_texture_swap_manifest_mutex; +std::unordered_map g_texture_swap_manifest_cache; + +bool EnsureParentExists(const std::filesystem::path& path, std::string* error_out); + std::filesystem::path GetUserDataRoot() { const std::string user_root = REXCVAR_GET(user_data_root); if (!user_root.empty()) { @@ -103,6 +127,60 @@ std::filesystem::path GetUserDataRoot() { return rex::filesystem::GetUserFolder() / "ac6recomp"; } +std::filesystem::path GetTextureDumpRoot() { + return GetUserDataRoot() / REXCVAR_GET(ac6_texture_swaps_dump_dir); +} + +std::string BuildTextureDumpSessionId() { + const auto now = std::chrono::system_clock::now(); + const auto time_value = std::chrono::system_clock::to_time_t(now); + std::tm local_time{}; +#if defined(_WIN32) + localtime_s(&local_time, &time_value); +#else + localtime_r(&time_value, &local_time); +#endif + const auto milliseconds = + std::chrono::duration_cast(now.time_since_epoch()) % + std::chrono::seconds(1); + + std::ostringstream stream; + stream << std::put_time(&local_time, "%Y%m%d_%H%M%S") << "_" << std::setw(3) + << std::setfill('0') << milliseconds.count(); + return stream.str(); +} + +const std::string& GetTextureDumpSessionId() { + static const std::string session_id = BuildTextureDumpSessionId(); + return session_id; +} + +std::filesystem::path GetTextureDumpSessionsRoot() { + return GetTextureDumpRoot() / "sessions"; +} + +void PublishCurrentTextureDumpSessionInfo() { + static std::once_flag once; + std::call_once(once, []() { + const std::filesystem::path session_root = + GetTextureDumpSessionsRoot() / GetTextureDumpSessionId(); + std::string error; + if (!EnsureParentExists(session_root / "placeholder", &error)) { + REXLOG_WARN("Texture swap dump session: failed to create session root ({})", error); + return; + } + + std::ofstream file(GetTextureDumpRoot() / "current_session.txt", std::ios::out | std::ios::trunc); + if (!file) { + REXLOG_WARN("Texture swap dump session: failed to write current_session.txt"); + return; + } + + file << "session_id=" << GetTextureDumpSessionId() << "\n"; + file << "session_path=" << session_root.string() << "\n"; + }); +} + bool EnsureParentExists(const std::filesystem::path& path, std::string* error_out) { std::error_code ec; std::filesystem::create_directories(path.parent_path(), ec); @@ -143,6 +221,266 @@ std::string EscapeJson(std::string_view value) { return escaped; } +char AsciiToLower(char value) { + return (value >= 'A' && value <= 'Z') ? char(value - 'A' + 'a') : value; +} + +bool EqualsIgnoreAsciiCase(std::string_view lhs, std::string_view rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (size_t i = 0; i < lhs.size(); ++i) { + if (AsciiToLower(lhs[i]) != AsciiToLower(rhs[i])) { + return false; + } + } + return true; +} + +bool WildcardMatchRecursive(std::string_view pattern, std::string_view value, size_t pattern_index, + size_t value_index) { + while (pattern_index < pattern.size()) { + const char pattern_char = pattern[pattern_index]; + if (pattern_char == '*') { + while (pattern_index + 1 < pattern.size() && pattern[pattern_index + 1] == '*') { + ++pattern_index; + } + if (pattern_index + 1 == pattern.size()) { + return true; + } + for (size_t candidate = value_index; candidate <= value.size(); ++candidate) { + if (WildcardMatchRecursive(pattern, value, pattern_index + 1, candidate)) { + return true; + } + } + return false; + } + if (value_index >= value.size()) { + return false; + } + if (pattern_char != '?' && AsciiToLower(pattern_char) != AsciiToLower(value[value_index])) { + return false; + } + ++pattern_index; + ++value_index; + } + return value_index == value.size(); +} + +bool WildcardMatch(std::string_view pattern, std::string_view value) { + return WildcardMatchRecursive(pattern, value, 0, 0); +} + +std::vector ParseStringList(const toml::node_view& node) { + std::vector values; + if (const auto value = node.value()) { + values.push_back(*value); + return values; + } + + const toml::array* array = node.as_array(); + if (!array) { + return values; + } + + values.reserve(array->size()); + for (const toml::node& item : *array) { + if (const auto string_value = item.value()) { + values.push_back(*string_value); + } + } + return values; +} + +bool PathStartsWith(const std::filesystem::path& path, const std::filesystem::path& prefix) { + auto path_it = path.begin(); + auto prefix_it = prefix.begin(); + while (prefix_it != prefix.end()) { + if (path_it == path.end()) { + return false; + } + if (!EqualsIgnoreAsciiCase(path_it->string(), prefix_it->string())) { + return false; + } + ++path_it; + ++prefix_it; + } + return true; +} + +std::optional ResolveManifestSourcePath(const std::filesystem::path& root, + std::string_view source_value) { + if (source_value.empty()) { + return std::nullopt; + } + + const std::filesystem::path source_path = std::filesystem::path(source_value); + if (source_path.is_absolute()) { + return std::nullopt; + } + + const std::filesystem::path normalized_root = root.lexically_normal(); + const std::filesystem::path resolved = (normalized_root / source_path).lexically_normal(); + if (!PathStartsWith(resolved, normalized_root)) { + return std::nullopt; + } + + return resolved; +} + +std::filesystem::path GetTextureSwapManifestPath(const std::filesystem::path& root) { + return root / "manifest.toml"; +} + +TextureSwapManifestCacheEntry LoadTextureSwapManifestCacheEntry(const std::filesystem::path& root) { + TextureSwapManifestCacheEntry entry; + const std::filesystem::path manifest_path = GetTextureSwapManifestPath(root); + std::error_code ec; + if (!std::filesystem::exists(manifest_path, ec) || ec) { + return entry; + } + + entry.present = true; + entry.last_write_time = std::filesystem::last_write_time(manifest_path, ec); + if (ec) { + entry.last_write_time = {}; + } + + toml::table manifest; + try { + manifest = toml::parse_file(manifest_path.string()); + } catch (const toml::parse_error& err) { + entry.parse_failed = true; + REXLOG_WARN("Texture swap manifest {}: parse error: {}", manifest_path.string(), err.description()); + return entry; + } + + const toml::array* swaps = manifest["swap"].as_array(); + if (!swaps) { + return entry; + } + + for (const toml::node& swap_node : *swaps) { + const toml::table* table = swap_node.as_table(); + if (!table) { + continue; + } + + const auto source_value = (*table)["source"].value(); + if (!source_value) { + continue; + } + + std::optional source_path = ResolveManifestSourcePath(root, *source_value); + if (!source_path) { + REXLOG_WARN("Texture swap manifest {}: ignoring rule with invalid source '{}'", + manifest_path.string(), *source_value); + continue; + } + + TextureSwapRule rule; + rule.source_path = *source_path; + auto append_values = [](std::vector& out, std::vector&& values) { + out.insert(out.end(), std::make_move_iterator(values.begin()), std::make_move_iterator(values.end())); + }; + + append_values(rule.stable_keys, ParseStringList((*table)["stable_key"])); + append_values(rule.stable_keys, ParseStringList((*table)["stable_keys"])); + append_values(rule.stable_keys, ParseStringList((*table)["key"])); + append_values(rule.stable_keys, ParseStringList((*table)["keys"])); + + append_values(rule.stable_key_globs, ParseStringList((*table)["stable_key_glob"])); + append_values(rule.stable_key_globs, ParseStringList((*table)["stable_key_globs"])); + append_values(rule.stable_key_globs, ParseStringList((*table)["pattern"])); + append_values(rule.stable_key_globs, ParseStringList((*table)["patterns"])); + + if (rule.stable_keys.empty() && rule.stable_key_globs.empty()) { + REXLOG_WARN("Texture swap manifest {}: ignoring rule for {} with no keys or patterns", + manifest_path.string(), rule.source_path.string()); + continue; + } + + entry.rules.push_back(std::move(rule)); + } + + return entry; +} + +const TextureSwapManifestCacheEntry& GetTextureSwapManifestCacheEntry(const std::filesystem::path& root) { + const std::filesystem::path normalized_root = root.lexically_normal(); + const std::string cache_key = normalized_root.string(); + const std::filesystem::path manifest_path = GetTextureSwapManifestPath(normalized_root); + + std::error_code ec; + const bool present = std::filesystem::exists(manifest_path, ec) && !ec; + std::filesystem::file_time_type last_write_time{}; + if (present) { + last_write_time = std::filesystem::last_write_time(manifest_path, ec); + if (ec) { + last_write_time = {}; + } + } + + std::lock_guard lock(g_texture_swap_manifest_mutex); + auto it = g_texture_swap_manifest_cache.find(cache_key); + if (it == g_texture_swap_manifest_cache.end() || it->second.present != present || + (present && it->second.last_write_time != last_write_time)) { + it = g_texture_swap_manifest_cache + .insert_or_assign(cache_key, LoadTextureSwapManifestCacheEntry(normalized_root)) + .first; + } + + return it->second; +} + +bool TextureSwapRuleMatches(const TextureSwapRule& rule, std::string_view stable_key) { + for (const std::string& exact_key : rule.stable_keys) { + if (EqualsIgnoreAsciiCase(exact_key, stable_key)) { + return true; + } + } + for (const std::string& pattern : rule.stable_key_globs) { + if (WildcardMatch(pattern, stable_key)) { + return true; + } + } + return false; +} + +std::optional ResolveManifestReplacementDdsPath( + const std::filesystem::path& root, std::string_view stable_key) { + const TextureSwapManifestCacheEntry& entry = GetTextureSwapManifestCacheEntry(root); + if (!entry.present || entry.parse_failed || entry.rules.empty()) { + return std::nullopt; + } + + std::optional resolved; + std::error_code ec; + for (const TextureSwapRule& rule : entry.rules) { + if (!TextureSwapRuleMatches(rule, stable_key)) { + continue; + } + if (std::filesystem::exists(rule.source_path, ec)) { + resolved = rule.source_path; + } + } + + return resolved; +} + +std::optional ResolveReplacementDdsPathInRoot( + const std::filesystem::path& root, std::string_view stable_key) { + const std::filesystem::path file_name = std::string(stable_key) + ".dds"; + std::error_code ec; + + const std::filesystem::path exact_path = root / file_name; + if (std::filesystem::exists(exact_path, ec)) { + return exact_path; + } + + return ResolveManifestReplacementDdsPath(root, stable_key); +} + std::string HexU32(uint32_t value) { std::ostringstream stream; stream << std::uppercase << std::hex << std::setw(8) << std::setfill('0') << value; @@ -390,13 +728,31 @@ std::string BuildTextureStableKey(uint64_t texture_key_hash, uint32_t base_page, } std::filesystem::path GetTextureDumpDdsPath(std::string_view stable_key) { - return GetUserDataRoot() / REXCVAR_GET(ac6_texture_swaps_dump_dir) / - (std::string(stable_key) + ".dds"); + PublishCurrentTextureDumpSessionInfo(); + return GetTextureDumpRoot() / (std::string(stable_key) + ".dds"); } std::filesystem::path GetTextureDumpMetadataPath(std::string_view stable_key) { - return GetUserDataRoot() / REXCVAR_GET(ac6_texture_swaps_dump_dir) / - (std::string(stable_key) + ".json"); + PublishCurrentTextureDumpSessionInfo(); + return GetTextureDumpRoot() / (std::string(stable_key) + ".json"); +} + +std::filesystem::path GetTextureDumpCurrentSessionRoot() { + PublishCurrentTextureDumpSessionInfo(); + return GetTextureDumpSessionsRoot() / GetTextureDumpSessionId(); +} + +std::filesystem::path GetTextureDumpCurrentSessionDdsPath(std::string_view stable_key) { + return GetTextureDumpCurrentSessionRoot() / (std::string(stable_key) + ".dds"); +} + +std::filesystem::path GetTextureDumpCurrentSessionMetadataPath(std::string_view stable_key) { + return GetTextureDumpCurrentSessionRoot() / (std::string(stable_key) + ".json"); +} + +std::filesystem::path GetTextureDumpCurrentSessionInfoPath() { + PublishCurrentTextureDumpSessionInfo(); + return GetTextureDumpRoot() / "current_session.txt"; } bool DumpExists(std::string_view stable_key) { @@ -404,18 +760,69 @@ bool DumpExists(std::string_view stable_key) { return std::filesystem::exists(GetTextureDumpDdsPath(stable_key), ec); } +bool MirrorDumpToCurrentSession(std::string_view stable_key, std::string* error_out) { + PublishCurrentTextureDumpSessionInfo(); + + const std::filesystem::path source_dds = GetTextureDumpDdsPath(stable_key); + const std::filesystem::path source_json = GetTextureDumpMetadataPath(stable_key); + const std::filesystem::path dest_dds = GetTextureDumpCurrentSessionDdsPath(stable_key); + const std::filesystem::path dest_json = GetTextureDumpCurrentSessionMetadataPath(stable_key); + + std::error_code ec; + if (!std::filesystem::exists(source_dds, ec) || ec) { + if (error_out) { + *error_out = "source DDS dump does not exist"; + } + return false; + } + ec.clear(); + if (!std::filesystem::exists(source_json, ec) || ec) { + if (error_out) { + *error_out = "source metadata dump does not exist"; + } + return false; + } + + std::string ensure_error; + if (!EnsureParentExists(dest_dds, &ensure_error) || !EnsureParentExists(dest_json, &ensure_error)) { + if (error_out) { + *error_out = ensure_error; + } + return false; + } + + auto copy_if_needed = [&](const std::filesystem::path& source, const std::filesystem::path& dest, + const char* label) -> bool { + std::error_code local_ec; + if (std::filesystem::exists(dest, local_ec) && !local_ec) { + return true; + } + local_ec.clear(); + if (!std::filesystem::copy_file(source, dest, std::filesystem::copy_options::overwrite_existing, + local_ec)) { + if (error_out) { + *error_out = std::string("failed to mirror ") + label + ": " + local_ec.message(); + } + return false; + } + return true; + }; + + return copy_if_needed(source_dds, dest_dds, "DDS") && + copy_if_needed(source_json, dest_json, "metadata"); +} + std::optional ResolveReplacementDdsPath(std::string_view stable_key) { if (!TextureReplacementEnabled()) { return std::nullopt; } const std::filesystem::path user_root = GetUserDataRoot(); - const std::filesystem::path file_name = std::string(stable_key) + ".dds"; std::error_code ec; - const std::filesystem::path loose_path = - user_root / REXCVAR_GET(ac6_texture_swaps_override_dir) / file_name; - if (std::filesystem::exists(loose_path, ec)) { + const std::filesystem::path loose_root = user_root / REXCVAR_GET(ac6_texture_swaps_override_dir); + if (const std::optional loose_path = + ResolveReplacementDdsPathInRoot(loose_root, stable_key)) { return loose_path; } @@ -437,8 +844,8 @@ std::optional ResolveReplacementDdsPath(std::string_view std::optional resolved; for (const std::filesystem::path& mod_root : mod_roots) { - const std::filesystem::path candidate = mod_root / "textures" / file_name; - if (std::filesystem::exists(candidate, ec)) { + if (const std::optional candidate = + ResolveReplacementDdsPathInRoot(mod_root / "textures", stable_key)) { resolved = candidate; } } diff --git a/src/ac6_texture_overrides.h b/src/ac6_texture_overrides.h index 9c7ecb91..9e5ba2e1 100644 --- a/src/ac6_texture_overrides.h +++ b/src/ac6_texture_overrides.h @@ -78,7 +78,12 @@ std::string BuildTextureStableKey(uint64_t texture_key_hash, uint32_t base_page, std::filesystem::path GetTextureDumpDdsPath(std::string_view stable_key); std::filesystem::path GetTextureDumpMetadataPath(std::string_view stable_key); +std::filesystem::path GetTextureDumpCurrentSessionRoot(); +std::filesystem::path GetTextureDumpCurrentSessionDdsPath(std::string_view stable_key); +std::filesystem::path GetTextureDumpCurrentSessionMetadataPath(std::string_view stable_key); +std::filesystem::path GetTextureDumpCurrentSessionInfoPath(); bool DumpExists(std::string_view stable_key); +bool MirrorDumpToCurrentSession(std::string_view stable_key, std::string* error_out = nullptr); std::optional ResolveReplacementDdsPath(std::string_view stable_key); diff --git a/thirdparty/rexglue-sdk/src/graphics/d3d12/texture_cache.cpp b/thirdparty/rexglue-sdk/src/graphics/d3d12/texture_cache.cpp index 2da7f50a..9328f49f 100644 --- a/thirdparty/rexglue-sdk/src/graphics/d3d12/texture_cache.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/d3d12/texture_cache.cpp @@ -2343,6 +2343,19 @@ void D3D12TextureCache::ProcessCompletedTextureTransfers() { } else if (!ac6::textures::WriteDumpMetadata( ac6::textures::GetTextureDumpMetadataPath(it->stable_key), metadata, &error)) { REXGPU_WARN("Texture swap dump {}: failed to write metadata ({})", it->stable_key, error); + } else { + std::string session_error; + if (!ac6::textures::WriteDdsToFile( + ac6::textures::GetTextureDumpCurrentSessionDdsPath(it->stable_key), dds_image, + &session_error)) { + REXGPU_WARN("Texture swap dump {}: failed to write session DDS ({})", it->stable_key, + session_error); + } else if (!ac6::textures::WriteDumpMetadata( + ac6::textures::GetTextureDumpCurrentSessionMetadataPath(it->stable_key), + metadata, &session_error)) { + REXGPU_WARN("Texture swap dump {}: failed to write session metadata ({})", + it->stable_key, session_error); + } } it = pending_texture_dumps_.erase(it); @@ -2363,6 +2376,11 @@ bool D3D12TextureCache::ScheduleTextureDump(D3D12Texture& texture, DXGI_FORMAT d key.scaled_resolve != 0); if (dumped_texture_keys_.contains(stable_key) || ac6::textures::DumpExists(stable_key)) { + std::string mirror_error; + if (!ac6::textures::MirrorDumpToCurrentSession(stable_key, &mirror_error)) { + REXGPU_WARN("Texture swap dump {}: failed to mirror existing dump into session ({})", + stable_key, mirror_error); + } dumped_texture_keys_.insert(stable_key); return false; }