mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 12:05:21 +02:00
A mod the merge could not use was left out silently. The game started without it, three minutes into a loading screen, and only ReSkate.log said which one. Merging before launch - PLAY runs the merge the game would have run behind its splash, on the launcher's worker, with the step and progress on the STATUS tile (Phase::merging). The launcher links dingosdk_mods for it, which costs about 600 KB of exe. - When every mod merges, the launch goes ahead and the game's own load_catalog finds the patch already built, so the splash is shorter rather than the work being done twice. - When mods were left out, the launch stops at Phase::mods_broken and a dialog names each one and its first problem, with three ways on: switch them off and play (which writes mods.json, so the next launch does not rediscover it the hard way), open the mod manager, or play anyway. - Mods built for another game version do not block. They already have their own notice and message box, and blocking on them would stop play after every Steam update. - The launcher resolves -dataPath out of the extra-arguments setting, so it merges against the root the game will read. One SDK identity for both binaries - Two places recorded "which ReSkate did this" as the module the code was running in, which is ReSkate.dll in the game and ReSkateLauncher.exe in the launcher now that both merge. merge_fingerprint hashes it, so the game matched no stamp the launcher wrote and merged everything a second time; sdk_identity keys the exclusions file, so each would have called the other's exclusions stale. - Both now name ReSkate.dll beside the running executable. Skate.exe and ReSkateLauncher.exe sit beside it and launch.cpp enforces that, so the two agree, and unlike the data root it does not move under -dataPath. The value is unchanged from what the runtime computed before, so nothing re-merges on upgrade. Naming the mod - In game, a mod left out of the merge altogether said nothing on screen: only a mod that merged partially did, which is the lesser failure. Both are named now. - The MOD MANAGER tile says which mod did not load, in the detail line's colour rather than a pill, clipped so a long name cannot run under the tile's artwork. - MY MODS carries a line naming them above the list, and NOT LOADED and OUTDATED are badges on the row instead of words at the front of its summary. MY MODS reaches Thunderstore parity - Rows carry the package's icon, the same one the store shows, through a shared mod_icon(); a mod that never came from Thunderstore keeps the placeholder. - An update is a button on the row, not a badge you had to open the mod to act on, and every row has Uninstall. Rows grew to fit, and "Remove" is "Uninstall" throughout. - No pill for a mod that is up to date: the absence of an update button says it, and eight green pills said nothing eight times. Fixes - The install panel was drawn on the Mods page's own draw list after its children, and ImGui renders a child after its parent, so it was painted behind the mod rows along with its Cancel button: installs ran with no sign of it. It is its own window over the page now. - pump_icons, which uploads decoded icons, only ran on the store page, so MY MODS showed placeholders until a visit to GET MODS uploaded them for it. The page does it for both lists. - The merge dialog's closing advice overlapped its buttons.
330 lines
16 KiB
C++
330 lines
16 KiB
C++
#include "mod_layers.h"
|
|
#include <thread>
|
|
#include "Extension/UI/Overlay/overlay.h"
|
|
|
|
#include "Engine/Vfs/mod_catalog.h"
|
|
#include "Engine/Vfs/mod_scoring.h"
|
|
#include "Engine/Core/Hooks/hooks.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Game/Build/addresses.h"
|
|
#include "Engine/Game/Build/20260929/mod_layers.h"
|
|
#include "Extension/UI/Startup/startup_window.h"
|
|
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <span>
|
|
#include <string>
|
|
#include <string_view>
|
|
|
|
namespace dingosdk {
|
|
namespace {
|
|
// Mods built for another game version are left out of the merge; say so in a window of its
|
|
// own (the game keeps loading behind it) and in the overlay once it draws.
|
|
void warn_outdated_mods(const mods::Catalog& catalog) {
|
|
if (catalog.outdated.empty()) return;
|
|
std::string list;
|
|
for (const auto& mod : catalog.outdated) {
|
|
list += "\n \xE2\x80\xA2 " + (mod.title.empty() ? mod.name : mod.title);
|
|
if (!mod.version.empty()) list += " (v" + mod.version + ")";
|
|
}
|
|
const auto count = catalog.outdated.size();
|
|
const auto text = std::string(count == 1 ? "This mod was" : "These mods were") +
|
|
" built for another version of Skate and " + (count == 1 ? "has" : "have") +
|
|
" been disabled:\n" + list +
|
|
"\n\nThey will not load until they are updated for the current game version. Get an updated "
|
|
"version from the mod's author, or rebuild it with the latest ReSkate Studio.";
|
|
overlay::notify(overlay::NoticeLevel::warning,
|
|
std::to_string(count) + (count == 1 ? " outdated mod disabled" : " outdated mods disabled"),
|
|
"Built for another game version: update " + std::string(count == 1 ? "it" : "them") +
|
|
" to load. See the Mods page or ReSkate.log.");
|
|
std::thread([text] {
|
|
const auto wide = [](const std::string &value) {
|
|
const int length = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
|
std::wstring result(static_cast<std::size_t>(std::max(length, 0)), L'\0');
|
|
if (length > 0) MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), length);
|
|
return result;
|
|
};
|
|
MessageBoxW(nullptr, wide(text).c_str(), L"ReSkate: outdated mods disabled",
|
|
MB_OK | MB_ICONWARNING | MB_TOPMOST | MB_SETFOREGROUND);
|
|
}).detach();
|
|
}
|
|
// Whether the enabled mods change how tricks score (Engine/Vfs/mod_scoring.h). Multiplayer
|
|
// reports it, and hosts keep a player whose tricks score differently out of throwdowns and
|
|
// coop challenges. Reading the mods takes a second or two, so it runs beside the game's startup.
|
|
void start_scoring_check(const mods::Catalog& catalog) {
|
|
std::thread([&catalog] {
|
|
const auto started = std::chrono::steady_clock::now();
|
|
std::vector<std::string> notes;
|
|
const auto check = mods::check_scoring(catalog, ¬es);
|
|
mods::publish_scoring(check);
|
|
for (const auto& note : notes) logging::write(logging::Level::warning, logging::Channel::assets, note);
|
|
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - started).count();
|
|
if (!check.fingerprint) {
|
|
logging::log(logging::Level::info, logging::Channel::assets,
|
|
"Scoring check: tricks score and the skater handles as in the game ({} mod(s) checked in {} ms).", catalog.mods.size(), ms);
|
|
return;
|
|
}
|
|
std::string names, assets;
|
|
for (const auto& mod : check.mods) names += (names.empty() ? "" : ", ") + mod;
|
|
for (std::size_t i = 0; i < check.assets.size() && i < 5; ++i) assets += (assets.empty() ? "" : ", ") + check.assets[i];
|
|
logging::log(logging::Level::warning, logging::Channel::assets,
|
|
"Scoring check: {} change(s) how tricks score or the skater handles ({} asset(s), e.g. {}; fingerprint {:016x}). In multiplayer, "
|
|
"dedicated servers and lobby hosts keep you out of throwdowns and coop challenges.",
|
|
names, check.assets.size(), assets, check.fingerprint);
|
|
overlay::notify(overlay::NoticeLevel::warning,
|
|
(check.mods.size() == 1 ? check.mods.front() + " changes" : names + " change") + " scoring or physics",
|
|
"In multiplayer you are kept out of throwdowns and coop challenges while it is enabled.");
|
|
}).detach();
|
|
}
|
|
namespace fs = std::filesystem;
|
|
|
|
// `classify` derives a layer kind from the layout.toc path; `load` adds one
|
|
// layer, recording the manifest's directory on it and handing the parsed
|
|
// manifest to the layout manager.
|
|
using ClassifyPath = std::uint8_t (*)(const char*);
|
|
using LoadLayer = void (*)(void*, void*, const char*);
|
|
|
|
namespace layers = addr::mod_layers;
|
|
constexpr std::uintptr_t classify_rva = layers::classify;
|
|
constexpr std::uintptr_t load_layer_rva = layers::load_layer;
|
|
// Slot 0x60 of the layout manager's vtable, which must still be `load`.
|
|
constexpr std::uintptr_t load_layer_slot_rva = layers::load_layer_slot;
|
|
|
|
// The kind the engine gives its own Patch layer. Every mod is merged into one
|
|
// patch, so exactly one extra layer exists and no two layers ever share a kind:
|
|
// the stock game only runs LCU(3) over Patch(2) over Data(0), and two layers of
|
|
// one kind desynchronise bundle reads as soon as content comes from them.
|
|
constexpr std::uint8_t patch_layer_kind = 2;
|
|
// layout_bootstrap loads Patch only when its manifest exists but always loads
|
|
// Data, so the merged layer attaches to whichever of the two arrives first.
|
|
constexpr std::string_view patch_prefix = "/native_data/Patch/";
|
|
constexpr std::string_view data_prefix = "/native_data/Data/";
|
|
constexpr std::string_view mods_prefix = "/native_data/Mods/";
|
|
|
|
std::atomic<ClassifyPath> original_classify{};
|
|
std::atomic<LoadLayer> original_load{};
|
|
std::atomic<void*> seen_manager{}, seen_layers{};
|
|
std::mutex install_mutex;
|
|
std::uintptr_t installed_base{};
|
|
|
|
struct State {
|
|
fs::path merged_root;
|
|
std::atomic<bool> expanded{};
|
|
};
|
|
// The hooks and this state outlive startup, like the pinned runtime.
|
|
State& state() { static auto* value = new State; return *value; }
|
|
|
|
bool read_memory(std::uintptr_t address, void* destination, std::size_t size) noexcept {
|
|
SIZE_T read{};
|
|
return ReadProcessMemory(GetCurrentProcess(), reinterpret_cast<const void*>(address),
|
|
destination, size, &read) && read == size;
|
|
}
|
|
|
|
// Supported September 8 build, checked after runtime image validation.
|
|
bool validate_contract(std::uintptr_t base) noexcept {
|
|
struct Contract { std::uintptr_t rva; std::span<const std::uint8_t> bytes; };
|
|
const Contract contracts[]{
|
|
{classify_rva, layers::classify_prefix}, {load_layer_rva, layers::load_layer_prefix},
|
|
{layers::layout_bootstrap, layers::layout_bootstrap_prefix}};
|
|
for (const auto& contract : contracts) {
|
|
std::array<std::uint8_t, 32> actual{};
|
|
if (!read_memory(base + contract.rva, actual.data(), contract.bytes.size()) ||
|
|
std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size())) return false;
|
|
}
|
|
std::uintptr_t slot{};
|
|
return read_memory(base + load_layer_slot_rva, &slot, sizeof(slot)) &&
|
|
slot == base + load_layer_rva;
|
|
}
|
|
|
|
bool starts_with_folded(const char* text, std::string_view prefix) noexcept {
|
|
if (!text) return false;
|
|
for (std::size_t index = 0; index < prefix.size(); ++index) {
|
|
const auto left = static_cast<unsigned char>(text[index]);
|
|
if (!left) return false;
|
|
const auto folded = left >= 'A' && left <= 'Z'
|
|
? static_cast<unsigned char>(left + ('a' - 'A')) : left;
|
|
const auto right = static_cast<unsigned char>(prefix[index]);
|
|
const auto expected = right >= 'A' && right <= 'Z'
|
|
? static_cast<unsigned char>(right + ('a' - 'A')) : right;
|
|
if (folded != expected) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The engine only asks about the layout.toc it is loading, and the merged patch
|
|
// is the one manifest that ever comes from the mods tree.
|
|
std::uint8_t classify_path(const char* path) {
|
|
if (starts_with_folded(path, mods_prefix)) return patch_layer_kind;
|
|
return original_classify.load(std::memory_order_acquire)(path);
|
|
}
|
|
|
|
void load_layer(void* self, void* manager, const char* path) {
|
|
const auto original = original_load.load(std::memory_order_acquire);
|
|
auto& value = state();
|
|
const auto stock = starts_with_folded(path, patch_prefix) ? patch_prefix
|
|
: starts_with_folded(path, data_prefix) ? data_prefix : std::string_view{};
|
|
if (stock.empty() || value.expanded.exchange(true)) {
|
|
original(self, manager, path);
|
|
return;
|
|
}
|
|
// -super renames the manifest, so reuse whatever name the engine asked for.
|
|
const std::string file(path + stock.size());
|
|
const auto merged = std::string(mods_prefix) + mods::generated_folder + "/" + file;
|
|
std::error_code error;
|
|
if (fs::is_regular_file(value.merged_root / file, error) && !error) {
|
|
original(self, manager, merged.c_str());
|
|
seen_manager.store(self);
|
|
seen_layers.store(manager);
|
|
logging::log(logging::Level::info, logging::Channel::assets,
|
|
"Merged mod patch mounted: {}", merged);
|
|
// A Patch folder beside the merged layer would be a second layer of the
|
|
// patch kind, which desynchronises bundle reads. Every mod loads from
|
|
// Mods now, so the old Patch is left unmounted.
|
|
if (stock == patch_prefix) {
|
|
logging::log(logging::Level::warning, logging::Channel::assets,
|
|
"Patch folder ignored: mods load from the Mods folder now. Move its contents into a "
|
|
"folder under Mods to keep using them.");
|
|
return;
|
|
}
|
|
} else {
|
|
logging::log(logging::Level::warning, logging::Channel::assets,
|
|
"Merged mod patch {} is missing; no mod content will load.", merged);
|
|
}
|
|
original(self, manager, path);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool start_mod_layers(std::uintptr_t base, std::string& error) {
|
|
std::lock_guard lock(install_mutex);
|
|
error.clear();
|
|
if (installed_base) {
|
|
if (installed_base == base) return true;
|
|
error = "Mod layers are already installed on another image";
|
|
return false;
|
|
}
|
|
|
|
// A merge only runs when a mod or the game changed, but then it takes
|
|
// seconds: the startup window shows how far it has got.
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const auto& catalog = mods::catalog([](const mods::MergeProgress& progress) {
|
|
startup::status("Merging " + std::to_string(progress.mods) + (progress.mods == 1 ? " mod" : " mods"),
|
|
progress.step,
|
|
progress.total ? static_cast<float>(progress.done) / static_cast<float>(progress.total)
|
|
: -1.0f);
|
|
});
|
|
startup::status("Starting ReSkate\xE2\x80\xA6");
|
|
logging::log(logging::Level::info, logging::Channel::assets, "Mods read in {} ms",
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - started).count());
|
|
for (const auto& note : catalog.notes)
|
|
logging::write(logging::Level::info, logging::Channel::assets, note);
|
|
for (const auto& warning : catalog.warnings)
|
|
logging::write(logging::Level::warning, logging::Channel::assets, warning);
|
|
warn_outdated_mods(catalog);
|
|
start_scoring_check(catalog);
|
|
// The same on screen once the overlay draws, so a player who never opens
|
|
// the log still learns which mod is broken before trying to load its map.
|
|
for (const auto& mod : catalog.mods) {
|
|
if (mod.problems.empty()) continue;
|
|
overlay::notify(overlay::NoticeLevel::warning, "Mod " + mod.name + " could not be merged in full",
|
|
std::string(mod.levels.empty() ? "Its content" : "Its map") +
|
|
" may never finish loading. Reinstall the whole mod folder, or rebuild it with a current "
|
|
"ReSkate Studio. Details are in ReSkate.log.");
|
|
}
|
|
// A mod left out of the merge altogether loads none of its content, which
|
|
// is worse, and used to say so only in the log. Name those too; the
|
|
// outdated ones have their own notice above.
|
|
{
|
|
std::vector<const mods::Mod*> dropped;
|
|
for (const auto& mod : catalog.excluded)
|
|
if (mod.outdated.empty()) dropped.push_back(&mod);
|
|
if (!dropped.empty()) {
|
|
std::string names;
|
|
for (const auto* mod : dropped)
|
|
names += (names.empty() ? "" : ", ") + (mod->title.empty() ? mod->name : mod->title);
|
|
const bool one = dropped.size() == 1;
|
|
overlay::notify(overlay::NoticeLevel::warning,
|
|
one ? "A mod could not be merged"
|
|
: std::to_string(dropped.size()) + " mods could not be merged",
|
|
names + (one ? " was" : " were") + " left out, so none of " + (one ? "its" : "their") +
|
|
" content is loaded. Reinstall the whole mod folder, or rebuild it with a current "
|
|
"ReSkate Studio. Details are in ReSkate.log.");
|
|
}
|
|
}
|
|
// A rejected catalogue is player-authored content, not a broken contract:
|
|
// report it and boot with the stock layers rather than failing startup.
|
|
if (!catalog.issue.empty()) {
|
|
logging::log(logging::Level::error, logging::Channel::assets,
|
|
"Mods folder ignored: {}.", catalog.issue);
|
|
overlay::notify(overlay::NoticeLevel::error, "Mods folder ignored",
|
|
catalog.issue + ". No mod is loaded. Details are in ReSkate.log.");
|
|
return true;
|
|
}
|
|
if (!catalog.present) {
|
|
logging::log(logging::Level::info, logging::Channel::assets,
|
|
"No Mods folder at {}; only Patch and Data are layered.", catalog.root.string());
|
|
return true;
|
|
}
|
|
if (!catalog.merged) {
|
|
logging::write(logging::Level::info, logging::Channel::assets,
|
|
"No mod provides a layout.toc; no merged patch was built.");
|
|
return true;
|
|
}
|
|
if (!validate_contract(base)) {
|
|
error = "Native layout-layer contract does not match the supported game";
|
|
return false;
|
|
}
|
|
|
|
state().merged_root = catalog.root / mods::generated_folder;
|
|
|
|
auto* classify_target = reinterpret_cast<void*>(base + classify_rva);
|
|
auto* load_target = reinterpret_cast<void*>(base + load_layer_rva);
|
|
ClassifyPath classify_original{};
|
|
LoadLayer load_original{};
|
|
auto created = hook_prepare(classify_target, reinterpret_cast<void*>(&classify_path),
|
|
reinterpret_cast<void**>(&classify_original));
|
|
if (created == HookOk && !classify_original) { hook_remove(classify_target); created = HookNotFound; }
|
|
if (created != HookOk) {
|
|
error = "Cannot create the layer classifier hook (Detours hook service status " +
|
|
std::to_string(created) + ")";
|
|
return false;
|
|
}
|
|
auto load_created = hook_prepare(load_target, reinterpret_cast<void*>(&load_layer),
|
|
reinterpret_cast<void**>(&load_original));
|
|
if (load_created == HookOk && !load_original) { hook_remove(load_target); load_created = HookNotFound; }
|
|
if (load_created != HookOk) {
|
|
hook_remove(classify_target);
|
|
error = "Cannot create the layout loader hook (Detours hook service status " +
|
|
std::to_string(load_created) + ")";
|
|
return false;
|
|
}
|
|
original_classify.store(classify_original, std::memory_order_release);
|
|
original_load.store(load_original, std::memory_order_release);
|
|
|
|
// Both hooks become visible in one Detours transaction before game startup.
|
|
auto enabled = hook_queue_enable(classify_target);
|
|
if (enabled == HookOk) enabled = hook_queue_enable(load_target);
|
|
if (enabled == HookOk) enabled = hook_apply_queued();
|
|
if (enabled != HookOk) {
|
|
hook_remove(load_target);
|
|
hook_remove(classify_target);
|
|
original_load.store(nullptr, std::memory_order_release);
|
|
original_classify.store(nullptr, std::memory_order_release);
|
|
error = "Cannot enable the merged mod patch (Detours hook service status " +
|
|
std::to_string(enabled) + ")";
|
|
return false;
|
|
}
|
|
installed_base = base;
|
|
logging::log(logging::Level::info, logging::Channel::assets,
|
|
"Merged mod patch ready at {}", state().merged_root.string());
|
|
return true;
|
|
}
|
|
LayoutObjects layout_objects() noexcept { return {seen_manager.load(), seen_layers.load()}; }
|
|
}
|