mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 17:27:08 +01:00
Mods: merge independent shader graphs and preserve additive variant bundles
This commit is contained in:
1 parent
55a7342dc6
commit
0a145850ba
8 files changed
+354
-40
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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") +
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user