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.
This commit is contained in:
ReSkate local committed 2026-10-05 07:32:27 +01:00
1 parent 5bfe4f7bfa
commit 2b39afcb62
2 files changed
+68 -6

No files matched your search

@@ -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;
+38 -6
View File
@@ -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 <algorithm>
#include <array>
#include <set>
#include <stdexcept>
@@ -68,15 +71,44 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& 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<const fb::ebx::Document*, 2> 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).