From b7932535e560ecc4390f5fa33937ebeeb550a70e Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 2 Oct 2026 18:56:57 -0500 Subject: [PATCH] Keep launcher alive through Skate startup; trim per-frame client work Launcher - "Close the launcher when Skate starts" now hides the launcher and exits when Skate.exe does, instead of exiting as the splash opens. Outside a Steam library, Skate's protection closed the game (TerminateProcess 0x32) when the launcher exited during startup; players confirmed turning the setting off avoided it. - STATUS tile warns when Steam isn't open or signed in that PLAY starts offline as Unknown Player (hidden when Offline mode is chosen or while launching). - Setting description updated to match. Startup interventions (20260929) - exit_return 0x87755f2 -> 0x87fe7fb: the last LEA RSP,[RSP]; JMP stub before the PUSHFQ / LOCK CMPXCHG routine, as 0x87367cf was in 20260908, and the return address player logs show. Only enforced once a frame site is armed; this build's protector has no equivalent rep stosb frame clear. Client frame cost (profiled 2026-10-02) - Transform interpolate is inline plain arithmetic (~1% of an MP frame). - Script gameplay-setting and native-setting gets are served from small per-thread caches keyed on asset/key address, name prefix and a new profile::Store::changes() counter, instead of a store lookup each call. - Hot-path reads in client tick, native settings and population controls use memory::peek instead of ReadProcessMemory. - Script error logging probes each argument pointer once. --- .../Build/20260929/startup_interventions.h | 5 +- Extension/Multiplayer/Net/protocol.cpp | 18 ----- Extension/Multiplayer/Net/protocol.h | 25 ++++++- Extension/Profile/local_profile.cpp | 7 ++ Extension/Profile/local_profile.h | 3 + Extension/Progression/script_error_log.cpp | 6 +- .../Settings/local_gameplay_settings.cpp | 39 +++++++++++ Extension/Settings/local_gameplay_settings.h | 10 +-- Extension/Settings/local_native_settings.cpp | 69 +++++++++++++++---- Extension/Settings/local_native_settings.h | 2 +- Extension/World/local_population_controls.cpp | 7 +- Extension/World/local_population_controls.h | 4 +- Launcher/gui.cpp | 11 ++- Launcher/gui_home.cpp | 15 +++- Launcher/gui_internal.h | 4 +- Launcher/gui_settings.cpp | 3 +- Runtime/client_tick.cpp | 9 +-- 17 files changed, 177 insertions(+), 60 deletions(-) diff --git a/Engine/Game/Build/20260929/startup_interventions.h b/Engine/Game/Build/20260929/startup_interventions.h index 11f7164..5cb7f94 100644 --- a/Engine/Game/Build/20260929/startup_interventions.h +++ b/Engine/Game/Build/20260929/startup_interventions.h @@ -9,9 +9,10 @@ inline constexpr std::array frame_instruction{0xf3, 0xaa}; // Handler and data expected in R9/R14 at the frame clear. inline constexpr std::uintptr_t frame_handler = 0x0880d710; inline constexpr std::uintptr_t frame_data = 0x086772fc; -// Exit thunk (JMP [rip+...]) and the return address it is expected to be called from. +// Exit thunk (JMP [rip+...]) and the return address it is expected to be called from: +// the last LEA RSP,[RSP]; JMP stub before the PUSHFQ / LOCK CMPXCHG routine, as in 20260908. inline constexpr std::uintptr_t exit_thunk = 0x08a06ff3; -inline constexpr std::uintptr_t exit_return = 0x087755f2; +inline constexpr std::uintptr_t exit_return = 0x087fe7fb; inline constexpr std::array exit_instruction{ 0xff, 0x25, 0x7f, 0x43, 0x5e, 0xfd}; } diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 8be095b..fc8eada 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -965,24 +965,6 @@ std::uint64_t map_hash(std::string_view value) noexcept { } return result; } -Transform interpolate(const Transform &a, const Transform &b, float t) { - Transform out; - t = std::clamp(t, 0.0f, 1.0f); - for (unsigned i = 0; i < 3; ++i) { - out.position[i] = std::lerp(a.position[i], b.position[i], t); - out.scale[i] = std::lerp(a.scale[i], b.scale[i], t); - } - float dot{}, norm{}; - for (unsigned i = 0; i < 4; ++i) - dot += a.rotation[i] * b.rotation[i]; - for (unsigned i = 0; i < 4; ++i) { - out.rotation[i] = std::lerp(a.rotation[i], dot < 0 ? -b.rotation[i] : b.rotation[i], t); - norm += out.rotation[i] * out.rotation[i]; - } - for (float &v : out.rotation) - v /= std::sqrt(norm); - return out; -} std::array to_matrix(const Transform &t) { const auto q = interpolate(t, t, 0).rotation; const float x = q[0], y = q[1], z = q[2], w = q[3]; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 913b8fb..5973f57 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -11,6 +11,7 @@ #include "Engine/Game/World/park_rotation.h" #include "Engine/Game/World/world_layers.h" #include +#include #include #include #include @@ -213,7 +214,29 @@ std::optional decode(std::span) noexcept; bool newer_sequence(std::uint32_t candidate, std::uint32_t previous) noexcept; std::uint64_t map_hash(std::string_view) noexcept; bool valid_map_destination(std::string_view) noexcept; -Transform interpolate(const Transform &, const Transform &, float); +// Normalized lerp between two transforms. Runs for every joint of every remote player each +// frame, so it is inline and plain arithmetic (std::lerp's edge-case branches and a divide +// per component were ~1% of a multiplayer client frame, profiled 2026-10-02). +inline Transform interpolate(const Transform &a, const Transform &b, float t) { + Transform out; + t = t < 0.0f ? 0.0f : t > 1.0f ? 1.0f : t; + for (unsigned i = 0; i < 3; ++i) { + out.position[i] = a.position[i] + (b.position[i] - a.position[i]) * t; + out.scale[i] = a.scale[i] + (b.scale[i] - a.scale[i]) * t; + } + const float dot = a.rotation[0] * b.rotation[0] + a.rotation[1] * b.rotation[1] + + a.rotation[2] * b.rotation[2] + a.rotation[3] * b.rotation[3]; + const float sign = dot < 0 ? -1.0f : 1.0f; + float norm{}; + for (unsigned i = 0; i < 4; ++i) { + out.rotation[i] = a.rotation[i] + (sign * b.rotation[i] - a.rotation[i]) * t; + norm += out.rotation[i] * out.rotation[i]; + } + const float inverse = 1.0f / std::sqrt(norm); + for (float &v : out.rotation) + v *= inverse; + return out; +} void offset_pose(Pose &, const std::array &); std::array to_matrix(const Transform &); Transform from_matrix(const std::array &); diff --git a/Extension/Profile/local_profile.cpp b/Extension/Profile/local_profile.cpp index d35dd0b..13683fb 100644 --- a/Extension/Profile/local_profile.cpp +++ b/Extension/Profile/local_profile.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -256,6 +257,9 @@ std::string with_cached_entitlements(std::string text) { std::string_view embedded_defaults() noexcept { return {reinterpret_cast(embedded_profile_defaults), sizeof(embedded_profile_defaults)}; } +namespace { +std::atomic store_changes{1}; +} Store::Store(std::filesystem::path path, const std::filesystem::path& defaults) : path_(storage::database_path(std::move(path))), database_(std::make_unique(path_, database::profile_schema())) { const auto seed = decode(with_cached_entitlements(defaults.empty() || !std::filesystem::exists(defaults) ? @@ -283,8 +287,10 @@ Store::Store(std::filesystem::path path, const std::filesystem::path& defaults) if (next.extensions.contains("location_travel") && next.extensions.at("location_travel").is_object()) for (const auto* key : {"destinations", "access_points"}) next.extensions.at("location_travel").erase(key); if (!database_->exists() || next != value_) commit(std::move(next)); + store_changes.fetch_add(1, std::memory_order_release); } Store::~Store() = default; +std::uint64_t Store::changes() noexcept { return store_changes.load(std::memory_order_acquire); } Snapshot Store::snapshot() const { std::lock_guard lock(mutex_); return value_; } std::shared_ptr Store::shared_snapshot() const { std::lock_guard lock(mutex_); @@ -452,6 +458,7 @@ void Store::commit(Snapshot next) { if (database_->exists()) database_->commit(std::move(tables)); else database_->initialize(std::move(tables)); value_ = std::move(next); + store_changes.fetch_add(1, std::memory_order_release); } } diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 8361776..c7b3577 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -137,6 +137,9 @@ public: std::string export_json() const; void import_json(std::string_view); std::uint64_t revision() const; + // Bumped whenever any Store's saved values may have changed (each commit, each Store made); + // read without the lock, so caches on hot paths can tell whether a lookup is still current. + static std::uint64_t changes() noexcept; // Same immutable object until a successful commit changes this Store's // revision. Retained readers remain valid after writes or Store destruction. std::shared_ptr mission_state() const; diff --git a/Extension/Progression/script_error_log.cpp b/Extension/Progression/script_error_log.cpp index e6af050..2c5b749 100644 --- a/Extension/Progression/script_error_log.cpp +++ b/Extension/Progression/script_error_log.cpp @@ -14,7 +14,7 @@ void script_error(std::uintptr_t, std::uintptr_t*, std::uintptr_t* arguments) { PreserveError preserve; // Authored graphs raise the same error every frame, from the same constant text: a text // pointer already looked at is not read again (each character was a system call). - static std::mutex mutex; static std::set seen; static std::set looked_at; + static std::mutex mutex; static std::set seen; static std::set looked_at, arguments_seen; std::lock_guard lock(mutex); try { // The message position is not known from the binding alone; take the @@ -35,6 +35,10 @@ void script_error(std::uintptr_t, std::uintptr_t*, std::uintptr_t* arguments) { for (std::size_t index = 0; arguments && index < 4 && message.empty(); ++index) { std::uintptr_t argument{}, inner{}; if (!memory::peek(reinterpret_cast(arguments) + index * 8, argument) || argument < 0x10000) break; + // The same arguments come back every frame: each is probed once (read, not peeked: + // it is often not a pointer, and a failed peek costs an exception). + if (arguments_seen.contains(argument)) continue; + if (arguments_seen.size() < 4096) arguments_seen.insert(argument); if (read(argument, inner) && inner >= 0x10000) message = printable(inner); if (message.empty()) message = printable(argument); } diff --git a/Extension/Settings/local_gameplay_settings.cpp b/Extension/Settings/local_gameplay_settings.cpp index d80c906..0794e29 100644 --- a/Extension/Settings/local_gameplay_settings.cpp +++ b/Extension/Settings/local_gameplay_settings.cpp @@ -3,6 +3,9 @@ #include "Extension/Progression/local_entitlement_trigger_runtime.h" #include "local_gameplay_settings.h" #include "local_user_settings.h" +#include +#include +#include namespace dingosdk::profile_runtime { // Applied gameplay values live in an ECS cache even when the user-value @@ -48,6 +51,42 @@ bool gameplay_setting_key(const void* reference, std::string& key) { return asset && identifier(reinterpret_cast(asset + 0x60), key); } +// Every script settings get the game makes (several a frame from its expressions) asks this. One +// store lookup per setting asset and profile change, cached on the asking thread in a small +// direct-mapped table: a hit is three peeks and compares, with no heap string, store lock or map +// walk (~0.5% of a multiplayer client frame before, profiled 2026-10-02). A hit needs the same +// asset, name address and first 8 name bytes, so a reused address cannot answer for another setting. +const dingosdk::Json* saved_gameplay_json(const void* reference) { + auto& s = local_runtime(); + if (!s.active.load(std::memory_order_acquire)) return nullptr; + std::uintptr_t asset{}, name{}; + if (!memory::peek(reinterpret_cast(reference), asset)) return nullptr; + asset &= ~std::uintptr_t{4}; + if (!asset || !memory::peek(asset + 0x60, name) || !name) return nullptr; + std::uint64_t prefix{}; + (void)memory::peek(name, prefix); // a short name at the end of a page stays 0: a miss refills + struct Entry { std::uintptr_t asset{}, name{}; std::uint64_t prefix{}, changes{}; std::optional value; }; + thread_local std::array cache; + const auto changes = profile::Store::changes(); + auto& entry = cache[((asset >> 4) ^ (asset >> 12)) & 255]; + if (entry.asset != asset || entry.name != name || entry.prefix != prefix || entry.changes != changes) { + char text[256]; + const auto length = memory::peek_cstring(name, text, sizeof(text)); + if (length <= 0) return nullptr; + for (std::ptrdiff_t i = 0; i < length; ++i) { + const auto c = static_cast(text[i]); + if (c < 32 || c == 127) return nullptr; + } + entry.changes = 0; + entry.value = s.store->user_value(std::string_view(text, static_cast(length))); + entry.asset = asset; + entry.name = name; + entry.prefix = prefix; + entry.changes = changes; + } + return entry.value ? &*entry.value : nullptr; +} + void save_gameplay_setting(const void* reference, const dingosdk::Json& value) { auto& s = local_runtime(); if (!s.active.load(std::memory_order_acquire)) return; diff --git a/Extension/Settings/local_gameplay_settings.h b/Extension/Settings/local_gameplay_settings.h index 09ab860..9bd8cd8 100644 --- a/Extension/Settings/local_gameplay_settings.h +++ b/Extension/Settings/local_gameplay_settings.h @@ -24,14 +24,14 @@ bool gameplay_user_edit(); bool gameplay_setting_key(const void* reference, std::string& key); +// The saved value for a script setting reference, or null: cached per setting asset and profile +// change on the asking thread (valid until this thread's next call). May throw. +const dingosdk::Json* saved_gameplay_json(const void* reference); + template std::optional saved_gameplay_setting(const void* reference) { - auto& s = local_runtime(); - if (!s.active.load(std::memory_order_acquire)) return {}; PreserveError preserve; try { - std::string key; - if (!gameplay_setting_key(reference, key)) return {}; - const auto value = s.store->user_value(key); + const auto* value = saved_gameplay_json(reference); if (!value) return {}; if constexpr (std::is_same_v) { if (value->is_boolean()) return value->get(); } if constexpr (std::is_same_v) { diff --git a/Extension/Settings/local_native_settings.cpp b/Extension/Settings/local_native_settings.cpp index 881e6b9..1334163 100644 --- a/Extension/Settings/local_native_settings.cpp +++ b/Extension/Settings/local_native_settings.cpp @@ -6,6 +6,9 @@ #include "Engine/Game/Build/20260929/local_native_settings.h" #include "Engine/Game/Build/20260929/named_settings.h" #include "local_user_settings.h" +#include +#include +#include namespace dingosdk::profile_runtime { namespace native_types = addr::named_settings; @@ -30,32 +33,65 @@ unsigned native_settings_location(std::uintptr_t group) { return 0; } +// Runs on every settings get the game's scripts make: peeked, not read (one ReadProcessMemory +// call per read, ~0.15% of a multiplayer client frame, profiled 2026-10-02). std::optional native_setting_json(const NativeSettingValue* input) { NativeSettingValue value{}; - if (!read(reinterpret_cast(input), value) || !value.data) return {}; + if (!memory::peek(reinterpret_cast(input), value) || !value.data) return {}; const auto type = value.type - local_runtime().base; switch (type) { case native_types::native_bool: { std::uint8_t byte{}; - if (read(reinterpret_cast(value.data), byte) && byte <= 1) return byte != 0; + if (memory::peek(reinterpret_cast(value.data), byte) && byte <= 1) return byte != 0; return {}; } case native_types::native_uint32: return native_scalar(value.data); case native_types::native_int32: return native_scalar(value.data); case native_types::native_float32: return native_scalar(value.data); case native_types::native_cstring: { - std::uintptr_t text{}; std::string result; - if (!read(reinterpret_cast(value.data), text)) return {}; - for (std::size_t i = 0; i <= 4096; ++i) { - char c{}; if (!read(text + i, c)) return {}; - if (!c) return result; - result += c; - } + std::uintptr_t text{}; + if (!memory::peek(reinterpret_cast(value.data), text)) return {}; + char buffer[4097]; + const auto length = memory::peek_cstring(text, buffer, sizeof(buffer)); + if (length < 0) return {}; + return std::string(buffer, static_cast(length)); } } return {}; } +namespace { +// The saved option for a native setting key, or null: one store lookup per key and profile change, +// cached on the asking thread in a small direct-mapped table (valid until this thread's next call). +// A hit needs the same key address, location and first 8 key bytes; the key text is read only on +// a miss. May throw. +const dingosdk::Json* saved_native_option(unsigned location, const char* key) { + const auto address = reinterpret_cast(key); + std::uint64_t prefix{}; + (void)memory::peek(address, prefix); // a short key at the end of a page stays 0: a miss refills + struct Entry { std::uintptr_t key{}; unsigned location{}; std::uint64_t prefix{}, changes{}; std::optional value; }; + thread_local std::array cache; + const auto changes = profile::Store::changes(); + auto& entry = cache[((address >> 3) ^ (address >> 11) ^ location) & 255]; + if (entry.key != address || entry.location != location || entry.prefix != prefix || entry.changes != changes) { + char text[256]; + const auto length = memory::peek_cstring(address, text, sizeof(text)); + if (length <= 0) return nullptr; + for (std::ptrdiff_t i = 0; i < length; ++i) { + const auto c = static_cast(text[i]); + if (c < 32 || c == 127) return nullptr; + } + entry.changes = 0; + entry.value = local_runtime().store->native_profile_option(location, std::string_view(text, static_cast(length))); + entry.key = address; + entry.location = location; + entry.prefix = prefix; + entry.changes = changes; + } + return entry.value ? &*entry.value : nullptr; +} +} + bool restore_native_setting(std::uintptr_t group, const char* key, std::uintptr_t type, const dingosdk::Json& saved) { const auto base = local_runtime().base; @@ -82,15 +118,18 @@ const NativeSettingValue* native_setting_get(std::uintptr_t group, const char* k auto* result = native_settings().get(group, key); PreserveError preserve; try { - const auto location = native_settings_location(group); std::string id; - if (!location || !identifier(&key, id)) return result; - const auto saved = local_runtime().store->native_profile_option(location, id); + const auto location = native_settings_location(group); + const auto* saved = location ? saved_native_option(location, key) : nullptr; if (!saved) return result; const auto current = native_setting_json(result); + if (!current || *current == *saved) return result; + // Copied before the restore: a get it causes on this thread reuses the cache. + const auto wanted = *saved; + std::string name; + (void)identifier(&key, name); NativeSettingValue native{}; - if (current && *current != *saved && read(reinterpret_cast(result), native) && - restore_native_setting(group, key, native.type, *saved)) - dingosdk::logging::event(dingosdk::logging::Channel::settings, dingosdk::Json{{"event","local_native_setting_restored"},{"location",location},{"key",id}}.dump().c_str()); + if (memory::peek(reinterpret_cast(result), native) && restore_native_setting(group, key, native.type, wanted)) + dingosdk::logging::event(dingosdk::logging::Channel::settings, dingosdk::Json{{"event","local_native_setting_restored"},{"location",location},{"key",name}}.dump().c_str()); } catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::settings, "{\"event\":\"local_native_setting_restore_failed\"}"); } return result; } diff --git a/Extension/Settings/local_native_settings.h b/Extension/Settings/local_native_settings.h index 8575baf..8054895 100644 --- a/Extension/Settings/local_native_settings.h +++ b/Extension/Settings/local_native_settings.h @@ -17,7 +17,7 @@ unsigned native_settings_location(std::uintptr_t group); template std::optional native_scalar(const void* data) { T value{}; - if (!read(reinterpret_cast(data), value)) return {}; + if (!memory::peek(reinterpret_cast(data), value)) return {}; if constexpr (std::is_floating_point_v) if (!std::isfinite(value)) return {}; if constexpr (std::is_same_v) if (value > INT64_MAX) return {}; return dingosdk::Json(value); diff --git a/Extension/World/local_population_controls.cpp b/Extension/World/local_population_controls.cpp index 0ad9156..fc3c032 100644 --- a/Extension/World/local_population_controls.cpp +++ b/Extension/World/local_population_controls.cpp @@ -21,13 +21,14 @@ bool population_config_identity(std::uintptr_t config) { // The validated manager supplies a TrafficSystemConfig, but the asset name // varies by map (BAM uses Gameplay/AI/BAM_TrafficSystemConfig). The exact // native class identifies the field layout; the default asset name does not. - return read(config + 8, type) && type == local_runtime().base + addr::population::traffic_system_config_type && + return memory::peek(config + 8, type) && type == local_runtime().base + addr::population::traffic_system_config_type && identifier(reinterpret_cast(config + 0x18), name); } +// Every population tick, on the validated live config: peeked. bool population_value(std::uintptr_t config, unsigned i, float& out) { - if (i == 0 || i == 3) return read(config + population_offsets[i], out) && std::isfinite(out) && out >= 0 && out <= 100; - std::uint32_t v{}; if (!read(config + population_offsets[i], v) || v > 1000) return false; out = static_cast(v); return true; + if (i == 0 || i == 3) return memory::peek(config + population_offsets[i], out) && std::isfinite(out) && out >= 0 && out <= 100; + std::uint32_t v{}; if (!memory::peek(config + population_offsets[i], v) || v > 1000) return false; out = static_cast(v); return true; } bool population_write(std::uintptr_t config, unsigned i, float value) { diff --git a/Extension/World/local_population_controls.h b/Extension/World/local_population_controls.h index cf20202..aa57bb3 100644 --- a/Extension/World/local_population_controls.h +++ b/Extension/World/local_population_controls.h @@ -68,7 +68,7 @@ template void population_tick(std::uintptr_t manager) { const auto found = p.nodes.find(manager); if (found != p.nodes.end() && population_identity(manager, found->second.realm)) { auto& node = found->second; std::uintptr_t config{}; std::uint8_t running{}; - if (read(manager + 0x108, config) && read(manager + 0x152, running) && running == 1) { + if (memory::peek(manager + 0x108, config) && memory::peek(manager + 0x152, running) && running == 1) { config &= ~std::uintptr_t{4}; if (config != node.config) { population_release_config(manager); node.config = config; } if (population_config_identity(config)) { @@ -90,7 +90,7 @@ template void population_tick(std::uintptr_t manager) { p.observed_at[Kind] = GetTickCount64(); r.model.population_ready[Kind] = ok; std::uintptr_t begin{}, end{}; const unsigned offset = Kind == 0 ? 0xa8 : 0xe8; - if (read(manager + offset, begin) && read(manager + offset + 8, end) && end >= begin && end - begin <= 8000) + if (memory::peek(manager + offset, begin) && memory::peek(manager + offset + 8, end) && end >= begin && end - begin <= 8000) r.model.population[Kind] = static_cast((end - begin) / 8); } } diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index d16d102..dd7e99e 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -275,11 +275,14 @@ int run(const launcher_app::Session& session, const std::vector& a auto& ui = *ui_storage; ModsPanel mods_panel; bool running = true; - // The game this launcher started: hide when its splash opens, then exit - // (Close the launcher when Skate starts) or wait and return when it closes. + // The game this launcher started: hide when its splash opens, then wait for it + // to close and return, or exit (Close the launcher when Skate starts). + // Staying alive until then matters: outside a Steam library, Skate's protection + // closed the game (TerminateProcess 0x32) when the launcher exited during startup. HANDLE game{}; DWORD game_id{}; bool hidden{}; + bool exit_with_game{}; while (running) { MSG message; while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { @@ -302,15 +305,17 @@ int run(const launcher_app::Session& session, const std::vector& a hidden = true; } // Exit only after injection succeeded, so a failed launch can still report. - if (hidden && launcher.launched() && launcher.settings().close_on_launch) break; + if (hidden && launcher.launched() && launcher.settings().close_on_launch) exit_with_game = true; if (hidden && !launcher.busy() && launcher.snapshot().phase == Phase::failed) { ShowWindow(window, SW_SHOWNORMAL); SetForegroundWindow(window); hidden = false; + exit_with_game = false; } if (WaitForSingleObject(game, 0) == WAIT_OBJECT_0) { CloseHandle(game); game = nullptr; + if (exit_with_game) break; launcher.game_exited(hidden); if (hidden) { ShowWindow(window, SW_SHOWNORMAL); diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 7f94c78..ef62801 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -239,7 +239,9 @@ bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 siz } // The STATUS tile, laid out like the HUB's BOUNTIES tile: a header and icon rows. -void status_tile(ImDrawList* draw, const Fonts& fonts, const State& state, ImVec2 position, float width, float time) { +// `steam_offline`: Steam isn't running or signed in, so PLAY starts offline. +void status_tile(ImDrawList* draw, const Fonts& fonts, const State& state, ImVec2 position, float width, float time, + bool steam_offline) { struct Row { Icon icon; std::string text; std::string detail; }; std::vector rows; Icon now = Icon::busy; @@ -252,6 +254,11 @@ void status_tile(ImDrawList* draw, const Fonts& fonts, const State& state, ImVec default: break; } rows.push_back({now, state.status, state.detail}); + // While launching, the status detail already says the game is starting offline. + if (steam_offline && state.phase != Phase::launching) + rows.push_back({Icon::warning, "Steam isn't open or signed in", + "PLAY starts skate. in offline mode as Unknown Player; multiplayer is hidden. " + "Open Steam and sign in to play online."}); if (state.config) { const auto build = state.config->game.build_id.empty() ? state.config->game.manifest_id : state.config->game.build_id; const bool game_ok = state.phase != Phase::game_missing && state.phase != Phase::game_outdated && @@ -330,7 +337,8 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane if (update::binary_updates_enabled() && state.config && !state.config->launcher.version.empty()) version = "Launcher " + state.config->launcher.version; if (time - ui.steam_checked > 5) { - ui.steam_name = launcher_app::steam_persona_name(); + ui.steam_signed_in = launcher_app::steam_signed_in(); + ui.steam_name = ui.steam_signed_in ? launcher_app::steam_persona_name() : std::string(); ui.steam_checked = time; } const bool greet = !launcher.settings().offline && !ui.steam_name.empty(); @@ -406,7 +414,8 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) act(); ImGui::EndDisabled(); - status_tile(draw, fonts, state, ImVec2(S(60), S(200)), S(360), time); + status_tile(draw, fonts, state, ImVec2(S(60), S(200)), S(360), time, + !ui.steam_signed_in && !launcher.settings().offline); if (modal) draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f)); g_drag_allowed = !modal && !ImGui::IsAnyItemHovered(); diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 5363e1a..8d8118b 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -233,8 +233,10 @@ struct Ui { // The MODS tile's "2 of 3 enabled", re-read every few seconds. std::string mods_detail; double mods_checked{-100}; - // Steam display name for the name plate, re-read every few seconds. + // Steam display name for the name plate, and whether Steam is running and + // signed in (PLAY starts offline otherwise), re-read every few seconds. std::string steam_name; + bool steam_signed_in{true}; double steam_checked{-100}; }; diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 9071d73..4aabf8f 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -184,7 +184,8 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui default: section_caption(fonts, "LAUNCHER"); setting_check("Close the launcher when Skate starts", - "Otherwise the launcher hides while you play and comes back when the game closes.", + "The launcher hides while you play and exits when the game closes. " + "Turn off to have it come back instead.", settings.close_on_launch); setting_check("Install ReSkate updates", "Turn off to keep the ReSkate.dll and launcher you have, such as a test build someone gave you. " diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index d0dc147..a735fc5 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -192,8 +192,9 @@ void update_model(std::uintptr_t client, TickState& frame) { auto& r = runtime(); std::uintptr_t vtable{}; DWORD state{}, game_type{}; - if (!read(client, vtable) || vtable != r.base + engine::client_vtable || client > highest - 0x2a0 || - !read(client + 0xc4, state) || state > 26 || !read(client + 0xc0, game_type) || game_type > 3) return; + // Every client tick: peeked, not read (three ReadProcessMemory calls a tick, profiled 2026-10-02). + if (!memory::peek(client, vtable) || vtable != r.base + engine::client_vtable || client > highest - 0x2a0 || + !memory::peek(client + 0xc4, state) || state > 26 || !memory::peek(client + 0xc0, game_type) || game_type > 3) return; DWORD no_thread{}; r.engine_thread.compare_exchange_strong(no_thread, GetCurrentThreadId()); if (r.engine_thread.load() != GetCurrentThreadId()) return; @@ -927,7 +928,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) { ? *tick_state.context : native_context(r.base, client, game_type); }; const bool multiplayer_ready=!r.observer_failed && !loading && - read(client+0xc4,state) && (state==13 || state==21) && read(client+0xc0,game_type) && + memory::peek(client+0xc4,state) && (state==13 || state==21) && memory::peek(client+0xc0,game_type) && context_ready(); { DINGO_PROFILE_ZONE("tick/multiplayer"); @@ -939,7 +940,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) { // session placed the remote skaters for this frame (and also out of play, to give up). std::uintptr_t camera_context{}; const bool camera_phase = tick_state.camera_issue ? *tick_state.camera_issue == nullptr : - read(client + 8, camera_context) && !dingosdk::camera_probe_unavailable_reason(camera_context); + memory::peek(client + 8, camera_context) && !dingosdk::camera_probe_unavailable_reason(camera_context); { DINGO_PROFILE_ZONE("tick/native party"); dingosdk::multiplayer::tick_native_party_actions(r.base, client,