diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 8be095b5..fc8eada1 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 913b8fb6..5973f575 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/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index ddf30793..a383882b 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -342,6 +342,11 @@ Pose out_of_sight(Pose pose) { // every frame again once back inside 190 m. Between samples the skater keeps its pose, which // saves the interpolation, the pose copy and the skeleton write for players too far away to see. constexpr int far_full_rate_return = 190, far_half_rate_start = 200, far_low_rate_start = 350; +// Out of the camera's view, players past this many metres are sampled at 20 Hz too: the whole- +// skeleton interpolation grows with the player count and was most of ReSkate's client-tick cost +// on busy servers (profiled 2026-10-03). Nearer ones stay every frame for collision and close +// shadows; one coming into view is sampled that frame. +constexpr float out_of_view_full_rate = 8.0f; // Metres from a player to the nearer of the local skater and the camera (infinite if neither // is known). float nearest_distance(const Peer &p, const NativeFrame &local, const std::optional &view) { @@ -355,6 +360,23 @@ float nearest_distance(const Peer &p, const NativeFrame &local, const std::optio if (view) consider(view->world[12], view->world[13], view->world[14]); return std::sqrt(nearest); } +// Whether a player is certainly outside the camera's view: a 2 m sphere around their body past +// the camera, or past a side, top or bottom of its view (taken as wide as a 21:9 screen). +bool out_of_view(const Peer &p, const std::optional &view) { + if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return false; + const auto &m = view->world; + const auto &at = p.render_pose.root.position; + const float dx = at[0] - m[12], dy = at[1] + 1.0f - m[13], dz = at[2] - m[14]; + const float depth = -(dx * m[8] + dy * m[9] + dz * m[10]); + const float side = dx * m[0] + dy * m[1] + dz * m[2], height = dx * m[4] + dy * m[5] + dz * m[6]; + constexpr float radius = 2.0f; + if (depth < -radius) return true; + const float tan_v = std::tan(view->vertical_fov * 3.14159265f / 360.0f), tan_h = tan_v * (21.0f / 9.0f); + const auto outside = [&](float offset, float tangent) { + return std::abs(offset) - depth * tangent > radius * std::sqrt(1.0f + tangent * tangent); + }; + return outside(side, tan_h) || outside(height, tan_v); +} std::uint64_t far_sample_interval(const Peer &p, float distance) { const auto beyond = [&](int metres) { return distance > static_cast(metres); }; if (!std::isfinite(distance) || !beyond(far_full_rate_return)) return 0; @@ -420,7 +442,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui const bool was_visible = p.visible; // A far player between samples: their skater keeps its pose; sound, the label and // the spectate position are still refreshed (sound events are released one per frame). - if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample) { + if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample && + !(p.far_for_view && !out_of_view(p, view))) { present_audio(p); update_party_position(&p.render_pose); if (labels) label(p); @@ -447,6 +470,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui p.native_status); const auto distance = nearest_distance(p, local, view); p.far_interval = p.visible && !hidden ? far_sample_interval(p, distance) : 0; + p.far_for_view = p.visible && !hidden && !p.far_interval && std::isfinite(distance) && + distance > out_of_view_full_rate && s.mode != Mode::echo && out_of_view(p, view); + if (p.far_for_view) p.far_interval = 50000; p.next_far_sample = now + p.far_interval; // The native work a far player's skater may skip (puppet_cost.cpp). if (p.visible) note_remote_distance(distance); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 8749db93..2a8540b8 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -87,9 +87,11 @@ struct Peer { ReceiveBudget budget; PoseBuffer poses; Pose render_pose; - // Far from the local skater and the camera, render_pose is sampled every - // far_interval (0: every frame) and the skater keeps it in between. + // Far from the local skater and the camera, or out of the camera's view, render_pose is + // sampled every far_interval (0: every frame) and the skater keeps it in between. std::uint64_t far_interval{}, next_far_sample{}; + // far_interval is only for being out of view: coming into view samples at once. + bool far_for_view{}; AppearanceBuffer appearance; // Bumped per accepted outfit; render compares it with the one its actor wears // (0 after a spawn) to spread native recipe applies over frames. diff --git a/Extension/Profile/local_profile.cpp b/Extension/Profile/local_profile.cpp index d35dd0bc..13683fb8 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 83617760..c7b35778 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/local_entitlement_trigger_runtime.cpp b/Extension/Progression/local_entitlement_trigger_runtime.cpp index 54c1923c..8e189f43 100644 --- a/Extension/Progression/local_entitlement_trigger_runtime.cpp +++ b/Extension/Progression/local_entitlement_trigger_runtime.cpp @@ -1,5 +1,4 @@ #include "Engine/Core/Log/logging.h" -#include "Engine/Core/Profiling/profiler.h" #include "Extension/Customization/local_customization_runtime.h" #include "Extension/Profile/runtime_internal.h" #include "local_challenge_runtime.h" @@ -27,32 +26,28 @@ thread_local std::uintptr_t executing_expression{}; thread_local const ExpressionExecutionScope* expression_scope{}; +// Both hooks run for every expression the game executes, thousands a frame on several threads: +// no profiler zones here (each was two timestamp reads and shared atomic adds whenever the +// profiler ran, which inflated the hooks in the very samples that measured them, 2026-10-03). +// The sampler attributes their time. void execute_expression_hook(std::uintptr_t vm, std::uint32_t pc) { // The setup helper only prepares registers. Native calls actually execute // in 1416d0b90 / 1416d1a10, including direct ECS interpreter entry paths. // Keep the exact cursor and profiler context when entering either runner. ExpressionExecutionScope scope(vm); - { - DINGO_PROFILE_ZONE("hooks/expression start"); - initialize_starter_from_expression(vm, pc); - } + initialize_starter_from_expression(vm, pc); multiplayer::execute_native_party_expression(vm, pc, 0, [](std::uintptr_t expression, std::uint32_t cursor, std::uintptr_t) { local_runtime().execute_expression(expression, cursor); }); - DINGO_PROFILE_ZONE("hooks/expression end"); deliver_local_challenge_completion(vm, pc); multiplayer::complete_native_throwdown_parameters(vm); } void execute_profiled_expression_hook(std::uintptr_t vm, std::uint32_t pc, std::uintptr_t profiler) { ExpressionExecutionScope scope(vm); - { - DINGO_PROFILE_ZONE("hooks/expression start"); - initialize_starter_from_expression(vm, pc); - } + initialize_starter_from_expression(vm, pc); multiplayer::execute_native_party_expression(vm, pc, profiler, local_runtime().execute_profiled_expression); - DINGO_PROFILE_ZONE("hooks/expression end"); deliver_local_challenge_completion(vm, pc); multiplayer::complete_native_throwdown_parameters(vm); } diff --git a/Extension/Progression/script_error_log.cpp b/Extension/Progression/script_error_log.cpp index e6af050c..2c5b7491 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 d80c906c..0794e296 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 09ab860a..9bd8cd8f 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 881e6b90..1334163b 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 8575baf8..80548959 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/Throwdowns/graph_throttle.cpp b/Extension/Throwdowns/graph_throttle.cpp index 91df584b..35cd04d0 100644 --- a/Extension/Throwdowns/graph_throttle.cpp +++ b/Extension/Throwdowns/graph_throttle.cpp @@ -162,6 +162,12 @@ void settle(Address base, Address resource, std::size_t index) noexcept { } } // namespace +bool throttled_graph(std::uint32_t hash) noexcept { + for (const auto& graph : graphs) + if (graph.hash == hash) return true; + return false; +} + void throttle_graph_ran(Address base, Address vm) noexcept { // The pump has just read these for the same run: the expression and its resource are alive. if (!vm) return; diff --git a/Extension/Throwdowns/graph_throttle.h b/Extension/Throwdowns/graph_throttle.h index 593f9ad3..b830ec8e 100644 --- a/Extension/Throwdowns/graph_throttle.h +++ b/Extension/Throwdowns/graph_throttle.h @@ -21,4 +21,6 @@ namespace dingosdk::multiplayer { // Call after every graph run, any realm (the expression pump). Other graphs return after one // hash compare, lock free; each loaded copy of the seven above is checked and patched once. void throttle_graph_ran(std::uintptr_t base, std::uintptr_t vm) noexcept; +// Whether the graph with this hash is one of the throttled ones (the pump skips the rest). +bool throttled_graph(std::uint32_t hash) noexcept; } // namespace dingosdk::multiplayer diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 151139b8..8a91bf01 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -1688,11 +1688,31 @@ void trace_expression(Address vm) { if (!seen.count++) seen.first_ms = now; seen.last_ms = now; } + +// The pump runs after every expression the game executes, thousands a frame, and almost all +// are none of the graphs it watches: one sorted lookup of the running graph decides +// (~0.5% of a busy multiplayer client frame before, profiled 2026-10-03). Keep this list in +// step with the graphs the pump's body compares. The handled graphs' zero padding stays in, as +// the body's search also matched a graph of 0. +bool lab_graph(Address vm) noexcept { + static const auto watched = [] { + std::vector v{graph_celebration, graph_criteria_attempt, graph_queue_filled, graph_round_end_update, + graph_turn_start, graph_skate_timer, graph_turn_end_begin, + 0xc33322b2, 0xc4a37359, 0xcc6467cf, 0xf073dd02}; + for (const auto& h : handled_events) v.insert(v.end(), h.graphs.begin(), h.graphs.end()); + std::ranges::sort(v); + v.erase(std::unique(v.begin(), v.end()), v.end()); + return v; + }(); + const auto resource = *reinterpret_cast(vm + 0x38); + const auto graph = resource ? *reinterpret_cast(resource + 0x10) : 0U; + return std::ranges::binary_search(watched, graph) || (resource && throttled_graph(graph)); +} } // namespace void pump_throwdown_lab(Address vm) noexcept { auto& l = lab(); - if (vm && l.installed.load(std::memory_order_acquire)) { + if (vm && l.installed.load(std::memory_order_acquire) && lab_graph(vm)) { profile_runtime::PreserveError preserve; try { const auto resource = *reinterpret_cast(vm + 0x38); diff --git a/Extension/World/local_population_controls.cpp b/Extension/World/local_population_controls.cpp index 0ad91563..fc3c0322 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 cf202025..aa57bb3a 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 d16d102b..b11eeb0f 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -172,23 +172,6 @@ void apply_style() { colours[ImGuiCol_NavHighlight] = ImVec4(0, 0, 0, 0); } -// True once `process` shows its startup splash (ReSkateStartupWindow) or, if the -// splash is disabled, the game's own window. -bool game_window_shown(DWORD process) { - struct Search { DWORD process; bool found; } search{process, false}; - EnumWindows([](HWND window, LPARAM parameter) -> BOOL { - auto& target = *reinterpret_cast(parameter); - DWORD owner{}; - GetWindowThreadProcessId(window, &owner); - if (owner != target.process || !IsWindowVisible(window)) return TRUE; - std::array name{}; - GetClassNameW(window, name.data(), static_cast(name.size())); - const std::wstring_view kind(name.data()); - if (kind == L"ReSkateStartupWindow" || kind == L"Skate") { target.found = true; return FALSE; } - return TRUE; - }, reinterpret_cast(&search)); - return search.found; -} } // namespace } // namespace detail @@ -275,11 +258,6 @@ 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. - HANDLE game{}; - DWORD game_id{}; - bool hidden{}; while (running) { MSG message; while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { @@ -292,34 +270,12 @@ int run(const launcher_app::Session& session, const std::vector& a launcher.restart(); break; } - if (!game && launcher.game()) { - game_id = launcher.game(); - game = OpenProcess(SYNCHRONIZE, FALSE, game_id); - } - if (game) { - if (!hidden && game_window_shown(game_id)) { - ShowWindow(window, SW_HIDE); - 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.busy() && launcher.snapshot().phase == Phase::failed) { - ShowWindow(window, SW_SHOWNORMAL); - SetForegroundWindow(window); - hidden = false; - } - if (WaitForSingleObject(game, 0) == WAIT_OBJECT_0) { - CloseHandle(game); - game = nullptr; - launcher.game_exited(hidden); - if (hidden) { - ShowWindow(window, SW_SHOWNORMAL); - SetForegroundWindow(window); - hidden = false; - } - } - } - if (hidden || IsIconic(window)) { Sleep(hidden ? 100 : 50); continue; } + // The launcher is done the moment injection succeeded and Skate is running + // on its own, so close immediately instead of staying beside the game. This + // is the earliest safe point: a launch that fails never sets launched(), so + // it still gets to report itself in the window. + if (launcher.launched()) break; + if (IsIconic(window)) { Sleep(50); continue; } ImGui_ImplDX12_NewFrame(); ImGui_ImplWin32_NewFrame(); ImGui::NewFrame(); @@ -328,7 +284,6 @@ int run(const launcher_app::Session& session, const std::vector& a renderer.render(); } ShowWindow(window, SW_HIDE); - if (game) CloseHandle(game); launcher.cancel(); // Closing the window with Settings still open must not lose changes. try { launcher.save(); } catch (...) {} diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 7f94c781..d5909d15 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -313,7 +313,18 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane } g_dropped.clear(); } - const bool modal = ui.settings || ui.mods || ui.sign_in || qr_open || state.prompt.has_value(); + // The Steam name for the plate, and the one-time offline notice, both come + // from the first Steam check; steam_offline_seen keeps it to one decision. + if (time - ui.steam_checked > 5) { + ui.steam_name = launcher_app::steam_persona_name(); + ui.steam_checked = time; + if (!ui.steam_offline_seen) { + ui.steam_offline_seen = true; + ui.steam_offline = !launcher.settings().offline && !launcher_app::steam_signed_in(); + } + } + const bool modal = ui.settings || ui.mods || ui.sign_in || qr_open || state.prompt.has_value() || + ui.steam_offline; ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(size); @@ -329,10 +340,6 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane std::string version = "Development build"; 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_checked = time; - } const bool greet = !launcher.settings().offline && !ui.steam_name.empty(); draw_plate(draw, fonts, ImVec2(S(50), S(40)), greet ? "Welcome, " + ui.steam_name : std::string("ReSkate"), version); draw_title(draw, fonts, ImVec2(S(52), S(88)), "RESKATE"); @@ -417,6 +424,7 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane else if (ui.sign_in) sign_in_window(launcher, fonts, size, ui); else if (ui.settings) settings_window(launcher, fonts, size, ui); else if (ui.mods) mods_window(launcher, fonts, size, ui, mods_panel, window); + else if (ui.steam_offline) steam_offline_window(fonts, size, ui); } } // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 5363e1ab..6c71b5ad 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -122,7 +122,6 @@ struct Settings { int console_key{static_cast(launcher::default_console_key)}; int log_level{2}; std::string arguments; - bool close_on_launch{true}; // Off: never replace ReSkate.dll or the launcher (keeps a test build someone handed out). bool updates{true}; // Off: a crash uploads nothing (RESKATE_CRASH_REPORTING=0 for the launcher and the game). @@ -158,12 +157,11 @@ public: State snapshot(); bool busy() const { return busy_; } bool restart_requested() const { return restart_; } - // Process id of the Skate this launcher started, until game_exited(). + // Process id of the Skate this launcher started, once it exists. DWORD game() const { return game_; } - // Injection finished and the game is running on its own. + // Injection finished and the game is running on its own; the launcher closes + // as soon as this is true. bool launched() const { return launched_; } - // Called once the started game has exited; `seen` if its splash screen ever opened. - void game_exited(bool seen); Settings& settings() { return settings_; } const launcher_app::Session& session() const { return session_; } void save(); @@ -236,6 +234,10 @@ struct Ui { // Steam display name for the name plate, re-read every few seconds. std::string steam_name; double steam_checked{-100}; + // One-time notice that Skate will start offline because Steam is not + // running or not signed in. Decided on the first Steam check. + bool steam_offline{}; + bool steam_offline_seen{}; }; // The Thunderstore listing, fetched in the background when the launcher @@ -345,6 +347,7 @@ void open_sign_in(Launcher& launcher, Ui& ui, bool validate); void sign_in_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui); void prompt_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const update::Prompt& prompt, Ui& ui); void qr_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const std::vector& rows); +void steam_offline_window(const Fonts& fonts, ImVec2 size, Ui& ui); void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui); void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window); diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index 856aba5e..7f50ad10 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -56,7 +56,6 @@ Settings load_settings(const fs::path& path) { for (std::size_t index = 0; index < log_levels.size(); ++index) if (level == log_levels[index]) settings.log_level = static_cast(index); settings.arguments = json.value("arguments", std::string()); - settings.close_on_launch = json.value("close_on_launch", settings.close_on_launch); settings.updates = json.value("updates", settings.updates); settings.crash_reports = json.value("crash_reports", settings.crash_reports); settings.steam_username = json.value("steam_username", std::string()); @@ -80,7 +79,6 @@ void save_settings(const fs::path& path, const Settings& settings) { json["console_key"] = settings.console_key; json["log_level"] = log_levels[static_cast(settings.log_level)]; json["arguments"] = settings.arguments; - json["close_on_launch"] = settings.close_on_launch; json["updates"] = settings.updates; json["crash_reports"] = settings.crash_reports; json["steam_username"] = settings.steam_username; @@ -133,14 +131,6 @@ State Launcher::snapshot() { return state_; } -void Launcher::game_exited(bool seen) { - game_ = 0; - launched_ = false; - if (busy_ || snapshot().phase == Phase::failed) return; // the failed launch explains itself - if (seen) check(); - else fail("Skate closed while it was starting."); -} - void Launcher::save() { save_settings(settings_path(session_), settings_); // The game inherits the launcher's environment, so this reaches the next launch; the @@ -400,7 +390,7 @@ void Launcher::run_play() { launched_ = true; set(Phase::launching, "Skate is starting", options.offline || !launcher_app::steam_signed_in() ? "Offline mode: playing without Steam as Unknown Player; multiplayer is hidden." : - "The launcher hides when the splash screen opens."); + "The launcher closes now; Skate keeps starting on its own."); } } // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 9071d738..a0291cad 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -183,9 +183,6 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui break; 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.", - 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. " "Skate's own Steam updates are not affected.", diff --git a/Launcher/gui_sign_in.cpp b/Launcher/gui_sign_in.cpp index ec62b83a..93d7474a 100644 --- a/Launcher/gui_sign_in.cpp +++ b/Launcher/gui_sign_in.cpp @@ -160,4 +160,21 @@ void qr_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const std::v ImGui::End(); } +// Shown once when the launcher opens without Steam, so nobody reaches the main +// menu as Unknown Player wondering why multiplayer is missing. +void steam_offline_window(const Fonts& fonts, ImVec2 size, Ui& ui) { + const auto panel = begin_panel("##steam_offline_panel", size, ImVec2(S(440), S(300))); + panel_title(fonts, "STEAM IS NOT RUNNING"); + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("Steam is not running, or no account is signed in. PLAY still works, but Skate starts in " + "offline mode: you play as Unknown Player and multiplayer is hidden."); + ImGui::Spacing(); + ImGui::TextDisabled("To play as your own account, start Steam, sign in, and open ReSkate again."); + ImGui::PopTextWrapPos(); + ImGui::SetCursorPosY(panel.y - S(24) - ImGui::GetFrameHeight()); + ImGui::SetCursorPosX(panel.x - S(28) - S(110)); + if (ImGui::Button("OK", ImVec2(S(110), 0))) ui.steam_offline = false; + ImGui::End(); +} + } // namespace dingosdk::launcher_gui::detail diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index d0dc147b..67704c44 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -24,6 +24,7 @@ #include "Extension/Throwdowns/native_throwdowns.h" #include "Extension/World/level_loading.h" #include "Extension/World/loading_screen.h" +#include #include #include #include @@ -122,18 +123,66 @@ void apply_performance_settings() { } } catch (...) {} } -// The mesh streaming pool holds every render mesh the world has loaded. GraphicsPC.lua fixes it -// at 532960 KB (520 MB) on every PC quality level, sized for San Van's streamed cells; a big -// custom map's meshes do not fit, so its level either never finishes loading or waits out the -// transition's 180 s world-quality timeout (MeshStreamingLoadStateProvider never reports high) -// and spawns the skater invisible. The pools are re-created from this setting at every level -// start (FUN_14507a2d0), so raising it before a load is enough; the quality script sets it -// again whenever graphics settings are applied, so it is kept here. The engine turns it into -// bytes in 32 bits (PoolSize << 10, plus PoolHeadroomSize), so the combined allocation -// must remain below 4 GiB. Match the 3.5 GiB PC presets instead of lowering them to 2 GiB. +// The mesh streaming pool holds every render mesh the world has loaded, in video memory. +// GraphicsPC.lua fixes it at 532960 KB (520 MB) on every PC quality level, sized for San Van's +// streamed cells; a big custom map's meshes do not fit, so its level either never finishes +// loading or waits out the transition's 180 s world-quality timeout +// (MeshStreamingLoadStateProvider never reports high) and spawns the skater invisible. The pools +// are re-created from this setting at every level start (FUN_14507a2d0), so raising it before a +// load is enough; the quality script sets it again whenever graphics settings are applied, so it +// is kept here. The engine turns it into bytes in 32 bits (PoolSize << 10, plus +// PoolHeadroomSize), so the combined allocation must remain below 4 GiB. +// How far it is raised depends on the card: 1.0.0 set 3.5 GiB on every card, the likely cause +// of older cards resetting the device while loading (DXGI_ERROR_DEVICE_RESET in +// gameRendBeginFrame on an RX 580, 2026-10-03). A quarter of the card's dedicated memory, from +// the game's own 520 MB up to 3.5 GiB (from 16 GB cards up); cards of 6 GB or less keep the +// game's value. +namespace mesh_pool { +constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u; +static_assert((std::uint64_t{most_kb} + 24576u) * 1024u <= 0xffffffffull); +struct Card { std::uint64_t memory{}; std::string name; }; +// The hardware adapter with the most dedicated memory: the one the game renders on (a laptop's +// integrated GPU has little or none). The game's own dxgi.dll, so nothing new is loaded. +Card largest_card() { + Card card; + const auto module = GetModuleHandleW(L"dxgi.dll"); + using Create = HRESULT(WINAPI*)(REFIID, void**); + const auto create = module ? reinterpret_cast(GetProcAddress(module, "CreateDXGIFactory1")) : nullptr; + IDXGIFactory1* factory{}; + if (!create || FAILED(create(__uuidof(IDXGIFactory1), reinterpret_cast(&factory))) || !factory) return card; + IDXGIAdapter1* adapter{}; + for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) { + DXGI_ADAPTER_DESC1 desc{}; + if (SUCCEEDED(adapter->GetDesc1(&desc)) && !(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) && + desc.DedicatedVideoMemory > card.memory) { + card.memory = desc.DedicatedVideoMemory; + card.name.clear(); + for (const auto* c = desc.Description; *c; ++c) card.name.push_back(*c < 128 ? static_cast(*c) : '?'); + } + adapter->Release(); + } + factory->Release(); + return card; +} +// KiB for the pool on this card, or 0 to keep the game's own value. +std::uint32_t size_kb(std::uint64_t memory) { + constexpr std::uint64_t six_gib = 6ull << 30, slack = 256ull << 20; // "6 GB" cards report a little under + if (memory <= six_gib + slack) return 0; + return static_cast(std::clamp(memory / 4 / 1024, stock_kb, most_kb)); +} +} void apply_mesh_streaming_pool() { - constexpr std::uint32_t pool_kb = 3584u * 1024u; // Exactly 3.5 GiB, expressed in KiB. - static_assert((std::uint64_t{pool_kb} + 24576u) * 1024u <= 0xffffffffull); + static const auto card = mesh_pool::largest_card(); + static const auto pool_kb = mesh_pool::size_kb(card.memory); + static bool reported = false; + if (!reported) { + reported = true; + dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime, + "Mesh streaming pool: {} for {} ({:.1f} GB of video memory).", + pool_kb ? std::format("{} MB", pool_kb / 1024) : std::string("the game's own 520 MB"), + card.name.empty() ? std::string("an unknown card") : card.name, static_cast(card.memory) / (1ull << 30)); + } + if (!pool_kb) return; static ULONGLONG next_check = 0; static std::string last_result; const auto now = GetTickCount64(); @@ -192,8 +241,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 +977,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 +989,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,