mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 16:17:06 +02:00
Players reported the game hanging for about a second while skating. A profile showed the memory line in the log walking every heap (HeapSummary) on the client thread, 0.5 to 2 s every two minutes. An audit of everything that runs on the game's update and render threads found more of the same, and Sentry's top crashes had causes of ours. Freezes: - Memory log line: no heap walk or thread snapshot; the GPU part and the write happen on a thread of their own. - Profile database: synchronous=NORMAL in WAL mode. A save is still kept when the game crashes; every play event no longer waits for the disk inside the game's hooks. - ReSkate's own preferences and values (menu clicks, session sliders, Discord) are saved by a background writer in order; reads see a value until it is saved. - Input: the foreground window and the keyboard are sampled on a thread of their own (GetAncestor and GetAsyncKeyState stalled the client tick 40-50 ms when another program held Windows' lock); flight input uses the sample; ClipCursor at most twice a second while a menu has the pointer. - Hooks installed while the game runs go in one transaction per place (HookBatch): throwdowns, remote players, the party, Hall of Meat. - Off the update thread: the host's map package lookup (one table read in the background, not the Mods folder per offer), the trainer's map file and settings save, Board Break's settings save, mod music playlists and their covers, the Spotify station covers. - Off the render thread: the Modding page's and the deck card's Mods folder reads. The overlay's three startup waits share five seconds. - Less work per frame: Steam names reused while one a frame is asked again, 2 friends a tick, steam_api64.dll checked once per process, cosmetic names read in place rather than a system call per character, an outfit load no longer copies the inventory per slot, the throwdown type scan runs at background priority over resident memory, the Board Break deck label is kept a minute. Crashes: - Render-resource pool: the metadata fields are written to the state owner only. The other renderer is 0x2daa00 bytes, and +0x11aae28 is far past its end (store_field crash; heap corruption in every release). - Other players' skateboard effects: a stale skateboard component is no longer followed, and the joint lookup is walked with a bound first (an endless loop in reused memory ended in GameStall). - Launcher: Oodle decodes into the buffer it asks for with corrupt data possible, and its assertions no longer stop the process (heap corruption in the store-copy scan; a breakpoint in oo2core). Timings (Send performance timings): - A frame of half a second or more in a level is sent as game.freeze: where the time went (ReSkate's tick, the game's frame, or outside it), whether the renderer kept presenting, and ReSkate's busiest zones. - Closing the game sends the level session it was in. Music after a connection drop: - Spotify device: an account Spotify could not be reached with is kept and tried again (3 s doubling to a minute); a session that drops is taken up again. Only an account Spotify refuses is asked for anew. - YouTube playlist pictures that could not be fetched are tried again, from 30 s to 10 minutes apart.
372 lines
19 KiB
C++
372 lines
19 KiB
C++
#include "profiler_internal.h"
|
|
#include <TlHelp32.h>
|
|
#include <psapi.h>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <array>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <thread>
|
|
|
|
namespace dingosdk::profiler {
|
|
std::atomic<bool> zones_enabled{false};
|
|
namespace detail {
|
|
std::atomic<std::uint32_t> client_thread{0}, present_thread{0};
|
|
std::atomic<bool> sampling{false};
|
|
}
|
|
namespace {
|
|
std::atomic<Site*> sites{nullptr};
|
|
std::atomic<bool> hud_visible{false}, window_visible{false}, monitor_running{false};
|
|
std::atomic<bool> zones_kept{false};
|
|
std::atomic<std::uint64_t> own_latest{0}, present_count{0};
|
|
|
|
// Recent values for the HUD's graphs. One writer each (the client update thread, the present
|
|
// thread); the overlay reads them racily, which at worst shows one value twice.
|
|
constexpr std::size_t history_size = 512;
|
|
struct Ring {
|
|
std::array<std::atomic<float>, history_size> values{};
|
|
std::atomic<std::uint64_t> written{0};
|
|
void push(float value) noexcept {
|
|
const auto at = written.load(std::memory_order_relaxed);
|
|
values[at % history_size].store(value, std::memory_order_relaxed);
|
|
written.store(at + 1, std::memory_order_release);
|
|
}
|
|
};
|
|
std::array<Ring, 4> rings;
|
|
|
|
// Totals since the monitor's last summary, taken with exchange().
|
|
struct Totals {
|
|
std::atomic<std::uint64_t> count{0}, sum{0}, longest{0};
|
|
void add(std::uint64_t value) noexcept {
|
|
count.fetch_add(1, std::memory_order_relaxed);
|
|
sum.fetch_add(value, std::memory_order_relaxed);
|
|
auto previous = longest.load(std::memory_order_relaxed);
|
|
while (value > previous && !longest.compare_exchange_weak(previous, value, std::memory_order_relaxed)) {}
|
|
}
|
|
struct Taken { std::uint64_t count, sum, longest; };
|
|
Taken take() noexcept {
|
|
return {count.exchange(0, std::memory_order_relaxed), sum.exchange(0, std::memory_order_relaxed),
|
|
longest.exchange(0, std::memory_order_relaxed)};
|
|
}
|
|
};
|
|
Totals client_interval, client_frame, client_own, present_interval;
|
|
|
|
std::mutex summary_mutex;
|
|
std::shared_ptr<const Summary> latest_summary = std::make_shared<Summary>();
|
|
|
|
bool wanted() noexcept {
|
|
return hud_visible.load(std::memory_order_acquire) || window_visible.load(std::memory_order_acquire) ||
|
|
detail::sampling.load(std::memory_order_acquire);
|
|
}
|
|
|
|
std::uint64_t filetime(const FILETIME& value) noexcept {
|
|
return (static_cast<std::uint64_t>(value.dwHighDateTime) << 32) | value.dwLowDateTime;
|
|
}
|
|
|
|
// Per-thread CPU use, from GetThreadTimes deltas. The thread list is refreshed every few
|
|
// seconds: a Toolhelp snapshot walks every thread on the system.
|
|
class ThreadMonitor {
|
|
struct Thread {
|
|
HANDLE handle{};
|
|
std::uint64_t last{};
|
|
std::string label;
|
|
bool seen{};
|
|
};
|
|
std::map<DWORD, Thread> threads_;
|
|
std::uint64_t last_process_{}, last_wall_{};
|
|
std::chrono::steady_clock::time_point next_refresh_{};
|
|
|
|
static std::string module_of(std::uintptr_t address) {
|
|
HMODULE module{};
|
|
if (!address || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
|
reinterpret_cast<LPCWSTR>(address), &module)) return {};
|
|
wchar_t name[MAX_PATH]{};
|
|
if (!GetModuleBaseNameW(GetCurrentProcess(), module, name, MAX_PATH)) return {};
|
|
std::string result;
|
|
for (const wchar_t* c = name; *c; ++c) result.push_back(*c < 128 ? static_cast<char>(*c) : '?');
|
|
return result;
|
|
}
|
|
static std::string label_of(HANDLE handle) {
|
|
PWSTR description{};
|
|
std::string result;
|
|
if (SUCCEEDED(GetThreadDescription(handle, &description)) && description) {
|
|
for (const wchar_t* c = description; *c; ++c) result.push_back(*c < 128 ? static_cast<char>(*c) : '?');
|
|
LocalFree(description);
|
|
}
|
|
if (!result.empty()) return result;
|
|
using Query = LONG(NTAPI*)(HANDLE, int, PVOID, ULONG, PULONG);
|
|
static const auto query = reinterpret_cast<Query>(
|
|
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationThread"));
|
|
void* start{};
|
|
if (query) query(handle, 9 /* ThreadQuerySetWin32StartAddress */, &start, sizeof(start), nullptr);
|
|
auto module = module_of(reinterpret_cast<std::uintptr_t>(start));
|
|
std::string lower = module;
|
|
for (auto& c : lower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
// Frostbite starts its threads through the CRT, so they all begin in ucrtbase.
|
|
if (lower == "reskate.dll") return "ReSkate worker";
|
|
if (lower == "ucrtbase.dll" || lower == "skate.exe") return "Skate";
|
|
if (lower.starts_with("steam") || lower.starts_with("tier0") || lower.starts_with("gameoverlay")) return "Steam";
|
|
if (lower.starts_with("amd") || lower.starts_with("nv") || lower.starts_with("igd") || lower.starts_with("d3d")) return "Graphics driver";
|
|
return module.empty() ? "Thread" : module;
|
|
}
|
|
void refresh() {
|
|
for (auto& [id, thread] : threads_) thread.seen = false;
|
|
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
|
if (snapshot == INVALID_HANDLE_VALUE) return;
|
|
const auto self = GetCurrentProcessId();
|
|
THREADENTRY32 entry{sizeof(entry)};
|
|
for (BOOL ok = Thread32First(snapshot, &entry); ok; ok = Thread32Next(snapshot, &entry)) {
|
|
if (entry.th32OwnerProcessID != self) continue;
|
|
auto [it, fresh] = threads_.try_emplace(entry.th32ThreadID);
|
|
it->second.seen = true;
|
|
if (fresh) {
|
|
// Full query rights read the start address (for the label); limited ones still time it.
|
|
it->second.handle = OpenThread(THREAD_QUERY_INFORMATION, FALSE, entry.th32ThreadID);
|
|
if (!it->second.handle)
|
|
it->second.handle = OpenThread(THREAD_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ThreadID);
|
|
if (it->second.handle) {
|
|
FILETIME created, exited, kernel, user;
|
|
if (GetThreadTimes(it->second.handle, &created, &exited, &kernel, &user))
|
|
it->second.last = filetime(kernel) + filetime(user);
|
|
it->second.label = label_of(it->second.handle);
|
|
}
|
|
}
|
|
}
|
|
CloseHandle(snapshot);
|
|
for (auto it = threads_.begin(); it != threads_.end();) {
|
|
if (it->second.seen) { ++it; continue; }
|
|
if (it->second.handle) CloseHandle(it->second.handle);
|
|
it = threads_.erase(it);
|
|
}
|
|
}
|
|
public:
|
|
static std::string label(HANDLE handle) { return label_of(handle); }
|
|
void sample(Summary& summary) {
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (now >= next_refresh_) { refresh(); next_refresh_ = now + std::chrono::seconds(3); }
|
|
FILETIME wall_time;
|
|
GetSystemTimePreciseAsFileTime(&wall_time);
|
|
const auto wall = filetime(wall_time);
|
|
const double elapsed = last_wall_ ? static_cast<double>(wall - last_wall_) : 0.0;
|
|
FILETIME created, exited, kernel, user;
|
|
if (GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) {
|
|
const auto process = filetime(kernel) + filetime(user);
|
|
if (elapsed > 0 && last_process_) summary.process_cpu_percent = 100.0 * static_cast<double>(process - last_process_) / elapsed;
|
|
last_process_ = process;
|
|
}
|
|
const auto client = detail::client_thread.load(std::memory_order_relaxed);
|
|
const auto present = detail::present_thread.load(std::memory_order_relaxed);
|
|
for (auto& [id, thread] : threads_) {
|
|
if (!thread.handle || !GetThreadTimes(thread.handle, &created, &exited, &kernel, &user)) continue;
|
|
const auto total = filetime(kernel) + filetime(user);
|
|
const double percent = elapsed > 0 ? 100.0 * static_cast<double>(total - thread.last) / elapsed : 0.0;
|
|
thread.last = total;
|
|
if (percent < 0.5 && id != client && id != present) continue;
|
|
std::string label = id == client ? "Client update" : id == present ? "Present" : thread.label;
|
|
summary.threads.push_back({static_cast<std::uint32_t>(id), std::move(label), percent});
|
|
}
|
|
last_wall_ = wall;
|
|
std::ranges::sort(summary.threads, [](const ThreadRow& a, const ThreadRow& b) { return a.cpu_percent > b.cpu_percent; });
|
|
}
|
|
~ThreadMonitor() {
|
|
for (auto& [id, thread] : threads_) if (thread.handle) CloseHandle(thread.handle);
|
|
}
|
|
};
|
|
|
|
void monitor() {
|
|
SetThreadDescription(GetCurrentThread(), L"ReSkate profiler");
|
|
ThreadMonitor threads;
|
|
auto last = std::chrono::steady_clock::now();
|
|
// Zone ticks to nanoseconds: the timestamp counter measured against the steady clock over
|
|
// each summary's second.
|
|
auto last_ticks = __rdtsc();
|
|
while (wanted()) {
|
|
zones_enabled.store(true, std::memory_order_relaxed);
|
|
Sleep(1000);
|
|
const auto now = std::chrono::steady_clock::now();
|
|
const double seconds = std::max(1e-3, std::chrono::duration<double>(now - last).count());
|
|
last = now;
|
|
const auto now_ticks = __rdtsc();
|
|
const double ms_per_tick = seconds * 1e3 / static_cast<double>(std::max<std::uint64_t>(1, now_ticks - last_ticks));
|
|
last_ticks = now_ticks;
|
|
auto next = std::make_shared<Summary>();
|
|
auto& s = *next;
|
|
s.seconds = seconds;
|
|
s.cores = GetActiveProcessorCount(ALL_PROCESSOR_GROUPS);
|
|
s.client_thread = detail::client_thread.load(std::memory_order_relaxed);
|
|
s.present_thread = detail::present_thread.load(std::memory_order_relaxed);
|
|
const auto ms = [](std::uint64_t ns) { return static_cast<double>(ns) / 1e6; };
|
|
const auto interval = client_interval.take(), frame = client_frame.take(), own = client_own.take();
|
|
s.client_updates_per_second = static_cast<double>(own.count) / seconds;
|
|
if (interval.count) {
|
|
s.client_interval_average_ms = ms(interval.sum) / static_cast<double>(interval.count);
|
|
s.client_interval_longest_ms = ms(interval.longest);
|
|
}
|
|
if (frame.count) {
|
|
s.client_frame_average_ms = ms(frame.sum) / static_cast<double>(frame.count);
|
|
s.client_frame_longest_ms = ms(frame.longest);
|
|
}
|
|
if (own.count) {
|
|
s.client_own_average_ms = ms(own.sum) / static_cast<double>(own.count);
|
|
s.client_own_longest_ms = ms(own.longest);
|
|
}
|
|
const auto presents = present_interval.take();
|
|
s.presents_per_second = static_cast<double>(presents.count) / seconds;
|
|
if (presents.count) {
|
|
s.present_interval_average_ms = ms(presents.sum) / static_cast<double>(presents.count);
|
|
s.present_interval_longest_ms = ms(presents.longest);
|
|
}
|
|
double hooks_ms{};
|
|
for (auto* site = sites.load(std::memory_order_acquire); site; site = site->next) {
|
|
const auto calls = site->calls.exchange(0, std::memory_order_relaxed);
|
|
const auto total = static_cast<double>(site->ticks.exchange(0, std::memory_order_relaxed)) * ms_per_tick;
|
|
const auto longest = static_cast<double>(site->longest.exchange(0, std::memory_order_relaxed)) * ms_per_tick;
|
|
if (!calls) continue;
|
|
ZoneRow row{site->name, static_cast<double>(calls) / seconds, total / seconds,
|
|
total * 1e3 / static_cast<double>(calls), longest};
|
|
if (std::string_view(site->name).starts_with("hooks/")) hooks_ms += total;
|
|
// Sites sharing a name (the same work in two hooks) show as one row.
|
|
const auto same = std::ranges::find(s.zones, row.name, &ZoneRow::name);
|
|
if (same == s.zones.end()) { s.zones.push_back(std::move(row)); continue; }
|
|
const auto calls_total = same->calls_per_second + row.calls_per_second;
|
|
same->average_microseconds = calls_total > 0 ? (same->average_microseconds * same->calls_per_second +
|
|
row.average_microseconds * row.calls_per_second) / calls_total : 0.0;
|
|
same->calls_per_second = calls_total;
|
|
same->milliseconds_per_second += row.milliseconds_per_second;
|
|
same->longest_milliseconds = std::max(same->longest_milliseconds, row.longest_milliseconds);
|
|
}
|
|
if (own.count) s.hooks_milliseconds_per_update = hooks_ms / static_cast<double>(own.count);
|
|
std::ranges::sort(s.zones, [](const ZoneRow& a, const ZoneRow& b) {
|
|
return a.milliseconds_per_second > b.milliseconds_per_second;
|
|
});
|
|
threads.sample(s);
|
|
std::lock_guard lock(summary_mutex);
|
|
latest_summary = std::move(next);
|
|
}
|
|
zones_enabled.store(zones_kept.load(std::memory_order_relaxed), std::memory_order_relaxed);
|
|
monitor_running.store(false, std::memory_order_release);
|
|
// Something may have asked again between the last check and the flag being cleared.
|
|
if (wanted()) detail::wake_monitor();
|
|
}
|
|
} // namespace
|
|
|
|
namespace detail {
|
|
std::string thread_label(HANDLE thread, std::uint32_t id) {
|
|
if (id == client_thread.load(std::memory_order_relaxed)) return "Client update";
|
|
if (id == present_thread.load(std::memory_order_relaxed)) return "Present";
|
|
return ThreadMonitor::label(thread);
|
|
}
|
|
void wake_monitor() noexcept {
|
|
if (!wanted() || monitor_running.exchange(true, std::memory_order_acq_rel)) return;
|
|
zones_enabled.store(true, std::memory_order_relaxed);
|
|
try { std::thread(monitor).detach(); }
|
|
catch (...) { monitor_running.store(false, std::memory_order_release); }
|
|
}
|
|
}
|
|
|
|
Site::Site(const char* site_name) noexcept : name(site_name) {
|
|
auto* head = sites.load(std::memory_order_relaxed);
|
|
do { next = head; } while (!sites.compare_exchange_weak(head, this, std::memory_order_release, std::memory_order_relaxed));
|
|
}
|
|
|
|
void record_client_update(std::uint64_t own_ns) noexcept {
|
|
// Known even while off, so a sample can target it straight away.
|
|
detail::client_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
|
own_latest.store(own_ns, std::memory_order_relaxed);
|
|
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
|
static std::uint64_t last{};
|
|
const auto now = now_ns();
|
|
if (last && now > last) {
|
|
client_interval.add(now - last);
|
|
rings[static_cast<std::size_t>(Series::client_interval)].push(static_cast<float>(now - last) / 1e6f);
|
|
}
|
|
last = now;
|
|
client_own.add(own_ns);
|
|
rings[static_cast<std::size_t>(Series::client_own)].push(static_cast<float>(own_ns) / 1e6f);
|
|
}
|
|
|
|
void record_client_frame(std::uint64_t frame_ns) noexcept {
|
|
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
|
client_frame.add(frame_ns);
|
|
rings[static_cast<std::size_t>(Series::client_frame)].push(static_cast<float>(frame_ns) / 1e6f);
|
|
}
|
|
|
|
void record_present() noexcept {
|
|
detail::present_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
|
present_count.fetch_add(1, std::memory_order_relaxed);
|
|
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
|
static std::uint64_t last{};
|
|
const auto now = now_ns();
|
|
if (last && now > last) {
|
|
present_interval.add(now - last);
|
|
rings[static_cast<std::size_t>(Series::present_interval)].push(static_cast<float>(now - last) / 1e6f);
|
|
}
|
|
last = now;
|
|
}
|
|
|
|
std::size_t history(Series series, std::span<float> out) noexcept {
|
|
auto& ring = rings[static_cast<std::size_t>(series)];
|
|
const auto written = ring.written.load(std::memory_order_acquire);
|
|
const auto count = static_cast<std::size_t>(std::min<std::uint64_t>({written, history_size, out.size()}));
|
|
for (std::size_t i = 0; i < count; ++i)
|
|
out[i] = ring.values[(written - count + i) % history_size].load(std::memory_order_relaxed);
|
|
return count;
|
|
}
|
|
|
|
void set_hud(bool visible) noexcept { hud_visible.store(visible, std::memory_order_release); detail::wake_monitor(); }
|
|
bool hud() noexcept { return hud_visible.load(std::memory_order_acquire); }
|
|
void set_window(bool visible) noexcept { window_visible.store(visible, std::memory_order_release); detail::wake_monitor(); }
|
|
bool window() noexcept { return window_visible.load(std::memory_order_acquire); }
|
|
bool active() noexcept { return wanted(); }
|
|
|
|
void keep_zones(bool on) noexcept {
|
|
zones_kept.store(on, std::memory_order_relaxed);
|
|
if (on) zones_enabled.store(true, std::memory_order_relaxed);
|
|
else if (!monitor_running.load(std::memory_order_acquire)) zones_enabled.store(false, std::memory_order_relaxed);
|
|
}
|
|
std::uint64_t last_client_own_ns() noexcept { return own_latest.load(std::memory_order_relaxed); }
|
|
std::uint64_t presents() noexcept { return present_count.load(std::memory_order_relaxed); }
|
|
|
|
void mark_zones(ZoneMark& mark) noexcept {
|
|
try {
|
|
mark.totals.clear();
|
|
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next)
|
|
mark.totals.emplace_back(site, site->ticks.load(std::memory_order_relaxed));
|
|
mark.tsc = __rdtsc();
|
|
mark.ns = now_ns();
|
|
} catch (...) { mark.totals.clear(); }
|
|
}
|
|
|
|
std::vector<ZoneTime> zones_since(const ZoneMark& mark, std::size_t most) noexcept {
|
|
std::vector<ZoneTime> result;
|
|
try {
|
|
const auto ns = now_ns(), tsc = __rdtsc();
|
|
if (ns <= mark.ns || tsc <= mark.tsc) return result;
|
|
// Ticks to milliseconds over this very stretch.
|
|
const double ms_per_tick = static_cast<double>(ns - mark.ns) / 1e6 / static_cast<double>(tsc - mark.tsc);
|
|
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next) {
|
|
const auto now = site->ticks.load(std::memory_order_relaxed);
|
|
// (Sites only ever join the list's head, so one the mark lacks began at zero; the
|
|
// profiler window's summary sets them back to zero each second, and a total below
|
|
// the mark's started again since.)
|
|
const auto found = std::ranges::find(mark.totals, site, &std::pair<const Site*, std::uint64_t>::first);
|
|
const auto before = found == mark.totals.end() ? 0 : found->second;
|
|
const auto ticks = now >= before ? now - before : now;
|
|
if (!ticks) continue;
|
|
const auto ms = static_cast<double>(ticks) * ms_per_tick;
|
|
const auto same = std::ranges::find(result, std::string_view(site->name), &ZoneTime::name);
|
|
if (same != result.end()) same->milliseconds += ms;
|
|
else result.push_back({site->name, ms});
|
|
}
|
|
std::ranges::sort(result, [](const ZoneTime& a, const ZoneTime& b) { return a.milliseconds > b.milliseconds; });
|
|
if (result.size() > most) result.resize(most);
|
|
} catch (...) { result.clear(); }
|
|
return result;
|
|
}
|
|
|
|
std::shared_ptr<const Summary> summary() noexcept {
|
|
std::lock_guard lock(summary_mutex);
|
|
return latest_summary;
|
|
}
|
|
} // namespace dingosdk::profiler
|