Files
ReSkate/Launcher/launch.cpp
T
AyeZeeBB 2e40aeb6e2 Game data of another build: the launcher asks for a verify, and mods are looked at again
After Steam updated skate., some installs had the supported Skate.exe over
the new build's data (a copied game, or a download that stopped part-way).
The launcher called that ready, every mod was left out as "made for game
build 29544144, not the installed 29663206; rebuild it with ReSkate Studio",
and the mods stayed out after the game files were put right.
Launcher
- Ready now also needs the game's data to be the supported build, read from
  Data\layout.toc against supported_build::game_data_changelist (29544144).
  Data of another build, or none, reads "Game files need verifying".
- DOWNLOAD in that state checks every file (-validate) instead of fetching
  only what is missing. A download that fails part-way no longer ends on
  ready because Skate.exe arrived.
- Data whose build cannot be read does not block.
Mods
- A mod left out because the game was of another build is no longer
  remembered in .reskate-excluded.json: it is checked on every launch. One
  already remembered for it is looked at again.
- When it is the game that is not the supported build, the mod's message
  says to verify the game files in the launcher, not to rebuild the mod.
2026-10-13 16:31:09 -05:00

861 lines
43 KiB
C++

#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Debug/trace.h"
#include "Engine/Core/Platform/launcher_support.h"
#include "Engine/Vfs/initfs.h"
#include "Engine/Vfs/content_cache_install.h"
#include "Engine/Vfs/world_layer_scan.h"
#include "launch.h"
#include "text_encoding.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/runtime.h"
#include <Windows.h>
#include <Psapi.h>
#include <ShlObj.h>
#include <shellapi.h>
#include <winternl.h>
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <format>
#include "Engine/Game/Build/supported_build.h"
#include <algorithm>
#include <fstream>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace fs = std::filesystem;
namespace {
constexpr wchar_t app_id[] = L"3354750";
constexpr DWORD remote_wait_ms = 30000;
// ReSkate's initialization merges the Mods folder whenever a mod or the game
// changed, with a startup window showing its progress. How long that takes
// depends on the mods, so it is waited for to the end: a deadline only cut
// big merges off, and the next launch started them again from scratch. The
// wait still ends if the game process dies, since its threads end with it.
constexpr DWORD initialize_wait_ms = INFINITE;
class Handle {
public:
explicit Handle(HANDLE value = nullptr) noexcept : value_(value) {}
~Handle() { reset(); }
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&& other) noexcept : value_(other.release()) {}
Handle& operator=(Handle&& other) noexcept {
if (this != &other) reset(other.release());
return *this;
}
HANDLE get() const noexcept { return value_; }
explicit operator bool() const noexcept {
return value_ && value_ != INVALID_HANDLE_VALUE;
}
HANDLE release() noexcept { const auto value = value_; value_ = nullptr; return value; }
void reset(HANDLE value = nullptr) noexcept {
if (value_ && value_ != INVALID_HANDLE_VALUE) CloseHandle(value_);
value_ = value;
}
private:
HANDLE value_{};
};
class ChildProcess {
public:
explicit ChildProcess(PROCESS_INFORMATION process) noexcept : process_(process) {}
~ChildProcess() {
if (terminate_ && process_.hProcess) {
TerminateProcess(process_.hProcess, ERROR_PROCESS_ABORTED);
WaitForSingleObject(process_.hProcess, 5000);
}
if (process_.hThread) CloseHandle(process_.hThread);
if (process_.hProcess) CloseHandle(process_.hProcess);
}
ChildProcess(const ChildProcess&) = delete;
ChildProcess& operator=(const ChildProcess&) = delete;
HANDLE process() const noexcept { return process_.hProcess; }
HANDLE primary_thread() const noexcept { return process_.hThread; }
DWORD id() const noexcept { return process_.dwProcessId; }
void release() noexcept { terminate_ = false; }
private:
PROCESS_INFORMATION process_{};
bool terminate_{true};
};
class RemoteAllocation {
public:
RemoteAllocation(HANDLE process, std::size_t size) : process_(process) {
value_ = VirtualAllocEx(process, nullptr, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!value_) throw std::runtime_error("VirtualAllocEx failed");
}
~RemoteAllocation() { if (value_) VirtualFreeEx(process_, value_, 0, MEM_RELEASE); }
RemoteAllocation(const RemoteAllocation&) = delete;
RemoteAllocation& operator=(const RemoteAllocation&) = delete;
void* get() const noexcept { return value_; }
private:
HANDLE process_{};
void* value_{};
};
using dingosdk::launcher_text::utf8;
std::wstring widen(std::string_view value) {
if (value.empty()) return {};
auto length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
UINT code_page = CP_UTF8;
DWORD flags = MB_ERR_INVALID_CHARS;
if (length <= 0) {
code_page = CP_ACP;
flags = 0;
length = MultiByteToWideChar(code_page, flags, value.data(),
static_cast<int>(value.size()), nullptr, 0);
}
if (length <= 0) return L"Unknown error";
std::wstring output(static_cast<std::size_t>(length), L'\0');
MultiByteToWideChar(code_page, flags, value.data(), static_cast<int>(value.size()),
output.data(), length);
return output;
}
std::wstring system_error(DWORD error) {
wchar_t* allocated = nullptr;
const auto length = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error, 0,
reinterpret_cast<wchar_t*>(&allocated), 0, nullptr);
std::wstring output = length && allocated ? std::wstring(allocated, length) : L"Unknown error";
if (allocated) LocalFree(allocated);
while (!output.empty() && (output.back() == L'\r' || output.back() == L'\n' ||
output.back() == L' ' || output.back() == L'.')) output.pop_back();
return output;
}
[[noreturn]] void win32_failure(std::wstring_view action, DWORD error = GetLastError()) {
std::wostringstream message;
message << action << L" failed (" << error << L"): " << system_error(error);
throw std::runtime_error(utf8(message.str()));
}
fs::path module_path() {
std::vector<wchar_t> buffer(32768);
const auto length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
if (!length || length >= buffer.size()) win32_failure(L"GetModuleFileNameW");
return fs::path(std::wstring(buffer.data(), length));
}
fs::path canonical_file(const fs::path& path, std::wstring_view label) {
std::error_code error;
if (!fs::is_regular_file(path, error) || error) {
throw std::runtime_error(utf8(std::wstring(label) + L" is missing: " + path.wstring()));
}
const auto canonical = fs::canonical(path, error);
if (error) throw std::runtime_error(utf8(L"Cannot resolve " + std::wstring(label) + L": " + path.wstring()));
return canonical;
}
bool same_file(const fs::path& first, const fs::path& second) {
Handle first_file(CreateFileW(first.c_str(), FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr));
Handle second_file(CreateFileW(second.c_str(), FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr));
BY_HANDLE_FILE_INFORMATION first_info{}, second_info{};
return first_file && second_file &&
GetFileInformationByHandle(first_file.get(), &first_info) &&
GetFileInformationByHandle(second_file.get(), &second_info) &&
first_info.dwVolumeSerialNumber == second_info.dwVolumeSerialNumber &&
first_info.nFileIndexHigh == second_info.nFileIndexHigh &&
first_info.nFileIndexLow == second_info.nFileIndexLow;
}
std::wstring quote_argument(std::wstring_view value) {
if (!value.empty() && value.find_first_of(L" \t\n\v\"") == std::wstring_view::npos) return std::wstring(value);
std::wstring output(1, L'\"');
std::size_t slashes{};
for (const auto character : value) {
if (character == L'\\') {
++slashes;
} else if (character == L'\"') {
output.append(slashes * 2 + 1, L'\\');
output.push_back(character);
slashes = 0;
} else {
output.append(slashes, L'\\');
slashes = 0;
output.push_back(character);
}
}
output.append(slashes * 2, L'\\');
output.push_back(L'\"');
return output;
}
void set_environment(const wchar_t* name, const wchar_t* value) {
if (!SetEnvironmentVariableW(name, value))
win32_failure(std::wstring(L"SetEnvironmentVariableW(") + name + L")");
}
std::optional<DWORD> registry_dword(const wchar_t* key, const wchar_t* name) {
DWORD value{}, size = sizeof(value);
if (RegGetValueW(HKEY_CURRENT_USER, key, name, RRF_RT_REG_DWORD, nullptr, &value, &size) != ERROR_SUCCESS)
return std::nullopt;
return value;
}
std::wstring registry_string(const wchar_t* key, const wchar_t* name) {
std::wstring value(1024, L'\0');
DWORD size = static_cast<DWORD>(value.size() * sizeof(wchar_t));
if (RegGetValueW(HKEY_CURRENT_USER, key, name, RRF_RT_REG_SZ, nullptr, value.data(), &size) != ERROR_SUCCESS)
return {};
value.resize(size / sizeof(wchar_t));
while (!value.empty() && value.back() == L'\0') value.pop_back();
return value;
}
constexpr wchar_t steam_key[] = LR"(Software\Valve\Steam)";
constexpr wchar_t steam_process_key[] = LR"(Software\Valve\Steam\ActiveProcess)";
constexpr std::uint64_t steam_id_base = 76561197960265728ull;
// Under Proton the Steam client is the native Linux one. Proton's stand-in
// steam.exe sets ActiveProcess\pid but never ActiveUser, and the
// C:\Program Files (x86)\Steam it points SteamPath at holds no account files.
// Steam passes the real client's directory to compatibility tools as a Unix
// path in STEAM_COMPAT_CLIENT_INSTALL_PATH, which Wine's kernel32 maps to a
// DOS one. Empty on Windows, and when Proton was not started by Steam.
// Reported, with the cause and the fix, by BenjaminJAnderson in
// https://github.com/Dingo-Shenanigans/ReSkate/issues/5.
fs::path proton_steam_directory() {
std::array<wchar_t, 32768> unix_path{};
const auto length = GetEnvironmentVariableW(L"STEAM_COMPAT_CLIENT_INSTALL_PATH", unix_path.data(),
static_cast<DWORD>(unix_path.size()));
if (!length || length >= unix_path.size()) return {};
using Convert = wchar_t*(__cdecl*)(const char*);
const auto convert = reinterpret_cast<Convert>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "wine_get_dos_file_name"));
if (!convert) return {};
const auto dos_path = convert(utf8(std::wstring_view(unix_path.data(), length)).c_str());
if (!dos_path) return {};
fs::path directory(dos_path);
HeapFree(GetProcessHeap(), 0, dos_path);
return directory;
}
// The Steam client's directory, where config/loginusers.vdf lists its accounts.
fs::path steam_directory() {
if (auto native = proton_steam_directory(); !native.empty()) return native;
return registry_string(steam_key, L"SteamPath");
}
// The account config/loginusers.vdf marks as most recent, otherwise its first.
// Steam has written that key as both MostRecent and mostrecent.
std::optional<std::uint64_t> recent_steam_id(const fs::path& steam) {
if (steam.empty()) return std::nullopt;
std::ifstream file(steam / L"config" / L"loginusers.vdf");
std::optional<std::uint64_t> first, recent, current;
std::string line;
while (std::getline(file, line)) {
const auto open = line.find('"');
const auto close = open == std::string::npos ? open : line.find('"', open + 1);
if (close == std::string::npos) continue;
const auto token = line.substr(open + 1, close - open - 1);
if (token.size() == 17 && token.starts_with("7656") &&
token.find_first_not_of("0123456789") == std::string::npos) {
current = std::stoull(token);
if (!first) first = current;
} else if (_stricmp(token.c_str(), "MostRecent") == 0 && current &&
line.find("\"1\"", close) != std::string::npos) {
recent = current;
}
}
return recent ? recent : first;
}
// The account the running Steam client is signed in to. Steam for Windows
// publishes it as ActiveUser. Proton does not, but there the launcher was
// started by the native client, which is signed in to its most recent account.
std::optional<std::uint64_t> active_steam_id() {
if (const auto user = registry_dword(steam_process_key, L"ActiveUser"); user && *user)
return steam_id_base + *user;
return recent_steam_id(proton_steam_directory());
}
// The account Steam was last signed in to: the live session when Steam is
// running, otherwise the most recent entry in config/loginusers.vdf. Offline
// mode reports it so the game keeps using the same settings save.
std::optional<std::uint64_t> last_steam_id() {
if (const auto active = active_steam_id()) return active;
return recent_steam_id(steam_directory());
}
void configure_environment(const fs::path& logs) {
set_environment(L"SteamAppId", app_id);
set_environment(L"SteamGameId", app_id);
set_environment(L"RESKATE_LOG_DIRECTORY", logs.c_str());
set_environment(L"RESKATE_DEBUG_TEST_GLOBAL_OFFLINE", L"1");
}
template<class T>
T remote_read(HANDLE process, std::uintptr_t address, std::wstring_view label) {
T output{};
SIZE_T count{};
if (!ReadProcessMemory(process, reinterpret_cast<const void*>(address), &output,
sizeof(output), &count) || count != sizeof(output)) win32_failure(label);
return output;
}
std::uintptr_t image_base(HANDLE process) {
using Query = NTSTATUS(NTAPI*)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
const auto ntdll = GetModuleHandleW(L"ntdll.dll");
const auto query = reinterpret_cast<Query>(GetProcAddress(ntdll, "NtQueryInformationProcess"));
if (!query) win32_failure(L"Resolve NtQueryInformationProcess");
PROCESS_BASIC_INFORMATION information{};
const auto status = query(process, ProcessBasicInformation, &information,
sizeof(information), nullptr);
if (status < 0 || !information.PebBaseAddress)
throw std::runtime_error("NtQueryInformationProcess failed");
const auto peb = reinterpret_cast<std::uintptr_t>(information.PebBaseAddress);
return remote_read<std::uintptr_t>(process, peb + 0x10, L"Read child image base");
}
template<std::size_t N>
bool remote_matches(HANDLE process, std::uintptr_t address,
const std::array<unsigned char, N>& expected) {
std::array<unsigned char, N> actual{};
SIZE_T count{};
return ReadProcessMemory(process, reinterpret_cast<const void*>(address), actual.data(),
actual.size(), &count) && count == actual.size() && actual == expected;
}
std::uintptr_t validate_child_image(HANDLE process, const fs::path& game) {
std::vector<wchar_t> path(32768);
DWORD length = static_cast<DWORD>(path.size());
if (!QueryFullProcessImageNameW(process, 0, path.data(), &length))
win32_failure(L"QueryFullProcessImageNameW");
if (!same_file(game, fs::path(std::wstring(path.data(), length))))
throw std::runtime_error("Created process is not the validated Skate.exe");
const auto base = image_base(process);
const auto dos = remote_read<IMAGE_DOS_HEADER>(process, base, L"Read child DOS header");
if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0 || dos.e_lfanew > 0x100000)
throw std::runtime_error("Created process has an invalid DOS header");
const auto nt = remote_read<IMAGE_NT_HEADERS64>(process,
base + static_cast<std::uintptr_t>(dos.e_lfanew), L"Read child PE header");
if (nt.Signature != IMAGE_NT_SIGNATURE || nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 ||
nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC ||
nt.OptionalHeader.SizeOfImage != dingosdk::launcher::expected_game_image_size)
throw std::runtime_error("Created process has the wrong loaded image identity");
// The same code fingerprints ReSkate.dll checks before it initializes.
namespace image = dingosdk::addr::runtime;
if (!remote_matches(process, base + image::client_tick, image::client_tick_prefix))
throw std::runtime_error("Created process failed the loaded-image fingerprint");
for (const auto& check : image::image_checks)
if (!remote_matches(process, base + check.rva, check.bytes))
throw std::runtime_error("Created process failed the loaded-image fingerprint");
return base;
}
struct RemoteModule {
std::uintptr_t base{};
DWORD size{};
fs::path path;
};
std::vector<RemoteModule> remote_modules(HANDLE process) { // not Toolhelp: it turns non-ANSI path characters into '?'
std::vector<HMODULE> handles(512);
for (unsigned attempt = 0; attempt < 8; ++attempt) {
const auto capacity = static_cast<DWORD>(handles.size() * sizeof(HMODULE));
DWORD needed{};
if (!EnumProcessModulesEx(process, handles.data(), capacity, &needed, LIST_MODULES_DEFAULT)) {
if (GetLastError() == ERROR_PARTIAL_COPY) continue; // the list changed while it was read
win32_failure(L"EnumProcessModulesEx");
}
if (needed > capacity) { handles.resize(needed / sizeof(HMODULE) + 16); continue; }
handles.resize(needed / sizeof(HMODULE));
std::vector<RemoteModule> output;
std::vector<wchar_t> path(32768);
for (const auto handle : handles) {
MODULEINFO info{};
const auto length = GetModuleFileNameExW(process, handle, path.data(), static_cast<DWORD>(path.size()));
if (!length || length >= path.size() || !GetModuleInformation(process, handle, &info, sizeof(info))) continue;
output.push_back({reinterpret_cast<std::uintptr_t>(info.lpBaseOfDll), info.SizeOfImage,
fs::path(std::wstring(path.data(), length))});
}
return output;
}
throw std::runtime_error("The child module list kept changing while it was read");
}
RemoteModule find_remote_module(HANDLE process, const fs::path& path) {
for (const auto& module : remote_modules(process))
if (same_file(path, module.path)) return module;
throw std::runtime_error("Injected ReSkate.dll is not present in the child module list");
}
DWORD run_remote_thread(HANDLE process, std::uintptr_t start, void* parameter,
std::wstring_view label, DWORD timeout_ms = remote_wait_ms) {
Handle thread(CreateRemoteThread(process, nullptr, 0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(start), parameter, 0, nullptr));
if (!thread) win32_failure(std::wstring(L"CreateRemoteThread(") + std::wstring(label) + L")");
// Waited in slices, so a long wait leaves a trail in the log instead of
// looking like a hang. The thread also ends if the child process dies.
for (DWORD waited = 0;;) {
const auto slice = timeout_ms == INFINITE ? remote_wait_ms : std::min(remote_wait_ms, timeout_ms - waited);
const auto wait = WaitForSingleObject(thread.get(), slice);
if (wait == WAIT_OBJECT_0) break;
if (wait != WAIT_TIMEOUT) win32_failure(std::wstring(L"Wait for ") + std::wstring(label));
waited += slice;
if (timeout_ms != INFINITE && waited >= timeout_ms)
throw std::runtime_error(utf8(std::wstring(label) + L" timed out after " +
std::to_wstring(waited / 1000) + L" s"));
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::launcher, std::wstring(label) +
L" still running after " + std::to_wstring(waited / 1000) + L" s (merging mods can take a few minutes)");
}
DWORD result{};
if (!GetExitCodeThread(thread.get(), &result))
win32_failure(std::wstring(L"GetExitCodeThread(") + std::wstring(label) + L")");
return result;
}
RemoteModule inject_dll(HANDLE process, const fs::path& dll) {
const auto path = dll.wstring();
const auto byte_count = (path.size() + 1) * sizeof(wchar_t);
RemoteAllocation remote_path(process, byte_count);
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)");
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());
const auto loader_result = run_remote_thread(process, loader, remote_path.get(), L"LoadLibraryW");
// A thread exit code is only 32 bits and cannot carry an x64 HMODULE. Confirm
// success and obtain the full module base from the target's module list.
const auto module = find_remote_module(process, dll);
std::wostringstream loaded;
loaded << L"ReSkate.dll loaded at 0x" << std::hex << module.base
<< L" (LoadLibraryW low result 0x" << loader_result << L')';
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::launcher, loaded.str());
return module;
}
} // namespace
namespace dingosdk::launcher_app {
Session open_session(const std::string& log_level) {
Session session;
session.self = canonical_file(module_path(), L"ReSkateLauncher.exe");
session.paths = launcher::sibling_paths(session.self);
session.paths.logs = logging::log_directory(session.paths.directory);
logging::Options logging_options;
logging_options.directory = session.paths.logs;
logging_options.truncate_file = true;
logging_options.level = logging::parse_level(log_level).value_or(logging::Level::info);
if (!logging::initialize(logging_options)) logging::write(logging::Level::warning,
logging::Channel::launcher, "File logging is unavailable; debugger output remains active.");
logging::write(logging::Level::info, logging::Channel::launcher, L"ReSkate launcher starting");
logging::write(logging::Level::info, logging::Channel::launcher, L"Current launch log: " + (session.paths.logs / L"ReSkate.log").wstring());
return session;
}
std::string game_data_build(const launcher::SiblingPaths& paths) {
try {
std::ifstream file(paths.directory / L"Data" / L"layout.toc", std::ios::binary);
if (!file) return {};
const std::string bytes{std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
// The number follows its name as text, after a few bytes that say what kind of value it is.
constexpr std::string_view name = "pipelineCodeChangelists";
const auto at = bytes.find(name);
if (at == std::string::npos) return {};
const auto is_digit = [](char c) { return c >= '0' && c <= '9'; };
auto from = at + name.size();
const auto limit = std::min(bytes.size(), from + 16);
while (from < limit && !is_digit(bytes[from])) ++from;
auto to = from;
while (to < bytes.size() && to - from < 16 && is_digit(bytes[to])) ++to;
return to - from >= 6 ? bytes.substr(from, to - from) : std::string{};
} catch (...) {
return {};
}
}
bool game_data_mismatched(const launcher::SiblingPaths& paths) {
std::error_code error;
if (!fs::is_regular_file(paths.directory / L"Data" / L"layout.toc", error)) return true;
const auto build = game_data_build(paths);
return !build.empty() && build != supported_build::game_data_changelist;
}
bool game_files_supported(const launcher::SiblingPaths& paths) {
std::error_code error;
if (!fs::is_regular_file(paths.game, error)) {
logging::write(logging::Level::info, logging::Channel::launcher, L"Skate.exe is not installed in " + paths.directory.wstring());
return false;
}
try {
launcher::validate_game_file(paths.game);
launcher::validate_steam_api_file(paths.steam_api);
// The right Skate.exe over another build's data is no supported game: its mods are
// left out and it may not load (seen 2026-10-13, after a Steam update of the game).
if (game_data_mismatched(paths)) {
const auto build = game_data_build(paths);
logging::log(logging::Level::warning, logging::Channel::launcher, "Game files are not the supported build: Skate.exe is, but the game's data is {} (supported: {}).",
build.empty() ? std::string("missing") : "build " + build, supported_build::game_data_changelist);
return false;
}
return true;
} catch (const std::exception& exception) {
logging::log(logging::Level::warning, logging::Channel::launcher,
"Game files are not the supported build: {}", exception.what());
return false;
}
}
bool config_matches_build(const launcher_update::Config& config) {
return config.game.skate_sha256 == supported_build::game_sha256 &&
config.game.app_id == supported_build::steam_app_id &&
config.game.depot_id == supported_build::steam_depot_id &&
config.game.manifest_id == supported_build::steam_manifest_id;
}
void relaunch(const fs::path& self, const std::vector<std::wstring>& arguments) {
std::wstring command = quote_argument(self.wstring());
for (const auto& argument : arguments) {
command.push_back(L' ');
command += quote_argument(argument);
}
std::vector<wchar_t> buffer(command.begin(), command.end());
buffer.push_back(L'\0');
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(self.c_str(), buffer.data(), nullptr, nullptr, FALSE, 0, nullptr,
self.parent_path().c_str(), &startup, &process)) win32_failure(L"Restart launcher");
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
bool under_wine() {
const auto ntdll = GetModuleHandleW(L"ntdll.dll");
const auto kernel32 = GetModuleHandleW(L"kernel32.dll");
return (ntdll && GetProcAddress(ntdll, "wine_get_version")) ||
(kernel32 && GetProcAddress(kernel32, "wine_get_dos_file_name"));
}
// MangoHud's Vulkan layer draws into every swapchain of a process it is on for,
// and the launcher's window crashes with it. The layer reads its switches from
// the Linux environment, which Wine fixes when the process starts, so turning it
// off takes a restart. start_game hands MangoHud back to the game.
bool relaunch_without_mangohud(const fs::path& self, const std::vector<std::wstring>& arguments) {
if (!under_wine()) return false;
if (GetEnvironmentVariableW(L"RESKATE_MANGOHUD_HELD", nullptr, 0)) {
logging::write(logging::Level::info, logging::Channel::launcher,
"MangoHud is off for the launcher window; the game still gets it.");
return false;
}
wchar_t enabled[8]{};
const auto length = GetEnvironmentVariableW(L"MANGOHUD", enabled, 8);
if (length != 1 || enabled[0] != L'1' || GetEnvironmentVariableW(L"DISABLE_MANGOHUD", nullptr, 0)) return false;
set_environment(L"DISABLE_MANGOHUD", L"1");
set_environment(L"RESKATE_MANGOHUD_HELD", L"1");
relaunch(self, arguments);
return true;
}
std::wstring steam_offline_reason() {
const auto pid = registry_dword(steam_process_key, L"pid");
if (!pid || !*pid) return L"no Steam client is registered as running (ActiveProcess pid is 0)";
if (!active_steam_id()) return L"Steam reports no signed-in account (ActiveProcess ActiveUser is 0)";
Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, *pid));
if (!process.get())
return std::format(L"the registered Steam process {} cannot be opened (Windows error {})", *pid, GetLastError());
DWORD code{};
if (!GetExitCodeProcess(process.get(), &code) || code != STILL_ACTIVE)
return std::format(L"the registered Steam process {} is no longer running", *pid);
std::wstring image(MAX_PATH, L'\0');
DWORD length = static_cast<DWORD>(image.size());
if (!QueryFullProcessImageNameW(process.get(), 0, image.data(), &length))
return std::format(L"the registered Steam process {} cannot be identified (Windows error {})", *pid,
GetLastError());
image.resize(length);
const auto name = fs::path(image).filename().wstring();
if (_wcsicmp(name.c_str(), L"steam.exe") != 0)
return std::format(L"the registered Steam process {} is {}, not steam.exe", *pid, name);
return {};
}
bool steam_signed_in() {
return steam_offline_reason().empty();
}
std::string steam_persona_name() {
if (!steam_signed_in()) return {};
const auto user = active_steam_id();
if (!user) return {};
const auto id = std::to_string(*user);
// loginusers.vdf: "<SteamID64>" { ... "PersonaName" "<name>" ... }, UTF-8 with VDF escapes.
const auto quoted = [](std::string_view line) {
std::vector<std::string> tokens;
for (std::size_t i = 0; i < line.size(); ++i) {
if (line[i] != '"') continue;
std::string token;
for (++i; i < line.size() && line[i] != '"'; ++i) {
if (line[i] == '\\' && i + 1 < line.size()) ++i;
token.push_back(line[i]);
}
tokens.push_back(std::move(token));
}
return tokens;
};
std::string name;
if (const auto steam = steam_directory(); !steam.empty()) {
std::ifstream file(steam / L"config" / L"loginusers.vdf");
bool account{};
for (std::string line; std::getline(file, line);) {
const auto tokens = quoted(line);
if (tokens.size() == 1 && tokens[0].size() == 17 && tokens[0].find_first_not_of("0123456789") == std::string::npos)
account = tokens[0] == id;
else if (account && tokens.size() == 2 && tokens[0] == "PersonaName") {
name = tokens[1];
break;
}
}
}
if (name.empty()) name = utf8(registry_string(steam_key, L"LastGameNameUsed"));
return name;
}
// Skate.exe hard-imports MiniDumpWriteDump from the Windows API set
// api-ms-win-core-debug-minidump-l1-1-0.dll. Windows resolves that set through
// its API set schema, so no such file exists on disk; Wine/Proton does not map
// it, leaving the import unresolved so the game aborts in the loader before
// injection (seen as "Cannot sample the executable primary thread", Windows
// error 5, once the suspended thread tears down). dbghelp.dll exports
// MiniDumpWriteDump under the same name, so a copy of it beside Skate.exe under
// the API set's file name satisfies the import. On Windows the schema handles
// this import without a shim, so do not create one there.
void ensure_minidump_apiset_shim(const fs::path& game_directory) noexcept {
try {
// Windows resolves this API set itself. Only Wine/Proton needs a file
// beside Skate.exe; copying a system DLL under an API-set name on
// Windows creates an unnecessary executable that looks suspicious.
if (!under_wine()) return;
const auto shim = game_directory / L"api-ms-win-core-debug-minidump-l1-1-0.dll";
std::error_code ec;
if (fs::exists(shim, ec)) return;
std::array<wchar_t, MAX_PATH> system{};
const auto length = GetSystemDirectoryW(system.data(), static_cast<UINT>(system.size()));
if (!length || length >= system.size()) return;
const auto source = fs::path(std::wstring(system.data(), length)) / L"dbghelp.dll";
if (!fs::exists(source, ec)) return;
if (fs::copy_file(source, shim, fs::copy_options::none, ec) && !ec)
logging::write(logging::Level::info, logging::Channel::launcher,
L"Created api-ms-win-core-debug-minidump-l1-1-0.dll from dbghelp.dll so Skate.exe's "
L"MiniDumpWriteDump import resolves under Wine/Proton");
else if (ec)
logging::log(logging::Level::warning, logging::Channel::launcher,
"Could not create the MiniDumpWriteDump API-set shim beside Skate.exe: {}", ec.message());
} catch (...) {}
}
DWORD start_game(const Session& session, const launcher::LaunchOptions& options,
const std::function<void(DWORD)>& created) {
trace::Scope timing("launcher.start_game", "app.start");
const auto& self = session.self;
auto paths = session.paths;
paths.game = canonical_file(paths.game, L"Skate.exe");
paths.dll = canonical_file(paths.dll, L"ReSkate.dll");
paths.steam_api = canonical_file(paths.steam_api, L"steam_api64.dll");
std::error_code path_error;
if (!fs::equivalent(paths.game.parent_path(), paths.directory, path_error) || path_error ||
!fs::equivalent(paths.dll.parent_path(), paths.directory, path_error) || path_error ||
!fs::equivalent(paths.steam_api.parent_path(), paths.directory, path_error) || path_error)
throw std::runtime_error(
"Skate.exe, ReSkate.dll, and the original steam_api64.dll must be beside ReSkateLauncher.exe");
// Satisfy Skate.exe's MiniDumpWriteDump API-set import under Wine/Proton.
ensure_minidump_apiset_shim(paths.directory);
logging::write(logging::Level::info, logging::Channel::launcher, L"Validating exact Skate.exe SHA-256 and PE identity");
launcher::validate_game_file(paths.game);
logging::write(logging::Level::info, logging::Channel::launcher, L"Validating the original sibling steam_api64.dll");
launcher::validate_steam_api_file(paths.steam_api);
logging::write(logging::Level::info, logging::Channel::launcher, L"Launcher SHA-256: " + widen(launcher::sha256_file(self)));
logging::write(logging::Level::info, logging::Channel::launcher, L"Runtime SHA-256: " + widen(launcher::sha256_file(paths.dll)));
const auto dll_info = launcher::inspect_pe_file(paths.dll);
if (!dll_info.pe64 || dll_info.machine != IMAGE_FILE_MACHINE_AMD64 ||
!(dll_info.characteristics & IMAGE_FILE_DLL) ||
!(dll_info.characteristics & IMAGE_FILE_EXECUTABLE_IMAGE))
throw std::runtime_error("ReSkate.dll is not an x64 Windows DLL");
const auto initialize_rva = launcher::exported_function_rva(
paths.dll, "DingoSDKDebugInitialize");
if (initialize_rva >= dll_info.image_size)
throw std::runtime_error("DingoSDKDebugInitialize is outside the DLL image");
// Park thumbnails are read from the game's own data now; the pack older
// launchers downloaded is no longer used.
{
std::array<wchar_t, 32768> local{};
const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
std::error_code ignored;
if (length && length < local.size())
fs::remove(fs::path(local.data()) / L"ReSkate" / L"profiles" / L"offline" / L"ReSkate-Object-Previews.bin", ignored);
}
// Catalogues (item names, challenges, entitlements) are read from the live
// game's content cache for this build, installed once into Local AppData.
const auto cache = content_cache::ensure_installed();
using CacheStatus = content_cache::InstallStatus;
if (cache.status == CacheStatus::installed) {
logging::write(logging::Level::info, logging::Channel::assets, "Game content cache ready.");
} else if (cache.status == CacheStatus::downloaded) {
logging::write(logging::Level::info, logging::Channel::assets, "Game content cache downloaded and installed.");
} else {
logging::log(logging::Level::error, logging::Channel::assets,
"Game content cache {} (HTTP {}; Windows error {}).",
cache.status == CacheStatus::busy ? "is being installed by another launcher" : "could not be installed",
cache.http_status, cache.error);
throw std::runtime_error(cache.status == CacheStatus::busy ?
"Another ReSkate launcher is installing the game content cache. Try again when it finishes." :
"ReSkate needs to download the game content cache once. Check your internet connection and try again.");
}
// World layers are read from the level data; the first launch of a build
// scans it here so the game does not wait on it. The runtime rescans if
// this cache is missing, so a failure is not fatal.
try {
const auto layers = world_layer_scan::load_or_scan(paths.directory, world_layer_scan::cache_file());
logging::log(logging::Level::info, logging::Channel::world, "World layers ready: {} layers.", layers.layers.size());
} catch (const std::exception& error) {
logging::log(logging::Level::warning, logging::Channel::world, "World layers could not be read: {}", error.what());
}
configure_environment(paths.logs);
bool loose_files = options.loose_files && initfs::loose_files_preference(paths.directory);
if (loose_files) {
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");
set_environment(L"RESKATE_WINDOW_WIDTH", options.force_windowed ? std::to_wstring(options.width).c_str() : nullptr);
set_environment(L"RESKATE_WINDOW_HEIGHT", options.force_windowed ? std::to_wstring(options.height).c_str() : nullptr);
std::wstring command = quote_argument(paths.game.wstring());
for (const auto& argument : options.game_arguments) {
command.push_back(L' ');
command += quote_argument(argument);
}
set_environment(L"RESKATE_GPU_DIAGNOSTICS", options.gpu_diagnostics ? L"1" : L"0");
// -map: the game loads it once it is up, as the console's "load" would (client_tick.cpp),
// ahead of any startup commands already asked for.
if (!options.map.empty()) {
std::wstring commands = L"load " + options.map;
std::wstring already(4096, L'\0');
const auto length = GetEnvironmentVariableW(L"RESKATE_STARTUP_COMMANDS", already.data(), 4096);
if (length && length < 4096) commands += L";" + already.substr(0, length);
set_environment(L"RESKATE_STARTUP_COMMANDS", commands.c_str());
}
set_environment(L"RESKATE_DISCORD", options.discord ? L"1" : L"0");
if (options.menu_key == options.console_key)
throw std::runtime_error("The menu and console need different keys. Change one in Settings.");
set_environment(L"RESKATE_MENU_KEY", std::to_wstring(options.menu_key).c_str());
set_environment(L"RESKATE_CONSOLE_KEY", std::to_wstring(options.console_key).c_str());
const auto steam_reason = options.offline ? std::wstring{} : launcher_app::steam_offline_reason();
const bool offline = options.offline || !steam_reason.empty();
set_environment(L"RESKATE_OFFLINE", offline ? L"1" : L"0");
const auto steam_id = offline ? last_steam_id() : std::nullopt;
set_environment(L"RESKATE_OFFLINE_STEAM_ID", steam_id ? std::to_wstring(*steam_id).c_str() : nullptr);
if (offline)
logging::write(logging::Level::info, logging::Channel::launcher, options.offline ?
std::wstring(L"Offline mode: requested; Steam is not used") :
L"Offline mode: Steam is not running or not signed in: " + steam_reason);
logging::write(logging::Level::info, logging::Channel::launcher, std::wstring(L"Graphics options: DRED=") +
(options.gpu_diagnostics ? L"enabled" : L"default"));
if (options.force_windowed)
logging::write(logging::Level::info, logging::Channel::launcher, L"Requested windowed resolution: " + std::to_wstring(options.width) +
L"x" + std::to_wstring(options.height));
else logging::write(logging::Level::info, logging::Channel::launcher, L"Display settings: using the game's saved preferences");
command += launcher::windowed_arguments(options);
const auto mod_arguments = launcher::mod_data_arguments(paths.directory, options.game_arguments);
command += mod_arguments;
if (!mod_arguments.empty()) logging::write(logging::Level::info, logging::Channel::launcher, L"ModData folder detected; loading ModData/Default");
logging::write(logging::Level::info, logging::Channel::launcher, L"Child command: " + command);
if (GetEnvironmentVariableW(L"RESKATE_MANGOHUD_HELD", nullptr, 0)) {
// Held off for the launcher's own window only (relaunch_without_mangohud).
set_environment(L"DISABLE_MANGOHUD", nullptr);
set_environment(L"RESKATE_MANGOHUD_HELD", nullptr);
}
std::vector<wchar_t> command_buffer(command.begin(), command.end());
command_buffer.push_back(L'\0');
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(paths.game.c_str(), command_buffer.data(), nullptr, nullptr, FALSE,
CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, nullptr, paths.directory.c_str(),
&startup, &process)) win32_failure(L"CreateProcessW(Skate.exe)");
ChildProcess child(process);
if (created) created(child.id());
logging::write(logging::Level::info, logging::Channel::launcher, L"Created suspended Skate.exe process " + std::to_wstring(child.id()));
const auto game_base = validate_child_image(child.process(), paths.game);
std::wostringstream validated;
validated << L"Loaded Skate.exe image validated at 0x" << std::hex << game_base;
logging::write(logging::Level::info, logging::Channel::launcher, validated.str());
auto loader_gate = launcher::prepare_loader_for_injection(
child.process(), child.primary_thread(), child.id(), game_base);
std::wostringstream loader_ready;
loader_ready << L"Windows loader ready at executable entrypoint 0x" << std::hex
<< loader_gate.entrypoint() << std::dec << L"; gated "
<< loader_gate.thread_count() << L" existing thread(s)";
if (const auto exiting = loader_gate.exiting_thread_count())
loader_ready << L", skipped " << exiting << L" already exiting";
logging::write(logging::Level::info, logging::Channel::launcher, loader_ready.str());
const auto remote_dll = inject_dll(child.process(), paths.dll);
const auto initialize_address = remote_dll.base + initialize_rva;
if (initialize_address < remote_dll.base || initialize_rva >= remote_dll.size)
throw std::runtime_error("Remote DingoSDKDebugInitialize address is invalid");
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 initialized = run_remote_thread(child.process(), initialize_address, nullptr,
L"DingoSDKDebugInitialize", initialize_wait_ms);
if (!initialized) throw std::runtime_error("DingoSDKDebugInitialize returned FALSE");
logging::write(logging::Level::info, logging::Channel::launcher, L"ReSkate.dll initialization confirmed");
loader_gate.resume();
child.release();
logging::write(logging::Level::info, logging::Channel::launcher, L"Skate.exe resumed; launcher complete");
return child.id();
}
} // namespace dingosdk::launcher_app