diff --git a/Engine/Core/Platform/launcher_injection.cpp b/Engine/Core/Platform/launcher_injection.cpp index 225a716..824f66d 100644 --- a/Engine/Core/Platform/launcher_injection.cpp +++ b/Engine/Core/Platform/launcher_injection.cpp @@ -1,5 +1,6 @@ #include "launcher_support.h" #include "launcher_support_internal.h" +#include "path_text.h" #include #include @@ -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 windows{}; + const auto length = GetWindowsDirectoryW(windows.data(), static_cast(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(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(jump), clean.end(), + actual.begin() + static_cast(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; } diff --git a/Engine/Core/Platform/launcher_support.h b/Engine/Core/Platform/launcher_support.h index c36f3f3..aeb1a0c 100644 --- a/Engine/Core/Platform/launcher_support.h +++ b/Engine/Core/Platform/launcher_support.h @@ -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 diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 5030c41..4180511 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -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> 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> claimedArchives; const auto claim = [&](const std::string& directory) -> std::optional { 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::max(); ++candidate) { + const auto index = static_cast(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::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; diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index 9223420..0bf6898 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -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::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(encoded.data()), static_cast(encoded.size()))) diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index e3355ee..9d1f579 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -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{}; diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 1b4e954..66f9eba 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -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 native; @@ -324,7 +322,13 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { profile::CosmeticLoadout defaults; std::vector 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(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; } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index a5e863b..41cba2d 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -16,6 +16,12 @@ struct CosmeticRuntime { std::uint32_t item_count{}; std::map> items; std::set 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> 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); } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 6d175e1..bdc3a21 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -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; diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 24200d7..7aa81c4 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -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"); diff --git a/Launcher/problem.h b/Launcher/problem.h index c572ac0..ab093bc 100644 --- a/Launcher/problem.h +++ b/Launcher/problem.h @@ -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."};