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)