Give each mod archive its own index; unblock launches and saves

Mod archives (Engine/Vfs/mod_merge*)
- A free archive index no longer has to sit at or below the highest index
  the layout already 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. Indices are taken lowest first, per package directory.
- So a mod that collides takes an index of its own and its file is hard
  linked rather than concatenated, and no two mods share one archive's
  4 GB of addressable bytes. Sharing is left for a mod added while the
  game runs, which has no declared index to take, and for a directory
  with no index left.
- A placement addresses its payload with a 32-bit offset, so both append
  paths now refuse to cross 4 GB instead of wrapping round and pointing
  the game at some other payload: CasStore::write fails with the archive
  that is full, and the live-apply path checks archive 1's size plus the
  mod's before appending and asks for a restart, which gives the mod an
  archive of its own anyway.

Injection through a hooked LoadLibraryW (Engine/Core/Platform)
- A player could not launch at all: "Child LoadLibraryW entry bytes match
  neither Windows' kernel32 file nor this process". Anti-virus and
  overlays hook the entry with a jump to a per-process trampoline, so the
  bytes differ in the launcher and in the child and neither comparison
  could match. The guard already allowed the same hook when both
  processes carried it identically, so it refused on an accident of how
  the hook was written rather than on intent.
- inline_jump_length recognises the forms a hooking engine writes (rel32
  jmp or call, push/ret, jmp [rip], mov rax/jmp rax, short jmp). When one
  is present and every byte after it still matches the clean kernel32
  file, the launch goes ahead: the body is Windows' own, and the provider
  image already matched on timestamp, checksum and size. A wholesale
  replacement is still refused, and now says so.
- The failure names the modules loaded into Skate that came from neither
  Windows nor the game folder, instead of guessing at "security
  software". An accepted hook is logged as a warning with the same list.

Launching (Launcher/launch.cpp)
- Exporting loose scripts is a convenience, and a folder that would not
  take it was failing the whole launch: fs::create_directories threw and
  nothing caught it. The export is skipped, RESKATE_LOOSE_FILES goes to
  0 so the runtime does not look for files that are not there, and Skate
  starts. Someone whose install is under Program Files can play; only
  editing loose scripts is lost.

Failures worth reading (Launcher/problem.h)
- A hooked LoadLibraryW, a refused write and ERROR_ELEVATION_REQUIRED
  each explain themselves now. Windows reports the last two in the user's
  own language -- "Zugriff verweigert", "La operación solicitada requiere
  elevación" -- so the rules key on the operation names and the error
  number, which are never translated.

Saved skaters (Extension/Customization, Extension/Profile)
- Outfits stopped saving and every slot showed the standard skater. The
  load hook marks a preset blocked on entry and clears it only on a full
  success, so a load refused for a catalog that was not ready yet left
  the preset blocked for the session and every later save was refused as
  previous_load_rejected. The game asks once per level load and does not
  ask again, so one lost race cost a skater until a restart. Logs show
  the catalog arriving three seconds after the refused load, at startup
  and again on a map switch 17 minutes later.
- The record is remembered in CosmeticRuntime::pending_loads and applied
  by retry_pending_cosmetic_loads from the customization pump, which
  already waits for refresh_cosmetic_catalog. The preset stays blocked
  until a load really succeeds, so nothing is written over the outfit on
  disk.
- Those records belong to the loadout manager's array, which can be
  replaced in between, so each pointer is checked against the live array
  exactly as a save checks one -- manager current, begin/end sane, 0x48
  stride, at most 10 records, in range and aligned. A record that no
  longer belongs is dropped as retry_record_moved rather than written
  through.
This commit is contained in:
AyeZeeBB committed 2026-10-03 18:30:28 -05:00
1 parent 1004e012bf
commit abdda77946
10 files changed
+240 -47

No files matched your search

+82 -4
View File
@@ -1,5 +1,6 @@
#include "launcher_support.h"
#include "launcher_support_internal.h"
#include "path_text.h"
#include <Windows.h>
#include <TlHelp32.h>
@@ -21,6 +22,60 @@ using detail::fail;
using detail::fail_windows;
using detail::Handle;
// What is loaded into the child that Windows did not put there. One of these
// is usually what hooked LoadLibraryW, and naming it beats telling someone to
// go looking for "security software".
std::string foreign_modules(HANDLE process) {
std::array<wchar_t, MAX_PATH> windows{};
const auto length = GetWindowsDirectoryW(windows.data(), static_cast<UINT>(windows.size()));
const std::wstring system_root(windows.data(), length && length < windows.size() ? length : 0);
Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetProcessId(process)));
if (snapshot.get() == INVALID_HANDLE_VALUE || !snapshot.get()) return {};
MODULEENTRY32W entry{};
entry.dwSize = sizeof(entry);
std::wstring game_root;
std::string names;
std::size_t shown = 0;
for (auto more = Module32FirstW(snapshot.get(), &entry); more;
more = Module32NextW(snapshot.get(), &entry)) {
const std::wstring path(entry.szExePath);
// The first module is Skate.exe itself; everything beside it is the
// install, which is not what we are looking for.
if (game_root.empty()) {
game_root = fs::path(path).parent_path().wstring();
continue;
}
const auto under = [&path](const std::wstring& root) {
return !root.empty() && path.size() > root.size() &&
_wcsnicmp(path.c_str(), root.c_str(), root.size()) == 0;
};
if (under(system_root) || under(game_root)) continue;
if (shown == 8) { names += ", and more"; break; }
names += (names.empty() ? "" : ", ") + path_utf8(fs::path(entry.szModule));
++shown;
}
return names;
}
// The length of a jump written over a function's first bytes, or zero when the
// bytes are not one of the forms a hooking engine writes. Anti-virus and
// overlays hook by patching a jump to their own code at the entry and running
// the original afterwards; what they do not do is replace the whole function.
std::size_t inline_jump_length(const unsigned char* bytes, std::size_t size) {
const auto at = [&](std::size_t index) { return index < size ? bytes[index] : 0u; };
if (size >= 5 && (at(0) == 0xE9 || at(0) == 0xE8)) return 5; // jmp/call rel32
if (size >= 6 && at(0) == 0x68 && at(5) == 0xC3) return 6; // push imm32; ret
if (size >= 6 && at(0) == 0xFF && at(1) == 0x25) {
// jmp [rip+rel32]. The common form targets the eight bytes that follow.
const bool next = at(2) == 0 && at(3) == 0 && at(4) == 0 && at(5) == 0;
return next && size >= 14 ? 14 : 6;
}
if (size >= 12 && at(0) == 0x48 && at(1) == 0xB8 && at(10) == 0xFF && at(11) == 0xE0)
return 12; // mov rax, imm64; jmp rax
if (size >= 2 && at(0) == 0xEB) return 2; // short jmp, as hotpatching writes
return 0;
}
bool executable(DWORD protection) {
if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false;
switch (protection & 0xff) {
@@ -390,7 +445,7 @@ LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread,
}
}
std::uintptr_t validated_remote_load_library(HANDLE process) {
std::uintptr_t validated_remote_load_library(HANDLE process, std::string* note) {
const auto kernel32 = GetModuleHandleW(L"kernel32.dll");
if (!kernel32) fail_windows("Cannot locate local kernel32.dll");
const auto symbol = GetProcAddress(kernel32, "LoadLibraryW");
@@ -468,9 +523,32 @@ std::uintptr_t validated_remote_load_library(HANDLE process) {
const auto clean = image_file_bytes(fs::path(std::wstring(provider_path, provider_length)),
static_cast<std::uint32_t>(address - local_base), fingerprint_size, remote_base);
const bool matches_file = clean.size() == actual.size() && std::equal(clean.begin(), clean.end(), actual.begin());
if (!matches_file && actual != local_bytes)
fail("Child LoadLibraryW entry bytes match neither Windows' kernel32 file nor this process. "
"Security software may be hooking new processes; try allowing ReSkateLauncher.exe and Skate.exe.");
// A hook that writes a jump to a per-process trampoline leaves different
// bytes in the launcher and in the child, so neither comparison above can
// match even though nothing is wrong. Accept that shape when the jump is
// the only difference: everything past it is still Windows' own code, and
// the provider image already matched by timestamp, checksum and size.
bool hooked_entry = false;
if (!matches_file && actual != local_bytes && clean.size() == actual.size()) {
const auto jump = inline_jump_length(actual.data(), actual.size());
hooked_entry = jump > 0 && jump < actual.size() &&
std::equal(clean.begin() + static_cast<std::ptrdiff_t>(jump), clean.end(),
actual.begin() + static_cast<std::ptrdiff_t>(jump));
}
if (!matches_file && actual != local_bytes && !hooked_entry) {
const auto foreign = foreign_modules(process);
std::string message = "Skate's LoadLibraryW has been replaced, not just hooked: its entry bytes match "
"neither Windows' kernel32 file nor this process, and what follows them is not "
"Windows' code either.";
if (!foreign.empty()) message += " Loaded into Skate and not part of Windows: " + foreign + ".";
fail(message.c_str());
}
if (hooked_entry && note) {
*note = "Skate's LoadLibraryW is hooked at its entry; the rest of it is Windows' own, so ReSkate is "
"loading through it.";
if (const auto foreign = foreign_modules(process); !foreign.empty())
*note += " Loaded into Skate and not part of Windows: " + foreign + ".";
}
return address;
}
+4 -1
View File
@@ -128,6 +128,9 @@ LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread,
// Returns the ordinary LoadLibraryW address for a same-architecture suspended
// child only after verifying the matching KnownDLL mapping and entry bytes.
std::uintptr_t validated_remote_load_library(HANDLE process);
// An entry hooked by anti-virus or an overlay is allowed through when the rest
// of the function is still Windows' own; `note`, when given, is then filled
// with what was found so the caller can log it.
std::uintptr_t validated_remote_load_library(HANDLE process, std::string* note = nullptr);
} // namespace dingosdk::launcher
+22 -14
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <array>
#include <cstring>
#include <limits>
#include <map>
#include <optional>
#include <set>
@@ -98,10 +99,11 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// 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 exhausted the range -- and it
// stays at or below the highest index that directory already declares:
// the engine sizes each install chunk's archive table from that range,
// and an index past it brings the game down during startup.
// (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<std::string, std::set<std::uint16_t>> declaredIn;
for (const auto* field : {"layeredInstallChunkFiles", "unlayeredInstallChunkFiles"}) {
const auto* node = layout.field(field);
@@ -110,16 +112,14 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
if (const auto* directory = store.find_directory(id)) declaredIn[*directory].insert(archive);
});
}
const auto ceiling = [&](const std::string& directory) -> std::uint16_t {
const auto found = declaredIn.find(directory);
return found == declaredIn.end() || found->second.empty() ? 0 : *found->second.rbegin();
};
std::map<std::string, std::set<std::uint16_t>> claimedArchives;
const auto claim = [&](const std::string& directory) -> std::optional<std::uint16_t> {
const auto& declared = declaredIn[directory];
auto& claimed = claimedArchives[directory];
for (std::uint16_t candidate = 1; candidate <= ceiling(directory); ++candidate)
if (!declared.contains(candidate) && claimed.insert(candidate).second) return candidate;
for (std::uint32_t candidate = 1; candidate <= std::numeric_limits<std::uint16_t>::max(); ++candidate) {
const auto index = static_cast<std::uint16_t>(candidate);
if (!declared.contains(index) && claimed.insert(index).second) return index;
}
return std::nullopt;
};
@@ -172,6 +172,14 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
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<std::uint32_t>::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) +
@@ -194,12 +202,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
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 where the base leaves one, so the file
// is hard linked rather than copied; failing that it is
// 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};
const auto keepable = number <= ceiling(directory) || declaredIn[directory].contains(number);
// 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
@@ -212,7 +220,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
} else {
spot.offset = append_file(mod->directory / path, output / path);
}
} else if (keepable && claimed.insert(number).second) {
} 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;
+5
View File
@@ -22,6 +22,11 @@ fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t arc
const auto existing = fs::file_size(path, error);
if (!error) offset = existing;
}
// A placement holds a 32-bit offset; one past it would wrap round and
// point the game at some other payload.
if (offset + encoded.size() > std::numeric_limits<std::uint32_t>::max())
throw std::runtime_error("The merged patch's own archive " + path_utf8(path) +
" is full (4 GB); restart Skate so it is built again");
std::ofstream out(path, std::ios::binary | std::ios::app);
if (!out || !out.write(reinterpret_cast<const char*>(encoded.data()),
static_cast<std::streamsize>(encoded.size())))
+9 -7
View File
@@ -39,14 +39,16 @@ struct RelativeFiles {
RelativeFiles scan(const fs::path& directory);
// Every mod's archives are concatenated into one file per index, so a patch
// reference keeps the archive number the mod was built for and moves only by
// the byte offset that mod's block landed at. Re-indexing was the alternative,
// and the engine reads a mod's payloads from no index but its own.
// 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: a free index of its own when one is
// available, which costs nothing to place, or a byte offset inside an
// archive it now shares with a higher-priority mod.
// 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{};
@@ -20,7 +20,8 @@ bool cosmetic_diagnostic(const char* operation, const char* reason, std::string_
// ..._rejected are), so a player's ordinary log says why; the rest are routine.
const std::string_view why(reason);
const bool routine = why == "callback_entered" || why == "unchanged" || why == "no_saved_loadout" ||
why == "card_only_preset_recovered" || why == "card_is_not_skater_preset";
why == "card_only_preset_recovered" || why == "card_is_not_skater_preset" ||
why == "retried_after_catalog_ready";
const dingosdk::Json event{{"event", routine ? "local_cosmetic_loadout_diagnostic" : "local_cosmetic_loadout_rejected"},
{"operation", operation}, {"reason", reason}, {"preset", id}};
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.dump().c_str());
@@ -297,14 +298,11 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou
return true;
}
bool load_cosmetic_hook(const void* wrapper, void* destination) {
// A load, taking the preset id instead of the game's wrapper so that
// retry_pending_cosmetic_loads can run it again. The caller holds native_mutex.
bool apply_saved_loadout(const std::string& id, void* destination) {
auto& s = local_runtime();
if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination);
PreserveError preserve;
std::lock_guard lock(s.native_mutex);
std::string id;
try {
if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id");
const auto saved = s.store->cosmetic_loadout(id);
if (!saved) {
profile::CosmeticLoadout starter; std::vector<CosmeticNativeRecipe> native;
@@ -324,7 +322,13 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
profile::CosmeticLoadout defaults; std::vector<CosmeticNativeRecipe> native;
if (!read_cosmetic_recipes(destination, defaults, native)) return cosmetic_diagnostic("load", "invalid_native_recipe", id);
if (recover_card_only_preset(id, *saved, defaults)) return false;
if (!refresh_cosmetic_catalog()) return cosmetic_diagnostic("load", "catalog_not_ready", id);
if (!refresh_cosmetic_catalog()) {
// The cosmetics manager is not ready this early in a level load.
// The preset stays blocked, so nothing is written over the outfit
// on disk, and the pump applies this record once it is ready.
c.pending_loads[id] = destination;
return cosmetic_diagnostic("load", "catalog_not_ready", id);
}
if (saved->recipes.size() != defaults.recipes.size()) return cosmetic_diagnostic("load", "recipe_count_mismatch", id);
const auto snapshot_shared = s.store->shared_snapshot();
const auto& snapshot = *snapshot_shared;
@@ -378,6 +382,7 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
}
s.copy_cosmetic(destination, native.data(), native.data() + native.size());
c.blocked_loadouts.erase(id);
c.pending_loads.erase(id);
if (held.empty()) c.held_slots.erase(id);
else c.held_slots[id] = std::move(held);
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_loaded\"}");
@@ -389,6 +394,45 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
}
}
bool load_cosmetic_hook(const void* wrapper, void* destination) {
auto& s = local_runtime();
if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination);
PreserveError preserve;
std::lock_guard lock(s.native_mutex);
std::string id;
try {
if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id");
} catch (...) {
return cosmetic_diagnostic("load", "invalid_preset_id");
}
return apply_saved_loadout(id, destination);
}
void retry_pending_cosmetic_loads() {
auto& c = cosmetic_runtime();
if (c.pending_loads.empty()) return;
auto& s = local_runtime();
// The records belong to the loadout manager's array, which can be replaced
// between the refused load and now. Check each pointer against the live
// array exactly as a save does, and drop one that no longer belongs rather
// than writing through it.
std::uintptr_t manager{}, begin{}, end{};
if (!read(s.base + addr::engine::loadout_manager, manager) || !manager ||
!read(manager + 0x18, begin) || !read(manager + 0x20, end) || begin > end ||
(end - begin) % 0x48 || (end - begin) / 0x48 > 10) return;
auto pending = std::move(c.pending_loads);
c.pending_loads.clear();
for (const auto& [id, destination] : pending) {
const auto p = reinterpret_cast<std::uintptr_t>(destination);
if (!destination || p < begin || p >= end || (p - begin) % 0x48) {
cosmetic_diagnostic("load", "retry_record_moved", id);
continue;
}
if (apply_saved_loadout(id, destination))
cosmetic_diagnostic("load", "retried_after_catalog_ready", id);
}
}
void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only) {
auto& s = local_runtime();
if (!s.active.load(std::memory_order_acquire)) { s.save_cosmetic(manager, record, raw_id, reason, changed_only); return; }
@@ -16,6 +16,12 @@ struct CosmeticRuntime {
std::uint32_t item_count{};
std::map<std::string, CosmeticItemInfo, std::less<>> items;
std::set<std::string> blocked_loadouts;
// Presets whose load was refused only because the cosmetics catalog was not
// ready yet, with the record the game asked us to fill. The game asks once
// per level load and does not ask again, so the pump retries these once the
// catalog is ready. Until one succeeds the preset stays in
// `blocked_loadouts`, so a save cannot write over the outfit on disk.
std::map<std::string, void*, std::less<>> pending_loads;
// Saved outfit items that were not installed when their preset loaded (a
// disabled or removed costume mod). The slot shows its default this session;
// saves keep the original item unless the player changes that slot.
@@ -106,6 +112,10 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou
const profile::CosmeticLoadout& defaults);
bool load_cosmetic_hook(const void* wrapper, void* destination);
// Applies the outfits whose load was refused for a catalog that was not ready.
// Call from the customization pump, holding native_mutex, once
// refresh_cosmetic_catalog() is true.
void retry_pending_cosmetic_loads();
void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only);
}
@@ -303,6 +303,10 @@ void update_local_customization() noexcept {
if (!c.catalog_failed && refresh_cosmetic_catalog()) {
publish_cosmetic_catalog();
publish_cosmetic_inventory();
// Outfits the game asked for before the catalog was ready: it does
// not ask again, so they are applied here instead of the player
// finding a standard skater that will not save over itself.
retry_pending_cosmetic_loads();
}
} catch (...) {
cosmetic_runtime().catalog_failed = true;
+25 -13
View File
@@ -423,7 +423,10 @@ RemoteModule inject_dll(HANDLE process, DWORD process_id, const fs::path& dll) {
SIZE_T written{};
if (!WriteProcessMemory(process, remote_path.get(), path.c_str(), byte_count, &written) ||
written != byte_count) win32_failure(L"WriteProcessMemory(ReSkate.dll path)");
const auto loader = dingosdk::launcher::validated_remote_load_library(process);
std::string hook_note;
const auto loader = dingosdk::launcher::validated_remote_load_library(process, &hook_note);
if (!hook_note.empty())
dingosdk::logging::write(dingosdk::logging::Level::warning, dingosdk::logging::Channel::launcher, hook_note);
std::wostringstream address;
address << L"Starting ordinary LoadLibraryW injection at 0x" << std::hex << loader;
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::launcher, address.str());
@@ -648,21 +651,30 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options,
logging::log(logging::Level::warning, logging::Channel::world, "World layers could not be read: {}", error.what());
}
configure_environment(paths.logs);
const bool loose_files = options.loose_files && initfs::loose_files_preference(paths.directory);
set_environment(L"RESKATE_LOOSE_FILES", loose_files ? L"1" : L"0");
bool loose_files = options.loose_files && initfs::loose_files_preference(paths.directory);
if (loose_files) {
fs::create_directories(paths.directory / L"scripts" / L"Custom");
const auto exported = initfs::export_files(paths.game,
initfs::data_directory(paths.directory, options.game_arguments), paths.directory, true);
if (exported.already_exported) {
logging::write(logging::Level::info, logging::Channel::assets,
"Using existing scripts/ and config/ export; edits and removed files are preserved.");
} else {
logging::log(logging::Level::info, logging::Channel::assets,
"InitFS exported: {} Lua, {} configs; {} created, {} existing files preserved, {} skipped.",
exported.scripts, exported.configs, exported.created, exported.preserved, exported.skipped);
try {
fs::create_directories(paths.directory / L"scripts" / L"Custom");
const auto exported = initfs::export_files(paths.game,
initfs::data_directory(paths.directory, options.game_arguments), paths.directory, true);
if (exported.already_exported) {
logging::write(logging::Level::info, logging::Channel::assets,
"Using existing scripts/ and config/ export; edits and removed files are preserved.");
} else {
logging::log(logging::Level::info, logging::Channel::assets,
"InitFS exported: {} Lua, {} configs; {} created, {} existing files preserved, {} skipped.",
exported.scripts, exported.configs, exported.created, exported.preserved, exported.skipped);
}
} catch (const std::exception& failure) {
// An install under Program Files is the usual reason. Skate plays
// without loose scripts; only editing them is lost.
loose_files = false;
logging::log(logging::Level::warning, logging::Channel::assets,
"Loose files are off for this launch: scripts/ and config/ could not be exported ({}). Skate still "
"starts; editing them needs a folder ReSkate is allowed to write to.", failure.what());
}
}
set_environment(L"RESKATE_LOOSE_FILES", loose_files ? L"1" : L"0");
set_environment(L"RESKATE_LOG_CONSOLE", options.window_console ? L"1" : L"0");
set_environment(L"RESKATE_LOG_LEVEL", widen(options.log_level).c_str());
set_environment(L"RESKATE_FORCE_WINDOWED", options.force_windowed ? L"1" : L"0");
+27
View File
@@ -69,6 +69,23 @@ inline Problem explain(std::string_view raw) {
if (mentions(raw, "need different keys"))
return {"The menu and console share a key",
"Open Settings > KEYS and pick a different key for one of them."};
// ERROR_ELEVATION_REQUIRED. Windows localises the text, so match the
// number win32_failure puts in front of it.
if (mentions(raw, "failed (740)"))
return {"Skate.exe is set to always run as administrator",
"ReSkate has to start Skate itself, and Windows does not let it start a program marked to "
"need administrator. Right-click Skate.exe, pick Properties, then Compatibility, and untick "
"\"Run this program as an administrator\". Starting ReSkateLauncher.exe as "
"administrator works too."};
if (mentions(raw, "LoadLibraryW is hooked")) {
std::string advice = "Security software or an overlay is modifying Skate while it starts, and ReSkate will "
"not load itself through that. Allow ReSkateLauncher.exe and Skate.exe in it, or close "
"it, then press RETRY.";
// The launcher named what it found in Skate; that is the useful half.
if (const auto found = raw.find("Loaded into Skate"); found != std::string_view::npos)
advice += " " + std::string(raw.substr(found));
return {"Something is hooking Skate as it starts", std::move(advice)};
}
if (mentions(raw, "Skate closed while it was starting"))
return {"Skate closed while it was starting", std::string(attach_advice)};
if (mentions(raw, "timed out after"))
@@ -79,6 +96,16 @@ inline Problem explain(std::string_view raw) {
mentions(raw, "module list") || mentions(raw, "VirtualAllocEx") || mentions(raw, "NtQueryInformationProcess") ||
mentions(raw, "DingoSDKDebugInitialize") || mentions(raw, "Remote "))
return {"ReSkate could not attach to Skate", std::string(attach_advice)};
// Windows reports a refused write in the user's own language, so these
// key on the operation names and the path, which are never translated.
const bool write_failure = mentions(raw, "create_directories") || mentions(raw, "create_directory") ||
mentions(raw, "Cannot write") || mentions(raw, "Cannot create") ||
mentions(raw, "Cannot publish") || mentions(raw, "Cannot append") ||
mentions(raw, "Cannot replace");
if (write_failure)
return {"ReSkate cannot write to its own folder",
"Move the whole ReSkate folder somewhere else, such as C:\\Games\\ReSkate, and start it from "
"there. Program Files and OneDrive do not let it write."};
return {std::string(raw),
"Press RETRY. If it keeps happening, copy the details below and share them with logs\\ReSkate.log "
"on the ReSkate Discord."};