Trim per-frame multiplayer client work; size mesh pool to the GPU

Mesh streaming pool (fixes loading crashes on older GPUs)
- 1.0.0 forced MeshStreaming.PoolSize to 3.5 GiB on every card. An RX 580
  crashed while loading with DXGI_ERROR_DEVICE_RESET, and the oversized pool
  is the likely cause. The pool is now a quarter of the largest hardware
  adapter's dedicated video memory, capped at 3.5 GiB; cards of 6 GB or less
  keep the game's own 520 MB. The card, its memory and the chosen size are
  logged once at startup.

Remote players (busy servers)
- Transform interpolate is inline plain arithmetic: 22 -> 5.8 ns per joint.
  It was 4% of a busy server's client frame, since every player within
  200 m has the whole skeleton interpolated every frame.
- Players out of the camera's view and more than 8 m away are sampled at
  20 Hz, like far players. Their position still updates every frame for
  collision, sound and nametags, and a player coming into view is sampled
  that frame.

Expression hooks (run for every expression the game executes)
- No profiler zones in the expression hooks: taking a sample switched them
  on, inflating the very samples that measured the hooks.
- The throwdown lab pump does one sorted lookup of the running graph and
  skips everything else for graphs it doesn't watch (graph_throttle gets
  throttled_graph for this).

Settings and memory reads
- Script gameplay-setting and native-setting gets are served from small
  per-thread caches, keyed on the asset/key address, its first 8 name
  bytes and a new profile::Store::changes() counter, instead of a store
  lookup each call.
- Per-frame reads in the client tick, native settings and population
  controls use memory::peek instead of ReadProcessMemory.
- Script error logging probes each argument pointer once.
This commit is contained in:
AyeZeeBB committed 2026-10-02 21:07:43 -05:00
1 parent f7fc909ca7
commit b763631b77
24 files changed
+320 -151

No files matched your search

-18
View File
@@ -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<float, 16> 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];
+24 -1
View File
@@ -11,6 +11,7 @@
#include "Engine/Game/World/park_rotation.h"
#include "Engine/Game/World/world_layers.h"
#include <array>
#include <cmath>
#include <cstdint>
#include <deque>
#include <optional>
@@ -213,7 +214,29 @@ std::optional<Packet> decode(std::span<const std::uint8_t>) 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<float, 3> &);
std::array<float, 16> to_matrix(const Transform &);
Transform from_matrix(const std::array<float, 16> &);
+27 -1
View File
@@ -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<GameView> &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<GameView> &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<float>(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);
@@ -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.
+7
View File
@@ -8,6 +8,7 @@
#include <algorithm>
#include <Windows.h>
#include <array>
#include <atomic>
#include <cmath>
#include <fstream>
#include <limits>
@@ -256,6 +257,9 @@ std::string with_cached_entitlements(std::string text) {
std::string_view embedded_defaults() noexcept {
return {reinterpret_cast<const char*>(embedded_profile_defaults), sizeof(embedded_profile_defaults)};
}
namespace {
std::atomic<std::uint64_t> 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<storage::SaveDatabase>(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<const Snapshot> 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);
}
}
+3
View File
@@ -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<const MissionState> mission_state() const;
@@ -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);
}
+5 -1
View File
@@ -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<std::string> seen; static std::set<std::uintptr_t> looked_at;
static std::mutex mutex; static std::set<std::string> seen; static std::set<std::uintptr_t> 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<std::uintptr_t>(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);
}
@@ -3,6 +3,9 @@
#include "Extension/Progression/local_entitlement_trigger_runtime.h"
#include "local_gameplay_settings.h"
#include "local_user_settings.h"
#include <optional>
#include <string_view>
#include <array>
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<const void*>(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<std::uintptr_t>(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<dingosdk::Json> value; };
thread_local std::array<Entry, 256> 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<unsigned char>(text[i]);
if (c < 32 || c == 127) return nullptr;
}
entry.changes = 0;
entry.value = s.store->user_value(std::string_view(text, static_cast<std::size_t>(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;
+5 -5
View File
@@ -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<class T> std::optional<T> 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<T, bool>) { if (value->is_boolean()) return value->get<bool>(); }
if constexpr (std::is_same_v<T, float>) {
+54 -15
View File
@@ -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 <optional>
#include <string_view>
#include <array>
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<dingosdk::Json> native_setting_json(const NativeSettingValue* input) {
NativeSettingValue value{};
if (!read(reinterpret_cast<std::uintptr_t>(input), value) || !value.data) return {};
if (!memory::peek(reinterpret_cast<std::uintptr_t>(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<std::uintptr_t>(value.data), byte) && byte <= 1) return byte != 0;
if (memory::peek(reinterpret_cast<std::uintptr_t>(value.data), byte) && byte <= 1) return byte != 0;
return {};
}
case native_types::native_uint32: return native_scalar<std::uint32_t>(value.data);
case native_types::native_int32: return native_scalar<std::int32_t>(value.data);
case native_types::native_float32: return native_scalar<float>(value.data);
case native_types::native_cstring: {
std::uintptr_t text{}; std::string result;
if (!read(reinterpret_cast<std::uintptr_t>(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<std::uintptr_t>(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<std::size_t>(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<std::uintptr_t>(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<dingosdk::Json> value; };
thread_local std::array<Entry, 256> 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<unsigned char>(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<std::size_t>(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<std::uintptr_t>(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<std::uintptr_t>(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;
}
+1 -1
View File
@@ -17,7 +17,7 @@ unsigned native_settings_location(std::uintptr_t group);
template<class T> std::optional<dingosdk::Json> native_scalar(const void* data) {
T value{};
if (!read(reinterpret_cast<std::uintptr_t>(data), value)) return {};
if (!memory::peek(reinterpret_cast<std::uintptr_t>(data), value)) return {};
if constexpr (std::is_floating_point_v<T>) if (!std::isfinite(value)) return {};
if constexpr (std::is_same_v<T, std::uint64_t>) if (value > INT64_MAX) return {};
return dingosdk::Json(value);
+6
View File
@@ -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;
+2
View File
@@ -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
+21 -1
View File
@@ -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<std::uint32_t> 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<const Address*>(vm + 0x38);
const auto graph = resource ? *reinterpret_cast<const std::uint32_t*>(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<const Address*>(vm + 0x38);
@@ -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<void*>(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<float>(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<float>(v); return true;
}
bool population_write(std::uintptr_t config, unsigned i, float value) {
+2 -2
View File
@@ -68,7 +68,7 @@ template<unsigned Kind> 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<unsigned Kind> 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<unsigned>((end - begin) / 8);
}
}
+6 -51
View File
@@ -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<Search*>(parameter);
DWORD owner{};
GetWindowThreadProcessId(window, &owner);
if (owner != target.process || !IsWindowVisible(window)) return TRUE;
std::array<wchar_t, 64> name{};
GetClassNameW(window, name.data(), static_cast<int>(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<LPARAM>(&search));
return search.found;
}
} // namespace
} // namespace detail
@@ -275,11 +258,6 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& 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<std::wstring>& 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<std::wstring>& 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 (...) {}
+13 -5
View File
@@ -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
+8 -5
View File
@@ -122,7 +122,6 @@ struct Settings {
int console_key{static_cast<int>(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<std::string>& 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);
+1 -11
View File
@@ -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<int>(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<std::size_t>(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
-3
View File
@@ -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.",
+17
View File
@@ -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
+65 -15
View File
@@ -24,6 +24,7 @@
#include "Extension/Throwdowns/native_throwdowns.h"
#include "Extension/World/level_loading.h"
#include "Extension/World/loading_screen.h"
#include <dxgi.h>
#include <algorithm>
#include <array>
#include <cctype>
@@ -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<Create>(GetProcAddress(module, "CreateDXGIFactory1")) : nullptr;
IDXGIFactory1* factory{};
if (!create || FAILED(create(__uuidof(IDXGIFactory1), reinterpret_cast<void**>(&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<char>(*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::uint32_t>(std::clamp<std::uint64_t>(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<double>(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,