Files
AyeZeeBB 719cba1166 Freezes and crashes: the game's threads no longer wait on ReSkate's slow work
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.
2026-10-15 18:10:53 -05:00

627 lines
33 KiB
C++

#include "Engine/Core/Platform/launcher_support.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Boot/exit_watch.h"
#include "overlay_internal.h"
#include "input_capture.h"
#include "playstation_input.h"
#include "cursor.h"
using namespace dingosdk::overlay::detail;
namespace dingosdk::overlay::detail {
bool console_toggle_key(UINT message, WPARAM wp, LPARAM lp) {
const bool key_message = message == WM_KEYDOWN || message == WM_SYSKEYDOWN
|| message == WM_KEYUP || message == WM_SYSKEYUP;
const auto console = dingosdk::launcher::overlay_keys().console;
// The default also matches by position (scan code 0x29, left of 1), so
// layouts that put another character there still open the console.
if (console != dingosdk::launcher::default_console_key) return key_message && wp == console;
const auto scan_code = (static_cast<ULONG_PTR>(lp) >> 16) & 0xffu;
return key_message && (wp == VK_OEM_3 || scan_code == 0x29u);
}
bool console_character(UINT message) {
return message == WM_CHAR || message == WM_SYSCHAR
|| message == WM_DEADCHAR || message == WM_SYSDEADCHAR;
}
// Reads one WM_INPUT packet without consuming it (the game or DefWindowProc
// still receives the message). True when it was a mouse packet and was noted.
bool note_raw_mouse(HRAWINPUT handle, bool overlay_owns_pointer) {
RAWINPUT raw{};
UINT size = sizeof(raw);
{
OverlayInputAccess access;
if (GetRawInputData(handle, RID_INPUT, &raw, &size, sizeof(RAWINPUTHEADER)) == UINT(-1)) return false;
}
if (raw.header.dwType != RIM_TYPEMOUSE) return false;
note_raw_mouse_packet(raw.data.mouse, overlay_owns_pointer);
return true;
}
// Focus changes of the game window, with the window and program on the other side: the
// game (and Steam Input's controller layout) follow them, and a stray one can leave the
// controller unanswered until the player tabs out and back.
void log_focus_change(HWND window, UINT message, WPARAM wp, LPARAM lp) {
static std::atomic<unsigned> logged{};
if (logged.fetch_add(1) >= 400) return;
const auto describe = [](HWND other) {
if (!other) return std::string("no window");
wchar_t name[128]{};
GetClassNameW(other, name, 128);
DWORD process{};
GetWindowThreadProcessId(other, &process);
std::string text;
for (const wchar_t* c = name; *c && text.size() < 100; ++c) text.push_back(*c < 128 ? static_cast<char>(*c) : '?');
return text + (process == GetCurrentProcessId() ? " (this game)" : " (process " + std::to_string(process) + ")");
};
std::string text;
if (message == WM_ACTIVATEAPP)
text = wp ? "game became the active application" : "game stopped being the active application (thread " +
std::to_string(static_cast<DWORD>(lp)) + " took over)";
else if (message == WM_ACTIVATE)
text = LOWORD(wp) == WA_INACTIVE ? "game window deactivated for " + describe(reinterpret_cast<HWND>(lp))
: "game window activated from " + describe(reinterpret_cast<HWND>(lp));
else if (message == WM_KILLFOCUS)
text = "keyboard focus moved to " + describe(reinterpret_cast<HWND>(wp));
else if (message == WM_SETFOCUS)
text = "keyboard focus returned from " + describe(reinterpret_cast<HWND>(wp));
else
return;
(void)window;
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Focus: %s.", text.c_str());
}
void release_game_buttons(HWND window, WNDPROC previous) {
auto& s = state();
if (!previous) return;
for (unsigned key = 0; key < s.game_keys.size(); ++key) if (s.game_keys[key]) {
s.game_keys[key] = false;
const LPARAM bits = 1 | (static_cast<LPARAM>(MapVirtualKeyW(key, MAPVK_VK_TO_VSC)) << 16) |
static_cast<LPARAM>((1ull << 30) | (1ull << 31));
CallWindowProcW(previous, window, WM_KEYUP, key, bits);
}
constexpr std::array releases{WM_LBUTTONUP, WM_RBUTTONUP, WM_MBUTTONUP, WM_XBUTTONUP, WM_XBUTTONUP};
for (unsigned i = 0; i < releases.size(); ++i) if (s.game_mouse_buttons & (1u << i))
CallWindowProcW(previous, window, releases[i], i >= 3 ? MAKEWPARAM(0, i == 3 ? XBUTTON1 : XBUTTON2) : 0, 0);
s.game_mouse_buttons = 0;
}
LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
struct NativeInputScope {
unsigned saved{overlay_input_access};
NativeInputScope() { overlay_input_access = 0; }
~NativeInputScope() { overlay_input_access = saved; }
} native_input;
auto& s = state();
WNDPROC previous{};
{
std::lock_guard lock(s.window_hook_mutex);
if (const auto found = s.window_hooks.find(window); found != s.window_hooks.end()) {
previous = found->second.previous;
if (message == WM_NCDESTROY) found->second.installed = false;
}
}
// Mouse movement and raw input arrive up to thousands of times a second, and
// whether the overlay owns the pointer costs window-manager calls (the
// foreground check). Nothing this procedure does with those messages changes
// it, so it is worked out once for them; keys, focus and everything else still
// ask at each step.
const bool pointer_message = window == s.window.load() && (message == WM_INPUT || message == WM_MOUSEMOVE ||
message == WM_SETCURSOR || message == WM_NCHITTEST);
const bool pointer_owned = pointer_message && owns_menu_cursor(s);
const auto owns_pointer = [&] { return pointer_message ? pointer_owned : owns_menu_cursor(s); };
if (window == s.window.load()) sync_raw_mouse_registration(owns_pointer());
if (message && message == cursor_sync_message()) {
window_cursor_input(window, message, wp);
return 0;
}
// An overlay above us may keep this forwarding link after the selected
// window changes. Its original procedure belongs to that HWND.
if (window != s.window.load())
return previous ? CallWindowProcW(previous, window, message, wp, lp)
: DefWindowProcW(window, message, wp, lp);
// The player closing the game: it is ended if it does not finish by itself.
dingosdk::exit_watch::note_window_message(window, message, wp);
struct ReleaseOnOpen {
HWND window; WNDPROC previous; bool was_visible;
~ReleaseOnOpen() {
if (!was_visible && interactive_visible(state())) release_game_buttons(window, previous);
}
} release_on_open{window, previous, interactive_visible(s)};
// The raw packet noted below stays noted while the game handles this message,
// so its own GetRawInputData read of the same packet is not counted again.
struct NotedRawInput {
HRAWINPUT saved{noted_raw_input};
~NotedRawInput() { noted_raw_input = saved; }
} noted_raw_input_scope;
if (message == WM_ACTIVATEAPP || message == WM_ACTIVATE || message == WM_KILLFOCUS || message == WM_SETFOCUS)
log_focus_change(window, message, wp, lp);
if ((message >= WM_KEYFIRST && message <= WM_KEYLAST && message != WM_CHAR) ||
(message >= WM_MOUSEFIRST && message <= WM_MOUSELAST))
note_input_message(message, wp);
if (!s.stop.load() && s.input_attached.load()) {
if (message == WM_INPUT && note_raw_mouse(reinterpret_cast<HRAWINPUT>(lp), pointer_owned))
noted_raw_input = reinterpret_cast<HRAWINPUT>(lp);
if (message == WM_KILLFOCUS) s.raw_mouse_buttons.store(0);
if (pointer_message) window_cursor_input(window, message, wp, pointer_owned);
else window_cursor_input(window, message, wp);
const bool key = message == WM_KEYDOWN || message == WM_SYSKEYDOWN;
const bool release = message == WM_KEYUP || message == WM_SYSKEYUP;
if (console_character(message) && s.console_character_pending.exchange(false)) return 0;
if (console_character(message) && s.chat_character_pending.exchange(false)) return 0;
if (console_character(message) && s.item_search_character_pending.exchange(false)) return 0;
// The skater item grids (item_browser_overlay.cpp): while one has the focus and nothing of ours
// is open, F favorites the highlighted item, X steps the filter and "-" (or Ctrl+F) opens the
// search box, which then takes the keys until Enter or Esc. (The game uses Q, E, T and G here:
// G opens its feedback card.)
if (s.item_search_visible.load() || s.item_search_escape_pending.load()) {
if (wp == VK_ESCAPE && (key || release) && !s.console_visible.load() && !s.visible.load()) {
if (key && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0) {
s.item_search_escape_pending.store(true);
s.item_search_visible.store(false);
item_browser_search_cleared();
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
} else if (release) s.item_search_escape_pending.store(false);
return 0;
}
} else if (key && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0 && !interactive_visible(s) &&
!(GetKeyState(VK_MENU) & 0x8000) && item_browser_open()) {
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
const bool minus = wp == VK_SUBTRACT || (MapVirtualKeyW(static_cast<UINT>(wp), MAPVK_VK_TO_CHAR) == L'-' &&
!(GetKeyState(VK_SHIFT) & 0x8000));
if ((minus && !control) || (wp == 'F' && control)) {
s.item_search_character_pending.store(true);
s.item_search_visible.store(true);
s.item_search_focus_requested.store(true);
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
return 0;
}
if (!control && wp == 'F') item_browser_key(ItemBrowserKey::favorite);
if (!control && wp == 'X') item_browser_key(ItemBrowserKey::filter);
}
// T opens the chat, the key the game itself reserves for it
// (Processor_Keyboard_Gameplay_Chat), while in a session and nothing
// else of ours is open. Its character is swallowed like the console's.
// "/" opens it too, already holding the "/" so the command list shows at once: the key
// that types "/" on this keyboard layout (unshifted), or the numpad's.
const bool slash = key && (wp == VK_DIVIDE ||
(MapVirtualKeyW(static_cast<UINT>(wp), MAPVK_VK_TO_CHAR) == L'/' &&
!(GetKeyState(VK_SHIFT) & 0x8000)));
if (key && (wp == 'T' || slash) && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0 &&
s.chat_available.load() && !interactive_visible(s) &&
!(GetKeyState(VK_CONTROL) & 0x8000) && !(GetKeyState(VK_MENU) & 0x8000)) {
s.chat_character_pending.store(true);
s.chat_command_requested.store(slash);
s.chat_visible.store(true);
s.chat_focus_requested.store(true);
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
return 0;
}
if ((s.chat_visible.load() || s.chat_escape_pending.load()) && !s.console_visible.load() &&
!s.visible.load() && wp == VK_ESCAPE && (key || release)) {
if (key && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0) {
s.chat_escape_pending.store(true);
s.chat_visible.store(false);
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
} else if (release) s.chat_escape_pending.store(false);
return 0;
}
if (console_toggle_key(message, wp, lp)) {
if (key) {
// TranslateMessage creates the character before the key message
// reaches this procedure. Consume that separate packet as well.
s.console_character_pending.store(true);
if ((static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0) {
const bool opened = !s.console_visible.load();
s.console_visible.store(opened);
if (opened) s.console_focus_requested.store(true);
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
}
} else if (release) {
s.console_character_pending.store(false);
}
return 0;
}
if ((s.console_visible.load() || s.console_escape_pending.load()) &&
wp == VK_ESCAPE && (key || release)) {
if (key && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0) {
s.console_escape_pending.store(true);
s.console_visible.store(false);
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
} else if (release) s.console_escape_pending.store(false);
return 0;
}
if (wp == dingosdk::launcher::overlay_keys().menu && (key || release)) {
if (key && (static_cast<ULONG_PTR>(lp) & (1ull << 30)) == 0) {
if (s.editor_visible.load()) s.editor_exit_requested.store(true);
else s.visible.store(!s.visible.load());
sync_menu_cursor();
window_cursor_input(window, message, wp);
std::lock_guard lock(s.input_mutex);
s.input.clear();
}
return 0;
}
if (message == WM_NCDESTROY) s.selected_window_destroyed.store(true);
if (message == WM_KILLFOCUS || message == WM_SETFOCUS || message == WM_NCDESTROY) sync_menu_cursor();
// ImGui's Win32 backend ignores raw input, so WM_INPUT is not queued.
// (The pause menu's server browser scrolls with the wheel, which only arrives this way.)
if ((interactive_visible(s) || message == WM_KILLFOCUS || message == WM_SETFOCUS ||
(message == WM_MOUSEWHEEL && s.hub_pointer.load())) && message != WM_INPUT) {
std::lock_guard lock(s.input_mutex);
if (message == WM_MOUSEMOVE && !s.input.empty() && s.input.back().message == WM_MOUSEMOVE &&
s.input.back().window == window) {
// Moves in a row: ImGui applies them all in the same frame and keeps
// the last, so only the newest is kept.
s.input.back() = {window, message, wp, lp};
} else if (s.input.size() < 512) s.input.push_back({window, message, wp, lp});
else {
// Mouse movement can exceed the bounded queue while a frame is
// delayed. Drop the oldest packet without changing menu state.
s.input.pop_front();
s.input.push_back({window, message, wp, lp});
}
}
const bool capture = owns_pointer();
const bool freecam_capture = s.freecam_controller_active.load(std::memory_order_relaxed) ||
s.prompt_input_active.load(std::memory_order_relaxed);
if (freecam_capture) release_game_buttons(window, previous);
if ((capture || freecam_capture) && is_input(message)) {
// Foreground raw-input packets still need DefWindowProc cleanup.
return message == WM_INPUT ? DefWindowProcW(window, message, wp, lp) : 0;
}
if (capture && message == WM_SETCURSOR && LOWORD(lp) == HTCLIENT) {
OverlayInputAccess access;
SetCursor(nullptr); // The software cursor is independent of the game's ShowCursor counter.
return TRUE;
}
if ((key || release) && wp < s.game_keys.size()) s.game_keys[wp] = key;
constexpr std::array<UINT, 4> down{WM_LBUTTONDOWN, WM_RBUTTONDOWN, WM_MBUTTONDOWN, WM_XBUTTONDOWN};
constexpr std::array<UINT, 4> up{WM_LBUTTONUP, WM_RBUTTONUP, WM_MBUTTONUP, WM_XBUTTONUP};
for (unsigned i = 0; i < down.size(); ++i) {
const auto bit = 1u << (i == 3 && GET_XBUTTON_WPARAM(wp) == XBUTTON2 ? 4 : i);
if (message == down[i]) s.game_mouse_buttons |= bit;
if (message == up[i]) s.game_mouse_buttons &= ~bit;
}
if (message == WM_KILLFOCUS) { s.game_keys.fill(false); s.game_mouse_buttons = 0; }
}
return previous ? CallWindowProcW(previous, window, message, wp, lp)
: DefWindowProcW(window, message, wp, lp);
}
}
namespace {
struct ControllerSample {
XINPUT_GAMEPAD pad{};
unsigned device{};
dingosdk::ControllerStyle style{};
};
using GetState = DWORD (WINAPI*)(DWORD, XINPUT_STATE*);
GetState xinput_get_state() {
// Optional OS API, resolved outside DllMain. Keep the module for process
// lifetime; a missing controller/runtime must not prevent keyboard flight.
static const auto get_state = []() -> GetState {
for (const auto name : {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll"}) {
const auto module = LoadLibraryExW(name, nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!module) continue;
const auto entry = GetProcAddress(module, "XInputGetState");
if (entry) {
GetState fn{};
static_assert(sizeof(fn) == sizeof(entry));
std::memcpy(&fn, &entry, sizeof(fn));
return fn;
}
FreeLibrary(module);
}
return nullptr;
}();
return get_state;
}
// The XInput slots with a pad in them, shared by every reader. Polling an empty
// slot is slow (it looks for a device), so readers poll only these, and a
// background thread retries the empty ones once a second while pads are read:
// the client tick, which reads bound combos, never waits for that.
struct XInputSlots {
std::atomic<unsigned> connected{};
std::atomic<ULONGLONG> next_probe{};
std::atomic<HANDLE> wake{}; // set when a probe is due; the probe thread waits on it
std::mutex start; // starting the thread (tried again at the next probe if it fails)
};
XInputSlots& xinput_slots() { static auto* value = new XInputSlots; return *value; } // process lifetime
DWORD WINAPI probe_xinput_slots(void* parameter) noexcept {
auto& slots = *static_cast<XInputSlots*>(parameter);
const auto get_state = xinput_get_state();
for (;;) {
WaitForSingleObject(slots.wake.load(), INFINITE);
keep_xinput_capture_first();
OverlayInputAccess access;
for (DWORD slot = 0; get_state && slot < XUSER_MAX_COUNT; ++slot) {
const unsigned bit = 1u << slot;
if (slots.connected.load() & bit) continue;
XINPUT_STATE sample{};
if (get_state(slot, &sample) == ERROR_SUCCESS) slots.connected.fetch_or(bit);
}
}
}
// Wakes the probe thread (starting it the first time) at most once a second.
void request_xinput_probe(XInputSlots& slots) {
const auto now = GetTickCount64();
auto due = slots.next_probe.load();
if (now < due || !slots.next_probe.compare_exchange_strong(due, now + 1000)) return;
if (!slots.wake.load()) {
std::lock_guard lock(slots.start);
if (!slots.wake.load()) {
if (const auto wake = CreateEventW(nullptr, FALSE, FALSE, nullptr)) {
slots.wake.store(wake);
if (const auto thread = CreateThread(nullptr, 0, probe_xinput_slots, &slots, 0, nullptr))
CloseHandle(thread);
else {
slots.wake.store(nullptr);
CloseHandle(wake);
}
}
}
}
if (const auto wake = slots.wake.load()) SetEvent(wake);
}
ControllerSample read_controller_sample() {
OverlayInputAccess access; // Binding capture needs the real controller while the menu is open.
const auto get_state = xinput_get_state();
// Virtual pads (DS4Windows, Steam, ViGEm leftovers) can take any slot, and
// an idle one may sit ahead of the pad in use, so merge every connected
// slot. Empty slots are retried in the background (xinput_slots above).
auto& slots = xinput_slots();
if (get_state) request_xinput_probe(slots);
const auto connected_slots = slots.connected.load();
unsigned connected = 0;
ControllerSample result;
for (DWORD slot = 0; get_state && slot < XUSER_MAX_COUNT; ++slot) {
const unsigned bit = 1u << slot;
if (!(connected_slots & bit)) continue;
XINPUT_STATE sample{};
if (get_state(slot, &sample) != ERROR_SUCCESS) { slots.connected.fetch_and(~bit); continue; }
connected |= bit;
const auto& pad = sample.Gamepad;
result.pad.wButtons |= pad.wButtons;
result.pad.bLeftTrigger = std::max(result.pad.bLeftTrigger, pad.bLeftTrigger);
result.pad.bRightTrigger = std::max(result.pad.bRightTrigger, pad.bRightTrigger);
const auto magnitude = [](SHORT x, SHORT y) { return std::abs(int(x)) + std::abs(int(y)); };
if (magnitude(pad.sThumbLX, pad.sThumbLY) > magnitude(result.pad.sThumbLX, result.pad.sThumbLY)) {
result.pad.sThumbLX = pad.sThumbLX; result.pad.sThumbLY = pad.sThumbLY;
}
if (magnitude(pad.sThumbRX, pad.sThumbRY) > magnitude(result.pad.sThumbRX, result.pad.sThumbRY)) {
result.pad.sThumbRX = pad.sThumbRX; result.pad.sThumbRY = pad.sThumbRY;
}
}
// A PlayStation pad the tool left visible still names the buttons, even
// while its virtual XInput twin supplies them.
const auto playstation = read_playstation_pads();
result.style = playstation.style;
if (connected) {
// XInput wins whenever a pad is there: reading both would double a
// DS4Windows pad and mix its remapped buttons with the physical ones.
result.device = connected;
return result;
}
if (!playstation.available) return {};
const auto& pad = playstation.pad;
result.pad.wButtons = pad.buttons;
result.pad.bLeftTrigger = pad.left_trigger; result.pad.bRightTrigger = pad.right_trigger;
result.pad.sThumbLX = pad.left_x; result.pad.sThumbLY = pad.left_y;
result.pad.sThumbRX = pad.right_x; result.pad.sThumbRY = pad.right_y;
result.device = 0x100u + (playstation.generation & 0xffffffu);
return result;
}
dingosdk::overlay::FlightInput read_player_flight_controller() {
const auto sample = read_controller_sample();
if (!sample.device) return {};
const auto& pad = sample.pad;
auto input = dingosdk::controller_flight_input(pad.sThumbLX, pad.sThumbLY, pad.bLeftTrigger, pad.bRightTrigger,
(pad.wButtons & XINPUT_GAMEPAD_LEFT_THUMB) != 0);
const float rx = static_cast<float>(pad.sThumbRX), ry = static_cast<float>(pad.sThumbRY);
const float magnitude = std::sqrt(rx * rx + ry * ry);
constexpr float deadzone = 8689.0f;
if (magnitude > deadzone) {
const float scale = std::clamp((magnitude - deadzone) / (32767.0f - deadzone), 0.0f, 1.0f) / magnitude;
input.look_x = rx * scale * 25.0f;
input.look_y = ry * scale * -25.0f;
}
return input;
}
}
namespace dingosdk::overlay {
void begin_input_access() noexcept { ++detail::overlay_input_access; }
void end_input_access() noexcept { --detail::overlay_input_access; }
Gamepad read_gamepad() {
const auto sample = read_controller_sample();
Gamepad out;
if (!sample.device) return out;
const auto axis = [](std::int16_t v) { return std::clamp(static_cast<float>(v) / 32767.0f, -1.0f, 1.0f); };
out.valid = true;
out.playstation = sample.device >= 0x100u;
out.left_x = axis(sample.pad.sThumbLX); out.left_y = axis(sample.pad.sThumbLY);
out.right_x = axis(sample.pad.sThumbRX); out.right_y = axis(sample.pad.sThumbRY);
out.left_trigger = static_cast<float>(sample.pad.bLeftTrigger) / 255.0f;
out.right_trigger = static_cast<float>(sample.pad.bRightTrigger) / 255.0f;
out.buttons = sample.pad.wButtons;
return out;
}
} // namespace dingosdk::overlay
extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) {
const bool previous = state().freecam_controller_active.exchange(active, std::memory_order_relaxed);
if (previous != active)
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input,
"Freecam controller input capture %s.", active ? "enabled" : "disabled");
}
extern "C" void DingoSDKOverlaySetPromptInputCapture(bool active) {
const bool before = state().prompt_input_active.exchange(active, std::memory_order_relaxed);
if (before == active) return;
// For the log: which of the ways input can be kept from the game the game actually read
// its controller by while it was held. All zero means it reads some other way, and the
// hold did not reach it.
if (active) {
keep_xinput_capture_first();
(void)take_prompt_reads();
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Card input hold on.");
} else {
const auto reads = take_prompt_reads();
// And which controller libraries the game has loaded at all.
std::string loaded;
for (const auto* name : {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"gameinput.dll", L"gameinputredist.dll",
L"windows.gaming.input.dll", L"steamclient64.dll", L"dinput8.dll"})
if (GetModuleHandleW(name)) {
if (!loaded.empty()) loaded += ", ";
for (const wchar_t* at = name; *at; ++at) loaded += static_cast<char>(*at);
}
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input,
"Card input hold off. While held the game read: XInput %u, DirectInput %u, raw input %u time(s). Controller libraries loaded: %s. XInput leads to: %s.",
reads.xinput, reads.direct_input, reads.raw, loaded.empty() ? "none" : loaded.c_str(), xinput_entry_owners().c_str());
}
}
extern "C" unsigned DingoSDKOverlayReadPromptKeys() {
struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error;
const auto& s = state();
const HWND window = s.window.load();
// (Not while a menu, the console or chat has the keyboard; the card itself having the pointer is no reason.)
const bool typing = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || s.hub_typing.load();
if (!window || s.stop.load() || s.failed.load() || typing || !game_window_foreground(window)) return 0;
unsigned keys{};
constexpr int wanted[]{VK_LEFT, VK_RIGHT, VK_RETURN, VK_ESCAPE};
if (std::array<std::uint64_t, 4> held{}; sampled_keys(window, held)) {
for (unsigned bit = 0; bit < 4; ++bit)
if (held[wanted[bit] / 64] >> (wanted[bit] % 64) & 1) keys |= 1u << bit;
return keys;
}
OverlayInputAccess access;
for (unsigned bit = 0; bit < 4; ++bit)
if (GetAsyncKeyState(wanted[bit]) & 0x8000) keys |= 1u << bit;
return keys;
}
extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* output, bool allow_menu) {
if (!output) return;
struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error;
*output = {};
const auto& s = state();
const HWND window = s.window.load();
if (!window || s.stop.load() || s.failed.load() || (!allow_menu && interactive_visible(s)) ||
!game_window_foreground(window)) return;
// Read through the overlay's bypass: freecam capture hides keys from the game. (As sampled
// off this thread when there is a recent sample, cursor.cpp.)
if (std::array<std::uint64_t, 4> held{}; sampled_keys(window, held)) {
for (unsigned key = 0; key < 256; ++key)
if (dingosdk::bindable_keyboard_key(key) && (held[key / 64] >> (key % 64) & 1))
output->keys[key / 64] |= std::uint64_t{1} << (key % 64);
} else {
OverlayInputAccess access;
for (unsigned key = 0; key < 256; ++key) {
if (dingosdk::bindable_keyboard_key(key) && (GetAsyncKeyState(key) & 0x8000)) {
output->keys[key / 64] |= std::uint64_t{1} << (key % 64);
}
}
}
const auto sample = read_controller_sample();
output->style = sample.style;
if (!sample.device) return;
const auto& pad = sample.pad;
output->available = true;
output->device = sample.device;
output->buttons = pad.wButtons & dingosdk::controller_button_mask;
if (pad.bLeftTrigger > XINPUT_GAMEPAD_TRIGGER_THRESHOLD) output->buttons |= 0x10000;
if (pad.bRightTrigger > XINPUT_GAMEPAD_TRIGGER_THRESHOLD) output->buttons |= 0x20000;
}
// Raw mouse packets arrived recently enough to trust the tracked buttons.
static bool raw_mouse_live(const State& s) {
const auto time = s.raw_mouse_time.load();
return time && GetTickCount64() < time + 10000;
}
extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* output, bool flight_active, bool player_flight) {
if (!output) return;
struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error;
*output = {};
// Only this client thread owns the mouse baseline; ImGui is never accessed.
thread_local POINT previous{};
thread_local bool looking = false;
const auto& s = state();
const HWND window = s.window.load();
const bool editor_flight = s.editor_visible.load() && s.editor_flight.load() && !s.console_visible.load() &&
GetTickCount64() < s.editor_input_time.load() + 150;
if (!flight_active || !window || s.stop.load() || s.failed.load() || (interactive_visible(s) && !editor_flight) ||
!game_window_foreground(window)) {
looking = false;
return;
}
OverlayInputAccess editor_access;
// (The keys as sampled off this thread when there is a sample, cursor.cpp: this runs every
// client frame while flying.)
std::array<std::uint64_t, 4> sampled{};
const bool have_sample = sampled_keys(window, sampled);
const auto held = [&](int key) {
return have_sample ? ((sampled[key / 64] >> (key % 64)) & 1) != 0 : (GetAsyncKeyState(key) & 0x8000) != 0;
};
// Physical right button: the logical one unless the buttons are swapped.
const bool swapped = GetSystemMetrics(SM_SWAPBUTTON) != 0;
const bool raw = raw_mouse_live(s);
const bool raw_look_button = raw && (s.raw_mouse_buttons.load() & (swapped ? 1u : 2u)) != 0;
const bool look_button = held(VK_RBUTTON) || raw_look_button;
if (s.editor_visible.load() && !look_button) { looking = false; return; }
output->active = true;
output->right = static_cast<float>(held('D')) - static_cast<float>(held('A'));
output->up = static_cast<float>(held('E')) - static_cast<float>(held('Q'));
output->forward = static_cast<float>(held('W')) - static_cast<float>(held('S'));
output->boost = held(VK_SHIFT);
if (player_flight) {
const auto controller = read_player_flight_controller();
output->right = std::clamp(output->right + controller.right, -1.0f, 1.0f);
output->forward = std::clamp(output->forward + controller.forward, -1.0f, 1.0f);
output->up = std::clamp(output->up + controller.up, -1.0f, 1.0f);
output->boost = output->boost || controller.boost;
output->look_x = controller.look_x;
output->look_y = controller.look_y;
looking = false;
return; // Native camera owns look input; do not sample or recenter the cursor.
}
POINT current{};
const long raw_x = state().raw_mouse_x.exchange(0), raw_y = state().raw_mouse_y.exchange(0);
if (look_button && GetCursorPos(&current)) {
if (looking) {
// Raw deltas when the game window receives raw input: they keep
// working whatever Skate does with the Windows cursor.
output->look_x = static_cast<float>(raw ? raw_x : current.x - previous.x);
output->look_y = static_cast<float>(raw ? raw_y : current.y - previous.y);
}
previous = current;
looking = true;
RECT bounds{};
if (GetClientRect(window, &bounds)) {
POINT center{(bounds.left + bounds.right) / 2, (bounds.top + bounds.bottom) / 2};
if (ClientToScreen(window, &center) && SetCursorPos(center.x, center.y)) previous = center;
}
} else looking = false;
}