From 2b39afcb6212b9cdf8a705d504de83ecdc434c06 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 07:32:27 +0100 Subject: [PATCH] Vfs: carry added assets from every mod that edits a shared asset When several mods change the same game asset (the master music playlist, for one), only the first mod's edit was read for the imports it names, so only that mod's added songs, waves and chunks were carried into the other mods' copies of the bundle (a map's level superbundle). Every later mod's additions went missing on those levels while the game's own levels kept them, which showed up as songs vanishing from the playlist on custom maps. Read the imports of every changed asset, and merge a later mod's edit into the stored change so the combined document's imports -- and the chunks they lead to -- are followed too. Add a regression test with two music mods whose songs must both reach a map's copy of the bundle. --- .../Vfs/Test/mod_merge_added_assets_tests.cpp | 30 +++++++++++++ Engine/Vfs/mod_merge_overrides.cpp | 44 ++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 34571741..b80b6815 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -11,6 +11,7 @@ // wave the song names, the wave's sound-bank resource, and an asset nothing names. #include "Engine/Resource/binary_bundle.h" #include "Engine/Resource/cas_codec.h" +#include "Engine/Resource/ebx_document.h" #include "Engine/Resource/toc.h" #include "Engine/Vfs/mod_catalog.h" #include "Engine/Vfs/native_db.h" @@ -353,6 +354,34 @@ void same_name_from_two_mods(bool different) { expect(noted(report, std::string("map: ") + bundle_name + ": 3 asset(s) added by other mods, e.g. test/song"), label + ": the map's copy still receives the first mod's three\n" + describe(report)); } + +// Two music mods both change test/playlist to add their own songs. Both mods' songs, +// waves and resources must be carried into the map's copy of the bundle, not just the first mod's. +void two_music_mods_both_carried_into_maps_copy(int baseline) { + Fixture fixture("two-music-mods"); + const auto song1 = guid(10), wave1 = guid(11); + const auto song2 = guid(20), wave2 = guid(21); + fixture.add("music1", false, shared_toc, {}, + {{"test/playlist", 1, ebx_document(guid(1), {song1})}, + {"test/other", 0, ebx_document(guid(2), {})}, + {"test/song1", 0, ebx_document(song1, {wave1})}, + {"test/wave1", 0, ebx_document(wave1, {})}}, + {{"test/wave1", {std::byte{1}}}}); + fixture.add("music2", false, shared_toc, {}, + {{"test/playlist", 2, ebx_document(guid(1), {song2})}, + {"test/other", 0, ebx_document(guid(2), {})}, + {"test/song2", 0, ebx_document(song2, {wave2})}, + {"test/wave2", 0, ebx_document(wave2, {})}}, + {{"test/wave2", {std::byte{2}}}}); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built, "two music mods: the merge builds the patch\n" + describe(report)); + expect(noted(report, std::string("map: ") + bundle_name + ": 6 asset(s) added by other mods, e.g. test/song1"), + "two music mods: the map's copy receives all six assets from both mods\n" + describe(report)); + const auto files = fixture.merged_files(map_toc); + expect(files == baseline + 6, "two music mods: the map's copy gained 6 files (3 per music mod): " + + std::to_string(files) + " vs " + std::to_string(baseline + 6)); +} } // namespace int main() try { @@ -363,6 +392,7 @@ int main() try { copy_in_the_adders_toc_is_left_alone(); same_name_from_two_mods(true); same_name_from_two_mods(false); + two_music_mods_both_carried_into_maps_copy(baseline); if (failures) { std::cerr << failures << " failure(s)\n"; return 1; diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 17e9adaf..5269a473 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -1,8 +1,11 @@ #include "mod_merge_internal.h" #include "Engine/Resource/cas_codec.h" #include "Engine/Resource/ebx_document.h" +#include "Engine/Resource/ebx_merge.h" +#include "Engine/Resource/ebx_writer.h" #include +#include #include #include @@ -68,15 +71,44 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}}); continue; } - auto& versions = out.changed[name]; - if (versions.contains(game_copy->second)) continue; - const auto& change = versions.emplace(game_copy->second, - AssetOverride{mod->name, asset.sha1, asset.originalSize, payload()}).first->second; - ++changed; + const auto encodedPayload = payload(); try { - for (const auto& reference : decoded(change.encoded).imports) + for (const auto& reference : decoded(encodedPayload).imports) imported.insert(reference.fileGuid); } catch (const std::exception&) {} + auto& versions = out.changed[name]; + const auto existing = versions.find(game_copy->second); + if (existing == versions.end()) { + versions.emplace(game_copy->second, + AssetOverride{mod->name, asset.sha1, asset.originalSize, encodedPayload}); + ++changed; + } else { + try { + std::size_t baseIndex = 0; + while (baseIndex < gameListing->manifest.ebx.size() && + lower(gameListing->manifest.ebx[baseIndex].name) != name) + ++baseIndex; + if (gameListing->first + baseIndex < gameListing->files.size()) { + const auto& baseFile = gameListing->files[gameListing->first + baseIndex]; + const auto baseBytes = store.read(baseRoot, baseFile.location, baseFile.offset, baseFile.size); + const auto baseDoc = decoded(baseBytes); + const auto existingDoc = decoded(existing->second.encoded); + const auto thisDoc = decoded(encodedPayload); + const std::array editDocs{&existingDoc, &thisDoc}; + fb::ebx::MergeSummary summary; + auto combined = fb::ebx::merge_documents(baseDoc, editDocs, &summary); + if (summary.instances || summary.arrayEntries) { + const auto rebuilt = fb::ebx::write_document(combined); + existing->second.sha1 = sha1_of(rebuilt); + existing->second.originalSize = rebuilt.size(); + existing->second.encoded = fb::encode_cas(rebuilt, {gameRoot}); + existing->second.mod += " + " + mod->name; + for (const auto& reference : combined.imports) + imported.insert(reference.fileGuid); + } + } + } catch (const std::exception&) {} + } } // Resources the mod adds, kept to go with an added EBX of the same name // (a wave's sound-bank resource registers the wave with the audio system).