Files
ReSkate/Engine/Vfs/mod_merge_internal.h
T
AyeZeeBB fffd34ca80 Mods: conflicts resolved in MY MODS, official mods live in Mods, and big maps merge faster
MY MODS: a table with an animated drag for the load order, a conflicts bar with Check conflicts, and a Resolve popup whose choices are kept. The check runs at startup and after changes; conflicts no longer show as problems, block Play, or hold mod files during an update.
Conflicts: only game EBX that cannot be combined, or Lua scripts with different versions; colourways and same-name additions are not conflicts.
Official mods: installed in Mods/<Namespace-Name> and turned on or off through mods.json, so they show in MY MODS; the old .reskate-official folder is moved over.
Merge: a scan cache (Mods/.reskate-scan.txt) keeps each unchanged mod's repair, mesh and texture scans, so big maps are not re-read every merge.
Launcher: an announcement from the backend shows under STATUS, with HIDE.
2026-10-18 16:33:53 -05:00

407 lines
22 KiB
C++

#pragma once
#include "mod_catalog.h"
#include "native_db.h"
#include "game_archives.h"
#include "Engine/Resource/material_grid.h"
#include "Engine/Resource/toc.h"
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
// Shared by the mod merge's source files; nothing outside them uses it.
namespace dingosdk::mods::detail {
namespace fs = std::filesystem;
namespace fb = frostbite;
std::string lower(std::string_view text);
std::vector<std::byte> read_file(const fs::path& path);
// A hash of `name` that ignores case (ASCII), to rule a name out before a lower-case copy of
// it is made to look it up: most names a merge looks at are in no table.
constexpr std::uint64_t name_hash(std::string_view name) noexcept {
std::uint64_t value = 0xCBF29CE484222325ULL;
for (char c : name) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
value = (value ^ static_cast<unsigned char>(c)) * 0x100000001B3ULL;
}
return value;
}
void write_file(const fs::path& path, std::span<const std::byte> bytes);
using vfs::archive_file;
void unshare(const fs::path& path);
std::uint64_t append_file(const fs::path& from, const fs::path& to);
void link_or_copy(const fs::path& from, const fs::path& to);
struct RelativeFiles {
std::vector<std::string> tocs; // Win32/... .toc, generic separators
std::vector<std::string> archives; // Win32/... cas_NN.cas
};
RelativeFiles scan(const fs::path& directory);
// Every mod is built against archive 1, so the merge moves each mod's archives
// to indices of their own and rewrites the mod's patch references to match. A
// placement addresses its payload with a 32-bit offset, so an archive holds at
// most 4 GB: one index per mod archive is what keeps the merged patch from
// having a size limit of its own.
struct ArchivePlacement {
// Where a mod's archive ended up: an index of its own, which costs nothing
// to place, or a byte offset inside an archive it shares. Sharing is for a
// mod added while the game runs, which has no declared index to take, and
// for a package directory with no index left.
struct Spot {
std::uint16_t archive{};
std::uint64_t offset{};
};
std::map<std::pair<std::string, std::uint16_t>, Spot> at;
// The mod's files as they were when its archives were placed (mod_fingerprint). A merge
// while the game runs places a mod again when they have changed since: one updated, or
// removed and installed again at another version, is not read where the old one lay.
std::string fingerprint;
};
// Reverses CasStore::shift: turns a placement in the merged patch back into the
// mod's own archive number and offset there. Several of a mod's archives can
// share one patch archive (a mod added while the game runs has them all
// appended to archive 1), so the block is the one the offset falls in, not the
// first in that archive. False when the mod has no block there.
bool unshift(const ArchivePlacement& placement, std::string_view directory, std::uint16_t& archive,
std::uint32_t& offset);
fb::Sha1 sha1_of(std::span<const std::byte> bytes);
// A mod's EBX as stored in cas, with the type-reference fixups that would crash the game's
// load taken out (Engine/Resource/ebx_fixup_repair.h): the payload is encoded again and the
// asset's sha1 and size follow it. How many came out; with none, or a payload that does not
// decode, both stay as they were.
std::size_t repair_ebx_payload(std::vector<std::byte>& encoded, fb::BundleAsset& asset, const fs::path& gameRoot);
// Where the launch's merge put every installed mod's archives, and which
// superbundle TOCs the patch holds: Mods/.reskate/reskate-placements.json.
inline constexpr wchar_t placements_file[] = L"reskate-placements.json";
struct PlacementRecord {
std::map<std::string, ArchivePlacement, std::less<>> mods;
std::vector<std::string> tocs;
// The mods the root level's TOC was last merged from, in merge order.
// A live merge keeps that order, so enabling or disabling a map leaves the
// root exactly as the game has already read it.
std::vector<std::string> root;
};
PlacementRecord read_placements(const fs::path& file);
void write_placements(const fs::path& file, const PlacementRecord& record);
// `storeKnown`: the content cache that says what the game's store sells is
// installed, so the mods were checked for copies of store items; a patch built
// without it is built again once it is there.
std::string merge_fingerprint(const Catalog& catalog, const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles, bool storeKnown);
inline constexpr wchar_t stamp_file[] = L"reskate-merge.stamp";
std::optional<MergeReport> previous_merge(const fs::path& output, const std::string& fingerprint);
void write_stamp(const fs::path& output, const std::string& fingerprint, const MergeReport& report);
using vfs::for_each_install_chunk_file;
struct Source {
fs::path root;
const ArchivePlacement* placement{};
fb::TocDocument toc;
};
// No bundle in this game keeps its manifest inline: the asset list is the
// region's first file, stored in cas like any other payload. Merging inside a
// bundle therefore means decoding that payload, and writing one back.
class CasStore final : public vfs::GameArchives {
public:
CasStore(fs::path baseRoot, fs::path output, const native_db::Node& layout);
// Appends an encoded payload to the merged patch's own archive for that
// install chunk, returning where it landed. A waiting store (below) keeps it
// instead and says where it is among what it holds.
[[nodiscard]] fb::BundleFileInfo write(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded);
// Superbundles are merged on several threads, and what each writes has to land
// where it would have had they been merged one after another. So each is merged
// with a waiting copy of the store. It reads as the store does and keeps what it
// is given, placing it in an archive index no file has (waiting_archive) at the
// offset within what it holds for that file. When every superbundle before its
// own has been settled, settle() appends what it holds to the real archives, and
// settled() then moves a placement that points into it to where that went.
static constexpr std::uint16_t waiting_archive = 0xFFFF;
// A carried payload, by its index in the waiting store's list: settle() writes it once per install chunk.
static constexpr std::uint16_t carried_archive = 0xFFFE;
[[nodiscard]] CasStore waiting() const;
[[nodiscard]] bool holding() const noexcept { return !held_.empty() || !carried_.empty(); }
[[nodiscard]] static bool placeholder(std::uint16_t archive) noexcept {
return archive == waiting_archive || archive == carried_archive;
}
[[nodiscard]] static bool placeholder(const fb::CasIdentifier& location) noexcept {
return location.patch && placeholder(location.archive);
}
// A waiting store only. The bytes must stay valid until settle().
[[nodiscard]] fb::BundleFileInfo carry(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded);
void settle(CasStore& waiting);
// True when the placement was one of this waiting store's; it now names the archive.
bool settled(fb::CasIdentifier& location, std::uint32_t& offset) const;
void shift(fb::CasIdentifier& location, std::uint32_t& offset,
const ArchivePlacement* placement) const;
private:
// Appends to one of the patch's own archives; returns where the bytes start.
std::uint64_t append(const fs::path& path, std::span<const std::byte> encoded);
[[nodiscard]] fs::path archive_path(std::uint32_t installChunk, std::uint16_t archive) const;
fs::path output_;
std::map<std::wstring, std::uint64_t> offsets_;
// A waiting store: what it holds for each archive file, the one archive index it
// was asked to write to, and where each file's bytes went once settled.
struct Held {
std::vector<std::byte> bytes;
std::uint64_t at{};
};
bool waiting_{};
std::optional<std::uint16_t> archive_;
std::map<std::wstring, Held> held_;
// Carried payloads (waiting store) and where each landed (settling store).
struct Carried {
std::uint32_t installChunk{};
fb::Sha1 sha1;
std::span<const std::byte> bytes;
fb::BundleFileInfo at;
};
std::vector<Carried> carried_;
std::map<std::pair<std::uint32_t, fb::Sha1>, fb::BundleFileInfo> carriedAt_;
};
using ArchiveUse = std::set<std::pair<std::uint32_t, std::uint16_t>>;
// A bundle's asset list and where each asset's payload lives.
struct Listing {
fb::BinaryBundle manifest;
std::vector<fb::BundleFileInfo> files;
std::size_t first{}; // region index of the manifest's first asset
};
// The asset list, found the way the merge reads it: inline, or as the
// cas-backed manifest among the region's small files. `root` is the layer the
// bundle came from; files it does not patch are read from `baseRoot`.
std::optional<Listing> list_bundle(const CasStore& store, const fs::path& root, const fs::path& baseRoot,
const fb::TocBundle& bundle, const fs::path& gameRoot);
// The manifest's `ebxIndex`th EBX asset, decoded.
std::vector<std::byte> read_asset(const CasStore& store, const fs::path& root, const fs::path& baseRoot,
const Listing& listing, std::size_t ebxIndex, const fs::path& gameRoot);
// A map's world textures (texture group Worlds_*) flagged to stream that keep every mip with the
// bundle, so the game loads them whole (an older Studio built maps that way: 404 MB of PQS
// Cordoba's, 2026-10-17). Cosmetics are not touched: Studio keeps them whole. Each is made
// to stream as the game's own world textures do: the bundle keeps the mip tail, from a 64 KB
// block, and the sharper mips stream from the chunk its TOC (`tocChunks`) lists in full. A
// texture is changed only when all of it can be: its chunk in this bundle whole, a block at the
// tail, and its record in the chunk metadata. `manifest` and `files` (the region's, `first` the
// region index of the manifest's first asset) are changed in place; the texture headers that
// change are returned encoded, by region index, for the merged patch.
struct StreamedTextures {
std::map<std::size_t, std::vector<std::byte>> headers;
std::uint64_t bytes{}; // what no longer stays loaded
std::string example;
};
// `only`: just these of the region's files (a ScanCache's: the textures changed before).
StreamedTextures stream_resident_textures(const CasStore& store, const fs::path& root, fb::BinaryBundle& manifest,
std::vector<fb::BundleFileInfo>& files, std::size_t first,
const std::set<fb::Guid>& tocChunks, const fs::path& gameRoot,
const std::vector<std::uint32_t>* only = nullptr);
// Which ReSkate.dll merges (its size and time): what it found reading a mod holds for it alone.
std::string sdk_stamp();
// What reading a mod's own bundles finds that its files alone decide: the EBX whose type references
// are repaired, a map's mesh sizes, the textures made to stream. Kept between merges in
// Mods/.reskate-scan.txt by each mod's fingerprint (mod_fingerprint) and the ReSkate that found it,
// so a merge reads again only the mods whose files changed: checking a big map's every EBX and mesh
// was most of a merge (a minute and more), on every merge, whichever mod had changed (2026-10-18).
// The superbundle jobs share one (thread-safe).
class ScanCache {
public:
struct Mesh {
std::uint32_t chunk{};
std::uint16_t archive{};
std::uint32_t offset{};
std::uint64_t bytes{};
};
// One copy of a bundle, by the places of its files in the region.
struct Bundle {
std::vector<std::uint32_t> repair; // EBX with type references to repair
std::vector<std::uint32_t> textures; // textures whose header is changed so they stream
std::vector<Mesh> meshes; // a map's meshes (where each lies in the mod, and its bytes)
};
// What `file` holds of the mods in `fingerprints` (folder name: mod_fingerprint) whose files are
// unchanged. Never throws: a missing or unreadable file holds nothing.
ScanCache(fs::path file, std::map<std::string, std::string, std::less<>> fingerprints) noexcept;
// By mod folder name and "superbundle|bundle" (lower case).
[[nodiscard]] std::optional<Bundle> find(const std::string& mod, const std::string& key) const;
void keep(const std::string& mod, const std::string& key, Bundle bundle);
// Writes it again, with the mods given only. Never throws.
void save() const noexcept;
private:
fs::path file_;
std::map<std::string, std::string, std::less<>> fingerprints_;
mutable std::mutex mutex_;
std::map<std::string, std::map<std::string, Bundle, std::less<>>, std::less<>> known_;
};
inline constexpr wchar_t scan_cache_file[] = L".reskate-scan.txt";
// The loading screens the mods add (the rows of the game's loading-screen
// configuration each one appends). Never throws; an unreadable mod is noted.
std::vector<MergeReport::LoadScreen> read_load_screens(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report);
// The physics material grid every map's collision addresses (see
// Engine/Resource/material_grid.h). Maps that author their own surfaces each
// ship a copy of the game's grid with additions from the same first free slot;
// the merge combines those into the one grid the game reads and renumbers each
// map's collision to where its surfaces landed.
struct GridPlan {
struct Remap {
std::string map; // the level folder the grid came from; empty for the root's own name
fb::material_grid::SlotMap slots;
};
std::string superbundle; // lower-case TOC path holding the grid's bundle
std::string bundle; // lower-case bundle name
std::string asset; // the live grid's asset name
std::vector<std::byte> payload; // the combined grid; empty when no mod adds surfaces
std::map<std::string, std::vector<Remap>> remaps; // by mod folder name
[[nodiscard]] bool rewrites(const std::string& mod) const;
// How a physics resource of `mod` has to be renumbered; null when it keeps its slots.
[[nodiscard]] const fb::material_grid::SlotMap* remap_for(const std::string& mod, std::string_view resource) const;
};
// Reads every mod's copy of the grid and the game's own, and combines them.
// Never throws for one bad mod: its surfaces fall back to the default one.
GridPlan plan_material_grid(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles, const CasStore& store,
const fs::path& baseRoot, const fs::path& gameRoot, MergeReport& report);
// An EBX asset a mod changed from the game's own copy. Maps carry the game's
// copies of what their levels need (a level's camera framing, for one), and
// those copies have to take the change too, or the mod works on the game's
// levels and not on the maps.
struct AssetOverride {
std::string mod;
fb::Sha1 sha1; // the mod's version
std::uint64_t originalSize{};
std::vector<std::byte> encoded; // its payload, as stored in cas
// Present for a Lua resource replacement: its identity and metadata must
// travel with the script bytes, including source and bytecode sizes.
std::optional<fb::BundleAsset> resource;
// The documents the changed EBX refers to that its mod added to the bundle it
// made the change in, by their guids. They go wherever the change goes: the
// game keeps a list in more bundles than one, each with what the list names.
std::set<fb::Guid> names;
};
inline constexpr std::uint32_t luaScriptResourceType = 0xEC383B87;
// An EBX asset a mod added to one of the game's bundles, or a resource it added
// under the same name as such an asset (a wave's sound-bank). Maps carry their own
// copy of a bundle like bam_coregameassets, and that copy loads instead of the
// game's on their levels; an edit to a list there (the master music playlist)
// propagates as a change, so the asset it now names has to come along too. The
// game also keeps such a list in more bundles than the one the mod put its
// assets in, and a copy of any of those takes the change the same way.
struct AssetAddition {
std::string mod;
fb::BundleAsset asset;
std::vector<std::byte> encoded; // its payload, as stored in cas
// The lower-case relative path of the TOC the mod ships this bundle in. A copy of the
// bundle in that same TOC merges with the mod's own bundle and already has the asset;
// only copies in other superbundles (a map's level TOC) need it carried.
std::string toc;
// For an EBX: its document's guid, which is what a list names it by, and the
// guids of the documents it refers to in turn (a song names its wave).
fb::Guid file;
std::vector<fb::Guid> names;
};
struct AssetOverrides {
// By lower-case asset name, then the game's sha1 the change replaces.
std::map<std::string, std::map<fb::Sha1, AssetOverride>, std::less<>> changed;
// Separate from EBX: a script's EBX wrapper can have the same asset name.
std::map<std::string, std::map<fb::Sha1, AssetOverride>, std::less<>> scripts;
// By lower-case bundle name, in priority order.
std::map<std::string, std::vector<AssetAddition>, std::less<>> added;
// By mod folder name: TOC chunks the mod adds that its carried additions name
// (a new wave's audio). A map's superbundle resolves chunks from its own TOC,
// so wherever the additions go these entries go too. Collected pointing into
// the mod's own archives; the merge shifts them to where those archives landed.
std::map<std::string, std::vector<fb::TocChunk>, std::less<>> chunks;
[[nodiscard]] bool empty() const noexcept { return changed.empty() && scripts.empty() && added.empty(); }
};
// The changes and additions asset mods (mods that add no levels) make to the
// game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an
// unreadable mod is noted and simply changes nothing elsewhere. The mods' files are read on up
// to `threads` threads beside this one (`background`: at a priority under the game's, for a
// merge while it runs); what they mean together is then settled in priority order, so the
// result is the same whatever the count.
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report,
std::size_t threads = 0, bool background = false);
// A bundle's chunk metadata is a list with one record for each of its chunks: the
// hash of the name of the resource the chunk belongs to ("h64") and what the
// engine needs before it streams the chunk (a texture's first mip). In the game's
// own bundles the records are in the order of the chunks' guids, not the order
// the chunks are listed in. A mod's copy starts from the game's list as it is and
// has, at the place each chunk it adds or replaces is listed at, a record its
// tool wrote: for an added chunk that is the chunk's own record, but for a
// replaced one it has taken the place of another chunk's (and older tools left
// its hash empty), so the game's record for that other chunk is gone from the
// mod's list.
struct ChunkRecord {
static constexpr std::size_t none = static_cast<std::size_t>(-1);
fb::Guid guid;
std::size_t copy{none}; // the list of the copy this version of the chunk came in; none when that copy has no list
std::size_t index{}; // where that copy lists the chunk
std::size_t shipped{none}; // where the game's copy lists it; none for a chunk mods add
};
// The list for a merged bundle, written as the game writes one: a record for each
// of `chunks` (the bundle's chunks, as listed), in the order of their guids. A
// chunk of the game's keeps the game's record for it, whichever copy carries it
// (`game` is the game's list among `lists`, `shipped` the chunks the game's copy
// lists); when the kept version is one a mod replaced it with, the first mip that
// mod's record gives goes into it. A chunk a mod adds has that mod's record, and
// one nothing describes gets a record that says nothing. In a bundle the game
// does not ship, or ships without a list, every chunk is one a mod adds. When
// every chunk is the game's own where the game lists it, the game's list comes
// back as it is; so does one mod's, for a bundle without a list in the game that
// is that mod's copy alone.
// Throws when a list that is needed cannot be read.
[[nodiscard]] std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
std::span<const ChunkRecord> chunks,
std::size_t game = ChunkRecord::none,
std::span<const fb::Guid> shipped = {});
fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
std::vector<Source>& sources, MergeReport& report,
const std::string& relative, ArchiveUse& used, CasStore& store,
std::uint16_t manifestArchive, const fs::path& gameRoot, const GridPlan& grid,
const AssetOverrides& overrides, ScanCache& scans);
} // namespace dingosdk::mods::detail