From 97f2406beadd3ae8680420c5b608050f5a607908 Mon Sep 17 00:00:00 2001 From: Vebjorhk <77414086+Vebjorhk@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:51:04 +0200 Subject: [PATCH] Launcher: when ReSkate stops while starting, the STATUS tile says why instead of blaming anti-virus DingoSDKDebugInitialize answers the launcher with a bare TRUE or FALSE. On FALSE the launcher threw "DingoSDKDebugInitialize returned FALSE", which the STATUS tile turned into "ReSkate could not attach to Skate" with anti-virus advice, although the attach had worked and the runtime had logged its reason. ReSkate.dll now exports DingoSDKStartupFailure, a 1 KB buffer that every FALSE path fills with its reason (the first one stays). The launcher reads it out of Skate before closing it. With an older ReSkate.dll it reads the ERROR lines that Skate process wrote to ReSkate.log during the call instead, and with neither it says it was not told why. problem.h explains these messages before the attach rules: a save in use or a folder another game holds says Skate is already running, hook and overlay failures keep the attach advice, and anything else leads with the runtime's own reason. Bootstrap and the local profile hand their reason back, and the profile checks that used to return false without a word now log one. Fixes #90, fixes #346. Co-Authored-By: Claude Opus 5.5 --- Engine/Core/Platform/launcher_pe.cpp | 29 ++++- Engine/Core/Platform/launcher_support.h | 4 + Engine/Core/Platform/startup_failure.h | 72 +++++++++++ Extension/Profile/local_profile_runtime.cpp | 26 ++-- Extension/Profile/local_profile_runtime.h | 3 +- Launcher/Test/startup_failure_tests.cpp | 136 ++++++++++++++++++++ Launcher/launch.cpp | 61 ++++++++- Launcher/problem.h | 41 ++++++ Runtime/bootstrap.cpp | 25 ++-- Runtime/bootstrap.h | 4 +- Runtime/runtime_init.cpp | 94 +++++++++----- cmake/Apps.cmake | 3 + 12 files changed, 433 insertions(+), 65 deletions(-) create mode 100644 Engine/Core/Platform/startup_failure.h create mode 100644 Launcher/Test/startup_failure_tests.cpp diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index 6fdd5be4..165269d2 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -267,7 +267,11 @@ PeFileInfo inspect_pe_file(const fs::path& path) { return parse_pe(file.data(), file.size()).info; } -std::uint32_t exported_function_rva(const fs::path& path, std::string_view export_name) { +namespace { +enum class ExportLookup { found, unnamed, forwarded }; +struct ExportEntry { ExportLookup lookup{}; std::uint32_t rva{}; }; + +ExportEntry find_export(const fs::path& path, std::string_view export_name) { const MappedFile file(path); const auto pe = parse_pe(file.data(), file.size()); const auto& directory = pe.nt.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; @@ -283,14 +287,27 @@ std::uint32_t exported_function_rva(const fs::path& path, std::string_view expor const auto ordinal = rva_object(file, pe, exports.AddressOfNameOrdinals + index * sizeof(std::uint16_t)); if (ordinal >= exports.NumberOfFunctions) fail("Invalid PE export ordinal"); - const auto function = rva_object(file, pe, + const auto rva = rva_object(file, pe, exports.AddressOfFunctions + ordinal * sizeof(std::uint32_t)); const std::uint64_t export_end = static_cast(directory.VirtualAddress) + directory.Size; - if (!function || (function >= directory.VirtualAddress && function < export_end)) - fail("Initializer export is missing or forwarded"); - return function; + if (!rva || (rva >= directory.VirtualAddress && rva < export_end)) return {ExportLookup::forwarded, rva}; + return {ExportLookup::found, rva}; } - fail("Required initializer export is missing"); + return {ExportLookup::unnamed, 0}; +} +} + +std::uint32_t exported_function_rva(const fs::path& path, std::string_view export_name) { + const auto entry = find_export(path, export_name); + if (entry.lookup == ExportLookup::forwarded) fail("Initializer export is missing or forwarded"); + if (entry.lookup == ExportLookup::unnamed) fail("Required initializer export is missing"); + return entry.rva; +} + +std::optional exported_rva(const fs::path& path, std::string_view export_name) { + const auto entry = find_export(path, export_name); + if (entry.lookup != ExportLookup::found) return std::nullopt; + return entry.rva; } void validate_game_file(const fs::path& path) { diff --git a/Engine/Core/Platform/launcher_support.h b/Engine/Core/Platform/launcher_support.h index 1e156047..81517f7f 100644 --- a/Engine/Core/Platform/launcher_support.h +++ b/Engine/Core/Platform/launcher_support.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,9 @@ SiblingPaths sibling_paths(const std::filesystem::path& launcher); PeFileInfo inspect_pe_file(const std::filesystem::path& path); std::uint32_t exported_function_rva(const std::filesystem::path& path, std::string_view export_name); +// An optional export, such as data: nullopt when the DLL does not export the name. +std::optional exported_rva(const std::filesystem::path& path, + std::string_view export_name); void validate_game_file(const std::filesystem::path& path); #else // Linux dedicated server: no PE image checks. Validation is a size/hash check diff --git a/Engine/Core/Platform/startup_failure.h b/Engine/Core/Platform/startup_failure.h new file mode 100644 index 00000000..9d7c04c3 --- /dev/null +++ b/Engine/Core/Platform/startup_failure.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +// DingoSDKDebugInitialize runs inside Skate and answers the launcher with a bare +// TRUE or FALSE. On FALSE the runtime has already said why in ReSkate.log; it +// also keeps that reason in an exported buffer, which the launcher reads out of +// the game before closing it, so the STATUS tile and Copy details carry the +// cause instead of a FALSE that reads like an injection failure. +namespace dingosdk::startup_failure { + +// ReSkate.dll exports a char[capacity] by this name: NUL-terminated UTF-8, +// empty until startup fails. The first reason written stays. +inline constexpr char export_name[] = "DingoSDKStartupFailure"; +inline constexpr std::size_t capacity = 1024; + +// The longest prefix of text, at most limit bytes, that does not end inside a +// UTF-8 sequence. +inline std::size_t utf8_prefix(std::string_view text, std::size_t limit) noexcept { + if (text.size() <= limit) return text.size(); + auto length = limit; + while (length && (static_cast(text[length]) & 0xc0) == 0x80) --length; + return length; +} + +// One line for the STATUS tile: control characters become spaces and the +// spaces around the text go. +inline std::string printable(std::string_view text) { + std::string line(text); + for (auto& c : line) + if (static_cast(c) < 0x20 || c == 0x7f) c = ' '; + const auto first = line.find_first_not_of(' '); + if (first == std::string::npos) return {}; + return line.substr(first, line.find_last_not_of(' ') - first + 1); +} + +// Runtimes without the export still logged why they stopped: the ERROR and +// CRITICAL messages process_id wrote, oldest first, at most limit of the newest. +// Lines look like "[time] [pid:tid] Context: [Channel] [LEVEL] message". +inline std::vector logged_errors(std::string_view log, std::uint32_t process_id, + std::size_t limit) { + std::vector found; + const auto tag = "] [" + std::to_string(process_id) + ":"; + for (std::size_t start = 0; start < log.size();) { + auto end = log.find('\n', start); + if (end == std::string_view::npos) end = log.size(); + auto line = log.substr(start, end - start); + start = end + 1; + if (!line.empty() && line.back() == '\r') line.remove_suffix(1); + // The first "] [" closes the timestamp; the process id follows it. + const auto stamp_end = line.find("] ["); + if (stamp_end == std::string_view::npos || !line.substr(stamp_end).starts_with(tag)) continue; + const auto context = line.find(": [", stamp_end + tag.size()); + if (context == std::string_view::npos) continue; + const auto channel_end = line.find("] [", context + 3); + if (channel_end == std::string_view::npos) continue; + const auto level_start = channel_end + 3; + const auto level_end = line.find("] ", level_start); + if (level_end == std::string_view::npos) continue; + const auto level = line.substr(level_start, level_end - level_start); + if (level != "ERROR" && level != "CRITICAL") continue; + found.emplace_back(line.substr(level_end + 2)); + if (found.size() > limit) found.erase(found.begin()); + } + return found; +} + +} diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index a3337bfe..178bd171 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -402,18 +402,23 @@ void observe_local_customization_selection(std::int32_t index) noexcept { } bool initialize_local_profile(std::uintptr_t base, bool authored_offline, - const std::filesystem::path& path) { + const std::filesystem::path& path, std::string& failure) { + const auto refuse = [&failure](std::string_view why) { + failure = "Profile initialization failed: " + std::string(why); + dingosdk::logging::write(dingosdk::logging::Level::error, dingosdk::logging::Channel::profile, failure); + return false; + }; auto& s = local_runtime(); - if (!authored_offline) return false; - if (s.attempted) return s.active.load() && s.base == base; + if (!authored_offline) return refuse("the offline runtime is not active"); + if (s.attempted) return s.active.load() && s.base == base ? true : refuse("an earlier attempt in this game did not finish"); s.attempted = true; std::vector created; bool enable_attempted{}; try { - if (reinterpret_cast(GetModuleHandleW(nullptr)) != base) return false; + if (reinterpret_cast(GetModuleHandleW(nullptr)) != base) return refuse("the game image moved"); std::array exe{}; const auto n = GetModuleFileNameW(nullptr, exe.data(), static_cast(exe.size())); - if (!n || n >= exe.size()) return false; + if (!n || n >= exe.size()) return refuse("cannot locate Skate.exe"); launcher::validate_game_file(exe.data()); for (const auto& f : {load_contract, ready_contract, send_contract, get_bool_contract, user_get_bool_contract, user_get_number_contract, user_get_integer_contract, user_get_string_contract, @@ -451,11 +456,11 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, object_register_contract, object_parse_contract, object_release_contract, challenge_begin_contract, challenge_end_contract, challenge_available_contract, challenge_select_contract, challenge_lookup_contract, challenge_end_invoke_contract, challenge_error_contract, challenge_counts_contract, challenge_index_insert_contract, challenge_index_append_contract, challenge_insert_contract, challenge_dispatch_contract, challenge_request_create_contract, rip_score_context_contract, multiplayer::menu_data::model_record_load_contract}) - if (!matches(base, f)) return false; + if (!matches(base, f)) return refuse(std::format("the game code at {:#x} is not what this build expects", f.rva)); if (!neighborhood_type_contract(base) || !rip_score_type_contract(base) || - !challenge_map_type_contract(base)) return false; - if (!prepare_main_mission_override(base)) return false; - if (!game::initialize_native_data(base)) return false; + !challenge_map_type_contract(base)) return refuse("the game's profile data types are not what this build expects"); + if (!prepare_main_mission_override(base)) return refuse("the main mission override could not be prepared"); + if (!game::initialize_native_data(base)) return refuse("the game's data model could not be reached"); s.base = base; s.store = std::make_unique(path); dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::profile, @@ -638,8 +643,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, // Retain attempted trampolines/store for any already-entered detours. if (!enable_attempted) s.store.reset(); dingosdk::logging::event(dingosdk::logging::Channel::profile, "{\"event\":\"local_profile_initialized\",\"active\":false}"); - dingosdk::logging::log(dingosdk::logging::Level::error, dingosdk::logging::Channel::profile, "Profile initialization failed: {}", e.what()); - return false; + return refuse(e.what()); } } diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 3a652b13..a945a8bb 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -157,6 +157,7 @@ bool reset_local_atmosphere_controls(unsigned kind); void update_local_world_controls(WorldMap map) noexcept; // Execute on the game update thread, never from ImGui's Present callback. bool set_local_mission_completed(std::string_view id, bool completed); +// On false, failure says why, in the words the log uses. bool initialize_local_profile(std::uintptr_t image_base, bool authored_offline, - const std::filesystem::path& path); + const std::filesystem::path& path, std::string& failure); } diff --git a/Launcher/Test/startup_failure_tests.cpp b/Launcher/Test/startup_failure_tests.cpp new file mode 100644 index 00000000..cd400dc2 --- /dev/null +++ b/Launcher/Test/startup_failure_tests.cpp @@ -0,0 +1,136 @@ +// A FALSE from DingoSDKDebugInitialize: the reason the runtime gives, the one an +// older runtime only logged, and what the STATUS tile makes of each. +#include "Engine/Core/Platform/startup_failure.h" +#include "Launcher/problem.h" + +#include +#include +#include + +using namespace dingosdk; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +bool contains(std::string_view text, std::string_view part) { return text.find(part) != std::string_view::npos; } + +void utf8_prefix_keeps_whole_characters() { + using startup_failure::utf8_prefix; + check(utf8_prefix("abc", 10) == 3, "a short text is kept whole"); + check(utf8_prefix("abcdef", 4) == 4, "ASCII is cut at the limit"); + const std::string e_acute = "ab\xC3\xA9"; // "abé" + check(utf8_prefix(e_acute, 3) == 2, "a two-byte character is not split"); + check(utf8_prefix(e_acute, 4) == 4, "a two-byte character that fits is kept"); + const std::string euro = "a\xE2\x82\xAC" "b"; // "a€b" + check(utf8_prefix(euro, 2) == 1, "a three-byte character cut after its first byte is dropped"); + check(utf8_prefix(euro, 3) == 1, "a three-byte character cut after its second byte is dropped"); + check(utf8_prefix(euro, 4) == 4, "a three-byte character that fits is kept"); +} + +void printable_makes_one_line() { + using startup_failure::printable; + check(printable(" Save is\r\nin use\t ") == "Save is in use", "control characters become spaces, ends are trimmed"); + check(printable("\n\r\t").empty(), "only control characters leave nothing"); + check(printable("caf\xC3\xA9") == "caf\xC3\xA9", "UTF-8 is left alone"); +} + +void logged_errors_reads_one_process() { + const std::string log = + "[2026-10-07 17:29:57.548] [34456:44852] SDK: [Launcher] [INFO] Calling DingoSDKDebugInitialize at 0x7ffb2e823200\r\n" + "[2026-10-07 17:30:03.100] [36556:44916] Native(C): [Profile] [ERROR] another game's error\r\n" + "[2026-10-07 17:30:03.200] [48792:25272] Native(C): [Assets] [INFO] text with ] [ERROR] inside an info line\r\n" + "[2026-10-07 17:30:03.436] [48792:25272] Native(C): [Profile] [ERROR] Profile initialization failed: Save is already in use or inaccessible; close the game before editing\r\n" + "[2026-10-07 17:30:03.436] [48792:25272] SDK: [Runtime] [ERROR] Launcher readiness: ready=false, reason=local_profile\r\n" + "[2026-10-07 17:30:03.454] [34456:44852] SDK: [Launcher] [ERROR] DingoSDKDebugInitialize returned FALSE\r\n" + "[2026-10-07 17:30:03.460] [48792:25272] SDK: [Runtime] [CRITICAL] Runtime initialization failed: test\n" + "[2026-10-07 17:30:03.470] [48792:25272] SDK: [Runtime] [WARNING] a warning is not a reason\n" + "a line that is not a log line"; + const auto found = startup_failure::logged_errors(log, 48792, 3); + check(found.size() == 3, "the child's ERROR and CRITICAL lines are found, up to the limit"); + if (found.size() == 3) { + check(found[0] == "Profile initialization failed: Save is already in use or inaccessible; close the game before editing", + "the message is taken without its prefix or line ending"); + check(found[1] == "Launcher readiness: ready=false, reason=local_profile", "lines stay in order"); + check(found[2] == "Runtime initialization failed: test", "CRITICAL counts"); + } + const auto newest = startup_failure::logged_errors(log, 48792, 1); + check(newest.size() == 1 && newest[0] == "Runtime initialization failed: test", "a limit keeps the newest"); + check(startup_failure::logged_errors(log, 4879, 3).empty(), "a process id is matched whole, not as a prefix"); + check(startup_failure::logged_errors(log, 48792, 0).empty(), "a zero limit finds nothing"); +} + +void the_reason_leads_the_status_tile() { + using launcher_problem::explain; + const auto in_use = explain("ReSkate stopped while starting: Profile initialization failed: Save is already in use " + "or inaccessible; close the game before editing"); + check(in_use.headline == "Skate is already running", "a save in use says Skate is already running"); + check(!contains(in_use.advice, "nti-virus"), "a save in use does not blame anti-virus"); + + const auto logged = explain("ReSkate stopped while starting; ReSkate.log says: Profile initialization failed: Save is " + "already in use or inaccessible; close the game before editing | Launcher readiness: " + "ready=false, reason=local_profile"); + check(logged.headline == "Skate is already running", "an older runtime's logged reason is recognised too"); + + const auto folder = explain("ReSkate stopped while starting: Another Skate is already running from this folder; " + "this one does not start."); + check(folder.headline == "Skate is already running", "a folder another game holds says Skate is already running"); + check(!contains(folder.advice, "nti-virus"), "a folder in use does not blame anti-virus"); + + const auto image = explain("ReSkate stopped while starting: Game image validation failed."); + check(image.headline == "ReSkate stopped while starting", "an unrecognised reason keeps the plain headline"); + check(image.advice.starts_with("Game image validation failed."), "the reason leads the advice"); + check(!contains(image.advice, "nti-virus"), "an unrecognised reason does not blame anti-virus"); + + const auto hook = explain("ReSkate stopped while starting: The engine update hook could not be installed: " + "Access is denied (5)"); + check(hook.headline == "ReSkate could not hook into Skate", "a hook failure says so"); + check(hook.advice.starts_with("The engine update hook could not be installed: Access is denied (5). "), + "the reason ends as a sentence before the advice"); + + const auto overlay = explain("ReSkate stopped while starting: Overlay startup failed."); + check(overlay.headline == "ReSkate could not hook into Skate", "an overlay that failed to start is a hook failure"); + check(overlay.advice.starts_with("Overlay startup failed. "), "a reason that ends a sentence is not given a second stop"); + + const auto silent = explain("ReSkate stopped while starting without saying why (DingoSDKDebugInitialize returned FALSE)"); + check(silent.headline == "ReSkate stopped while starting", "no reason keeps the plain headline"); + check(contains(silent.advice, "Open logs") && !contains(silent.advice, "nti-virus"), + "no reason points at the log, not at anti-virus"); + + const auto unsupported = explain("ReSkate stopped while starting: Mod layout layers: failed - Native layout-layer " + "contract does not match the supported game"); + check(unsupported.headline == "ReSkate stopped while starting", + "a reason is not mistaken for an unsupported game build by the rules after it"); +} + +void attach_failures_keep_their_advice() { + using launcher_problem::explain; + check(explain("CreateRemoteThread(DingoSDKDebugInitialize) failed (5): Access is denied.").headline == + "ReSkate could not attach to Skate", "a remote thread that cannot start is still an attach failure"); + check(explain("Remote DingoSDKDebugInitialize address is invalid").headline == "ReSkate could not attach to Skate", + "a bad remote address is still an attach failure"); + check(explain("DingoSDKDebugInitialize timed out after 30 s").headline == "Skate took too long to start", + "a timeout keeps its own advice"); +} + +} // namespace + +int main() { + utf8_prefix_keeps_whole_characters(); + printable_makes_one_line(); + logged_errors_reads_one_process(); + the_reason_leads_the_status_tile(); + attach_failures_keep_their_advice(); + if (failures) { + std::cerr << failures << " startup failure check(s) failed\n"; + return 1; + } + std::cout << "startup failure checks passed\n"; + return 0; +} diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 18730d9d..86342aa7 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -1,6 +1,7 @@ #include "Engine/Core/Log/logging.h" #include "Engine/Core/Debug/trace.h" #include "Engine/Core/Platform/launcher_support.h" +#include "Engine/Core/Platform/startup_failure.h" #include "Engine/Vfs/initfs.h" #include "Engine/Vfs/content_cache_install.h" #include "Engine/Vfs/world_layer_scan.h" @@ -17,6 +18,7 @@ #include #include +#include #include #include #include @@ -461,6 +463,54 @@ RemoteModule inject_dll(HANDLE process, const fs::path& dll) { return module; } +std::uint64_t file_size_or_zero(const fs::path& path) { + std::error_code error; + const auto size = fs::file_size(path, error); + return error ? 0 : size; +} + +// Older runtimes have no reason to read out of the game, but they logged one. +// Only what the child wrote after log_start counts, from the last few MiB. +std::string logged_startup_failure(const fs::path& log, std::uint64_t log_start, DWORD process_id) { + namespace failure = dingosdk::startup_failure; + constexpr std::uint64_t window = 4 * 1024 * 1024; + const auto end = file_size_or_zero(log); + if (end <= log_start) return {}; + const auto from = std::max(log_start, end > window ? end - window : 0); + std::ifstream input(log, std::ios::binary); + input.seekg(static_cast(from)); + std::string text(static_cast(end - from), '\0'); + input.read(text.data(), static_cast(text.size())); + text.resize(static_cast(input.gcount())); + std::string logged; + for (const auto& line : failure::logged_errors(text, process_id, 3)) { + if (!logged.empty()) logged += " | "; + logged += failure::printable(std::string_view(line).substr(0, failure::utf8_prefix(line, 300))); + } + return logged; +} + +// DingoSDKDebugInitialize answered FALSE: the attach worked and the runtime +// stopped on purpose. Say why in its own words, read before the child is +// closed. problem.h keys on the opening words. +std::string startup_failure_message(HANDLE process, std::uintptr_t reason_address, DWORD process_id, + const fs::path& log, std::uint64_t log_start) { + namespace failure = dingosdk::startup_failure; + if (reason_address) { + std::array reason{}; + SIZE_T read{}; + if (ReadProcessMemory(process, reinterpret_cast(reason_address), reason.data(), + reason.size(), &read) && read == reason.size()) { + reason.back() = '\0'; + if (auto text = failure::printable(reason.data()); !text.empty()) + return "ReSkate stopped while starting: " + text; + } + } + if (auto logged = logged_startup_failure(log, log_start, process_id); !logged.empty()) + return "ReSkate stopped while starting; ReSkate.log says: " + logged; + return "ReSkate stopped while starting without saying why (DingoSDKDebugInitialize returned FALSE)"; +} + } // namespace namespace dingosdk::launcher_app { @@ -779,6 +829,11 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, paths.dll, "DingoSDKDebugInitialize"); if (initialize_rva >= dll_info.image_size) throw std::runtime_error("DingoSDKDebugInitialize is outside the DLL image"); + // Runtimes before this export only answer FALSE; a misplaced one is ignored. + auto failure_rva = launcher::exported_rva(paths.dll, startup_failure::export_name); + if (failure_rva && (*failure_rva >= dll_info.image_size || + dll_info.image_size - *failure_rva < startup_failure::capacity)) + failure_rva.reset(); // Park thumbnails are read from the game's own data now; the pack older // launchers downloaded is no longer used. @@ -924,9 +979,13 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, std::wostringstream initializing; initializing << L"Calling DingoSDKDebugInitialize at 0x" << std::hex << initialize_address; logging::write(logging::Level::info, logging::Channel::launcher, initializing.str()); + const auto log = paths.logs / L"ReSkate.log"; + const auto log_start = file_size_or_zero(log); const auto initialized = run_remote_thread(child.process(), initialize_address, nullptr, L"DingoSDKDebugInitialize", initialize_wait_ms); - if (!initialized) throw std::runtime_error("DingoSDKDebugInitialize returned FALSE"); + if (!initialized) + throw std::runtime_error(startup_failure_message(child.process(), + failure_rva ? remote_dll.base + *failure_rva : 0, child.id(), log, log_start)); logging::write(logging::Level::info, logging::Channel::launcher, L"ReSkate.dll initialization confirmed"); loader_gate.resume(); diff --git a/Launcher/problem.h b/Launcher/problem.h index 632f7b0f..1f1b1e78 100644 --- a/Launcher/problem.h +++ b/Launcher/problem.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -24,7 +25,47 @@ inline constexpr std::string_view attach_advice = "Anti-virus software usually blocks this. Add your skate. folder as an exclusion, close overlays " "(Discord, MSI Afterburner, RTSS), then press RETRY."; +// DingoSDKDebugInitialize answered FALSE (launch.cpp): ReSkate.dll was in and +// running, and stopped on purpose. Its reason leads; anti-virus is not a cause +// here unless the reason is a hook another program got in the way of. +inline constexpr std::string_view stopped_while_starting = "ReSkate stopped while starting"; + +// The runtime's reason as the start of the advice, ending like a sentence. +inline std::string sentence(std::string_view text) { + std::string line(text); + if (!line.empty() && line.back() != '.' && line.back() != '!' && line.back() != '?') line += '.'; + return line; +} + +inline Problem explain_stop(std::string_view raw) { + const std::string headline(stopped_while_starting); + if (raw.starts_with(headline + " without saying why")) + return {headline, + "It did not say why. Press Open logs and look at the last ERROR lines, then press RETRY. If it keeps " + "happening, share ReSkate.log on the ReSkate Discord."}; + auto reason = raw.substr(headline.size()); + for (const auto opening : {std::string_view(": "), std::string_view("; ReSkate.log says: ")}) + if (reason.starts_with(opening)) { reason.remove_prefix(opening.size()); break; } + if (mentions(raw, "Save is already in use")) + return {"Skate is already running", + "Your save is open in another copy of Skate. Close that one, or end Skate.exe in Task Manager if " + "it is stuck, then press RETRY."}; + // The folder's game_instance mutex: two games started at the same moment (the launcher checks first). + if (mentions(raw, "already running from this folder")) + return {"Skate is already running", + "Another copy of Skate is running from this folder. Close that one, or end Skate.exe in Task " + "Manager if it is stuck, then press RETRY."}; + if (mentions(raw, "hook") || mentions(raw, "Hook") || mentions(raw, "Overlay startup failed")) + return {"ReSkate could not hook into Skate", + sentence(reason) + " Another program is probably changing Skate as it starts. Close overlays " + "(Discord, MSI Afterburner, RTSS) and allow your ReSkate folder in your anti-virus, then press RETRY."}; + return {headline, + sentence(reason) + " Press RETRY. If it keeps happening, press Copy details and share it with " + "logs\\ReSkate.log on the ReSkate Discord."}; +} + inline Problem explain(std::string_view raw) { + if (raw.starts_with(stopped_while_starting)) return explain_stop(raw); // Most specific first: the later rules match whole families of failures. if (mentions(raw, "must be beside ReSkateLauncher.exe")) return {"ReSkate's files are not together", diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index 76ec4666..c4a4b944 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -24,25 +24,30 @@ #include "Extension/World/world_pool_usage.h" #include "Extension/World/physics_world_size.h" #include "Extension/World/spatial_cull_ids.h" +#include #include namespace dingosdk::runtime { -namespace { -bool stage(logging::Channel channel, const char* name, bool ready, const std::string& error) { - logging::log(ready ? logging::Level::info : logging::Level::error, channel, - "{}: {}{}", name, ready ? "ready" : "failed", error.empty() ? "" : " - " + error); - return ready; -} -} -bool initialize_bootstrap(std::uintptr_t base) { - if (!base) return false; +bool initialize_bootstrap(std::uintptr_t base, std::string& failure) { + if (!base) { + failure = "Bootstrap started without the game image."; + logging::write(logging::Level::error, logging::Channel::runtime, failure); + return false; + } using logging::Channel; + const auto stage = [&failure](Channel channel, const char* name, bool ready, const std::string& error) { + const auto line = std::format("{}: {}{}", name, ready ? "ready" : "failed", error.empty() ? "" : " - " + error); + logging::write(ready ? logging::Level::info : logging::Level::error, channel, line); + if (!ready) failure = line; + return ready; + }; std::string error; bool ready = start_startup_interventions(base, error); if (!stage(Channel::runtime, "Startup interventions", ready, error)) return false; const auto hooks = hook_initialize(); if (hooks != HookOk && hooks != HookAlreadyInitialized) { - logging::log(logging::Level::error, Channel::hooks, "Hook service failed: {} ({})", hook_status_string(hooks), static_cast(hooks)); + failure = std::format("Hook service failed: {} ({})", hook_status_string(hooks), static_cast(hooks)); + logging::write(logging::Level::error, Channel::hooks, failure); return false; } logging::write(logging::Level::info, Channel::hooks, "Microsoft Detours hook service ready."); diff --git a/Runtime/bootstrap.h b/Runtime/bootstrap.h index 6e6387d2..5bede12c 100644 --- a/Runtime/bootstrap.h +++ b/Runtime/bootstrap.h @@ -1,8 +1,10 @@ #pragma once #include +#include namespace dingosdk::runtime { // Call after image validation, before executable entry, outside the loader lock. // Failure leaves initialization incomplete; the launcher must abort startup. -bool initialize_bootstrap(std::uintptr_t base); +// On failure, failure holds the logged line that says why. +bool initialize_bootstrap(std::uintptr_t base, std::string& failure); } diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index d0a0e760..eb0a70d5 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -73,11 +73,30 @@ #include "Engine/Vfs/content_catalogs.h" #include "Engine/Vfs/world_layer_scan.h" #include "Engine/Game/World/world_layer_catalog.h" +#include "Engine/Core/Platform/startup_failure.h" #include +#include #include +// Why startup stopped, for the launcher to show (startup_failure.h). +extern "C" __declspec(dllexport) char DingoSDKStartupFailure[dingosdk::startup_failure::capacity]{}; + namespace dingosdk::runtime::detail { namespace { +// Keeps the first reason startup stopped. The launcher reads it only after +// DingoSDKDebugInitialize has returned, so nothing reads it while it is written. +BOOL startup_failed(std::string_view reason) noexcept { + if (DingoSDKStartupFailure[0]) return FALSE; + const auto length = dingosdk::startup_failure::utf8_prefix(reason, sizeof(DingoSDKStartupFailure) - 1); + std::memcpy(DingoSDKStartupFailure, reason.data(), length); + DingoSDKStartupFailure[length] = '\0'; + return FALSE; +} +BOOL stop_startup(dingosdk::logging::Level level, dingosdk::logging::Channel channel, + std::string_view reason) noexcept { + dingosdk::logging::write(level, channel, reason); + return startup_failed(reason); +} bool exact_environment_one(const wchar_t* name) noexcept { const auto incoming_error = GetLastError(); std::array value{}; @@ -109,20 +128,16 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::logging::startup_banner(); if (!files_ready) dingosdk::logging::write(dingosdk::logging::Level::warning, dingosdk::logging::Channel::runtime, "File logging is unavailable; console and debugger output remain active."); - if (!validate_image(r.base)) { - dingosdk::logging::write(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, "Game image validation failed."); - return FALSE; - } + if (!validate_image(r.base)) + return stop_startup(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, "Game image validation failed."); // Before anything is merged or opened in the folder: one game in it at a time // (game_instance.h). The launcher checks first; this is two started at the same moment. { std::wstring game(32768, L'\0'); game.resize(GetModuleFileNameW(nullptr, game.data(), static_cast(game.size()))); - if (!game.empty() && !dingosdk::game_instance::claim(std::filesystem::path(game).parent_path())) { - dingosdk::logging::write(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, + if (!game.empty() && !dingosdk::game_instance::claim(std::filesystem::path(game).parent_path())) + return stop_startup(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, "Another Skate is already running from this folder; this one does not start."); - return FALSE; - } } if (const auto& content = dingosdk::content_cache::catalogs(); content.available) { dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::assets, @@ -162,7 +177,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { } } startup_window; dingosdk::initialize_client_source_spawn(r.base); - if (!dingosdk::runtime::initialize_bootstrap(r.base)) return FALSE; + std::string bootstrap_failure; + if (!dingosdk::runtime::initialize_bootstrap(r.base, bootstrap_failure)) return startup_failed(bootstrap_failure); // Skate imports its graphics factory from the sibling Streamline // interposer. Load that ordinary dependency while the game is still @@ -173,17 +189,17 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(&DingoSDKDebugInitialize), &self)) { - dingosdk::logging::log(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, - "Cannot locate the runtime module (Windows error {}).", GetLastError()); - return FALSE; + const auto error = GetLastError(); + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, + std::format("Cannot locate the runtime module (Windows error {}).", error)); } #pragma warning(pop) wchar_t self_path[32768]{}; const auto self_length = GetModuleFileNameW(self, self_path, 32768); if (!self_length || self_length >= 32768) { - dingosdk::logging::log(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, - "Cannot resolve the runtime path (Windows error {}).", GetLastError()); - return FALSE; + const auto error = GetLastError(); + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, + std::format("Cannot resolve the runtime path (Windows error {}).", error)); } const auto interposer_path = std::filesystem::path( std::wstring(self_path, self_length)).parent_path() / L"sl.interposer.dll"; @@ -220,9 +236,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (!interposer) interposer = LoadLibraryExW(interposer_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); if (!interposer) { - dingosdk::logging::log(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, - "Cannot load the game's sl.interposer.dll (Windows error {}).", GetLastError()); - return FALSE; + const auto error = GetLastError(); + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, + std::format("Cannot load the game's sl.interposer.dll (Windows error {}).", error)); } wchar_t interposer_loaded_path[32768]{}; const auto interposer_length = GetModuleFileNameW( @@ -231,11 +247,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (!interposer_length || interposer_length >= 32768 || !std::filesystem::equivalent(interposer_path, std::filesystem::path(std::wstring(interposer_loaded_path, interposer_length)), - interposer_error) || interposer_error) { - dingosdk::logging::write(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, + interposer_error) || interposer_error) + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, "Loaded graphics provider does not match the game's sibling sl.interposer.dll."); - return FALSE; - } dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::graphics, "Game graphics provider loaded: sl.interposer.dll."); const dingosdk::overlay::CallbacksV3 callbacks{ @@ -317,10 +331,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::overlay::set_park_paste_queue(dingosdk::queue_local_park_paste); dingosdk::overlay::set_park_object_set_queue(dingosdk::queue_local_park_object_set); dingosdk::overlay::set_park_delete_queue(dingosdk::queue_local_park_delete); - if (!DingoSDKOverlayStartV3(&callbacks)) { - dingosdk::logging::write(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, "Overlay startup failed."); - return FALSE; - } + if (!DingoSDKOverlayStartV3(&callbacks)) + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, "Overlay startup failed."); // Install the offline boot hooks before the passive observers and other // native hooks, while Skate's primary thread remains suspended. The // launcher always sets the global-offline variable; install the dependent @@ -356,16 +368,19 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (global_offline_requested && (!global_offline_hook || !progression_guard_armed)) { record("{\"event\":\"launcher_readiness\",\"ready\":false," "\"reason\":\"authored_offline_hooks\"}"); - return FALSE; + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, + std::format("The offline hooks could not be installed (offline boot {}, progression guard {}).", + global_offline_hook ? "ready" : "failed", progression_guard_armed ? "armed" : "not armed")); } const bool graphics_labels = dingosdk::initialize_graphics_labels(r.base, global_offline_hook && progression_guard_armed); record("{\"event\":\"graphics_labels_initialized\",\"active\":" + std::string(graphics_labels ? "true" : "false") + "}"); + std::string profile_failure; if (global_offline_hook && progression_guard_armed && - !dingosdk::initialize_local_profile(r.base, true, dingosdk::profile::default_path())) { + !dingosdk::initialize_local_profile(r.base, true, dingosdk::profile::default_path(), profile_failure)) { record("{\"event\":\"launcher_readiness\",\"ready\":false,\"reason\":\"local_profile\"}"); - return FALSE; + return startup_failed(profile_failure); } if (global_offline_hook && progression_guard_armed) { dingosdk::set_local_location_travel_queue(&queue_location_travel); @@ -539,7 +554,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (global_offline_requested) { record("{\"event\":\"launcher_readiness\",\"ready\":false," "\"reason\":\"native_tick_hook\"}"); - return FALSE; + return stop_startup(dingosdk::logging::Level::error, dingosdk::logging::Channel::runtime, + std::format("The engine update hook could not be installed: {} ({}).", + dingosdk::hook_status_string(hook_status), static_cast(hook_status))); } startup_window.hand_over(); return TRUE; @@ -565,13 +582,20 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { startup_window.hand_over(); return TRUE; } catch (const std::exception& error) { - dingosdk::logging::log(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, - "Runtime initialization failed: {}", error.what()); - return FALSE; + // Nothing may escape the launcher's remote thread: a reason that cannot + // be built falls back to a fixed one. + using namespace dingosdk::runtime::detail; + try { + return stop_startup(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, + std::string("Runtime initialization failed: ") + error.what()); + } catch (...) { + return stop_startup(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, + "Runtime initialization failed."); + } } catch (...) { - dingosdk::logging::write(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, + using namespace dingosdk::runtime::detail; + return stop_startup(dingosdk::logging::Level::critical, dingosdk::logging::Channel::runtime, "Runtime initialization failed with an unknown exception."); - return FALSE; } } diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 3e4e99d2..23cf2935 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -54,6 +54,9 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_depot_output_tests Launcher/Test/depot_output_tests.cpp) target_include_directories(dingosdk_depot_output_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_depot_output COMMAND dingosdk_depot_output_tests) + add_executable(dingosdk_startup_failure_tests Launcher/Test/startup_failure_tests.cpp) + dingosdk_configure_target(dingosdk_startup_failure_tests) + add_test(NAME launcher_startup_failure COMMAND dingosdk_startup_failure_tests) add_executable(dingosdk_content_catalogs_tests Engine/Vfs/Test/content_catalogs_tests.cpp) target_link_libraries(dingosdk_content_catalogs_tests PRIVATE dingosdk_content_cache) add_test(NAME content_catalogs COMMAND dingosdk_content_catalogs_tests)