From 0a145850ba2eed8252de0756ad3e95e824cdccec Mon Sep 17 00:00:00 2001 From: hypnomalice <344329436+hypnomalice@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:36 +0100 Subject: [PATCH] Mods: merge independent shader graphs and preserve additive variant bundles --- Engine/Resource/Test/shader_lookup_tests.cpp | 105 ++++++++++++-- Engine/Resource/shader_lookup.cpp | 140 ++++++++++++++++--- Engine/Resource/shader_lookup.h | 14 +- Engine/Vfs/Test/mod_variants_tests.cpp | 44 +++++- Engine/Vfs/mod_catalog.cpp | 49 +++++++ Engine/Vfs/mod_merge_combine.cpp | 4 +- Engine/Vfs/variant_additions.h | 29 ++++ contrib/custom-shader-coexistence.md | 9 ++ 8 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 Engine/Vfs/variant_additions.h create mode 100644 contrib/custom-shader-coexistence.md diff --git a/Engine/Resource/Test/shader_lookup_tests.cpp b/Engine/Resource/Test/shader_lookup_tests.cpp index 5b86d754..54ac8eee 100644 --- a/Engine/Resource/Test/shader_lookup_tests.cpp +++ b/Engine/Resource/Test/shader_lookup_tests.cpp @@ -32,9 +32,12 @@ struct Lookup { }; Lookup lookup(std::uint32_t records, const std::map>& rows) { Lookup out; - append(out.resource, records); - append(out.resource, std::uint32_t{0}); // the rest of the blob + out.resource.resize(16 + static_cast(records) * 168); + std::memcpy(out.resource.data(), &records, sizeof(records)); + const std::uint64_t start = 16; + std::memcpy(out.resource.data() + 4, &start, sizeof(start)); const auto program = static_cast(out.resource.size()); + append(out.resource, std::uint32_t{4}); append(out.resource, static_cast(rows.size())); for (const auto& [key, values] : rows) { append(out.resource, key); @@ -42,14 +45,53 @@ Lookup lookup(std::uint32_t records, const std::map(out.resource.size() - program)); + append(out.meta, std::uint32_t{4}); + append(out.meta, static_cast(out.resource.size() - program - 4)); append(out.meta, std::uint32_t{0}); return out; } +// Real-sized records with a relocated heap, rather than a count-only malformed +// fixture. +Lookup extended_lookup(bool extended, std::uint64_t key, std::byte marker = std::byte{7}) { + Lookup out; + const std::uint32_t count = extended ? 2U : 1U; + const std::uint32_t heap = extended ? 376U : 200U; + const std::uint32_t size = extended ? 408U : 216U; + out.resource.resize(size); + const std::uint64_t start = 32; + const std::uint64_t pointer = heap; + std::memcpy(out.resource.data(), &count, sizeof(count)); + std::memcpy(out.resource.data() + 4, &start, sizeof(start)); + std::memcpy(out.resource.data() + 132, &pointer, sizeof(pointer)); + out.resource[heap] = std::byte{3}; + append(out.resource, std::uint32_t{132}); + if (extended) { + const std::uint64_t extra = 392; + std::memcpy(out.resource.data() + 300, &extra, sizeof(extra)); + out.resource[392] = marker; + append(out.resource, std::uint32_t{300}); + } + append(out.resource, extended ? std::uint32_t{2} : std::uint32_t{1}); + append(out.resource, std::uint64_t{1}); + append(out.resource, std::uint32_t{1}); + append(out.resource, std::uint32_t{0}); + if (extended) { + append(out.resource, key); + append(out.resource, std::uint32_t{1}); + append(out.resource, std::uint32_t{1}); + } + const auto fix = extended ? std::uint32_t{8} : std::uint32_t{4}; + append(out.meta, size); + append(out.meta, fix); + append(out.meta, static_cast(out.resource.size() - size - fix)); + append(out.meta, std::uint32_t{0}); + return out; +} + // The merged rows, read back. std::map> rows_of(const fb::shader::MergedTable& merged) { - std::size_t at = read(merged.resourceMeta, 0); + std::size_t at = static_cast(read(merged.resourceMeta, 0)) + + read(merged.resourceMeta, 4); const auto count = read(merged.resource, at); at += 4; std::map> rows; @@ -69,7 +111,7 @@ int main() { // One mod adds a row onto a program the game has; another brings programs of its own and // points rows at them (indices 3 and 4, which the base's three records do not have). const auto good = lookup(3, {{1, {0}}, {2, {2}}}); - const auto own = lookup(5, {{1, {0}}, {3, {4}}, {4, {1, 3}}, {5, {1}}}); + const auto own = lookup(3, {{1, {0}}, {3, {4}}, {4, {1, 3}}, {5, {1}}}); const std::vector edits{good.table(), own.table()}; const auto merged = fb::shader::merge_program_lookup(base.table(), edits); const auto rows = rows_of(merged); @@ -77,10 +119,57 @@ int main() { check(merged.outside == 2 && !rows.contains(3) && !rows.contains(4), "Rows pointing past the base's records are left out, also when only one of their indices does"); check(rows.size() == 3 && rows.at(1) == std::vector{0}, "The base's own rows stay as they were"); - check(read(merged.resourceMeta, 8) == merged.resource.size() - read(merged.resourceMeta, 0), + check(read(merged.resourceMeta, 8) == merged.resource.size() - (read(merged.resourceMeta, 0) + read(merged.resourceMeta, 4)), "The lookup size in the metadata matches the merged rows"); for (const auto& [key, values] : rows) - for (const auto value : values) check(value < 3, "No merged row points past the base's records"); + for (const auto value : values) check(value < read(merged.resource, 0), "No merged row points past the combined records"); + const auto native = extended_lookup(false, 0); + const auto extension = extended_lookup(true, 10); + const auto incompatible = extended_lookup(true, 11, std::byte{8}); + const std::vector graphs{extension.table(), + incompatible.table()}; + const auto combined = + fb::shader::merge_program_lookup(native.table(), graphs); + const auto graph_rows = rows_of(combined); + check(read(combined.resource, 0) == 3 && + graph_rows.contains(10), + "A bounded extension retains its extra program and material row"); + check( + graph_rows.contains(11) && combined.outside == 0 && graph_rows.at(10) != graph_rows.at(11), + "Independent graphs retain distinct program indices"); + check(read(combined.resource, 132) == 200, + "The native program heap remains unchanged"); + const auto program_table = read(combined.resource, 4); + for (const auto key : {std::uint64_t{10}, std::uint64_t{11}}) { + const auto record = program_table + graph_rows.at(key).front() * 168; + const auto pointer = read(combined.resource, static_cast(record + 100)); + check(combined.resource.at(static_cast(pointer)) == (key == 10 ? std::byte{7} : std::byte{8}), + "Each remapped program still addresses its own opaque heap bytes"); + } + const auto program_size = read(combined.resourceMeta, 0); + const auto relocation_size = read(combined.resourceMeta, 4); + for (std::size_t at = 0; at < relocation_size; at += 4) { + const auto field = read(combined.resource, program_size + at); + check(field <= program_size && 8 <= program_size - field && + read(combined.resource, field) <= program_size, + "Every merged relocation remains bounded by the combined program payload"); + } + auto damaged = extension; + damaged.resource[376] = std::byte{9}; + const std::vector damaged_graph{damaged.table()}; + const auto rejected = + fb::shader::merge_program_lookup(native.table(), damaged_graph); + check(read(rejected.resource, 0) == 2 && + rows_of(rejected).contains(10) && rejected.resource[200] == std::byte{3}, + "A private graph cannot overwrite the native program heap"); + auto invalid = extension; + const std::uint32_t outside = 409; + std::memcpy(invalid.resource.data() + 412, &outside, sizeof(outside)); + const std::vector invalid_graph{invalid.table()}; + check( + fb::shader::merge_program_lookup(native.table(), invalid_graph).outside == + 1, + "A relocation past the extension payload is rejected"); if (failures) return 1; std::cout << "shader lookup tests passed\n"; return 0; diff --git a/Engine/Resource/shader_lookup.cpp b/Engine/Resource/shader_lookup.cpp index d720445d..643bd5cc 100644 --- a/Engine/Resource/shader_lookup.cpp +++ b/Engine/Resource/shader_lookup.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include namespace dingosdk::frostbite::shader { namespace { @@ -78,37 +80,137 @@ TextureRows read_texture_rows(const Table& table) { } // namespace -MergedTable merge_program_lookup(const Table& base, const std::span edits) { - const auto start = lookup_offset(base); - const auto records = read(base.resource, 0); // the base blob's record count - auto rows = read_program_rows(base); - const auto baseRows = rows; +namespace { +struct ProgramGraph { + std::uint32_t size{}, count{}; + std::uint64_t start{}; + std::vector fields; +}; +std::optional program_graph(const Table& table) { + (void)lookup_offset(table); + ProgramGraph graph; + graph.size = read(table.resourceMeta, 0); + if (graph.size < 12) return std::nullopt; + graph.count = read(table.resource, 0); + graph.start = read(table.resource, 4); + if (graph.start > graph.size || graph.count > (graph.size - graph.start) / 168) return std::nullopt; + const auto fix = read(table.resourceMeta, 4); + if (fix % 4) return std::nullopt; + for (std::size_t at = 0; at < fix; at += 4) { + const auto field = read(table.resource, graph.size + at); + if (field > graph.size || 8 > graph.size - field || read(table.resource, field) > graph.size) + return std::nullopt; + graph.fields.push_back(field); + } + // Every program array must stay inside this graph. Its pointers are copied + // through the relocation list; an omitted relocation cannot be rebased. + const std::array, 7> arrays{{ + {0x0c,0x10,16},{0x18,0x1c,8},{0x30,0x34,16},{0x3c,0x40,6}, + {0x48,0x4c,8},{0x54,0x58,2},{0x60,0x64,8}}}; + for (std::uint32_t i = 0; i < graph.count; ++i) { + const auto record = static_cast(graph.start) + i * 168; + for (const auto& array : arrays) { + const auto count = read(table.resource, record + array[0]); + const auto pointer = read(table.resource, record + array[1]); + if (pointer > graph.size || count > (graph.size - pointer) / array[2]) return std::nullopt; + if (count && std::find(graph.fields.begin(), graph.fields.end(), record + array[1]) == graph.fields.end()) + return std::nullopt; + } + } + return graph; +} +} +MergedTable merge_program_lookup(const Table& base, const std::span edits) { + const auto native = program_graph(base); + if (!native) throw std::runtime_error("Base shader program graph is invalid"); + auto rows = read_program_rows(base); + const auto base_rows = rows; MergedTable merged; + merged.resource.assign(base.resource.begin(), base.resource.begin() + native->size); + auto fields = native->fields; + struct Copy { std::vector bytes; std::vector fields; }; + std::vector copies; + const auto put = [](auto& bytes, std::size_t at, auto value) { std::memcpy(bytes.data() + at, &value, sizeof(value)); }; for (const auto& edit : edits) { - for (const auto& [key, values] : read_program_rows(edit)) { - if (baseRows.contains(key)) continue; - if (std::any_of(values.begin(), values.end(), [&](std::uint32_t index) { return index >= records; })) { + const auto graph = program_graph(edit); + const auto edit_rows = read_program_rows(edit); + std::optional offset; + std::map indices; + for (const auto& [key, values] : edit_rows) { + if (base_rows.contains(key)) continue; + if (!graph || std::any_of(values.begin(), values.end(), [&](auto index) { return index >= graph->count; })) { ++merged.outside; continue; } - const auto [row, inserted] = rows.try_emplace(key, values); - if (inserted) ++merged.added; - else if (row->second != values) ++merged.conflicts; + // Existing rows win by mod order. Never replace the game's rows. + if (rows.contains(key)) { ++merged.conflicts; continue; } + if (!offset) { + // Retain each graph's complete heap, including opaque records. + // Its program indices are local and must never address another mod's graph. + constexpr std::size_t maximum_bytes = 64 * 1024 * 1024; + if (graph->size > maximum_bytes || merged.resource.size() > maximum_bytes - graph->size) throw std::runtime_error("Combined shader graphs exceed 64 MiB"); + merged.resource.resize((merged.resource.size() + 15) & ~std::size_t{15}); + offset = merged.resource.size(); + merged.resource.insert(merged.resource.end(), edit.resource.begin(), edit.resource.begin() + graph->size); + for (const auto field : graph->fields) { + const auto pointer = read(edit.resource, field); + put(merged.resource, *offset + field, pointer ? pointer + *offset : 0); + fields.push_back(static_cast(*offset + field)); + } + } + std::vector mapped; + for (const auto index : values) { + auto [entry, inserted] = indices.emplace(index, native->count + static_cast(copies.size())); + if (inserted) { + if (copies.size() >= 64 * 1024 * 1024 / 168) throw std::runtime_error("Too many combined shader programs"); + const auto record = static_cast(graph->start) + index * 168; + Copy copy; + copy.bytes.assign(merged.resource.begin() + static_cast(*offset + record), + merged.resource.begin() + static_cast(*offset + record + 168)); + for (const auto field : graph->fields) if (field >= record && field < record + 168) + copy.fields.push_back(static_cast(field - record)); + copies.push_back(std::move(copy)); + } + mapped.push_back(entry->second); + } + rows.emplace(key, std::move(mapped)); + ++merged.added; } } - - merged.resource.assign(base.resource.begin(), - base.resource.begin() + static_cast(start)); + if (!copies.empty()) { + merged.resource.resize((merged.resource.size() + 15) & ~std::size_t{15}); + const auto table = static_cast(merged.resource.size()); + const auto source_start = static_cast(native->start); + std::vector originals(base.resource.begin() + static_cast(source_start), + base.resource.begin() + static_cast(source_start + native->count * 168)); + merged.resource.insert(merged.resource.end(), originals.begin(), originals.end()); + for (const auto field : native->fields) if (field >= source_start && field < source_start + native->count * 168) + fields.push_back(static_cast(table + field - source_start)); + for (const auto& copy : copies) { + const auto at = merged.resource.size(); + merged.resource.insert(merged.resource.end(), copy.bytes.begin(), copy.bytes.end()); + for (const auto field : copy.fields) fields.push_back(static_cast(at + field)); + } + put(merged.resource, 0, native->count + static_cast(copies.size())); + put(merged.resource, 4, table); + fields.push_back(4); + } + if (merged.resource.size() > 128 * 1024 * 1024) throw std::runtime_error("Combined shader database exceeds 128 MiB"); + const auto size = static_cast(merged.resource.size()); + std::sort(fields.begin(), fields.end()); + fields.erase(std::unique(fields.begin(), fields.end()), fields.end()); + for (const auto field : fields) append(merged.resource, field); + const auto fix = static_cast(fields.size() * 4); + const auto start = merged.resource.size(); append(merged.resource, static_cast(rows.size())); for (const auto& [key, values] : rows) { - append(merged.resource, key); - append(merged.resource, static_cast(values.size())); - for (const auto value : values) append(merged.resource, value); + append(merged.resource, key);append(merged.resource, static_cast(values.size())); + for (const auto index : values) append(merged.resource, index); } merged.resourceMeta.assign(base.resourceMeta.begin(), base.resourceMeta.end()); - const auto size = static_cast(merged.resource.size() - start); - std::memcpy(merged.resourceMeta.data() + 8, &size, sizeof(size)); + put(merged.resourceMeta, 0, size);put(merged.resourceMeta, 4, fix); + put(merged.resourceMeta, 8, static_cast(merged.resource.size() - start)); return merged; } diff --git a/Engine/Resource/shader_lookup.h b/Engine/Resource/shader_lookup.h index 50457ae3..06dbf7ae 100644 --- a/Engine/Resource/shader_lookup.h +++ b/Engine/Resource/shader_lookup.h @@ -25,18 +25,12 @@ struct MergedTable { std::vector resourceMeta; std::size_t added{}; // rows taken from the edits std::size_t conflicts{}; // rows two edits disagreed on; the first won - std::size_t outside{}; // rows left out: they point past the base's programs + std::size_t outside{}; // rows left out: outside or incompatible program graph }; -// `resourceMeta` is 16 bytes: program size, relocation size, lookup size. The -// program blob and its relocations are identical across copies because a mod -// only ever points new keys at programs that are already there. The blob starts -// with its record count (u32), and every value of a lookup row is a record's -// index: the game takes it without a bounds check (an index past the end puts -// a pointer outside the database into its shader registry, Skate.exe 0x3f72280), -// so an added row with an index the base's blob does not have is left out -// (`outside`): a mod that brought programs of its own cannot have them merged -// onto the base's. +// resourceMeta holds program, relocation and lookup sizes. Each mod's bounded +// graph keeps its own heap and relocations. Added material rows receive remapped +// program indices; the base's rows and program data remain unchanged. [[nodiscard]] MergedTable merge_program_lookup(const Table& base, std::span edits); [[nodiscard]] MergedTable merge_texture_lookup(const Table& base, std::span edits); diff --git a/Engine/Vfs/Test/mod_variants_tests.cpp b/Engine/Vfs/Test/mod_variants_tests.cpp index 2cae3a90..4704d74a 100644 --- a/Engine/Vfs/Test/mod_variants_tests.cpp +++ b/Engine/Vfs/Test/mod_variants_tests.cpp @@ -1,4 +1,5 @@ #include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/variant_additions.h" #include "Engine/Game/Build/supported_build.h" #include #include @@ -30,7 +31,48 @@ bool aside(const mods::Catalog &catalog, std::string_view name) { } } // namespace -int main() { +int main(int argc, char** argv) { + if (argc == 2) { + const auto text = std::string(argv[1]); + const auto path = fs::path(std::u8string(reinterpret_cast(text.data()), text.size())); + const auto catalog = mods::load_catalog(path, {}, false); + for (const auto& note : catalog.notes) std::cout << note << '\n'; + for (const auto& mod : catalog.mods) std::cout << "enabled: " << mod.name << '\n'; + return catalog.issue.empty() ? 0 : 1; + } + { + frostbite::BinaryBundle base; + frostbite::BundleAsset asset; + asset.name = "shared/deck"; + base.ebx.push_back(asset); + asset.kind = frostbite::AssetKind::resource; + asset.resourceId = 123; + asset.resourceMeta = {std::byte{7}}; + base.resources.push_back(asset); + asset.kind = frostbite::AssetKind::chunk; + asset.guid.bytes[0] = std::byte{1}; + base.chunks.push_back(asset); + auto addition = base; + asset.name = "creator/private-deck"; + asset.kind = frostbite::AssetKind::ebx; + addition.ebx.push_back(asset); + check(mods::preserves_variant_bundle(base, addition), "Private additions should preserve variant compatibility"); + auto changed = addition; + changed.resources[0].sha1.bytes[0] = std::byte{1}; + check(!mods::preserves_variant_bundle(base, changed), "Changed shared mesh must still displace the mod"); + changed = addition; + changed.chunks[0].logicalSize = 1; + check(!mods::preserves_variant_bundle(base, changed), "Changed native chunk metadata must be refused"); + changed = addition; + changed.ebx.clear(); + check(!mods::preserves_variant_bundle(base, changed), "Dropped original blueprint must be refused"); + changed = addition; + changed.resources[0].resourceMeta[0] = std::byte{8}; + check(!mods::preserves_variant_bundle(base, changed), "Changed native resource metadata must be refused"); + changed = addition; + changed.ebx.push_back(changed.ebx.front()); + check(!mods::preserves_variant_bundle(base, changed), "Ambiguous duplicate stock assets must be refused"); + } wchar_t temp[MAX_PATH]{}; GetTempPathW(MAX_PATH, temp); const auto root = fs::path(temp) / ("reskate-mod-variants-" + std::to_string(GetCurrentProcessId())); diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index 61cf835d..05a0b850 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -5,6 +5,8 @@ #include "mod_merge.h" #include "mod_conflicts.h" #include "native_db.h" +#include "game_bundles.h" +#include "variant_additions.h" #include "Engine/Core/Json/json.h" #include "Engine/Game/Build/supported_build.h" @@ -104,6 +106,49 @@ bool lists_bundle(const fs::path& mod, const std::vector& bundles) } return false; } +// A private cosmetic can add models to a deck's native delivery bundle without +// replacing the shared deck. Keep those additions where the engine loads them; +// unreadable bundles or any changed/dropped original still trigger displacement. +bool only_adds_to_variant_bundles(const fs::path& game, const fs::path& mod, + const std::vector& displaced) noexcept { + try { + const auto layout = vfs::read_layout(mod / L"layout.toc"); + vfs::GameArchives archives(mod, layout.root); + vfs::GameData original(game); + std::error_code error; + bool checked{}; + for (fs::directory_iterator at(mod / L"Win32", error), end; !error && at != end; at.increment(error)) { + if (!at->is_regular_file(error) || at->path().extension() != L".toc") continue; + std::ifstream file(at->path(), std::ios::binary | std::ios::ate); + const auto size = file.tellg(); + if (size < 0 || size > 64 * 1024 * 1024) return false; + file.seekg(0); + std::vector bytes(static_cast(size)); + if (!file.read(bytes.data(), static_cast(bytes.size()))) return false; + const auto toc = frostbite::read_toc(std::as_bytes(std::span(bytes))); + std::optional base_toc; + for (const auto& bundle : toc.bundles) { + const auto name = lower(bundle.name); + if (std::ranges::none_of(displaced, [&](const auto& stem) { return name.find(stem) != std::string::npos; })) continue; + if (!base_toc) base_toc = original.read_toc("Win32/" + at->path().filename().string()); + const auto base_bundle = original.read_bundle(*base_toc, bundle.name); + if (!base_bundle) return false; + const auto region = frostbite::read_bundle_region(bundle.region); + frostbite::BinaryBundle incoming; + if (!region.inlineManifest.empty()) incoming = frostbite::read_binary_bundle(region.inlineManifest); + else { + if (region.files.empty()) return false; + const auto& manifest = region.files.front(); + incoming = archives.read_manifest(manifest.location.patch ? mod : game / L"Data", + manifest.location, manifest.offset, manifest.size, game); + } + if (!preserves_variant_bundle(base_bundle->manifest, incoming)) return false; + checked = true; + } + } + return !error && checked; + } catch (...) { return false; } +} // The choices of variant are kept beside ReSkate's other files for the user, so that they are there // before the profile is: the mods are put together as the game starts. // {"": ""} @@ -371,6 +416,10 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver result.notes.push_back("Variants: " + list.entries.front().mod.name + " loads first, so it is the one of " + owner + "'s in the game"); for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) { if (!entry->enabled || (!of_owner(*entry) && !lists_bundle(entry->mod.directory, declared.displaces))) continue; + if (!of_owner(*entry) && only_adds_to_variant_bundles(data_root, entry->mod.directory, declared.displaces)) { + result.notes.push_back("Variants: " + entry->mod.name + " only adds assets to the displaced bundle; original assets are unchanged"); + continue; + } entry->enabled = false; result.set_aside.push_back(entry->mod.name); result.notes.push_back("Variants: " + entry->mod.name + (of_owner(*entry) ? " is another of " + owner + "'s" : " brings what they replace") + diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index a9a5dcbf..a4a1315c 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -1149,8 +1149,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, : fb::shader::merge_texture_lookup(baseTable, tables); if (table.outside) report.notes.push_back(entry.asset.name + ": " + std::to_string(table.outside) + - " material row(s) left out: they point past the shader programs the game ships, " - "which the game would read outside its shader database"); + " material row(s) left out: outside or incompatible with the retained shader programs; " + "their indices cannot safely address this shader database"); if (!table.added) continue; rebuilt = table.resource; entry.asset.resourceMeta = table.resourceMeta; diff --git a/Engine/Vfs/variant_additions.h b/Engine/Vfs/variant_additions.h new file mode 100644 index 00000000..7db7f6c9 --- /dev/null +++ b/Engine/Vfs/variant_additions.h @@ -0,0 +1,29 @@ +#pragma once +#include "Engine/Resource/binary_bundle.h" +#include + +namespace dingosdk::mods { +// Sharing a delivery bundle is safe only when every original asset is intact. +// New names/GUIDs may coexist with a variant; replacing or dropping originals may not. +inline bool preserves_variant_bundle(const frostbite::BinaryBundle& base, + const frostbite::BinaryBundle& incoming) { + const auto intact = [](const auto& originals, const auto& candidates, bool chunks) { + for (const auto& original : originals) { + if (std::ranges::count_if(candidates, [&](const auto& candidate) { + return chunks ? candidate.guid == original.guid : candidate.name == original.name; + }) != 1) return false; + const auto found = std::ranges::find_if(candidates, [&](const auto& candidate) { + return chunks ? candidate.guid == original.guid : candidate.name == original.name; + }); + if (found == candidates.end() || found->sha1 != original.sha1 || + found->kind != original.kind || found->originalSize != original.originalSize || + found->resourceId != original.resourceId || found->resourceType != original.resourceType || + found->resourceMeta != original.resourceMeta || found->logicalOffset != original.logicalOffset || + found->logicalSize != original.logicalSize) return false; + } + return true; + }; + return intact(base.ebx, incoming.ebx, false) && intact(base.resources, incoming.resources, false) && + intact(base.chunks, incoming.chunks, true); +} +} diff --git a/contrib/custom-shader-coexistence.md b/contrib/custom-shader-coexistence.md new file mode 100644 index 00000000..711be24a --- /dev/null +++ b/contrib/custom-shader-coexistence.md @@ -0,0 +1,9 @@ +# Additive custom material resources + +Program lookup edits may carry their own bounded shader program graph, not only rows pointing into the base graph. Merging preserves native program data/rows, appends validated independent heaps and relocation fields, and remaps each added row to the correct program indices. Existing rows win by mod order; incompatible graphs are refused. Combined graph data is bounded to 64 MiB. + +This enables private custom material programs to coexist without replacing unrelated programs. It does not resolve two mods intentionally changing the same key: exporters must still generate independent identities. + +Variant displacement now distinguishes a replacement bundle from a bundle that contains every original unchanged and only adds assets. Original names/GUIDs, content hashes, types, sizes and resource metadata must all match; removal or mutation keeps the existing displacement behavior. This preserves original delivery locations for additive cosmetic content without claiming arbitrary replacements are compatible. + +Tests: `shader_lookup`, `mod_variants`. No effect artwork, game-derived shader binaries or local capture data are included.