mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 20:57:08 +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.
258 lines
12 KiB
C++
258 lines
12 KiB
C++
#include "overlay_internal.h"
|
|
#include "cursor.h"
|
|
#include "input_capture.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
|
|
namespace dingosdk::overlay::detail {
|
|
namespace {
|
|
bool query_foreground(HWND window) {
|
|
if (!window || !IsWindow(window)) return false;
|
|
const auto root = GetAncestor(window, GA_ROOT);
|
|
return GetForegroundWindow() == (root ? root : window) && !IsIconic(root ? root : window);
|
|
}
|
|
|
|
// Whether the game's window is in front, and which keys are held, as Windows said a few
|
|
// milliseconds ago, asked on a thread of its own. Those calls (GetAncestor, GetForegroundWindow,
|
|
// GetAsyncKeyState) wait on a lock Windows shares among every program's windows, and another
|
|
// program holding it held up the game's own frame with them: 40 to 50 ms at a time inside
|
|
// GetAncestor, from the client tick's controller reads (a player's profile, 2026-10-15). The
|
|
// readers, the client tick among them, take the answer from here instead.
|
|
struct DesktopSample {
|
|
std::atomic<HWND> window{};
|
|
std::atomic<bool> foreground{};
|
|
std::array<std::atomic<std::uint64_t>, 4> keys{};
|
|
std::atomic<ULONGLONG> at{}; // GetTickCount64 when the last sample was complete
|
|
std::atomic<ULONGLONG> wanted{}; // the last time a reader asked for it
|
|
std::atomic<bool> started{};
|
|
};
|
|
DesktopSample& desktop() {
|
|
static auto* sample = new DesktopSample; // process lifetime, as its thread
|
|
return *sample;
|
|
}
|
|
// A sample is used however old it is once there is one: one that is late means the sampler is
|
|
// waiting on Windows, and asking Windows from the game's thread then would wait as well. Before
|
|
// the first, the caller asks itself.
|
|
constexpr ULONGLONG idle_ms = 2000;
|
|
constexpr LONGLONG period_100ns = 4 * 10000; // 4 ms: 250 samples a second while read
|
|
|
|
DWORD WINAPI sample_desktop(void*) noexcept {
|
|
auto& d = desktop();
|
|
const HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
|
for (;;) {
|
|
// (Nothing asking, as when the game is minimised or closing: no polling.)
|
|
if (GetTickCount64() - d.wanted.load(std::memory_order_relaxed) > idle_ms) {
|
|
Sleep(50);
|
|
continue;
|
|
}
|
|
const auto window = state().window.load();
|
|
const bool front = query_foreground(window);
|
|
std::array<std::uint64_t, 4> keys{};
|
|
if (front) {
|
|
OverlayInputAccess access; // the real keyboard, whatever the menus hold from the game
|
|
for (int key = 1; key < 255; ++key)
|
|
if (GetAsyncKeyState(key) & 0x8000) keys[key / 64] |= std::uint64_t{1} << (key % 64);
|
|
}
|
|
d.window.store(window, std::memory_order_relaxed);
|
|
d.foreground.store(front, std::memory_order_relaxed);
|
|
for (std::size_t i = 0; i < keys.size(); ++i) d.keys[i].store(keys[i], std::memory_order_relaxed);
|
|
d.at.store(GetTickCount64(), std::memory_order_release);
|
|
LARGE_INTEGER due{};
|
|
due.QuadPart = -period_100ns;
|
|
if (timer && SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE)) WaitForSingleObject(timer, INFINITE);
|
|
else Sleep(4);
|
|
}
|
|
}
|
|
// The sample of this window, when there is a recent one; starts the sampler the first time.
|
|
bool recent_sample(HWND window) noexcept {
|
|
auto& d = desktop();
|
|
const auto now = GetTickCount64();
|
|
d.wanted.store(now, std::memory_order_relaxed);
|
|
if (!d.started.load(std::memory_order_relaxed) && !d.started.exchange(true)) {
|
|
if (const auto thread = CreateThread(nullptr, 0, sample_desktop, nullptr, 0, nullptr)) CloseHandle(thread);
|
|
else d.started.store(false);
|
|
}
|
|
return d.at.load(std::memory_order_acquire) && d.window.load(std::memory_order_relaxed) == window;
|
|
}
|
|
} // namespace
|
|
|
|
bool game_window_foreground(HWND window) {
|
|
if (recent_sample(window)) return desktop().foreground.load(std::memory_order_relaxed);
|
|
return query_foreground(window); // (before the first sample, or with the sampler stopped)
|
|
}
|
|
|
|
bool sampled_keys(HWND window, std::array<std::uint64_t, 4>& held) {
|
|
if (!recent_sample(window)) return false;
|
|
auto& d = desktop();
|
|
if (!d.foreground.load(std::memory_order_relaxed)) return false;
|
|
for (std::size_t i = 0; i < held.size(); ++i) held[i] = d.keys[i].load(std::memory_order_relaxed);
|
|
return true;
|
|
}
|
|
|
|
bool owns_menu_cursor(const State& s) {
|
|
// WM_KILLFOCUS can refer to a child control. A cached flag must not let the
|
|
// game resume clipping/centering while its top-level window is still active.
|
|
return s.input_attached.load() && !s.stop.load() && !s.failed.load() &&
|
|
interactive_visible(s) && game_window_foreground(s.window.load());
|
|
}
|
|
|
|
UINT cursor_sync_message() {
|
|
static const auto message = RegisterWindowMessageW(L"ReSkate.Overlay.CursorOwnership");
|
|
return message;
|
|
}
|
|
|
|
void request_window_cursor_sync(HWND window) {
|
|
const auto message = cursor_sync_message();
|
|
if (window && message) PostMessageW(window, message, 0, 0);
|
|
}
|
|
|
|
void sync_menu_cursor(bool release) {
|
|
auto& s = state();
|
|
bool changed{};
|
|
const auto window = s.window.load();
|
|
{
|
|
std::lock_guard lock(s.cursor_mutex);
|
|
const auto clip = original<ClipCursorFn>(s.clip_cursor);
|
|
if (!clip) return;
|
|
const bool own = !release && owns_menu_cursor(s);
|
|
changed = own != s.cursor_released;
|
|
if (own) {
|
|
// Freed when the menu takes the pointer, then again at most twice a second: this runs
|
|
// each frame the hub is drawn, and ClipCursor takes Windows' input lock for itself.
|
|
static ULONGLONG freed_at{};
|
|
const auto now = GetTickCount64();
|
|
if (!s.cursor_released || now - freed_at >= 500) {
|
|
if (!s.cursor_released) s.cursor_clip_pending = GetClipCursor(&s.cursor_clip) != FALSE;
|
|
s.cursor_released = true;
|
|
freed_at = now;
|
|
clip(nullptr);
|
|
}
|
|
} else if (s.cursor_released) {
|
|
s.cursor_released = false;
|
|
// Do not restore a game's clip over another foreground application.
|
|
clip(!release && game_window_foreground(window) && s.cursor_clip_pending ? &s.cursor_clip : nullptr);
|
|
s.cursor_clip_pending = false;
|
|
}
|
|
}
|
|
if (changed) {
|
|
// GetCapture/ReleaseCapture only affect the calling thread's queue.
|
|
// The present thread cannot release the window thread's game capture.
|
|
request_window_cursor_sync(window);
|
|
logging::write(logging::Level::info, logging::Channel::input,
|
|
!release && owns_menu_cursor(s) ? "Input ownership: overlay." : "Input ownership: game or desktop.");
|
|
}
|
|
}
|
|
|
|
BOOL WINAPI clip_cursor(const RECT* rectangle) {
|
|
auto& s = state();
|
|
std::lock_guard lock(s.cursor_mutex);
|
|
const auto native = original<ClipCursorFn>(s.clip_cursor);
|
|
if (owns_menu_cursor(s)) {
|
|
// Remember the latest native request for closing the menu; never apply
|
|
// it while either overlay surface owns the pointer.
|
|
if (!s.cursor_released) {
|
|
s.cursor_released = true;
|
|
request_window_cursor_sync(s.window.load());
|
|
}
|
|
s.cursor_clip_pending = rectangle != nullptr;
|
|
if (rectangle) s.cursor_clip = *rectangle;
|
|
return native(nullptr);
|
|
}
|
|
s.cursor_released = false; s.cursor_clip_pending = false;
|
|
return native(rectangle);
|
|
}
|
|
|
|
BOOL WINAPI set_cursor_pos(int x, int y) {
|
|
if (block_polled_input()) return TRUE;
|
|
return original<SetCursorPosFn>(state().set_cursor_pos)(x, y);
|
|
}
|
|
|
|
void window_cursor_input(HWND window, UINT message, WPARAM wp) {
|
|
if (GetWindowThreadProcessId(window, nullptr) != GetCurrentThreadId()) return;
|
|
const auto& s = state();
|
|
window_cursor_input(window, message, wp, message != WM_NCDESTROY && window == s.window.load() && owns_menu_cursor(s));
|
|
}
|
|
|
|
void window_cursor_input(HWND window, UINT message, WPARAM wp, bool overlay_owns_pointer) {
|
|
auto& s = state();
|
|
if (GetWindowThreadProcessId(window, nullptr) != GetCurrentThreadId()) return;
|
|
// These fields belong to the HWND thread, including when Present posts the
|
|
// handoff after initial setup, a close button, or swapchain recreation.
|
|
thread_local HWND owner{};
|
|
thread_local unsigned buttons{};
|
|
const bool active = message != WM_NCDESTROY && window == s.window.load() && overlay_owns_pointer;
|
|
if (!active) {
|
|
if (owner == window) {
|
|
owner = nullptr; buttons = 0;
|
|
OverlayInputAccess access;
|
|
if (GetCapture() == window) ReleaseCapture();
|
|
}
|
|
return;
|
|
}
|
|
OverlayInputAccess access;
|
|
if (owner != window) {
|
|
const auto captured = GetCapture();
|
|
const auto previous = owner;
|
|
owner = window; buttons = 0;
|
|
if (captured && (captured == previous || captured == window || IsChild(window, captured))) ReleaseCapture();
|
|
}
|
|
const auto button = [&]() -> unsigned {
|
|
switch (message) {
|
|
case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK: case WM_LBUTTONUP: return 1;
|
|
case WM_RBUTTONDOWN: case WM_RBUTTONDBLCLK: case WM_RBUTTONUP: return 2;
|
|
case WM_MBUTTONDOWN: case WM_MBUTTONDBLCLK: case WM_MBUTTONUP: return 4;
|
|
case WM_XBUTTONDOWN: case WM_XBUTTONDBLCLK: case WM_XBUTTONUP: return HIWORD(wp) == XBUTTON2 ? 16 : 8;
|
|
default: return 0;
|
|
}
|
|
}();
|
|
const bool up = message == WM_LBUTTONUP || message == WM_RBUTTONUP ||
|
|
message == WM_MBUTTONUP || message == WM_XBUTTONUP;
|
|
if (button) {
|
|
if (up) buttons &= ~button;
|
|
else buttons |= button;
|
|
if (buttons && GetCapture() != window) SetCapture(window);
|
|
else if (!buttons && GetCapture() == window) ReleaseCapture();
|
|
}
|
|
if (message == WM_CANCELMODE || message == WM_KILLFOCUS) {
|
|
buttons = 0;
|
|
if (GetCapture() == window) ReleaseCapture();
|
|
}
|
|
}
|
|
|
|
void update_menu_pointer() {
|
|
auto& s = state();
|
|
auto& io = ImGui::GetIO();
|
|
const bool active = owns_menu_cursor(s);
|
|
// The pause menu's server browser watches the pointer while the game keeps it: the same
|
|
// reads, with the game's own cursor on screen and nothing taken from the game.
|
|
const bool watching = !active && s.hub_pointer.load() && s.input_attached && !s.stop && !s.failed &&
|
|
game_window_foreground(s.window);
|
|
io.AddFocusEvent(active || watching);
|
|
io.MouseDrawCursor = active;
|
|
if (!active && !watching) {
|
|
io.ClearInputKeys(); io.ClearInputMouse();
|
|
s.raw_mouse_buttons.store(0);
|
|
return;
|
|
}
|
|
if (watching) io.ClearInputKeys();
|
|
OverlayInputAccess access;
|
|
POINT point{};
|
|
// The Win32 backend only polls when it thinks WM_MOUSEMOVE tracking ended.
|
|
// Skate can stop legacy messages during gameplay without WM_MOUSELEAVE.
|
|
if (GetCursorPos(&point) && ScreenToClient(s.window.load(), &point))
|
|
io.AddMousePosEvent(static_cast<float>(point.x), static_cast<float>(point.y));
|
|
const bool swapped = GetSystemMetrics(SM_SWAPBUTTON) != 0;
|
|
const std::array keys{swapped ? VK_RBUTTON : VK_LBUTTON, swapped ? VK_LBUTTON : VK_RBUTTON,
|
|
VK_MBUTTON, VK_XBUTTON1, VK_XBUTTON2};
|
|
// Raw buttons fill in when Skate's controller mode leaves the legacy state stale.
|
|
const auto time = s.raw_mouse_time.load();
|
|
const auto raw = time && GetTickCount64() < time + 10000 ? s.raw_mouse_buttons.load() : 0u;
|
|
const std::array<unsigned, 3> raw_bits{swapped ? 2u : 1u, swapped ? 1u : 2u, 4u};
|
|
for (int button = 0; button < static_cast<int>(keys.size()); ++button)
|
|
io.AddMouseButtonEvent(button, (GetAsyncKeyState(keys[button]) & 0x8000) != 0 ||
|
|
(button < 3 && (raw & raw_bits[static_cast<std::size_t>(button)]) != 0));
|
|
}
|
|
}
|