#include "mod_merge.h" #include "content_cache.h" #include "content_catalogs.h" #include "mod_item_sources.h" #include "mod_merge_internal.h" #include "mod_store_copies.h" #include "native_db.h" #include "Engine/Core/Platform/path_text.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace dingosdk::mods { using namespace detail; namespace { using Node = native_db::Node; // Where maps register themselves; read by the game only at launch. constexpr std::string_view launch_level_registry = "win32/globals.toc"; // The root level: its sublevel manager lists every map, and it carries the // shader-state tables (material rows) and the material grid maps add to. The // renderer prepares the shader tables once, at launch, so the root must not // change under it while the game runs: every installed map's root edits go in // at launch, disabled ones included, and a live merge keeps them as they are. constexpr std::string_view root_level = "win32/levels/game/dingolevel_root/dingolevel_root.toc"; // Superbundles the game mounts once, at launch. The merged patch always carries // its own copy of each, even one no enabled mod changes, so a mod enabled or // installed while the game runs has a mounted copy to replace in memory // (Extension/Assets/live_mods.cpp) and one disabled can be swapped back out. constexpr std::array launch_superbundles{"Win32/globals.toc", "Win32/items.toc"}; // Each enabled mod that adds copies of items the game's store sells // (mod_store_copies.h) gets a problem; true when any does. The problem says no // more than that the mod could not be merged: what was found is not for the // mod's author to read. The catalogue is only read once a mod turns out to add // an item at all. `added` gets the items each mod read adds, when the check ran. bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vector& checked, std::size_t threads, bool background, std::optional>& added) { // (Only when some are skipped is the catalogue copied, without them.) std::optional fewer; if (!checked.empty()) { fewer = all; std::erase_if(fewer->mods, [&](const Mod& mod) { return std::ranges::any_of(checked, [&](const std::string& name) { return lower(name) == lower(mod.name); }); }); } const Catalog& catalog = fewer ? *fewer : all; std::optional store; const auto found = check_store_copies(catalog, [&store](const std::string& key) { if (!store) store = content_cache::read_catalogs(content_cache::directory()); return store->reserved(key); }, &report.notes, threads, background); // (No mod to read is a check that ran and found no items.) if (found.read || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; })) added = std::move(found.added); for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem); return !found.mods.empty(); } } // namespace MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, const MergeOptions& options) noexcept { MergeReport report; try { // How long each step took, for the log: a merge that takes many minutes // can then be traced to the step that takes them. using Clock = std::chrono::steady_clock; std::vector> times; auto lapStart = Clock::now(); const auto lap = [&](std::string step) { const auto now = Clock::now(); times.emplace_back(std::move(step), now - lapStart); lapStart = now; }; const auto output = catalog.root / generated_folder; std::error_code error; std::vector mods; for (const auto& mod : catalog.mods) if (mod.provides_layout) mods.push_back(&mod); // With no mod enabled the patch stays, holding the game's own copy of // what it reads at launch (and any disabled mods' archives), so a mod // enabled or added later loads without a restart. std::map modFiles; for (const auto* mod : mods) modFiles[mod] = scan(mod->directory); // What the store sells comes from the content cache, which the launcher installs. const bool storeKnown = content_cache::installed(); // Disabled mods count too: their archives and map registration are placed at launch. auto fingerprint = merge_fingerprint(catalog, mods, modFiles, storeKnown); for (const auto& mod : catalog.inactive) fingerprint += " inactive " + mod.name + " " + mod_fingerprint(mod.directory); if (options.live) { fs::remove(output / stamp_file, error); } else if (auto previous = previous_merge(output, fingerprint); previous && (!storeKnown || fs::exists(output / item_sources_file, error))) { // (A patch from before items were traced to their mods is built again, once.) return std::move(*previous); } // A mod that adds copies of store items is not loaded at all. Found before // anything is built: the caller merges again without it, as it does for a // mod that cannot be merged, and the patch on disk stays for that merge to // reuse or replace. // Threads for the three steps that read many files: as many as there are cores at // launch (the player is waiting on nothing else), half of them and fewer while the game runs. const auto cores = std::max(1U, std::thread::hardware_concurrency()); const std::size_t readers = std::min(options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U); std::optional> itemsRead; if (storeKnown && store_copy_problems(catalog, report, options.live ? options.checked : std::vector{}, readers - 1, options.live, itemsRead)) return report; // Which mod each item is from (mod_item_sources.h): what was just read, and for a mod a // live merge did not read again, what the launch's merge found. Read before the patch // folder is cleared. std::optional> itemSources; if (itemsRead) { const auto before = options.live ? read_item_sources(output / item_sources_file) : std::vector{}; itemSources.emplace(); for (const auto* mod : mods) { std::error_code outside; const auto folder = fs::relative(mod->directory, catalog.root, outside); ItemSource source{mod->name, mod->title, mod->author, mod->version, outside ? std::string() : path_utf8(folder.generic_wstring()), {}}; const auto read = std::ranges::find(*itemsRead, mod->name, &StoreCopies::Added::mod); const auto earlier = std::ranges::find(before, mod->name, &ItemSource::mod); if (read != itemsRead->end()) source.keys = std::move(read->keys); else if (earlier != before.end()) source.keys = earlier->keys; if (!source.keys.empty()) itemSources->push_back(std::move(source)); } } if (!options.live) fs::remove_all(output, error); lap("checking the mods"); // Progress: each mod's archives, the material grid, a live merge's load // screens, the asset overrides, each superbundle, then the layout. std::set distinctTocs; for (const auto& [mod, files] : modFiles) for (const auto& toc : files.tocs) distinctTocs.insert(lower(toc)); for (const auto relative : launch_superbundles) distinctTocs.insert(lower(relative)); MergeProgress progress{0, mods.size() + distinctTocs.size() + (options.live ? 4 : 3), mods.size(), {}}; const auto advance = [&](std::string step) { if (!observe) return; progress.step = std::move(step); try { observe(progress); } catch (...) {} ++progress.done; }; // Every superbundle any mod ships, and who ships it, in priority order. std::map, std::less<>> providers; std::map placements; std::vector superbundles; // layeredInstallChunkFiles enumerates the archives the engine expects, // so every index a mod's payloads moved to has to be declared there. ArchiveUse used; // Every mod keeps the archive indices it was built for; where two mods // ship the same one, their archives are concatenated into a single file // and the later blocks are addressed by byte offset. // A mod's layout is the game's (as of the build it was made for) plus // its own entries. The merged layout starts from the installed game's // layout and takes only each mod's own entries, so mods made for an // earlier game build keep working after an update: a mod's full copy // would carry the old build's layout, and the game then waits on the // splash for bundles that layout places wrongly. auto parsedLayout = vfs::read_layout(catalog.data_root / L"Data" / L"layout.toc"); std::vector layoutSources = mods; if (layoutSources.empty() && !catalog.inactive.empty()) layoutSources.push_back(&catalog.inactive.front()); auto& layout = parsedLayout.root; const auto baseRoot = catalog.data_root / L"Data"; const auto gameRoot = catalog.data_root; CasStore store(baseRoot, output, layout); // Mods are built against archive 1, so that is where the rebuilt // manifests go too: an index the engine already knows in every package. constexpr std::uint16_t manifestArchive = 1; // What the layout declares in each package directory. A free index is // judged against its own directory rather than every directory at once // (judged against the union, three mods used up the gaps), lowest // first, and carries on past the highest index the game declares: the // engine keeps archives in a table keyed by layer, install chunk and a // 16-bit index, with no range to stay inside. What it cannot survive is // a reference to an archive the layout never declared. std::map> declaredIn; for (const auto* field : {"layeredInstallChunkFiles", "unlayeredInstallChunkFiles"}) { const auto* node = layout.field(field); if (!node || node->type != 19) continue; for_each_install_chunk_file(node->payload(), [&](std::uint32_t id, std::uint16_t archive) { if (const auto* directory = store.find_directory(id)) declaredIn[*directory].insert(archive); }); } std::map> claimedArchives; const auto claim = [&](const std::string& directory) -> std::optional { const auto& declared = declaredIn[directory]; auto& claimed = claimedArchives[directory]; for (std::uint32_t candidate = 1; candidate <= std::numeric_limits::max(); ++candidate) { const auto index = static_cast(candidate); if (!declared.contains(index) && claimed.insert(index).second) return index; } return std::nullopt; }; // Where every mod's archives go. The launch's merge places the archives // of every installed mod, disabled ones too, and records it: a merge // while the game runs must not move an archive the game may hold open, // so it reuses that record, and a mod disabled at launch can then be // enabled without a restart. const auto placementsPath = output / placements_file; PlacementRecord record; if (options.live) record = read_placements(placementsPath); std::vector placedMods = mods; for (const auto& mod : catalog.inactive) placedMods.push_back(&mod); // A mod whose files changed since its archives were placed (updated, or removed and // installed again at another version, while the game runs) is forgotten here and so // placed again below, as one installed since launch is; the old archives stay where // the game may still hold them. Its place in the root's order goes too: the root is // written again with it last. if (options.live) for (const auto* mod : placedMods) { const auto found = record.mods.find(mod->name); if (found == record.mods.end() || found->second.fingerprint.empty() || found->second.fingerprint == mod_fingerprint(mod->directory)) continue; record.mods.erase(found); std::erase(record.root, mod->name); report.notes.push_back(mod->name + ": its files changed since it was loaded, so it is placed again"); } for (const auto* mod : placedMods) { const bool active = std::find(mods.begin(), mods.end(), mod) != mods.end(); if (options.live && !active && !record.mods.contains(mod->name)) continue; // installed since launch, still off const auto files = active ? modFiles[mod] : scan(mod->directory); if (!active) modFiles[mod] = files; // the material grid plan reads a disabled map's root edits too if (active) advance("Linking " + mod->name); for (const auto& relative : files.tocs) { // A map registers itself in globals (its level description and // the root's on-demand entry), which the game reads only at // launch. A disabled map's globals go in too, so enabling it // later only needs what is read at each load; while its level // and root TOCs are left out it cannot load. if (!active && !(mod->provides_levels && (lower(relative) == launch_level_registry || lower(relative) == root_level))) continue; auto& list = providers[lower(relative)]; if (list.empty()) superbundles.push_back(relative); list.push_back(mod); } if (options.live) { if (const auto found = record.mods.find(mod->name); found != record.mods.end()) { placements[mod] = found->second; report.archives += found->second.at.size(); continue; } // Installed since launch: no free archive index was declared for // it then, so its archives go on the end of the patch's own // archive 1 in each package (the game reads appended data from an // archive it already has open) and are addressed by byte offset. // The record keeps the spots, so a later merge reuses them. for (const auto& relative : files.archives) { const auto path = fs::path(relative); const auto number = vfs::GameArchives::archive_index(path.stem().string()); if (!number) throw std::runtime_error("Unexpected archive name: " + relative); auto directory = lower(path.parent_path().generic_string()); if (directory.starts_with("win32/")) directory.erase(0, 6); const auto target = output / path.parent_path() / archive_file(manifestArchive); if (!fs::exists(target, error)) throw std::runtime_error(mod->name + " needs a restart: the patch has no archive 1 in " + directory); // Checked before anything is appended: past 4 GB the mod's // payloads could not be addressed, and the next launch // gives it an archive of its own anyway. const auto held = fs::file_size(target, error); const auto added = error ? std::uintmax_t{} : fs::file_size(mod->directory / path, error); if (error || held + added > std::numeric_limits::max()) throw std::runtime_error(mod->name + " needs a restart: the patch's archive 1 in " + directory + " has no room left for it while the game runs"); ArchivePlacement::Spot spot{manifestArchive, append_file(mod->directory / path, target)}; placements[mod].at.emplace(std::pair{directory, *number}, spot); report.notes.push_back(mod->name + ": " + directory + "/cas_" + std::to_string(*number) + " appended to cas_" + std::to_string(manifestArchive) + " at byte " + std::to_string(spot.offset) + " (added while the game runs)"); ++report.archives; } placements[mod].fingerprint = mod_fingerprint(mod->directory); record.mods[mod->name] = placements[mod]; continue; } for (const auto& relative : files.archives) { const auto path = fs::path(relative); const auto index = vfs::GameArchives::archive_index(path.stem().string()); if (!index) { // the mod's problem: the catalog merges again without it report.problems[mod->name].push_back("Unexpected archive name: " + relative); continue; } const auto number = *index; // CasStore keys its directories relative to Win32, so the mod's // own Win32/ prefix comes off before the two are matched up. auto directory = lower(path.parent_path().generic_string()); if (directory.starts_with("win32/")) directory.erase(0, 6); // The first mod to ship an index keeps it. A later one takes a // free index of its own, so the file is hard linked rather // than copied and no two mods share an archive's 4 GB of // addressable bytes; only with every index taken is it // concatenated onto the archive it collides with. auto& claimed = claimedArchives[directory]; ArchivePlacement::Spot spot{number, 0}; // Archive 1 stays the patch's own file, never a link to a mod's: // a live apply appends manifests and new mods to it while the // game has it open, and a linked file cannot be swapped for a // private copy then (Windows keeps it in use). Placeholder indexes name no file. claimed.insert({manifestArchive, CasStore::waiting_archive, CasStore::carried_archive}); if (number == manifestArchive) { if (const auto free = claim(directory); free) { spot.archive = *free; link_or_copy(mod->directory / path, output / path.parent_path() / archive_file(*free)); } else { spot.offset = append_file(mod->directory / path, output / path); } } else if (claimed.insert(number).second) { link_or_copy(mod->directory / path, output / path); } else if (const auto free = claim(directory); free) { spot.archive = *free; link_or_copy(mod->directory / path, output / path.parent_path() / archive_file(*free)); } else { spot.offset = append_file(mod->directory / path, output / path); } placements[mod].at.emplace(std::pair{directory, number}, spot); // A disabled mod's archives are read by no TOC yet, so nothing // else declares them; the engine only accepts archives // declared at launch. if (!active) for (const auto chunk : store.chunks_in(directory)) used.emplace(chunk, spot.archive); if (spot.archive != number || spot.offset) report.notes.push_back(mod->name + ": " + directory + "/cas_" + std::to_string(number) + " placed as cas_" + std::to_string(spot.archive) + (spot.offset ? " at byte " + std::to_string(spot.offset) : std::string{}) + (active ? "" : " (disabled, ready to enable)")); ++report.archives; } placements[mod].fingerprint = mod_fingerprint(mod->directory); record.mods[mod->name] = placements[mod]; } for (const auto relative : launch_superbundles) if (providers.try_emplace(lower(relative)).second) superbundles.emplace_back(relative); // A mod added while the game runs has no archive index declared for it, // so its archives go on the end of the patch's own archive 1 (see the // live placement above). Every package gets one from launch, empty if // nothing needs it, so that works in any package. if (!options.live) for (const auto& [directory, declared] : declaredIn) { if (!declared.contains(manifestArchive)) continue; const auto path = output / L"Win32" / fs::path(directory) / archive_file(manifestArchive); if (fs::exists(path, error)) continue; fs::create_directories(path.parent_path(), error); write_file(path, {}); } lap("linking"); // Maps that author their own surfaces all number them from the same // first free slot of the game's material grid; they are combined into // the one grid the game reads before any bundle is merged, so each // map's collision can be renumbered as its bundles go by. // The root keeps the order it was first merged in: the launch's mods as // they were then, and any map installed since after them, so nothing // the game already holds from the root moves. auto& rootMods = providers[std::string(root_level)]; if (options.live && !record.root.empty()) { std::vector ordered; for (const auto& name : record.root) for (const auto* mod : rootMods) if (mod->name == name) ordered.push_back(mod); for (const auto* mod : rootMods) if (std::find(ordered.begin(), ordered.end(), mod) == ordered.end()) ordered.push_back(mod); rootMods = std::move(ordered); } std::vector rootNames; for (const auto* mod : rootMods) rootNames.push_back(mod->name); // Unchanged since the root was last written (only maps enabled or // disabled that were installed at launch): the file stays, byte for byte. const bool keepRoot = options.live && rootNames == record.root && !rootNames.empty() && fs::exists(output / fs::path(root_level), error); record.root = rootNames; // Without steps of their own, the material grid and the asset overrides showed // the last "Linking" for as long as they ran // (https://github.com/Dingo-Shenanigans/ReSkate/issues/138). advance("Planning the material grid"); // rootMods belongs to providers; finish using it before erasing its node. const auto grid = plan_material_grid(rootMods, modFiles, store, baseRoot, gameRoot, report); if (rootMods.empty()) { providers.erase(std::string(root_level)); std::erase_if(superbundles, [](const std::string& relative) { return lower(relative) == root_level; }); } lap("material grid"); if (options.live) { advance("Reading load screens"); report.load_screens = read_load_screens(mods, modFiles, store, baseRoot, gameRoot, report); lap("load screens"); } advance("Collecting asset overrides"); auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report, readers - 1, options.live); // Carried chunks point into their mod's archives; move them to where those landed. for (auto& [name, chunks] : overrides.chunks) for (const auto* mod : mods) if (mod->name == name) for (auto& chunk : chunks) store.shift(chunk.location, chunk.offset, &placements[mod]); lap("asset overrides"); // The count above missed the root level when only a disabled map brings it, and // that is merged too: from here each superbundle is a step, then the layout. progress.total = progress.done + superbundles.size() + 1; // Each superbundle is merged by itself: it reads the game's and the mods' files and // what was worked out above, and what it writes waits in its own copy of the store // (CasStore::waiting). So they are merged on several threads, and settled here one // after another in the order they always were, which puts every byte where merging // them in turn would have. A merge while the game runs uses fewer threads, at a lower // priority: the game is using the others, and a player is waiting on this one. struct Job { std::string relative; std::optional store; // waiting: what the merge wrote MergeReport report; // its notes, problems and counts ArchiveUse used; fb::TocDocument merged; // The bundles with a file in the waiting store, read back to be moved. std::vector> held; std::exception_ptr failure; bool done{}; }; std::vector jobs; jobs.reserve(superbundles.size()); for (const auto& relative : superbundles) { if (keepRoot && lower(relative) == root_level) continue; auto& job = jobs.emplace_back(); job.relative = relative; job.store.emplace(store.waiting()); } // Made here: the jobs only read the map. for (const auto& [relative, shippedBy] : providers) for (const auto* mod : shippedBy) placements[mod]; const auto run = [&](Job& job) noexcept { try { const auto& relative = job.relative; std::vector sources; for (const auto* mod : providers.at(lower(relative))) { Source source; source.root = mod->directory; source.placement = &placements.at(mod); try { source.toc = fb::read_toc(read_file(mod->directory / fs::path(relative))); } catch (const std::exception& failure) { // the mod's problem: the catalog merges again without it job.report.problems[mod->name].push_back(relative + " could not be read: " + failure.what()); continue; } sources.push_back(std::move(source)); } job.merged = combine(baseRoot / fs::path(relative), baseRoot, sources, job.report, relative, job.used, *job.store, manifestArchive, gameRoot, grid, overrides); if (job.store->holding()) for (std::size_t index = 0; index < job.merged.bundles.size(); ++index) { auto region = fb::read_bundle_region(job.merged.bundles[index].region); if (std::ranges::any_of(region.files, [](const fb::BundleFileInfo& file) { return CasStore::placeholder(file.location); })) job.held.emplace_back(index, std::move(region)); } } catch (...) { job.failure = std::current_exception(); } }; std::mutex jobsMutex; std::condition_variable jobDone; std::atomic nextJob{0}; std::atomic stopJobs{false}; std::vector workers; // Declared after the workers, so it goes first: a merge that fails stops them // taking more, and they are joined before anything they use goes. struct Stop { std::atomic& flag; ~Stop() { flag = true; } } stopOnExit{stopJobs}; const std::size_t wantedWorkers = jobs.size() < 2 ? 0 : std::min({jobs.size(), options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U}); try { // Threads of their own, not the system's pool: the launcher holds the pool's // threads back while the game starts (see world_layer_scan.cpp). for (std::size_t index = 0; index < wantedWorkers; ++index) workers.emplace_back([&] { // (Under the game's own threads, so a merge shows as a wait and not as stutter.) if (options.live) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); for (;;) { const auto mine = nextJob.fetch_add(1); if (mine >= jobs.size() || stopJobs) return; run(jobs[mine]); { std::lock_guard lock(jobsMutex); jobs[mine].done = true; } jobDone.notify_all(); } }); } catch (const std::system_error&) {} // fewer threads, or none: this one does the rest std::size_t settledJobs{}; for (const auto& relative : superbundles) { if (keepRoot && lower(relative) == root_level) { advance("Keeping the root level as the game has it"); ++report.superbundles; continue; } auto& job = jobs[settledJobs++]; const auto& shippedBy = providers.at(lower(relative)); advance("Merging " + fs::path(relative).stem().string() + (shippedBy.empty() ? std::string(" (the game's own)") : shippedBy.size() > 1 ? " from " + std::to_string(shippedBy.size()) + " mods" : " from " + shippedBy.front()->name)); if (workers.empty()) { run(job); } else { std::unique_lock lock(jobsMutex); jobDone.wait(lock, [&] { return job.done; }); } if (job.failure) std::rethrow_exception(job.failure); // What it wrote goes into the patch's archives now, and what points at it follows. store.settle(*job.store); for (auto& [index, region] : job.held) { for (auto& file : region.files) job.store->settled(file.location, file.offset); job.merged.bundles[index].region = fb::write_bundle_region(region.files, region.inlineManifest); } for (auto& chunk : job.merged.chunks) job.store->settled(chunk.location, chunk.offset); for (const auto& [installChunk, archive] : job.used) used.emplace(installChunk, CasStore::placeholder(archive) ? manifestArchive : archive); report.mergedBundles += job.report.mergedBundles; report.mergedAssets += job.report.mergedAssets; report.unchangedCopies += job.report.unchangedCopies; report.notes.insert(report.notes.end(), std::make_move_iterator(job.report.notes.begin()), std::make_move_iterator(job.report.notes.end())); for (auto& [mod, problems] : job.report.problems) { auto& list = report.problems[mod]; list.insert(list.end(), std::make_move_iterator(problems.begin()), std::make_move_iterator(problems.end())); } auto bytes = fb::write_patch_toc(job.merged.bundles, job.merged.chunks, job.merged.flags); // Read the result back before publishing it: a TOC whose perfect // hash does not resolve would take the game down at load time. const auto check = fb::read_toc(bytes); fb::verify_toc(check); if (check.bundles.size() != job.merged.bundles.size() || check.chunks.size() != job.merged.chunks.size()) throw std::runtime_error("Merged TOC did not round-trip: " + relative); write_file(output / fs::path(relative), bytes); ++report.superbundles; // Done with: a big merge would otherwise hold every superbundle's until the end. job.merged = {}; job.held = {}; job.store.reset(); } // A live merge takes out the TOCs of superbundles no enabled mod ships // any more, so the next load reads the game's own copy instead. { std::set written; for (const auto& relative : superbundles) written.insert(lower(relative)); if (options.live) for (const auto& previous : record.tocs) if (!written.contains(lower(previous)) && fs::remove(output / fs::path(previous), error)) report.removed_tocs.push_back(previous); record.tocs = superbundles; write_placements(placementsPath, record); } lap("superbundles"); // The layout lists every superbundle the merged layer now provides. advance("Writing the layout"); auto* list = layout.field("superBundles"); if (!list || list->type != 1) throw std::runtime_error("layout.toc has no superBundles list"); std::set> known; for (const auto& row : list->children) { const auto* name = row.field("name"); if (name && name->type == 7) known.emplace(lower(name->text)); } // A mod's own layout.toc. A damaged one is that mod's problem: the merge runs again without it. const auto mod_layout = [&](const Mod& mod) -> std::optional { try { return vfs::read_layout(mod.directory / L"layout.toc"); } catch (const std::exception& error) { report.problems[mod.name].push_back(std::string("layout.toc could not be read: ") + error.what()); return std::nullopt; } }; for (const auto* mod : mods) { const auto ownLayout = mod_layout(*mod); if (!ownLayout) continue; const auto& own = ownLayout->root; const auto* ownManifest = own.field("installManifest"); const auto* ownChunks = ownManifest ? ownManifest->field("installChunks") : nullptr; if (!ownChunks) continue; for (const auto& chunk : ownChunks->children) { const auto* chunkName = chunk.field("name"); const auto* held = chunk.field("superbundles"); if (!chunkName || !held) continue; for (const auto& entry : held->children) if (entry.type == 7) report.superbundle_chunks.emplace_back(entry.text, chunkName->text); } } for (const auto* mod : layoutSources) { const auto otherLayout = mod_layout(*mod); if (!otherLayout) continue; const auto& other = otherLayout->root; const auto* otherList = other.field("superBundles"); if (!otherList) continue; for (const auto& row : otherList->children) { const auto* name = row.field("name"); if (!name || name->type != 7 || !known.emplace(lower(name->text)).second) continue; // The list is ordered by name and the engine searches it that // way, so an entry appended at the end is never found. const auto key = lower(name->text); auto at = list->children.begin(); for (; at != list->children.end(); ++at) { const auto* existing = at->field("name"); if (existing && existing->type == 7 && lower(existing->text) > key) break; } list->children.insert(at, native_db::make_named_record("name", name->text)); report.notes.push_back("Merged layout adds " + name->text + " from " + mod->name); } // The superbundle list is only half of it: each install chunk in the // manifest names the superbundles it holds, and that is what tells // the engine where a bundle's data lives. Without it the level is // requested and then waits for content that is never located. auto* chunks = layout.field("installManifest") ? layout.field("installManifest")->field("installChunks") : nullptr; const auto* otherManifest = other.field("installManifest"); const auto* otherChunks = otherManifest ? otherManifest->field("installChunks") : nullptr; if (!chunks || !otherChunks) continue; for (const auto& source : otherChunks->children) { const auto* sourceName = source.field("name"); const auto* sourceList = source.field("superbundles"); if (!sourceName || !sourceList) continue; Node* target{}; for (auto& candidate : chunks->children) { const auto* candidateName = candidate.field("name"); if (candidateName && candidateName->text == sourceName->text) { target = &candidate; break; } } auto* targetList = target ? target->field("superbundles") : nullptr; if (!targetList) continue; for (const auto& entry : sourceList->children) { if (entry.type != 7) continue; const auto key = lower(entry.text); bool held{}; for (const auto& existing : targetList->children) if (existing.type == 7 && lower(existing.text) == key) { held = true; break; } if (held) continue; auto position = targetList->children.begin(); for (; position != targetList->children.end(); ++position) if (position->type == 7 && lower(position->text) > key) break; Node added; added.type = 7; added.text = entry.text; // A string node is written from its payload, terminator included. added.owned.assign(entry.text.begin(), entry.text.end()); added.owned.push_back(0); targetList->children.insert(position, std::move(added)); report.notes.push_back("Merged layout puts " + entry.text + " in " + sourceName->text); } } } // Declare the re-indexed archives, and only under the install chunks the // merged patch really reads them from: a record for an install chunk // that has no such file sends the engine looking for one that is absent. const auto declare = [&](const char* field) { auto* node = layout.field(field); if (!node || node->type != 19) return; const auto source = node->payload(); if (source.size() % 8) throw std::runtime_error(std::string(field) + " is not a record table"); std::vector table(source.begin(), source.end()); std::size_t added{}; for (const auto& [installChunk, archive] : used) { const std::array id{ static_cast(installChunk & 0xFF), static_cast((installChunk >> 8) & 0xFF), static_cast((installChunk >> 16) & 0xFF), static_cast((installChunk >> 24) & 0xFF)}; // The table is grouped by install chunk and every group the game // ships is ordered by archive index, so a new archive goes inside // its own group and in that order, not after it and not last. std::size_t insertAt{}; bool owns{}, already{}, placed{}; for (std::size_t at = 0; at + 8 <= table.size(); at += 8) { if (std::memcmp(table.data() + at + 2, id.data(), id.size())) continue; owns = true; const auto current = static_cast(table[at] | (table[at + 1] << 8)); if (current == archive) already = true; if (!placed && current > archive) { insertAt = at; placed = true; } if (!placed) insertAt = at + 8; } if (!owns || already) continue; std::array record{}; record[0] = static_cast(archive & 0xFF); record[1] = static_cast(archive >> 8); std::memcpy(record.data() + 2, id.data(), id.size()); table.insert(table.begin() + static_cast(insertAt), record.begin(), record.end()); ++added; } if (!added) return; report.notes.push_back(std::string(field) + ": declared " + std::to_string(added) + " patch archive(s)"); node->owned = std::move(table); node->bytes = {}; }; declare("layeredInstallChunkFiles"); declare("unlayeredInstallChunkFiles"); if (itemSources) { try { write_item_sources(output / item_sources_file, *itemSources); } catch (const std::exception& failure) { report.notes.push_back(std::string("Item cards will not say which mod they are from: ") + failure.what()); } } if (!options.live) { const auto rebuilt = native_db::write(layout); std::vector file(native_db::envelope_size, 0); std::memcpy(file.data(), native_db::magic, sizeof(native_db::magic)); file.insert(file.end(), rebuilt.begin(), rebuilt.end()); write_file(output / L"layout.toc", std::span(reinterpret_cast(file.data()), file.size())); // The patch is complete without its stamp; lacking one only means the // next launch builds it again. try { write_stamp(output, fingerprint, report); } catch (const std::exception& failure) { report.notes.push_back(std::string("The merged patch will be rebuilt next launch: ") + failure.what()); } } lap("layout"); // After the stamp, which keeps the notes: a patch that is reused must not // report the times of the merge that built it. const auto seconds = [](Clock::duration elapsed) { const auto tenths = (std::chrono::duration_cast(elapsed).count() + 50) / 100; return std::to_string(tenths / 10) + "." + std::to_string(tenths % 10) + " s"; }; std::string summary; Clock::duration all{}; for (const auto& [step, elapsed] : times) { summary += (summary.empty() ? "Merge times: " : ", ") + step + " " + seconds(elapsed); all += elapsed; } report.notes.push_back(summary + " (" + seconds(all) + " for this merge)"); report.built = true; } catch (const std::exception& failure) { report.issue = failure.what(); // A live merge leaves the running patch alone: the game is reading it. std::error_code error; if (!options.live) fs::remove_all(catalog.root / generated_folder, error); } catch (...) { report.issue = "The mod merge failed"; std::error_code error; if (!options.live) fs::remove_all(catalog.root / generated_folder, error); } return report; } } // namespace dingosdk::mods