Shaders: validate retained graphs and preserve safe aliases

This commit is contained in:
hypnomalice committed 2026-10-25 10:54:20 +00:00
1 parent dd74399e8a
commit 8f3611e283
6 files changed
+243 -42

No files matched your search

@@ -146,6 +146,11 @@ int main() {
auto shortMeta = extension;
shortMeta.meta.resize(8);
check(!fb::shader::preserves_stock_programs(shortMeta.table(), stock.table()), "Short retained metadata is refused");
auto inflatedCount = extension;
const std::uint32_t impossibleCount = 0xffffffffU;
std::memcpy(inflatedCount.resource.data(), &impossibleCount, sizeof(impossibleCount));
check(!fb::shader::preserves_stock_programs(inflatedCount.table(), stock.table()),
"A declared private program count must fit inside the retained heap");
#ifdef _WIN32
SYSTEM_INFO system{};
GetSystemInfo(&system);
@@ -199,6 +204,35 @@ int main() {
"Stock aliases cannot address the selected graph's private program slots");
check(fb::shader::merge_program_lookup(extension.table(), stockEdits).outside == 1,
"The stock exception requires the caller's actual game table");
auto otherCustom = preserved_lookup(true, {{1, {0}}, {12, {0}}, {13, {1}}});
otherCustom.resource[400] = std::byte{9}; // a different private record, leaving stock intact
const std::vector<fb::shader::Table> competing{otherCustom.table()};
const auto sharedAliases = fb::shader::merge_program_lookup(extension.table(), competing, stock.table());
check(sharedAliases.added == 1 && sharedAliases.outside == 1 && rows_of(sharedAliases).contains(12) &&
!rows_of(sharedAliases).contains(13), "A non-retained custom graph contributes proven stock aliases, never its private indices");
const auto stockRetained = fb::shader::merge_program_lookup(stock.table(), competing, stock.table());
check(rows_of(stockRetained).contains(12) && !rows_of(stockRetained).contains(13),
"Stock aliases from an extended graph survive when stock is retained too");
const auto clash = preserved_lookup(false, {{1, {0}}, {9, {0}}});
const std::vector<fb::shader::Table> clashing{clash.table()};
const auto graphOwnerWins = fb::shader::merge_program_lookup(extension.table(), clashing, stock.table());
check(graphOwnerWins.conflicts == 1 && graphOwnerWins.added == 0 &&
rows_of(graphOwnerWins).at(9) == std::vector<std::uint32_t>{1},
"Retained graph rows win clashes with a stock-alias winner, and the disagreement is reported");
const auto sameGraphClash = lookup(5, {{1, {0}}, {9, {3}}});
const std::vector<fb::shader::Table> graphClashes{sameGraphClash.table()};
const auto selectedWins = fb::shader::merge_program_lookup(selected.table(), graphClashes);
check(selectedWins.conflicts == 1 && rows_of(selectedWins).at(9) == std::vector<std::uint32_t>{4},
"The selected contributor wins a same-graph clash with a lower-priority edit");
const auto invalidWinner = lookup(5, {{1, {0}}, {99, {5}}});
const auto filtered = fb::shader::merge_program_lookup(invalidWinner.table(), {});
check(filtered.removed == 1 && filtered.outside == 1 && !rows_of(filtered).contains(99),
"The retained winner's rows must be inside its own program count");
auto corruptEdit = extension;
corruptEdit.resource.resize(12);
const std::vector<fb::shader::Table> corrupt{corruptEdit.table()};
const auto kept = fb::shader::merge_program_lookup(stock.table(), corrupt, stock.table());
check(kept.outside == 1 && rows_of(kept).contains(1), "Malformed edits cannot discard an intact retained table");
if (failures) return 1;
std::cout << "shader lookup tests passed\n";
return 0;
+34 -14
View File
@@ -73,6 +73,7 @@ bool preserves_stock(const Table& retained, const Table& stock) {
if (!count || count > keptCount || stockSize < 12 || keptSize < stockSize ||
start < 12 || start > stockSize || recordBytes > stockSize - start ||
keptStart < 12 || keptStart > keptSize || recordBytes > keptSize - keptStart ||
static_cast<std::uint64_t>(keptCount) * 168 > keptSize - keptStart ||
!std::equal(stock.resourceMeta.begin() + 12, stock.resourceMeta.end(), retained.resourceMeta.begin() + 12) ||
!std::equal(stock.resource.begin() + 12, stock.resource.begin() + stockSize, retained.resource.begin() + 12) ||
!std::equal(stock.resource.begin() + static_cast<std::ptrdiff_t>(start),
@@ -166,6 +167,10 @@ TextureRows read_texture_rows(const Table& table) {
bool preserves_stock_programs(const Table& candidate, const Table& stock) {
return preserves_stock(candidate, stock);
}
bool same_program_graph(const Table& candidate, const Table& other) {
try { return same_graph(candidate, other); }
catch (const std::exception&) { return false; }
}
bool only_stock_aliases(const Table& candidate, const Table& stock) {
try {
@@ -194,22 +199,37 @@ MergedTable merge_program_lookup(const Table& base, const std::span<const Table>
const bool stockPreserved = preserves_stock(base, stock);
const auto stockRecords = stockPreserved ? read<std::uint32_t>(stock.resource, 0) : 0;
MergedTable merged;
for (auto at = rows.begin(); at != rows.end();) {
if (std::any_of(at->second.begin(), at->second.end(), [&](auto index) { return index >= records; })) {
at = rows.erase(at);
++merged.outside;
++merged.removed;
} else ++at;
}
for (const auto& edit : edits) {
// Indices have meaning only within their owning graph. Keep the selected
// bundle's heap and relocations verbatim: importing another graph can
// make the game instantiate programs before their compiled files load.
const bool sameGraph = same_graph(base, edit);
const bool stockGraph = stockPreserved && same_graph(stock, edit);
const auto available = sameGraph ? records : stockRecords;
for (const auto& [key, values] : read_program_rows(edit)) {
if (baseRows.contains(key)) continue;
if ((!sameGraph && !stockGraph) || std::any_of(values.begin(), values.end(), [&](std::uint32_t index) { return index >= available; })) {
++merged.outside;
continue;
try {
// Indices have meaning only within their owning graph. Keep the selected
// bundle's heap and relocations verbatim: importing another graph can
// make the game instantiate programs before their compiled files load.
const bool sameGraph = same_graph(base, edit);
const bool stockGraph = stockPreserved && (same_graph(stock, edit) || preserves_stock(edit, stock));
const auto available = sameGraph ? records : stockRecords;
for (const auto& [key, values] : read_program_rows(edit)) {
if (const auto held = baseRows.find(key); held != baseRows.end()) {
if (held->second != values) ++merged.conflicts;
continue;
}
if ((!sameGraph && !stockGraph) || std::any_of(values.begin(), values.end(), [&](std::uint32_t index) { return index >= available; })) {
++merged.outside;
continue;
}
const auto [row, inserted] = rows.try_emplace(key, values);
if (inserted) ++merged.added;
else if (row->second != values) ++merged.conflicts;
}
const auto [row, inserted] = rows.try_emplace(key, values);
if (inserted) ++merged.added;
else if (row->second != values) ++merged.conflicts;
} catch (const std::exception&) {
// A malformed edit is not permission to discard a verified graph.
++merged.outside;
}
}
+9 -3
View File
@@ -14,6 +14,8 @@ namespace dingosdk::frostbite::shader {
inline constexpr std::uint32_t programLookupType = 0xE2E6955B;
inline constexpr std::uint32_t textureLookupType = 0x2D254A89;
// Native ShaderProgram bytecode resource type, also used by Studio's native
// shader resource writer; confirmed on the pinned build's shaders/bytecode/*.
inline constexpr std::uint32_t bytecodeType = 0x0E2B02F7;
struct Table {
@@ -25,18 +27,22 @@ struct MergedTable {
std::vector<std::byte> resource;
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 conflicts{}; // disagreements: retained graph wins, then first edit
std::size_t outside{}; // rows left out: they point past the base's programs
std::size_t removed{}; // invalid rows removed from the retained table itself
};
// The selected bundle contributor owns the retained program graph. Merge only
// aliases whose program heap and relocation bytes match it exactly. No program
// private aliases whose program heap and relocation bytes match it exactly. No program
// records or relocations are copied or rebased. An optional stock table allows
// stock aliases when its heap, records and relocations survive unchanged. `outside` includes
// stock aliases from other graphs that also preserve the stock graph. Retained
// graph rows win clashes, even against higher-priority alias contributors; other
// fresh keys use edit order. `outside` includes
// rows from incompatible graphs as well as out-of-range indices.
// Selection helpers for contributors to the SAME bundle. A caller selecting a
// different contributor must also retain that contributor's compiled resources.
[[nodiscard]] bool preserves_stock_programs(const Table& candidate, const Table& stock);
[[nodiscard]] bool same_program_graph(const Table& candidate, const Table& other);
[[nodiscard]] bool only_stock_aliases(const Table& candidate, const Table& stock);
[[nodiscard]] MergedTable merge_program_lookup(const Table& base, std::span<const Table> edits, const Table& stock = {});
@@ -14,6 +14,7 @@
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/toc.h"
#include "Engine/Resource/shader_lookup.h"
#include "Engine/Vfs/game_archives.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_merge_internal.h"
@@ -865,9 +866,137 @@ void two_merges_write_the_same_bytes() {
expect(read(patch / "Win32" / "pkg" / "cas_01.cas") == archive, "same bytes: the patch's own archive differs");
expect(read(patch / "Win32" / fs::path(second_map_toc)) == toc, "same bytes: the second map's TOC differs");
}
void shader_selection_checks_the_winner(bool delivered, bool changed_heap, bool alias_first, bool overwritten = false,
bool encoder_available = true) {
Fixture fixture("shader-selection-" + std::to_string(delivered) + std::to_string(changed_heap) +
std::to_string(alias_first) + std::to_string(overwritten));
const auto table = [](bool extended) {
Resource resource{"test/program_lookup", Bytes(extended ? 544 : 208)};
const std::uint32_t count = extended ? 2 : 1;
const std::uint64_t start = extended ? 208 : 16;
std::memcpy(resource.payload.data(), &count, 4);
std::memcpy(resource.payload.data() + 4, &start, 8);
resource.payload[192] = std::byte{7};
put32(resource.payload, 4); // the header pointer's relocation
return resource;
};
const auto rows = [](Resource resource, const std::map<std::uint64_t, std::uint32_t>& values) {
put32(resource.payload, static_cast<std::uint32_t>(values.size()));
for (const auto& [key, index] : values) {
for (int shift = 0; shift < 64; shift += 8) resource.payload.push_back(static_cast<std::byte>(key >> shift));
put32(resource.payload, 1); put32(resource.payload, index);
}
return resource;
};
auto stock = rows(table(false), {{1, 0}});
auto aliases = rows(table(false), {{1, 0}, {2, 0}});
auto custom = rows(table(true), {{1, 0}, {2, 0}, {5, 0}, {9, 1}, {99, 2}});
if (changed_heap) custom.payload[192] = std::byte{8};
const auto write_layer = [&](const fs::path& root, bool patch, const Resource& programs, bool code, unsigned code_version) {
fs::create_directories(root / "Win32" / "pkg");
write(root / "layout.toc", layout_toc());
fb::BinaryBundle manifest;
fb::BundleAsset asset;
asset.kind = fb::AssetKind::resource; asset.name = programs.name; asset.resourceId = 42;
asset.resourceType = fb::shader::programLookupType;
asset.sha1 = mods::detail::sha1_of(programs.payload); asset.originalSize = programs.payload.size();
const auto size = programs.payload.size() > 544 ? 544U : 208U;
put32(asset.resourceMeta, size); put32(asset.resourceMeta, 4);
put32(asset.resourceMeta, static_cast<std::uint32_t>(programs.payload.size() - size - 4)); put32(asset.resourceMeta, 0);
manifest.resources.push_back(asset);
const Bytes bytecode{static_cast<std::byte>(code_version)};
if (code) {
asset.name = "shaders/bytecode/test"; asset.resourceId = 43; asset.resourceType = fb::shader::bytecodeType;
asset.sha1 = mods::detail::sha1_of(bytecode); asset.originalSize = bytecode.size(); asset.resourceMeta.assign(16, std::byte{0});
manifest.resources.push_back(asset);
}
auto archive = fb::write_binary_bundle(manifest);
std::vector<fb::BundleFileInfo> files{{{patch, package, 1}, 0, static_cast<std::uint32_t>(archive.size())}};
const auto append_payload = [&](const Bytes& payload) {
const auto bytes = encoded(payload);
files.push_back({{patch, package, 1}, static_cast<std::uint32_t>(archive.size()), static_cast<std::uint32_t>(bytes.size())});
archive.insert(archive.end(), bytes.begin(), bytes.end());
};
append_payload(programs.payload);
if (code) append_payload(bytecode);
write(root / "Win32" / "pkg" / "cas_01.cas", archive);
const std::vector<fb::TocBundle> bundles{{bundle_name, fb::write_bundle_region(files), 1}};
write(root / "Win32" / fs::path(shared_toc), fb::write_patch_toc(bundles));
};
write_layer(fixture.catalog.data_root / "Data", false, stock, false, 0);
write_layer(fixture.catalog.root / "custom", true, custom, delivered, 1);
write_layer(fixture.catalog.root / "aliases", true, aliases, overwritten, 2);
for (const auto* name : alias_first ? std::vector<const char*>{"aliases", "custom"} : std::vector<const char*>{"custom", "aliases"}) {
mods::Mod mod; mod.name = name; mod.directory = fixture.catalog.root / name; mod.provides_layout = true;
fixture.catalog.mods.push_back(std::move(mod));
}
const auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "shader graph selection builds\n" + describe(report));
Patch patch(fixture);
const auto toc = fb::read_toc(read(patch.root / "Win32" / fs::path(shared_toc)));
const auto listing = mods::detail::list_bundle(patch.store, patch.root, patch.base, toc.bundles.front(), patch.game);
expect(listing && !listing->manifest.resources.empty(), "shader result has its native resource manifest");
if (!listing || listing->manifest.resources.empty()) return;
const auto program = std::find_if(listing->manifest.resources.begin(), listing->manifest.resources.end(),
[](const auto& resource) { return resource.resourceType == fb::shader::programLookupType; });
expect(program != listing->manifest.resources.end(), "program lookup resource survives");
if (program == listing->manifest.resources.end()) return;
const auto program_index = listing->manifest.ebx.size() + static_cast<std::size_t>(program - listing->manifest.resources.begin());
const auto payload = mods::detail::read_asset(patch.store, patch.root, patch.base, *listing, program_index, patch.game);
if (payload.size() < 12) { expect(false, "program lookup payload is complete"); return; }
if (!encoder_available) {
// This case starts before any codec has been loaded. A failed rebuild
// must retain the selected alias payload AND its matching metadata.
expect(payload == aliases.payload, "failed encoding keeps the selected payload");
std::uint32_t kept_size{}; std::memcpy(&kept_size, program->resourceMeta.data(), 4);
expect(kept_size == 208, "failed encoding does not publish replacement graph metadata");
expect(std::any_of(report.notes.begin(), report.notes.end(), [](const auto& note) {
return note.find("edits could not be combined") != std::string::npos;
}), "missing encoder exercises the failed-rebuild path");
return;
}
std::uint32_t count{}; std::memcpy(&count, payload.data(), 4);
const bool private_graph = delivered && !changed_heap && !overwritten;
expect(count == (private_graph ? 2U : 1U), "winning and promoted custom graphs need both stock preservation and retained bundle-owned bytecode");
const auto& meta = program->resourceMeta;
std::uint32_t size{}, fixups{}, number{};
std::memcpy(&size, meta.data(), 4); std::memcpy(&fixups, meta.data() + 4, 4);
std::size_t at = static_cast<std::size_t>(size) + fixups;
if (at + 4 > payload.size()) { expect(false, "program lookup section is bounded"); return; }
std::memcpy(&number, payload.data() + at, 4); at += 4;
std::set<std::uint64_t> keys;
for (std::uint32_t row = 0; row < number; ++row) {
if (at + 12 > payload.size()) { expect(false, "program row header is bounded"); return; }
std::uint64_t key{}; std::uint32_t values{};
std::memcpy(&key, payload.data() + at, 8); std::memcpy(&values, payload.data() + at + 8, 4); at += 12;
keys.insert(key);
for (std::uint32_t value = 0; value < values; ++value) {
if (at + 4 > payload.size()) { expect(false, "program row indices are bounded"); return; }
std::uint32_t index{}; std::memcpy(&index, payload.data() + at, 4); at += 4;
expect(index < count, "native output rows stay in the retained graph's range");
}
}
expect(keys.contains(2) && !keys.contains(99), "safe stock aliases survive and invalid winning rows are actually written out");
expect(keys.contains(9) == private_graph, "unproved graphs cannot leak private program indices into stock");
expect(keys.contains(5) == !changed_heap, "a non-retained custom contributor still contributes a proven stock alias");
}
} // namespace
int main() try {
int main(int argc, char** argv) try {
// Rebuilt native resources use the game's Oodle encoder. The normal suite
// needs no game files; supplying a game root enables these archive checks.
if (argc > 1 && *argv[1]) {
(void)fb::encode_cas(Bytes{std::byte{1}}, {fs::path(argv[1])});
shader_selection_checks_the_winner(true, false, false);
shader_selection_checks_the_winner(true, false, true);
shader_selection_checks_the_winner(false, false, false);
shader_selection_checks_the_winner(true, true, false);
shader_selection_checks_the_winner(true, false, true, true);
} else {
shader_selection_checks_the_winner(true, false, true, false, false);
std::cout << "Successful shader archive selection checks require a local game root; skipped\n";
}
replaced_while_the_game_runs();
const auto baseline = map_only();
expect(baseline > 0, "map only: the map's copy exists (" + std::to_string(baseline) + ")");
+35 -23
View File
@@ -1113,6 +1113,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
for (const auto* edit : edits) editBytes.push_back(decode(*edit));
std::vector<std::byte> rebuilt;
auto rebuiltMeta = entry.asset.resourceMeta;
std::string what;
if (refTable) {
std::vector<fb::bundle_ref::Table> tables;
@@ -1121,7 +1122,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const auto table = fb::bundle_ref::merge({baseBytes, baseCopy->resourceMeta}, tables);
if (!table.added) continue;
rebuilt = table.resource;
entry.asset.resourceMeta = table.resourceMeta;
rebuiltMeta = table.resourceMeta;
what = std::to_string(table.added) + " preset(s)";
if (table.conflicts)
what += ", " + std::to_string(table.conflicts) + " disagreed";
@@ -1153,15 +1154,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const auto* selected = retained;
auto retainedBytes = decode(*retained);
const fb::shader::Table stockTable{baseBytes, baseCopy->resourceMeta};
if (entry.asset.resourceType == fb::shader::programLookupType &&
fb::shader::only_stock_aliases({retainedBytes, retained->resourceMeta}, stockTable)) {
// A stock-alias edit owns no new programs. It must not
// erase a graph another contributor already delivers
// to this same bundle. Highest custom contributor wins.
for (std::size_t i = 0; i < edits.size(); ++i) {
const fb::shader::Table candidate{editBytes[i], edits[i]->resourceMeta};
if (fb::shader::only_stock_aliases(candidate, stockTable) ||
!fb::shader::preserves_stock_programs(candidate, stockTable)) continue;
if (entry.asset.resourceType == fb::shader::programLookupType) {
// Apply identical ownership checks to the winning and
// lower-priority custom graphs. Unproved custom heaps
// fall back to stock, keeping safe stock aliases usable.
const auto usable = [&](const fb::shader::Table& candidate, const Contribution* source) {
if (fb::shader::same_program_graph(candidate, stockTable)) return true;
if (!fb::shader::preserves_stock_programs(candidate, stockTable)) return false;
bool dependenciesRetained = true;
bool bytecodeDelivered = false;
for (const auto& resource : state.assets) {
@@ -1172,20 +1171,28 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const auto key = asset_key(resource.asset);
const auto history = state.history.find(key);
if (history == state.history.end()) continue;
const auto shipped = state.baseSha.find(key);
for (const auto& copy : history->second) {
if (copy.root != edits[i]->root || copy.base ||
(shipped != state.baseSha.end() && copy.sha1 == shipped->second)) continue;
for (const auto& copy : history->second) if (!copy.base && copy.root == source->root) {
if (copy.resourceType == fb::shader::bytecodeType) bytecodeDelivered = true;
if (copy.sha1 != resource.asset.sha1 ||
copy.resourceMeta != resource.asset.resourceMeta ||
copy.resourceId != resource.asset.resourceId ||
copy.resourceType != resource.asset.resourceType) dependenciesRetained = false;
if (copy.sha1 != resource.asset.sha1 || copy.resourceMeta != resource.asset.resourceMeta ||
copy.resourceId != resource.asset.resourceId || copy.resourceType != resource.asset.resourceType)
dependenciesRetained = false;
}
}
if (!dependenciesRetained || !bytecodeDelivered) continue;
retained = edits[i];
retainedBytes = editBytes[i];
return dependenciesRetained && bytecodeDelivered;
};
if (!usable({retainedBytes, retained->resourceMeta}, retained)) {
report.notes.push_back(entry.asset.name + ": custom graph ownership or stock preservation could not be proved; retaining stock programs");
retained = baseCopy;
retainedBytes = baseBytes;
}
if (fb::shader::only_stock_aliases({retainedBytes, retained->resourceMeta}, stockTable)) {
for (std::size_t i = 0; i < edits.size(); ++i) {
const fb::shader::Table candidate{editBytes[i], edits[i]->resourceMeta};
if (fb::shader::same_program_graph(candidate, stockTable) || !usable(candidate, edits[i])) continue;
retained = edits[i];
retainedBytes = editBytes[i];
break;
}
}
}
const fb::shader::Table baseTable =
@@ -1199,9 +1206,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
report.notes.push_back(entry.asset.name + ": " + std::to_string(table.outside) +
" material row(s) left out: their graph or indices do not match the retained "
"bundle shader database");
if (!table.added && retained == selected) continue;
if (!table.added && !table.removed && retained == selected) {
if (table.conflicts) report.notes.push_back(entry.asset.name + ": " + std::to_string(table.conflicts) +
" material row(s) disagreed; retained graph rows win");
continue;
}
rebuilt = table.resource;
entry.asset.resourceMeta = table.resourceMeta;
rebuiltMeta = table.resourceMeta;
what = std::to_string(table.added) + " material row(s)";
if (table.conflicts)
what += ", " + std::to_string(table.conflicts) + " disagreed";
@@ -1317,6 +1328,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const auto placement = store.write(state.manifestChunk, manifestArchive,
fb::encode_cas(rebuilt, {gameRoot}));
entry.file = placement;
entry.asset.resourceMeta = std::move(rebuiltMeta);
entry.asset.sha1 = sha1_of(rebuilt);
entry.asset.originalSize = rebuilt.size();
used.emplace(placement.location.installChunk, placement.location.archive);
+1 -1
View File
@@ -127,7 +127,7 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
add_executable(dingosdk_mod_merge_added_assets_tests Engine/Vfs/Test/mod_merge_added_assets_tests.cpp)
target_link_libraries(dingosdk_mod_merge_added_assets_tests PRIVATE dingosdk_mods dingosdk_game_archives)
target_include_directories(dingosdk_mod_merge_added_assets_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME mod_merge_added_assets COMMAND dingosdk_mod_merge_added_assets_tests)
add_test(NAME mod_merge_added_assets COMMAND dingosdk_mod_merge_added_assets_tests "${DINGOSDK_TEST_GAME_ROOT}")
add_executable(dingosdk_item_list_merge_tests Engine/Vfs/Test/item_list_merge_tests.cpp)
target_link_libraries(dingosdk_item_list_merge_tests PRIVATE dingosdk_mods)
target_include_directories(dingosdk_item_list_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}")