Mods: merge independent shader graphs and preserve additive variant bundles

This commit is contained in:
hypnomalice committed 2026-10-24 22:50:36 +01:00
1 parent 55a7342dc6
commit 0a145850ba
8 files changed
+354 -40

No files matched your search

+97 -8
View File
@@ -32,9 +32,12 @@ struct Lookup {
};
Lookup lookup(std::uint32_t records, const std::map<std::uint64_t, std::vector<std::uint32_t>>& 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<std::size_t>(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<std::uint32_t>(out.resource.size());
append(out.resource, std::uint32_t{4});
append(out.resource, static_cast<std::uint32_t>(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<std::uint64_t, std::vector<s
for (const auto value : values) append(out.resource, value);
}
append(out.meta, program);
append(out.meta, std::uint32_t{0});
append(out.meta, static_cast<std::uint32_t>(out.resource.size() - program));
append(out.meta, std::uint32_t{4});
append(out.meta, static_cast<std::uint32_t>(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<std::uint32_t>(out.resource.size() - size - fix));
append(out.meta, std::uint32_t{0});
return out;
}
// The merged rows, read back.
std::map<std::uint64_t, std::vector<std::uint32_t>> rows_of(const fb::shader::MergedTable& merged) {
std::size_t at = read<std::uint32_t>(merged.resourceMeta, 0);
std::size_t at = static_cast<std::size_t>(read<std::uint32_t>(merged.resourceMeta, 0)) +
read<std::uint32_t>(merged.resourceMeta, 4);
const auto count = read<std::uint32_t>(merged.resource, at);
at += 4;
std::map<std::uint64_t, std::vector<std::uint32_t>> 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<fb::shader::Table> 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<std::uint32_t>{0}, "The base's own rows stay as they were");
check(read<std::uint32_t>(merged.resourceMeta, 8) == merged.resource.size() - read<std::uint32_t>(merged.resourceMeta, 0),
check(read<std::uint32_t>(merged.resourceMeta, 8) == merged.resource.size() - (read<std::uint32_t>(merged.resourceMeta, 0) + read<std::uint32_t>(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<std::uint32_t>(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<fb::shader::Table> 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<std::uint32_t>(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<std::uint64_t>(combined.resource, 132) == 200,
"The native program heap remains unchanged");
const auto program_table = read<std::uint64_t>(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<std::uint64_t>(combined.resource, static_cast<std::size_t>(record + 100));
check(combined.resource.at(static_cast<std::size_t>(pointer)) == (key == 10 ? std::byte{7} : std::byte{8}),
"Each remapped program still addresses its own opaque heap bytes");
}
const auto program_size = read<std::uint32_t>(combined.resourceMeta, 0);
const auto relocation_size = read<std::uint32_t>(combined.resourceMeta, 4);
for (std::size_t at = 0; at < relocation_size; at += 4) {
const auto field = read<std::uint32_t>(combined.resource, program_size + at);
check(field <= program_size && 8 <= program_size - field &&
read<std::uint64_t>(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<fb::shader::Table> damaged_graph{damaged.table()};
const auto rejected =
fb::shader::merge_program_lookup(native.table(), damaged_graph);
check(read<std::uint32_t>(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<fb::shader::Table> 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;
+121 -19
View File
@@ -5,6 +5,8 @@
#include <map>
#include <stdexcept>
#include <string>
#include <array>
#include <optional>
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<const Table> edits) {
const auto start = lookup_offset(base);
const auto records = read<std::uint32_t>(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<std::uint32_t> fields;
};
std::optional<ProgramGraph> program_graph(const Table& table) {
(void)lookup_offset(table);
ProgramGraph graph;
graph.size = read<std::uint32_t>(table.resourceMeta, 0);
if (graph.size < 12) return std::nullopt;
graph.count = read<std::uint32_t>(table.resource, 0);
graph.start = read<std::uint64_t>(table.resource, 4);
if (graph.start > graph.size || graph.count > (graph.size - graph.start) / 168) return std::nullopt;
const auto fix = read<std::uint32_t>(table.resourceMeta, 4);
if (fix % 4) return std::nullopt;
for (std::size_t at = 0; at < fix; at += 4) {
const auto field = read<std::uint32_t>(table.resource, graph.size + at);
if (field > graph.size || 8 > graph.size - field || read<std::uint64_t>(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<std::array<std::size_t, 3>, 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<std::size_t>(graph.start) + i * 168;
for (const auto& array : arrays) {
const auto count = read<std::uint32_t>(table.resource, record + array[0]);
const auto pointer = read<std::uint64_t>(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<const Table> 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<std::byte> bytes; std::vector<std::uint32_t> fields; };
std::vector<Copy> 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<std::size_t> offset;
std::map<std::uint32_t, std::uint32_t> 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<std::uint64_t>(edit.resource, field);
put(merged.resource, *offset + field, pointer ? pointer + *offset : 0);
fields.push_back(static_cast<std::uint32_t>(*offset + field));
}
}
std::vector<std::uint32_t> mapped;
for (const auto index : values) {
auto [entry, inserted] = indices.emplace(index, native->count + static_cast<std::uint32_t>(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<std::size_t>(graph->start) + index * 168;
Copy copy;
copy.bytes.assign(merged.resource.begin() + static_cast<std::ptrdiff_t>(*offset + record),
merged.resource.begin() + static_cast<std::ptrdiff_t>(*offset + record + 168));
for (const auto field : graph->fields) if (field >= record && field < record + 168)
copy.fields.push_back(static_cast<std::uint32_t>(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<std::ptrdiff_t>(start));
if (!copies.empty()) {
merged.resource.resize((merged.resource.size() + 15) & ~std::size_t{15});
const auto table = static_cast<std::uint64_t>(merged.resource.size());
const auto source_start = static_cast<std::size_t>(native->start);
std::vector<std::byte> originals(base.resource.begin() + static_cast<std::ptrdiff_t>(source_start),
base.resource.begin() + static_cast<std::ptrdiff_t>(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<std::uint32_t>(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<std::uint32_t>(at + field));
}
put(merged.resource, 0, native->count + static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(fields.size() * 4);
const auto start = merged.resource.size();
append(merged.resource, static_cast<std::uint32_t>(rows.size()));
for (const auto& [key, values] : rows) {
append(merged.resource, key);
append(merged.resource, static_cast<std::uint32_t>(values.size()));
for (const auto value : values) append(merged.resource, value);
append(merged.resource, key);append(merged.resource, static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(merged.resource.size() - start));
return merged;
}
+4 -10
View File
@@ -25,18 +25,12 @@ struct MergedTable {
std::vector<std::byte> 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<const Table> edits);
[[nodiscard]] MergedTable merge_texture_lookup(const Table& base, std::span<const Table> edits);
+43 -1
View File
@@ -1,4 +1,5 @@
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/variant_additions.h"
#include "Engine/Game/Build/supported_build.h"
#include <Windows.h>
#include <algorithm>
@@ -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<const char8_t*>(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()));
+49
View File
@@ -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<std::string>& 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<std::string>& 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<char> bytes(static_cast<std::size_t>(size));
if (!file.read(bytes.data(), static_cast<std::streamsize>(bytes.size()))) return false;
const auto toc = frostbite::read_toc(std::as_bytes(std::span(bytes)));
std::optional<frostbite::TocDocument> 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.
// {"<mod folder, lower-case>": "<variant>"}
@@ -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") +
+2 -2
View File
@@ -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;
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "Engine/Resource/binary_bundle.h"
#include <algorithm>
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);
}
}
+9
View File
@@ -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.