From 732afd659cdfd44c6325f4815a86e12a058b1c43 Mon Sep 17 00:00:00 2001 From: JusSkates <336291719+JusSlabs@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:49:22 +0100 Subject: [PATCH 001/119] Keep the first-person view level and steady; optional third person on foot First person copied the head bone's full rotation into the camera, so every nod, tilt and wobble of the skater's animation shook the view. True first person (on by default) splits the head into heading, nod and tilt. The heading is followed in full; a share of the nod (86%) and of the tilt (0%, a level horizon) is kept. All three pass a One Euro filter: heavy smoothing while the head barely moves, little lag in spins (a 720 stays within about 10 degrees). The eye's movement around the body is filtered the same way, and Head bob sets how much of it is kept. Upside down (flips, bails) the view blends back to the real head unless Follow flips is off. It runs before the existing spring arm, which is unchanged; switching it off gives the plain head camera back exactly. Third person on foot (off by default) hands the camera back to the game while the physics state is Offboard (504) and takes it again on the board, through the same actions as the menu toggle. It only runs while first person is on or paused by it, and the player's own toggle, Freecam or the Park Editor end it. New settings are saved with the other FirstPerson.* choices. Engine/Game/Skater/Test/first_person_stabilize_tests.cpp covers the math. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 + .../Test/first_person_stabilize_tests.cpp | 162 ++++++++++++++++++ Engine/Game/Skater/first_person_spring.h | 114 +++++++++++- Extension/Skater/client_debug.cpp | 78 ++++++++- Extension/Skater/client_first_person.cpp | 15 ++ .../Skater/client_source_spawn_internal.h | 7 + Extension/UI/Overlay/overlay.h | 7 + Extension/UI/Overlay/skate_menu_skater.cpp | 29 +++- 8 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 Engine/Game/Skater/Test/first_person_stabilize_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 70a0b077..85c792a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) target_include_directories(dingosdk_first_person_spring_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME first_person_spring COMMAND dingosdk_first_person_spring_tests) endif() + add_executable(dingosdk_first_person_stabilize_tests Engine/Game/Skater/Test/first_person_stabilize_tests.cpp) + target_include_directories(dingosdk_first_person_stabilize_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME first_person_stabilize COMMAND dingosdk_first_person_stabilize_tests) add_executable(dingosdk_playstation_report_tests Engine/Game/Input/Test/playstation_report_tests.cpp) target_include_directories(dingosdk_playstation_report_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME playstation_report COMMAND dingosdk_playstation_report_tests) diff --git a/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp b/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp new file mode 100644 index 00000000..393d0f77 --- /dev/null +++ b/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp @@ -0,0 +1,162 @@ +#include "Engine/Game/Skater/first_person_spring.h" + +#include +#include + +// True first person (first_person::stabilize): animated head wobble in, a level and +// steady view out, while spins, flips and teleports still come through. +namespace { +using namespace dingosdk::first_person; +constexpr float pi = std::numbers::pi_v, deg = pi / 180; +int failures = 0; + +void expect(bool condition, std::string_view what) { + if (condition) return; + ++failures; + std::printf("FAIL: %.*s\n", static_cast(what.size()), what.data()); +} +// A head in the game's row layout (right, up, backward, position). +Matrix head_at(float yaw, float pitch, float roll, Vec3 position) { + Matrix m{}; + m[15] = 1; + write_level(m, yaw, pitch, roll); + for (unsigned i = 0; i < 3; ++i) m[12 + i] = position[i]; + return m; +} +struct Angles { float yaw, pitch, roll; }; +Angles angles_of(const Matrix& m) { + const Vec3 forward{-m[8], -m[9], -m[10]}; + Angles a{std::atan2(forward[0], forward[2]), std::asin(std::clamp(forward[1], -1.0f, 1.0f)), 0}; + Matrix level{}; + write_level(level, a.yaw, a.pitch, 0); + const Vec3 right{m[0], m[1], m[2]}; + a.roll = std::atan2(dot(right, {level[4], level[5], level[6]}), dot(right, {level[0], level[1], level[2]})); + return a; +} + +void frame_layout() { + const auto a = angles_of(head_at(0.7f, -0.3f, 0.2f, {})); + expect(std::abs(a.yaw - 0.7f) < 1e-4f && std::abs(a.pitch + 0.3f) < 1e-4f && std::abs(a.roll - 0.2f) < 1e-4f, + "write_level round trips yaw, pitch and roll"); + const auto m = head_at(0.7f, -0.3f, 0.2f, {}); + const auto backward = cross({m[0], m[1], m[2]}, {m[4], m[5], m[6]}); + expect(std::abs(backward[0] - m[8]) + std::abs(backward[1] - m[9]) + std::abs(backward[2] - m[10]) < 1e-4f, + "right x up = backward, as the game's camera rows"); +} + +// Cruising straight with the head nodding, tilting, swaying and bobbing. +void wobble() { + const Settings settings; + Spring spring; + float roll{}, nod{}, sway{}, bob{}; + double t = 0; + for (int i = 0; i < 600; ++i, t += 1.0 / 60) { + const auto w = static_cast(t); + const Vec3 origin{w * 5, 0, 0}; + const auto head = head_at(1.0f + 6 * deg * std::sin(2 * pi * 2.0f * w), + -10 * deg + 12 * deg * std::sin(2 * pi * 2.5f * w), 10 * deg * std::sin(2 * pi * 3.0f * w), + {origin[0], 1.6f + 0.04f * std::sin(2 * pi * 2 * w), 0}); + const auto view = update(spring, head, settings, t, origin); + if (i < 120) continue; + const auto a = angles_of(view); + roll = std::max(roll, std::abs(a.roll)); + nod = std::max(nod, std::abs(a.pitch + 10 * deg * settings.head_pitch * 0.01f)); + sway = std::max(sway, std::abs(wrap_angle(a.yaw - 1.0f))); + bob = std::max(bob, std::abs(view[13] - 1.6f)); + } + expect(roll < 0.01f * deg, "head tilt 0% keeps the horizon level"); + expect(nod < 12 * deg * settings.head_pitch * 0.01f * 0.5f, "the kept head nod loses over half its wobble"); + expect(sway < 3 * deg, "side-to-side sway loses half its wobble"); + expect(bob < 0.025f, "head bob is reduced"); +} + +void spin() { + const Settings settings; + Spring spring; + float lag{}; + double t = 0; + for (int i = 0; i < 120; ++i, t += 1.0 / 60) { + const float yaw = 4 * pi * static_cast(t); // two turns a second + const auto view = update(spring, head_at(wrap_angle(yaw), -5 * deg, 0, {0, 1.6f, 0}), settings, t, {}); + if (i > 30) lag = std::max(lag, std::abs(wrap_angle(angles_of(view).yaw - yaw))); + } + expect(lag < 30 * deg, "a 720 keeps the view within 30 degrees"); +} + +void ollie() { + const Settings settings; + Spring spring; + float gap{}; + double t = 0; + for (int i = 0; i < 90; ++i, t += 1.0 / 60) { + const float w = static_cast(t) - 0.5f; + const float rise = w < 0 ? 0 : w < 0.2f ? 0.5f * w / 0.2f : w < 0.6f ? 0.5f : std::max(0.0f, 0.5f - (w - 0.6f) * 2.5f); + const auto view = update(spring, head_at(0, 0, 0, {0, 1.6f + rise, 0}), settings, t, {}); + if (w > 0.45f && w < 0.6f) gap = std::max(gap, std::abs(view[13] - 1.6f - rise)); + } + expect(gap < 0.05f, "the eye catches up by the top of an ollie"); +} + +void snapping() { + const Settings settings; + Spring spring; + auto view = update(spring, head_at(2.0f, 0, 0, {0, 1.6f, 0}), settings, 1.0, {}); + expect(std::abs(wrap_angle(angles_of(view).yaw - 2.0f)) < 1e-3f, "the first frame takes the head as it is"); + view = update(spring, head_at(-1.0f, 0, 0, {500, 1.6f, 0}), settings, 2.0, {500, 0, 0}); + expect(std::abs(wrap_angle(angles_of(view).yaw + 1.0f)) < 1e-3f && std::abs(view[12] - 500) < 1e-3f, + "a pause or teleport snaps instead of smoothing across it"); +} + +// One full backflip: with Follow flips the view turns over, without it stays upright. +void flips() { + Settings follow, upright; + upright.follow_flips = false; + Spring a, b; + float lowest_follow = 1, lowest_upright = 1; + bool finite = true; + double t = 0; + for (int i = 0; i < 90; ++i, t += 1.0 / 60) { + const float turn = std::min(1.0f, static_cast(t) / 1.2f) * 2 * pi; + Matrix head{}; + head[15] = 1; + write(head, multiply({0, std::sin(0.5f), 0, std::cos(0.5f)}, {std::sin(-turn / 2), 0, 0, std::cos(-turn / 2)}), {0, 1.6f, 0}); + const auto va = update(a, head, follow, t, {}), vb = update(b, head, upright, t, {}); + for (unsigned j = 0; j < 16; ++j) finite = finite && std::isfinite(va[j]) && std::isfinite(vb[j]); + lowest_follow = std::min(lowest_follow, va[5]); + lowest_upright = std::min(lowest_upright, vb[5]); + } + expect(finite, "a flip gives finite views"); + expect(lowest_follow < -0.5f, "Follow flips turns the view over"); + expect(lowest_upright > 0.1f, "without Follow flips the view stays upright"); +} + +void off_and_limits() { + Settings settings; + settings.stabilize = false; + Spring spring; + const auto head = head_at(0.3f, 0.2f, 0.4f, {1, 2, 3}); + const auto view = update(spring, head, settings, 1.0, {}); + float difference = 0; + for (unsigned i = 0; i < 16; ++i) difference += std::abs(view[i] - head[i]); + expect(difference < 1e-4f, "True first person off is the plain head camera"); + expect(valid(Settings{}), "the defaults are valid"); + settings.smoothing = 101; + expect(!valid(settings), "smoothing above 100% is refused"); +} +} // namespace + +int main() { + frame_layout(); + wobble(); + spin(); + ollie(); + snapping(); + flips(); + off_and_limits(); + if (failures) { + std::printf("%d first-person stabilize test(s) failed\n", failures); + return 1; + } + std::printf("first-person stabilize tests passed\n"); + return 0; +} diff --git a/Engine/Game/Skater/first_person_spring.h b/Engine/Game/Skater/first_person_spring.h index d20c44af..4d771137 100644 --- a/Engine/Game/Skater/first_person_spring.h +++ b/Engine/Game/Skater/first_person_spring.h @@ -13,12 +13,28 @@ struct Settings { bool enabled = false; Vec3 offset{}, rotation{}; float up = 50, down = 50, left = 50, right = 50; + // True first person: the view takes its heading from the head but keeps the + // horizon level, and filters the animation's wobble. Percentages, 0..100. + bool stabilize = true, follow_flips = true; + // Hand the view back to the game's camera while the skater walks (opt-in). + bool board_only = false; + float smoothing = 60, head_pitch = 86, head_roll = 0, bob = 25; +}; +// Adaptive low-pass ("One Euro") state for one channel: heavy smoothing while the +// value barely moves, little lag while it moves fast (spins, carving). +struct Euro { float value{}, rate{}; }; +struct Stable { + bool ready = false; + double time{}; + Euro yaw, pitch, roll; + std::array offset{}; }; struct Spring { bool ready = false; double time{}; Vec3 position{}, velocity{}, angular_velocity{}, previous_target{}, previous_origin{}; Quat rotation{0, 0, 0, 1}; + Stable stable{}; }; inline Vec3 add(const Vec3& a, const Vec3& b) { return {a[0]+b[0], a[1]+b[1], a[2]+b[2]}; } inline Vec3 subtract(const Vec3& a, const Vec3& b) { return {a[0]-b[0], a[1]-b[1], a[2]-b[2]}; } @@ -70,10 +86,101 @@ inline bool valid(const Settings& settings) { const auto bounded = [](float v, float limit) { return std::isfinite(v) && std::abs(v) <= limit; }; for (unsigned i = 0; i < 3; ++i) if (!bounded(settings.offset[i], offset_limit) || !bounded(settings.rotation[i], rotation_limit)) return false; - for (float v : {settings.up, settings.down, settings.left, settings.right}) + for (float v : {settings.up, settings.down, settings.left, settings.right, + settings.smoothing, settings.head_pitch, settings.head_roll, settings.bob}) if (!bounded(v, strength_limit) || v < 0) return false; return true; } +inline float dot(const Vec3& a, const Vec3& b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; } +inline Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]}; +} +inline float wrap_angle(float radians) { return std::remainder(radians, 2*std::numbers::pi_v); } +inline float euro_alpha(float cutoff, float seconds) { + const float tau = 1 / (2*std::numbers::pi_v*cutoff); + return 1 / (1 + tau/seconds); +} +// `change` is the raw step since the last sample (already wrapped for angles). +// The rate is filtered at 0.5 Hz so an animation's back-and-forth averages out +// and only sustained turns (spins, carves) open the filter up. +inline void euro(Euro& state, float change, float seconds, float min_cutoff, float beta) { + state.rate += (change/seconds - state.rate) * euro_alpha(0.5f, seconds); + state.value += change * euro_alpha(min_cutoff + beta*std::abs(state.rate), seconds); +} +// Rows (right, up, backward) of a level view looking along yaw / pitch, then +// rolled by `roll` about the view axis. World is Y-up; yaw 0 looks along +Z. +inline void write_level(Matrix& m, float yaw, float pitch, float roll) { + const Vec3 forward{std::sin(yaw)*std::cos(pitch), std::sin(pitch), std::cos(yaw)*std::cos(pitch)}; + const Vec3 level_right{-std::cos(yaw), 0, std::sin(yaw)}; + const auto level_up = cross(level_right, forward); + const float c = std::cos(roll), s = std::sin(roll); + for (unsigned i = 0; i < 3; ++i) { + m[i] = level_right[i]*c + level_up[i]*s; + m[4+i] = level_up[i]*c - level_right[i]*s; + m[8+i] = -forward[i]; + } +} +// Splits the head into heading, nod and tilt, keeps the heading, scales the nod +// and tilt by the settings and filters all three, so the view stops wobbling +// with the animation. Flips and bails (head upside down) hand the view back to +// the real head when follow_flips is on. +inline Matrix stabilize(Stable& state, const Matrix& head, const Settings& settings, double now, const Vec3& origin) { + const Vec3 right{head[0], head[1], head[2]}, up{head[4], head[5], head[6]}; + const Vec3 forward{-head[8], -head[9], -head[10]}; + const float level = std::sqrt(forward[0]*forward[0] + forward[2]*forward[2]); + // Looking nearly straight up or down, the top of the head says where the + // skater is heading better than the face does. + const float yaw = level > 0.25f ? std::atan2(forward[0], forward[2]) + : forward[1] < 0 ? std::atan2(up[0], up[2]) : std::atan2(-up[0], -up[2]); + const float pitch = std::asin(std::clamp(forward[1], -1.0f, 1.0f)); + Matrix level_frame = head; + write_level(level_frame, yaw, pitch, 0); + const float roll = std::atan2(dot(right, {level_frame[4], level_frame[5], level_frame[6]}), + dot(right, {level_frame[0], level_frame[1], level_frame[2]})); + const float pitch_target = pitch * settings.head_pitch * 0.01f; + const float roll_target = roll * settings.head_roll * 0.01f; + const Vec3 eye{head[12], head[13], head[14]}; + const auto offset = subtract(eye, origin); + + const double elapsed = now - state.time; + bool snap = !state.ready || !std::isfinite(elapsed) || elapsed < 0 || elapsed > 0.25; + for (unsigned i = 0; i < 3 && !snap; ++i) snap = std::abs(offset[i] - state.offset[i].value) > 3; + if (snap) { + state = {true, now, {yaw, 0}, {pitch_target, 0}, {roll_target, 0}, + {Euro{offset[0], 0}, Euro{offset[1], 0}, Euro{offset[2], 0}}}; + } else if (elapsed > 0.00001) { + state.time = now; + const float seconds = static_cast(elapsed); + // Smoothing 0% follows the head directly; 100% leaves a 0.15 Hz floor. + const float s = settings.smoothing * 0.01f; + const float angle_cutoff = s <= 0 ? 1000.0f : 0.15f * std::pow(40.0f, 1 - s); + constexpr float angle_beta = 0.6f; + euro(state.yaw, wrap_angle(yaw - state.yaw.value), seconds, angle_cutoff, angle_beta); + state.yaw.value = wrap_angle(state.yaw.value); + euro(state.pitch, pitch_target - state.pitch.value, seconds, angle_cutoff, angle_beta); + euro(state.roll, wrap_angle(roll_target - state.roll.value), seconds, angle_cutoff, angle_beta); + for (unsigned i = 0; i < 3; ++i) euro(state.offset[i], offset[i] - state.offset[i].value, seconds, 0.6f, 1.5f); + } + auto result = head; + write_level(result, state.yaw.value, state.pitch.value, state.roll.value); + // Head bob: the share of the eye's movement around the body that is kept. + const float bob = settings.bob * 0.01f; + for (unsigned i = 0; i < 3; ++i) { + const float smooth = state.offset[i].value; + result[12+i] = origin[i] + smooth + (offset[i] - smooth) * bob; + } + // Upside down (up.y below 0.4, fully past -0.1): blend to the real head. + const float flip = settings.follow_flips ? std::clamp((0.4f - up[1]) / 0.5f, 0.0f, 1.0f) : 0.0f; + if (flip > 0) { + auto stable = orientation(result); + auto real = orientation(head); + if (stable[0]*real[0] + stable[1]*real[1] + stable[2]*real[2] + stable[3]*real[3] < 0) for (auto& v : real) v = -v; + Quat blended{}; + for (unsigned i = 0; i < 4; ++i) blended[i] = stable[i] + (real[i] - stable[i]) * flip; + write(result, normalized(blended), {result[12], result[13], result[14]}); + } + return result; +} inline Matrix target(Matrix head, const Settings& settings) { const auto head_rotation = orientation(head); const auto position = add({head[12], head[13], head[14]}, @@ -113,14 +220,15 @@ inline void damp(float& error, float& velocity, float strength, float seconds) { } inline Matrix update(Spring& spring, const Matrix& head, const Settings& settings, double now, const Vec3& origin = {}) { if (!valid(settings) || !std::isfinite(now)) return head; - auto result = target(head, settings); + if (!settings.stabilize) spring.stable = {}; + auto result = target(settings.stabilize ? stabilize(spring.stable, head, settings, now, origin) : head, settings); const auto position = subtract({result[12],result[13],result[14]}, origin); const auto rotation = orientation(result); const double elapsed = now - spring.time; if (!spring.ready || !settings.enabled || elapsed < 0 || elapsed > 0.25 || length(subtract(position, spring.previous_target)) > 5 || length(subtract(origin, spring.previous_origin)) > 5) { - spring = {true, now, position, {}, {}, position, origin, rotation}; + spring = {true, now, position, {}, {}, position, origin, rotation, spring.stable}; return result; } spring.time = now; diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index db209dc6..d5bccb5b 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -212,7 +212,9 @@ void debug_flight_tick(SourceTrial& trial, std::uintptr_t client, bool ready, bo arm.thread.store(GetCurrentThreadId(), std::memory_order_release); if (debug.first_person_waiting) { debug.first_person_waiting = false; - debug.status = "First person on. The camera follows the skater's head."; + debug.status = debug.first_person_settings.stabilize + ? "First person on. True first person keeps the view level and steady." + : "First person on. The camera follows the skater's head."; } } else { next = step_free_flight(debug.flight_matrix, input ? *input : idle, seconds, debug.flight_speed); @@ -248,6 +250,10 @@ constexpr const char* spring = "FirstPerson.Spring"; constexpr std::array offset{"FirstPerson.OffsetX", "FirstPerson.OffsetY", "FirstPerson.OffsetZ"}; constexpr std::array rotation{"FirstPerson.Pitch", "FirstPerson.Yaw", "FirstPerson.Roll"}; constexpr std::array strength{"FirstPerson.Up", "FirstPerson.Down", "FirstPerson.Left", "FirstPerson.Right"}; +constexpr const char* stabilize = "FirstPerson.Stabilize"; +constexpr const char* follow_flips = "FirstPerson.FollowFlips"; +constexpr const char* board_only = "FirstPerson.ThirdPersonOnFoot"; +constexpr std::array steady{"FirstPerson.Smoothing", "FirstPerson.HeadPitch", "FirstPerson.HeadRoll", "FirstPerson.Bob"}; constexpr ULONGLONG delay_ms = 750; std::optional number(const char* key) { @@ -281,6 +287,11 @@ void save_debug(InteractiveDebug& debug) noexcept { } const std::array strengths{arm.up, arm.down, arm.left, arm.right}; for (std::size_t i = 0; i < 4; ++i) values.emplace_back(saved::strength[i], static_cast(strengths[i])); + values.emplace_back(saved::stabilize, arm.stabilize); + values.emplace_back(saved::follow_flips, arm.follow_flips); + values.emplace_back(saved::board_only, arm.board_only); + const std::array steady{arm.smoothing, arm.head_pitch, arm.head_roll, arm.bob}; + for (std::size_t i = 0; i < 4; ++i) values.emplace_back(saved::steady[i], static_cast(steady[i])); profile_runtime::set_local_values(values); } catch (...) { /* Saving is best effort; the choices already apply. */ } } @@ -317,6 +328,12 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { const std::array strengths{&arm.up, &arm.down, &arm.left, &arm.right}; for (std::size_t i = 0; i < 4; ++i) if (const auto value = saved::number(saved::strength[i])) *strengths[i] = *value; + if (const auto enabled = profile_runtime::local_preference(saved::stabilize)) arm.stabilize = *enabled; + if (const auto enabled = profile_runtime::local_preference(saved::follow_flips)) arm.follow_flips = *enabled; + if (const auto enabled = profile_runtime::local_preference(saved::board_only)) arm.board_only = *enabled; + const std::array steady{&arm.smoothing, &arm.head_pitch, &arm.head_roll, &arm.bob}; + for (std::size_t i = 0; i < 4; ++i) + if (const auto value = saved::number(saved::steady[i])) *steady[i] = *value; if (first_person::valid(arm)) { debug.first_person_settings = arm; first_person_arm().settings = arm; @@ -393,17 +410,29 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b case Action::set_first_person_spring_down: settings.down = request.value; break; case Action::set_first_person_spring_left: settings.left = request.value; break; case Action::set_first_person_spring_right: settings.right = request.value; break; + case Action::set_first_person_stabilize: settings.stabilize = request.enabled; break; + case Action::set_first_person_follow_flips: settings.follow_flips = request.enabled; break; + case Action::set_first_person_smoothing: settings.smoothing = request.value; break; + case Action::set_first_person_head_pitch: settings.head_pitch = request.value; break; + case Action::set_first_person_head_roll: settings.head_roll = request.value; break; + case Action::set_first_person_bob: settings.bob = request.value; break; + case Action::set_first_person_board_only: settings.board_only = request.enabled; break; case Action::reset_first_person_arm: settings = {}; break; default: break; } source_require(first_person::valid(settings), "First-person arm setting is outside its supported range."); auto& arm = first_person_arm(); - if (settings.enabled != debug.first_person_settings.enabled || request.action == Action::reset_first_person_arm) + if (settings.enabled != debug.first_person_settings.enabled || + settings.stabilize != debug.first_person_settings.stabilize || request.action == Action::reset_first_person_arm) arm.spring.reset(); debug.first_person_settings = settings; arm.settings = settings; mark_debug_changed(debug); - debug.status = "First-person arm settings updated."; + debug.status = request.action == Action::set_first_person_stabilize + ? (settings.stabilize ? "True first person on: level horizon, steady view." : "True first person off: the view follows the raw head.") + : request.action == Action::set_first_person_board_only + ? (settings.board_only ? "Third person while walking; first person on the board." : "First person while walking too.") + : "First-person settings updated."; return; } if (request.action == overlay::DebugAction::set_free_camera_fov) { @@ -658,6 +687,42 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b } source_require(false, "Unknown debug request."); } +// "Third person on foot": runs only while first person is on or this paused +// it, so players who never use first person are untouched. Switches through +// the same actions as the menu toggle, after the state has held briefly. +void first_person_board_tick(SourceTrial& trial, std::uintptr_t client, bool can_control, bool phase, DWORD error) { + auto& debug = trial.debug; + // Choosing Freecam or the Park Editor while handed back ends the hand-back. + if (debug.first_person_paused && (debug.park_editor || (debug.camera_owned && !debug.first_person))) + debug.first_person_paused = false; + // With the option off, a hand-back still in progress resumes below. + if (!debug.first_person_paused && !(debug.first_person && debug.first_person_settings.board_only)) return; + if (!can_control || !phase || debug.park_editor || debug.noclip || debug.editor_transition) return; + const auto now = GetTickCount64(); + const auto on_foot = debug.first_person_settings.board_only ? first_person_on_foot(trial.base, client) : std::optional(false); + if (!on_foot) return; + if (*on_foot != debug.first_person_last_on_foot) { + debug.first_person_last_on_foot = *on_foot; + debug.first_person_foot_since = now; + } + const auto held = now - debug.first_person_foot_since; + if (now < debug.first_person_retry_after) return; + try { + if (debug.first_person && *on_foot && held >= 250) { + debug_action(trial, client, can_control, phase, {overlay::DebugAction::set_first_person, false}, error); + debug.first_person_paused = true; + debug.status = "On foot: third person until you are back on the board."; + } else if (debug.first_person_paused && !*on_foot && held >= 120) { + debug_action(trial, client, can_control, phase, {overlay::DebugAction::set_first_person, true}, error); + debug.first_person_paused = !debug.first_person; + } + } catch (const SourceGuard& guard) { + debug.status = guard.message; + debug.first_person_retry_after = now + 500; + } catch (...) { + debug.first_person_retry_after = now + 500; + } +} } } @@ -704,10 +769,14 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli } const bool no_bail_allowed = session_no_bail_allowed(); if (request) { + // The player's own First person choice replaces any on-foot hand-back. + if (request->action == overlay::DebugAction::set_first_person || request->action == overlay::DebugAction::restore_debug) + debug.first_person_paused = false; try { debug_action(trial, client, can_control, camera_phase_observed, *request, error.value); } catch (const SourceGuard& guard) { debug.status = guard.message; } catch (...) { debug.status = "Debug action failed; inspect the current state before retrying."; } } + first_person_board_tick(trial, client, can_control, camera_phase_observed, error.value); std::optional flight_camera; try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); } catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; } @@ -726,7 +795,8 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli const auto camera = flight_camera ? *flight_camera : source_camera_snapshot(trial, client); const bool owned_view = camera.mode == 1 && camera.active == camera.identity.camera; model.free_camera = owned_view && !debug.first_person; - model.first_person = owned_view && debug.first_person; + // Handed back on foot still reads as on, so the toggle can switch it off. + model.first_person = (owned_view && debug.first_person) || debug.first_person_paused; model.first_person_fov = debug.first_person_fov; model.free_camera_fov = debug.free_camera_fov; model.camera_available = can_control && camera_phase_observed && !debug.camera_ambiguous; diff --git a/Extension/Skater/client_first_person.cpp b/Extension/Skater/client_first_person.cpp index 916ae5ca..dc07d259 100644 --- a/Extension/Skater/client_first_person.cpp +++ b/Extension/Skater/client_first_person.cpp @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Remote/native_pose_layout.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/no_bail.h" #include "free_flight.h" #include @@ -140,6 +141,20 @@ std::uintptr_t first_person_component(std::uintptr_t base, std::uintptr_t client "Skater animation is unavailable."); return component; } +// The physics state selector's current state: skater component +0x70 is the +// physics core, core +0x3c0 its context, context +0x1414 the state (no_bail.cpp). +std::optional first_person_on_foot(std::uintptr_t base, std::uintptr_t client) noexcept { + try { + const auto component = first_person_component(base, client); + std::uintptr_t core{}, core_type{}, context{}; + std::uint32_t state{}; + if (!first_person_read(component + 0x70, &core, 8) || !first_person_read(core, &core_type, 8) || + core_type != base + addr::no_bail::bail_core_vtable || + !first_person_read(core + 0x3c0, &context, 8) || !first_person_read(context + 0x1414, &state, 4)) + return std::nullopt; + return state == addr::no_bail::offboard_physics_state; + } catch (...) { return std::nullopt; } +} // Writes the head camera into the rows (right, up, backward, position) of a // native camera matrix, leaving each row's fourth lane as the camera had it. first_person::Vec3 first_person_head_matrix(std::uintptr_t base, std::uintptr_t component, std::array& matrix) { diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index a1efea73..af98fa4c 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -102,6 +103,10 @@ struct InteractiveDebug { // Shares Freecam's owned mode-1 camera; each tick publishes the head pose // instead of a flight step. bool first_person{}, first_person_waiting{}; + // "Third person on foot": first person handed the camera back while the + // skater walks and takes it again once they are back on the board. + bool first_person_paused{}, first_person_last_on_foot{}; + ULONGLONG first_person_foot_since{}, first_person_retry_after{}; // FreeCamera vertical FOV (+0xac): chosen value (0 = unchanged) and the value // it held before first person took it, restored when first person ends. float first_person_fov{}, first_person_saved_fov{}; @@ -215,6 +220,8 @@ bool first_person_write_fov(std::uintptr_t camera, float fov) noexcept; void first_person_restore_fov(InteractiveDebug& debug) noexcept; void free_camera_restore_fov(InteractiveDebug& debug) noexcept; std::uintptr_t first_person_component(std::uintptr_t base, std::uintptr_t client); +// Whether the local skater is walking (physics state Offboard); empty if unreadable. +std::optional first_person_on_foot(std::uintptr_t base, std::uintptr_t client) noexcept; first_person::Vec3 first_person_head_matrix(std::uintptr_t base, std::uintptr_t component, std::array& matrix); void first_person_on_render(std::uintptr_t animation_interface) noexcept; void first_person_on_animation(std::uintptr_t component) noexcept; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index e5547d81..deade161 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -65,6 +65,13 @@ enum class DebugAction { set_first_person_spring_down, set_first_person_spring_left, set_first_person_spring_right, + set_first_person_stabilize, + set_first_person_follow_flips, + set_first_person_smoothing, + set_first_person_head_pitch, + set_first_person_head_roll, + set_first_person_bob, + set_first_person_board_only, reset_first_person_arm, set_free_camera_fov, // 0 = the game's own FOV // Keep the last action in sync with the bound in request_scheduler.h. diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 6c6e7d16..251953ac 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -51,7 +51,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal begin_card(menu, "first-person", "FIRST PERSON"); bool first_person = debug.first_person; - if (toggle_row(menu, "First person", "Attach the camera to the skater's head.", first_person, + if (toggle_row(menu, "First person", "See the world through the skater's eyes.", first_person, debug.available && debug.camera_available && callbacks.queue_debug)) debug_request(menu, callbacks, {DebugAction::set_first_person, first_person}); field(menu, "Field of view"); @@ -60,6 +60,33 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (ImGui::SliderInt("##first-person-fov", &fov, 40, 120, "%d degrees", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {DebugAction::set_first_person_fov, false, static_cast(fov)}); ImGui::EndDisabled(); + { + auto settings = debug.first_person_arm; + const bool can_edit = debug.available && callbacks.queue_debug; + if (toggle_row(menu, "True first person", + "Keep the horizon level and the view steady; the head's heading is still followed.", settings.stabilize, can_edit)) + debug_request(menu, callbacks, {DebugAction::set_first_person_stabilize, settings.stabilize}); + ImGui::BeginDisabled(!settings.stabilize || !can_edit); + const auto percent = [&](const char* label, const char* id, float value, DebugAction action) { + field(menu, label); + if (ImGui::SliderFloat(id, &value, 0, dingosdk::first_person::strength_limit, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {action, false, value}); + }; + percent("Smoothing", "##fp-smoothing", settings.smoothing, DebugAction::set_first_person_smoothing); + percent("Head nod (up / down)", "##fp-head-pitch", settings.head_pitch, DebugAction::set_first_person_head_pitch); + percent("Head tilt (horizon roll)", "##fp-head-roll", settings.head_roll, DebugAction::set_first_person_head_roll); + percent("Head bob", "##fp-bob", settings.bob, DebugAction::set_first_person_bob); + if (toggle_row(menu, "Follow flips", "Turn the view with the skater during flips and bails.", settings.follow_flips, + can_edit && settings.stabilize)) + debug_request(menu, callbacks, {DebugAction::set_first_person_follow_flips, settings.follow_flips}); + ImGui::EndDisabled(); + if (toggle_row(menu, "Third person on foot", + "Use the game's camera while walking; first person comes back when you get on the board.", + settings.board_only, can_edit)) + debug_request(menu, callbacks, {DebugAction::set_first_person_board_only, settings.board_only}); + note("Nod, tilt and bob are the share of the head's own movement kept. Use Spring arm > Pitch to look " + "further down at the board."); + } if (ImGui::TreeNode("Spring arm")) { namespace fp = dingosdk::first_person; auto settings = debug.first_person_arm; From 13a64c2e913b5f6b4325cfa2eb1d29fc8a5ca7f4 Mon Sep 17 00:00:00 2001 From: ComputerKWasTaken Date: Sun, 4 Oct 2026 16:40:57 -0500 Subject: [PATCH 002/119] Add random community park loading and launch randomization --- CMakeLists.txt | 5 ++ .../World/Test/park_randomization_tests.cpp | 52 +++++++++++++++++++ Engine/Game/World/park_randomization.h | 37 +++++++++++++ Engine/Game/World/park_rotation.h | 1 + .../Profile/Test/profile_change_tests.cpp | 17 ++++++ Extension/Profile/local_profile.h | 1 + Extension/Profile/local_profile_runtime.h | 2 + Extension/Profile/local_profile_store.cpp | 11 ++++ Extension/Profile/local_profile_world.cpp | 42 +++++++++++++++ Extension/UI/Overlay/skate_menu_world.cpp | 18 +++++++ Extension/World/console_commands.cpp | 26 ++++++++++ Extension/World/local_park_rotation.cpp | 4 ++ Extension/World/local_park_rotation.h | 2 + README.md | 11 ++++ Server/README.txt | 1 + Server/server_host.cpp | 7 ++- 16 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 Engine/Game/World/Test/park_randomization_tests.cpp create mode 100644 Engine/Game/World/park_randomization.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 63b78dbb..6c2700ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,11 @@ endif() option(DINGOSDK_BUILD_TESTS "Build logging regression tests" OFF) option(DINGOSDK_BUILD_PARK_EDITOR_TESTS "Build park editor regression tests" OFF) option(DINGOSDK_BUILD_TRAINER_TESTS "Build the trainer's tools and regression tests" OFF) +if(DINGOSDK_BUILD_PARK_EDITOR_TESTS) + enable_testing() + add_executable(dingosdk_park_randomization_tests Engine/Game/World/Test/park_randomization_tests.cpp) + add_test(NAME park_randomization COMMAND dingosdk_park_randomization_tests) +endif() if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32) enable_testing() add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp) diff --git a/Engine/Game/World/Test/park_randomization_tests.cpp b/Engine/Game/World/Test/park_randomization_tests.cpp new file mode 100644 index 00000000..b9904529 --- /dev/null +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -0,0 +1,52 @@ +#include "Engine/Game/World/park_randomization.h" +#include +#include +#include + +namespace { +int failures{}; +void check(bool condition, const char* message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +} +int main() { + using namespace dingosdk; + std::mt19937 generator{12345}; + std::array, park_lots.size()> seen; + constexpr unsigned rolls = 30000; + for (unsigned roll = 0; roll < rolls; ++roll) { + const auto choices = random_park_choices(generator); + for (unsigned lot = 0; lot < choices.size(); ++lot) { + check(!choices[lot].empty() && choices[lot] != "empty" && valid_park(lot, choices[lot]), + "random layouts are non-empty and supported by their lot"); + ++seen[lot][choices[lot]]; + } + } + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + std::set expected; + for (unsigned family = 0; family < park_families.size(); ++family) + for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) + expected.insert(park_id(family, variant)); + check(seen[lot].size() == expected.size(), "every supported layout can be selected"); + const auto average = rolls / static_cast(expected.size()); + for (const auto& layout : expected) { + const auto count = seen[lot][layout]; + // Broad deterministic bound catches equal-family weighting without + // making this a fragile statistical test or fixing a particular RNG sequence. + check(count > average * 3 / 4 && count < average * 5 / 4, + "layouts have equal weight regardless of family size"); + } + } + ParkLaunchRandomization launch; + check(!launch.consume(true, false), "launch randomization is off by default"); + launch.pending = true; + check(!launch.consume(false, false) && launch.pending, "wait for park content before consuming launch"); + check(launch.consume(true, false) && !launch.pending, "randomize once when the controller becomes ready"); + check(!launch.consume(false, false) && !launch.consume(true, false), "map reloads do not reroll"); + launch.pending = true; + check(!launch.consume(true, true) && !launch.pending, "guests never randomize host-controlled parks"); + check(!launch.consume(true, false), "leaving a host does not defer startup randomization"); + if (failures) return 1; + std::cout << "park randomization: ok\n"; + return 0; +} diff --git a/Engine/Game/World/park_randomization.h b/Engine/Game/World/park_randomization.h new file mode 100644 index 00000000..5c0ad538 --- /dev/null +++ b/Engine/Game/World/park_randomization.h @@ -0,0 +1,37 @@ +#pragma once +#include "park_rotation.h" +#include +#include + +namespace dingosdk { +// Sample layouts, rather than families, so every authored layout has equal weight. +// Empty lots are deliberately excluded from the random pool. +template ParkChoices random_park_choices(Generator& generator) { + ParkChoices choices; + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + unsigned count{}; + for (const auto variants : park_lots[lot].counts) count += variants; + auto selected = std::uniform_int_distribution(0, count - 1)(generator); + for (unsigned family = 0; family < park_families.size(); ++family) { + const auto variants = park_lots[lot].counts[family]; + if (selected < variants) { choices[lot] = park_id(family, selected + 1); break; } + selected -= variants; + } + } + return choices; +} +inline ParkChoices random_park_choices() { + thread_local std::mt19937 generator{std::random_device{}()}; + return random_park_choices(generator); +} + +struct ParkLaunchRandomization { + bool pending{}; + // Waiting for content does not consume the launch. Joining a host does: + // leaving that session or reconstructing the controller must not reroll. + bool consume(bool ready, bool controlled_by_host) { + if (!ready || !std::exchange(pending, false)) return false; + return !controlled_by_host; + } +}; +} diff --git a/Engine/Game/World/park_rotation.h b/Engine/Game/World/park_rotation.h index 54f23c38..357c563a 100644 --- a/Engine/Game/World/park_rotation.h +++ b/Engine/Game/World/park_rotation.h @@ -49,6 +49,7 @@ inline std::string park_label(std::string_view id) { } struct ParksModel { bool available{}, ready{}, controlled_by_host{}; + bool randomize_on_launch{}; ParkChoices choices; std::string feedback; }; diff --git a/Extension/Profile/Test/profile_change_tests.cpp b/Extension/Profile/Test/profile_change_tests.cpp index 4fe8c045..6066069b 100644 --- a/Extension/Profile/Test/profile_change_tests.cpp +++ b/Extension/Profile/Test/profile_change_tests.cpp @@ -43,6 +43,21 @@ int main() { check(announced(), "a profile option save is announced"); store.set_selected_cosmetic_preset(3); check(announced(), "a save outside the settings is announced"); + const ParkChoices parks{"megapark_04", "flumppark_10", "streetpark_06"}; + store.save_park_choices(parks); + check(announced() && profile::park_choices(store.snapshot()) == parks, "all park slots save together"); + store.save_park_choices(parks); + check(!announced(), "saving the same park choices announces nothing"); + auto invalid = parks; + invalid[0] = "skatepark_01"; + invalid[2] = "flumppark_10"; // Historic-only variant, invalid for Financial. + bool rejected{}; + try { store.save_park_choices(invalid); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && !announced() && profile::park_choices(store.snapshot()) == parks, + "an invalid batch never saves earlier slots"); + check(!store.user_value("ReSkate.RandomizeParksOnLaunch"), "existing profiles have no launch randomization preference"); + store.set_user_value("ReSkate.RandomizeParksOnLaunch", true); + check(announced(), "the launch preference is saved and announced"); } { // A restart: the saved values and their types (a whole number saved as a float stays one). @@ -52,6 +67,8 @@ int main() { store.user_value("enableCompass") == Json(false) && store.user_value("cameraMode") == Json(2) && number && number->is_number_float() && number->get() == 20.0 && store.selected_cosmetic_preset() == 3, "saved settings come back after a restart"); + check(profile::park_choices(store.snapshot()) == ParkChoices{"megapark_04", "flumppark_10", "streetpark_06"} && + store.user_value("ReSkate.RandomizeParksOnLaunch") == Json(true), "parks and launch preference survive a restart"); } std::filesystem::remove_all(folder, error); if (failures) return 1; diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 23d4e29f..82b27756 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -175,6 +175,7 @@ public: std::uint32_t selected_cosmetic_preset() const; void set_selected_cosmetic_preset(std::uint32_t); void save_park_choice(unsigned lot, std::string_view id); + void save_park_choices(const ParkChoices&); void save_world_controls(const WorldControls&); void save_graphics_controls(const GraphicsControls&); std::uint32_t noclip_binding() const; diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 17586388..fc7d85d9 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -91,6 +91,8 @@ PlayerCardModel local_profile_player_card(); bool set_local_player_card_name(std::string_view name); ParksModel local_profile_parks(); bool set_local_park(std::string_view lot, std::string_view layout); +bool load_random_local_parks(); +bool set_local_park_randomize_on_launch(bool enabled); // Transient lobby authority. Guest choices never overwrite saved preferences. void set_lobby_park_mode(bool active, bool guest); void apply_host_park_choices(const ParkChoices& choices); diff --git a/Extension/Profile/local_profile_store.cpp b/Extension/Profile/local_profile_store.cpp index a040226e..ec932174 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -253,4 +253,15 @@ void Store::save_park_choice(unsigned lot, std::string_view id) { } +void Store::save_park_choices(const ParkChoices& choices) { + for (unsigned lot = 0; lot < choices.size(); ++lot) + require(valid_park(lot, choices[lot]), "Unknown park layout for this lot"); + std::lock_guard lock(mutex_); + if (park_choices(value_) == choices) return; + Update update(*this); + for (unsigned lot = 0; lot < choices.size(); ++lot) + update.json(value_.settings, {"parks", park_lots[lot].key}) = choices[lot]; + update.commit(); +} + } diff --git a/Extension/Profile/local_profile_world.cpp b/Extension/Profile/local_profile_world.cpp index 1d96e805..11f3d1f9 100644 --- a/Extension/Profile/local_profile_world.cpp +++ b/Extension/Profile/local_profile_world.cpp @@ -223,6 +223,7 @@ bool set_local_park(std::string_view lot, std::string_view layout) { } const auto index = static_cast(it - park_lots.begin()); s.store->save_park_choice(index, layout); + r.launch_randomization.pending = false; r.model.choices[index] = layout; r.sent[index].clear(); r.model.feedback = "Park choice saved. It loads when BAM's park controller is ready."; @@ -232,6 +233,47 @@ bool set_local_park(std::string_view lot, std::string_view layout) { return false; } } +bool load_random_local_parks() { + auto& s = local_runtime(); auto& r = park_runtime(); + std::lock_guard lock(s.native_mutex); + if (!s.active || !r.active || !s.store) return false; + if (r.model.controlled_by_host) { + r.model.feedback = "The lobby host controls community park layouts."; + return false; + } + try { + auto choices = random_park_choices(); + // Publish only after all three choices are durably saved together. + s.store->save_park_choices(choices); + r.model.choices = std::move(choices); + r.sent = {}; r.clear_unset = {}; + r.launch_randomization.pending = false; + // Respect an in-flight clear/load delay; the pending load uses the new choice. + if (r.pending_lot >= park_lots.size()) r.next_request = 0; + r.model.feedback = "Random park choices saved. Loading when the park controller is ready."; + return true; + } catch (...) { + r.model.feedback = "Random park choices could not be saved."; + return false; + } +} +bool set_local_park_randomize_on_launch(bool enabled) { + auto& s = local_runtime(); auto& r = park_runtime(); + std::lock_guard lock(s.native_mutex); + if (!s.active || !r.active || !s.store) return false; + try { + s.store->set_user_value("ReSkate.RandomizeParksOnLaunch", enabled); + r.model.randomize_on_launch = enabled; + // Enabling schedules the next launch, never an unexpected mid-session load. + if (!enabled) r.launch_randomization.pending = false; + r.model.feedback = enabled ? "Parks will randomize on the next game launch when you control the layouts." : + "Randomize on Launch is off. Your saved layouts will be used."; + return true; + } catch (...) { + r.model.feedback = "Randomize on Launch could not be saved."; + return false; + } +} void set_lobby_park_mode(bool active, bool guest) { auto& s = local_runtime(); auto& r = park_runtime(); std::lock_guard lock(s.native_mutex); diff --git a/Extension/UI/Overlay/skate_menu_world.cpp b/Extension/UI/Overlay/skate_menu_world.cpp index e937e570..c238371a 100644 --- a/Extension/UI/Overlay/skate_menu_world.cpp +++ b/Extension/UI/Overlay/skate_menu_world.cpp @@ -302,6 +302,24 @@ void parks(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { : model.parks.controlled_by_host ? "You are an admin of this server: a layout you load changes for everyone." : "Choose a layout for each slot. Choices save and are shared when you host."); note("Construction controls both the construction site and the Hedgemont park."); + const auto park_command = [&](const char* command) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, command, result.data(), result.size()); + result.back() = '\0'; feedback(menu, result.data()); + }; + ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command); + if (ImGui::Button("Load Random Parks")) { + park_command("park random"); + menu.park_edit = {}; // Discard staged dropdown edits, including slots that reroll the same layout. + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!model.parks.available || !callbacks.queue_console_command); + auto random_on_launch = model.parks.randomize_on_launch; + if (ImGui::Checkbox("Randomize on Launch", &random_on_launch)) + park_command(random_on_launch ? "park random-on-launch 1" : "park random-on-launch 0"); + ImGui::EndDisabled(); + note("Pick a random layout for each slot. Randomize on Launch runs once per game launch; multiplayer follows the host."); ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command); if (ImGui::BeginTable("park-layouts", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthStretch, 0.9f); diff --git a/Extension/World/console_commands.cpp b/Extension/World/console_commands.cpp index 7a8a09ce..6831e497 100644 --- a/Extension/World/console_commands.cpp +++ b/Extension/World/console_commands.cpp @@ -248,6 +248,32 @@ void register_world_commands(Commands ®istry) { out((saved ? "" : "error: ") + local_profile_world_layers().feedback); }; registry.add(std::move(defaults)); + auto random_parks = action("park random", "Load a random layout in every park slot", Group::world); + random_parks.inspect = [](const Model &m) { + return State{m.parks.available && (!m.parks.controlled_by_host || m.multiplayer.server_admin), {}, + "The host controls park layouts, or park controls are unavailable.", {}, false}; + }; + random_parks.run = [](const Model &, const Values &, const Output &out) { + if (multiplayer::model().server_admin) { + const bool sent = multiplayer::queue_command("server", "park random", ""); + out(sent ? "Sent to the server." : "error: The request queue is busy; try again."); + return; + } + const bool saved = load_random_local_parks(); + out((saved ? "" : "error: ") + local_profile_parks().feedback); + }; + registry.add(std::move(random_parks)); + auto random_on_launch = variable("park random-on-launch", "Randomize local parks on the next game launch", + Group::world, argument("0|1", Type::boolean)); + random_on_launch.inspect = [](const Model &m) { + return boolean_state(m.parks.available, m.parks.randomize_on_launch, "Park controls are unavailable.", + "Local preference; the host still controls multiplayer layouts"); + }; + random_on_launch.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_park_randomize_on_launch(std::get(args[0])); + out((saved ? "" : "error: ") + local_profile_parks().feedback); + }; + registry.add(std::move(random_on_launch)); for (unsigned lot = 0; lot < park_lots.size(); ++lot) { auto layout = argument("layout|empty"); layout.choices = {"empty"}; diff --git a/Extension/World/local_park_rotation.cpp b/Extension/World/local_park_rotation.cpp index 2ceb60b6..0e18d555 100644 --- a/Extension/World/local_park_rotation.cpp +++ b/Extension/World/local_park_rotation.cpp @@ -114,6 +114,8 @@ void update_park_rotation(std::uintptr_t manager, std::uint64_t now) { if (!park_owner_ready(manager, context)) { reset_park_session(); return; } if (r.owner != manager || r.context != context) reset_park_session(); r.owner = manager; r.context = context; r.model.ready = true; + if (r.launch_randomization.consume(r.model.ready, r.model.controlled_by_host)) + load_random_local_parks(); // Serialize clear/load across ticks. The native graph's empty-name branch // unloads every family at this lot, including a previously selected family. if (now < r.next_request) return; @@ -180,6 +182,8 @@ void start_park_rotation() noexcept { std::vector created; try { r.model.choices = profile::park_choices(*local_runtime().store->shared_snapshot()); + r.model.randomize_on_launch = local_preference("RandomizeParksOnLaunch").value_or(false); + r.launch_randomization.pending = r.model.randomize_on_launch; for (const auto& site : sites) { std::array bytes{}; if (!read(base + site.rva, bytes) || bytes != site.bytes) diff --git a/Extension/World/local_park_rotation.h b/Extension/World/local_park_rotation.h index 5badb009..67fde317 100644 --- a/Extension/World/local_park_rotation.h +++ b/Extension/World/local_park_rotation.h @@ -1,5 +1,6 @@ #pragma once #include "Extension/Profile/runtime_internal.h" +#include "Engine/Game/World/park_randomization.h" namespace dingosdk::profile_runtime { struct ParkRuntime { @@ -12,6 +13,7 @@ struct ParkRuntime { Notify original_notify{}; std::atomic active{}; ParksModel model; + ParkLaunchRandomization launch_randomization; ParkChoices sent; bool lobby_active{}; std::array clear_unset{}; diff --git a/README.md b/README.md index 2bd7b2ba..b603bca1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ ReSkate supports one game build at a time (Steam build `25414733`). The menu and console keys can be changed in the launcher's Settings. +In **MAP → ROTATING PARKS**, **Load Random Parks** chooses and loads one random layout for each of +San Vansterdam's three slots. Each supported layout has an equal chance; empty lots are excluded and +a roll can pick a layout already loaded. Choices save with your profile and are shared when you host. +**Randomize on Launch** is off by default. Turn it on to pick fresh layouts once on each subsequent +game launch, when the park controller is ready. Changing maps does not roll again. Guests follow the +host's layouts; a dedicated server admin can use the button to randomize the server's parks for everyone. +The launch preference only affects your own parks, including when you host a lobby. + +The console equivalents are `park random` and `park random-on-launch 0|1`. A dedicated server also +accepts `park random` from its console or an admin. + ### Where things are | What | Where | diff --git a/Server/README.txt b/Server/README.txt index 6cc89fad..a836fab0 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -133,6 +133,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. tpall [player] Everyone to you (admins in game) or to a player. tphere One player to you (admins in game). park + park random Randomize all three park slots (excludes empty lots). layer-sync on|off layer default|on|off tod Time of day on every map (needs layer-sync on). diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 1a452eb5..1cd3f5c2 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -5,6 +5,7 @@ #include "Engine/Core/Text/word_filter.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/World/world_names.h" +#include "Engine/Game/World/park_randomization.h" #ifdef _WIN32 #include #include @@ -46,7 +47,7 @@ constexpr std::string_view help_text = "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \n" "placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" - "park | layer-sync on|off | layer default|on|off | tod \n" + "park | park random | layer-sync on|off | layer default|on|off | tod \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ]\n" "admin add|remove | admins | update | quit"; @@ -1194,6 +1195,10 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed("Deleted " + std::to_string(removed) + " placed object" + (removed == 1 ? "." : "s.")); } if (name == "park") { + if (lower(argument) == "random") { + config_.parks = random_park_choices(); + return changed("Random layouts selected for every park slot."); + } const auto [lot_name, layout] = split(argument); const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto &l) { return l.key == lot_name; }); if (lot == park_lots.end()) return "park construction|historic|financial "; From 360338e6e10f8acf28d237c4f6a8a79f99305395 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 14:27:08 +0000 Subject: [PATCH 003/119] Launcher: controller navigation and the Steam Deck trackpad as a mouse The launcher read no controller at all. Started from Steam (Desktop or Game Mode on a Steam Deck), Steam Input applies the game's gamepad layout to it, where the right trackpad is the right stick and its click is R3, so the trackpad did nothing; only the touch screen, which arrives as mouse messages, worked. - Every XInput pad is merged (Xbox, Steam Input's virtual pad), else a DualShock 4 / DualSense is read over HID with the reader the in-game menu already uses, now its own library (dingosdk_playstation_input). - D-pad and left stick move ImGui's focus, outlined in the launcher's blue; A / Cross presses. The hand-drawn tiles, switches and menu buttons take part in navigation (ImGuiButtonFlags_EnableNav). - B / Circle closes an open combo or menu and leaves a text field, and is Escape otherwise, which the launcher's pages already go back on. Escape now also closes the mod overviews and answers Cancel in the uninstall and replace confirmations; closing an overview no longer also leaves the page. - The right stick (the trackpad) moves a pointer that ImGui draws, and R3 (the trackpad's click) is the left button. The system cursor follows it; a mouse move or a touch elsewhere takes the pointer back. - The ImGui Win32 backend's own gamepad code (slot 0 only) is compiled out so it cannot fight the launcher's events; nothing else used it. --- Launcher/Test/gamepad_input_tests.cpp | 207 ++++++++++++++++++++++++++ Launcher/gamepad_input.cpp | 137 +++++++++++++++++ Launcher/gamepad_input.h | 51 +++++++ Launcher/gui.cpp | 26 +++- Launcher/gui_gamepad.cpp | 94 ++++++++++++ Launcher/gui_internal.h | 6 + Launcher/gui_mods.cpp | 15 +- Launcher/gui_mods_browse.cpp | 3 +- cmake/Apps.cmake | 7 +- cmake/Dependencies.cmake | 5 +- cmake/UI.cmake | 7 +- 11 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 Launcher/Test/gamepad_input_tests.cpp create mode 100644 Launcher/gamepad_input.cpp create mode 100644 Launcher/gamepad_input.h create mode 100644 Launcher/gui_gamepad.cpp diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp new file mode 100644 index 00000000..bdc62a53 --- /dev/null +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -0,0 +1,207 @@ +// Controller input in the launcher: focus, back, the trackpad pointer and its click, +// checked through real ImGui frames. +#include "Launcher/gamepad_input.h" + +#include + +#include +#include +#include + +using dingosdk::launcher_gui::PadFeed; +using dingosdk::launcher_gui::PadState; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +constexpr std::uint16_t dpad_down = 0x0002, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; + +struct Seen { + std::string pressed; // the button that reported a press this frame + bool escape{}; + bool menu_open{}; +}; + +// One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. +// Two buttons stacked at the top left, and a menu popup opened on request. +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::optional* warp = nullptr) { + auto& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(800, 600); + io.DeltaTime = 1.0f / 60.0f; + const auto moved = feed.update(io, pad, 1.0f); + if (warp) *warp = moved; + ImGui::NewFrame(); + Seen seen; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(800, 600)); + ImGui::Begin("##root", nullptr, ImGuiWindowFlags_NoDecoration); + ImGui::SetCursorScreenPos(ImVec2(100, 100)); + if (ImGui::InvisibleButton("first", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "first"; + ImGui::SetCursorScreenPos(ImVec2(100, 200)); + if (ImGui::InvisibleButton("second", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "second"; + if (open_menu) ImGui::OpenPopup("##menu"); + if (ImGui::BeginPopup("##menu")) { + seen.menu_open = true; + ImGui::MenuItem("Details"); + ImGui::EndPopup(); + } + seen.escape = ImGui::IsKeyPressed(ImGuiKey_Escape, false); + ImGui::End(); + ImGui::Render(); + return seen; +} + +PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0) { + PadState state; + state.connected = true; + state.buttons = buttons; + state.right_x = right_x; + state.right_y = right_y; + return state; +} + +// Presses and lets go, returning what the frames saw. +std::string press(PadFeed& feed, std::uint16_t button) { + std::string pressed = frame(feed, pad(button)).pressed; + const auto released = frame(feed, pad()).pressed; + return pressed.empty() ? released : pressed; +} + +void fresh() { + if (ImGui::GetCurrentContext()) ImGui::DestroyContext(); + ImGui::CreateContext(); + auto& io = ImGui::GetIO(); + io.IniFilename = nullptr; + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + io.Fonts->AddFontDefault(); + io.Fonts->Build(); +} + +} // namespace + +int main() { + // ------------------------------------------------ focus + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad()); + press(feed, dpad_down); + check(ImGui::GetIO().NavVisible, "The D-pad shows the focus"); + // The first move lands on the first item; the next one goes down. + press(feed, dpad_down); + check(press(feed, button_a) == "second", "A presses the item the D-pad moved to"); + check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "No pointer is drawn while the focus is used"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad(button_a)); + check(ImGui::IsKeyDown(ImGuiKey_GamepadFaceDown), "A (Cross) is the gamepad's activate button"); + PadState gone; // unplugged with A held + frame(feed, gone); + frame(feed, gone); + check(!ImGui::IsKeyDown(ImGuiKey_GamepadFaceDown), "An unplugged pad lets go of A"); + check(!(ImGui::GetIO().BackendFlags & ImGuiBackendFlags_HasGamepad), "An unplugged pad is no gamepad"); + } + + // ------------------------------------------------ back + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad()); + frame(feed, pad(button_b)); + check(ImGui::IsKeyPressed(ImGuiKey_Escape, false), "B is Escape with nothing open"); + frame(feed, pad()); + check(!ImGui::IsKeyDown(ImGuiKey_Escape), "Letting go of B lets go of Escape"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + check(frame(feed, pad(), true).menu_open, "The menu opens"); + frame(feed, pad()); + const auto with_b = frame(feed, pad(button_b)); + const auto after = frame(feed, pad()); + check(!after.menu_open, "B closes an open menu"); + check(!with_b.escape && !after.escape, "Closing the menu is not also Escape, which would leave the page"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + ImGui::GetIO().AddKeyEvent(ImGuiKey_Escape, true); // the keyboard's Escape, held + frame(feed, pad()); + frame(feed, pad()); + check(ImGui::IsKeyDown(ImGuiKey_Escape), "An idle pad does not let go of the keyboard's Escape"); + } + + // ------------------------------------------------ trackpad pointer + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(400, 300); + frame(feed, pad()); + std::optional warp; + frame(feed, pad(0, 3000, 0), false, &warp); + check(!warp && !feed.pointing(), "A thumb resting near the middle does not move the pointer"); + + frame(feed, pad(0, 16000, 0), false, &warp); + check(feed.pointing() && ImGui::GetIO().MouseDrawCursor, "Tilting takes the pointer and draws it"); + check(warp && warp->x > 400 && warp->y == 300, "Tilting right moves the pointer right, from the mouse's place"); + for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767), false, &warp); + check(warp && warp->x == 799 && warp->y == 0, "The pointer stays inside the window"); + frame(feed, pad()); + check(ImGui::GetIO().MousePos.x == 799 && ImGui::GetIO().MousePos.y == 0, "ImGui's mouse is where the pad left it"); + + // The system cursor reporting a stale place (Proton, gamescope) does not take the pointer back. + ImGui::GetIO().AddMousePosEvent(10, 10); + frame(feed, pad(), false, &warp); + frame(feed, pad()); + check(!warp && ImGui::GetIO().MousePos.x == 799, "Without tilt the pointer stays put"); + + feed.mouse_moved(ImVec2(799, 0)); // the echo of our own cursor move + check(feed.pointing(), "The echo of our own cursor move keeps the pointer"); + feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen + ImGui::GetIO().AddMousePosEvent(50, 60); + frame(feed, pad()); + frame(feed, pad()); + check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "The mouse takes the pointer back"); + check(ImGui::GetIO().MousePos.x == 50, "The mouse's place wins once it moved"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(150, 225); // over "second" + frame(feed, pad()); + frame(feed, pad()); + std::string pressed = frame(feed, pad(right_thumb)).pressed; + check(ImGui::GetIO().MouseDown[0], "The trackpad's click (R3) holds the left button"); + const auto released = frame(feed, pad()).pressed; + if (pressed.empty()) pressed = released; + check(pressed == "second", "The trackpad's click presses what the pointer is over"); + check(!ImGui::GetIO().MouseDown[0], "Letting go of R3 lets go of the left button"); + + frame(feed, pad(0, 20000, 0)); + check(feed.pointing(), "The pointer is the pad's"); + press(feed, dpad_down); + check(!feed.pointing() && ImGui::GetIO().NavVisible, "The D-pad hands the screen back to the focus"); + } + + ImGui::DestroyContext(); + if (failures) { + std::cerr << failures << " gamepad input check(s) failed\n"; + return 1; + } + std::cout << "Gamepad input checks passed.\n"; + return 0; +} diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp new file mode 100644 index 00000000..8380948a --- /dev/null +++ b/Launcher/gamepad_input.cpp @@ -0,0 +1,137 @@ +#include "gamepad_input.h" + +#include + +#include +#include + +namespace dingosdk::launcher_gui { +namespace { + +// XINPUT_GAMEPAD_* bits, spelled out so this file needs no Windows headers. +constexpr std::uint16_t dpad_up = 0x0001, dpad_down = 0x0002, dpad_left = 0x0004, dpad_right = 0x0008; +constexpr std::uint16_t right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; + +struct Button { std::uint16_t bit; ImGuiKey key; }; +// Only what moves and presses the focus. X/Y and the shoulders stay out: ImGui +// reads them as its window switcher and tweak modifiers, which the launcher has +// no use for. +constexpr Button focus_buttons[]{ + {dpad_up, ImGuiKey_GamepadDpadUp}, {dpad_down, ImGuiKey_GamepadDpadDown}, + {dpad_left, ImGuiKey_GamepadDpadLeft}, {dpad_right, ImGuiKey_GamepadDpadRight}, + {button_a, ImGuiKey_GamepadFaceDown}}; +constexpr std::uint16_t focus_bits = dpad_up | dpad_down | dpad_left | dpad_right | button_a; + +// XInput's left stick dead zone. +constexpr float left_dead_zone = 7849.0f / 32767.0f; +// Larger on the right: a thumb resting on the Deck's trackpad is never quite +// centred, and the pointer must not creep under it. +constexpr float right_dead_zone = 0.2f; +// Pointer speed at full tilt, in design pixels a second. +constexpr float pointer_speed = 1400.0f; +// A mouse move this close to the pad's pointer is the echo of moving the +// system cursor there; one further away is the mouse or a finger. +constexpr float pointer_slack = 2.0f; + +float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } + +void stick(ImGuiIO& io, ImGuiKey negative, ImGuiKey positive, float value) { + const auto amount = [](float tilt) { return std::clamp((tilt - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f); }; + io.AddKeyAnalogEvent(negative, -value > left_dead_zone, amount(-value)); + io.AddKeyAnalogEvent(positive, value > left_dead_zone, amount(value)); +} + +// B goes to ImGui's own cancel while ImGui has something to cancel: a combo or +// menu to close, or a text field to leave. Otherwise it is Escape, the +// launcher's way back out of a page or a modal. +bool imgui_cancels() { + const ImGuiContext& g = *GImGui; + if (ImGui::IsAnyItemActive()) return true; + if (g.OpenPopupStack.Size == 0) return false; + const ImGuiWindow* top = g.OpenPopupStack.back().Window; + return top && !(top->Flags & ImGuiWindowFlags_Modal); +} + +bool moved_away(ImVec2 position, ImVec2 pointer) { + return std::abs(position.x - pointer.x) > pointer_slack || std::abs(position.y - pointer.y) > pointer_slack; +} + +} // namespace + +std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { + // A pad that went away lets go of everything it held. + const PadState pad = input.connected ? input : PadState{}; + const auto changed = static_cast(pad.buttons ^ buttons_); + const auto pressed = static_cast(pad.buttons & changed); + if (pad.connected) io.BackendFlags |= ImGuiBackendFlags_HasGamepad; + else io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad; + + // Only changes are sent: the keyboard shares Escape and the mouse shares the + // left button, and a pad repeating "up" every frame would cancel them. + for (const auto& button : focus_buttons) + if (changed & button.bit) io.AddKeyEvent(button.key, (pad.buttons & button.bit) != 0); + if (changed & button_b) { + const bool down = (pad.buttons & button_b) != 0; + // The release goes to whichever key the press went to. + if (down) back_key_ = imgui_cancels() ? ImGuiKey_GamepadFaceRight : ImGuiKey_Escape; + io.AddKeyEvent(back_key_, down); + } + const float left_x = axis(pad.left_x), left_y = axis(pad.left_y); + if (pad.connected || connected_) { + // Analog values ImGui already has are dropped by ImGui itself. + stick(io, ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, left_x); + stick(io, ImGuiKey_GamepadLStickDown, ImGuiKey_GamepadLStickUp, left_y); + } + connected_ = pad.connected; + buttons_ = pad.buttons; + + // Moving the focus hands the screen back to it: ImGui hides the focus while + // a pointer moves, so the pointer goes away instead. + if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) + pointing_ = false; + + bool warp = false; + const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward + const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); + if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) { + pointing_ = true; + warp = true; + // From wherever the mouse left it, which may be outside the window. + pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos + : ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); + } + if (pointing_ && tilt > right_dead_zone) { + // Squared, so a small tilt places the pointer precisely and a full one + // crosses the window in about a second. + const float speed = (tilt - right_dead_zone) / (1 - right_dead_zone); + const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt; + pointer_.x += x * step; + pointer_.y += y * step; + warp = true; + } + if (pointing_) { + pointer_.x = std::clamp(pointer_.x, 0.0f, std::max(0.0f, io.DisplaySize.x - 1)); + pointer_.y = std::clamp(pointer_.y, 0.0f, std::max(0.0f, io.DisplaySize.y - 1)); + // Every frame, after the backend's own: it may report a system cursor + // that Proton or gamescope did not move along with ours. + io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); + io.AddMousePosEvent(pointer_.x, pointer_.y); + } + // The release is sent even when the mouse took the pointer meanwhile: the + // press was ours. + if (changed & right_thumb) { + io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, (pad.buttons & right_thumb) != 0); + } + io.MouseDrawCursor = pointing_; + if (warp) return pointer_; + return std::nullopt; +} + +void PadFeed::mouse_moved(ImVec2 position) { + // Our own cursor move comes back as a mouse move too; only one that lands + // somewhere else is the mouse or a finger. + if (pointing_ && moved_away(position, pointer_)) pointing_ = false; +} + +} // namespace dingosdk::launcher_gui diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h new file mode 100644 index 00000000..45904a95 --- /dev/null +++ b/Launcher/gamepad_input.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include +#include + +// Controller input for the launcher window. Kept free of Windows headers so +// the mapping can be tested anywhere; gui_gamepad.cpp reads the pads. +namespace dingosdk::launcher_gui { + +// One frame of a controller in XInput's XINPUT_GAMEPAD layout: an Xbox pad, +// Steam Input's virtual pad (Steam Deck), or a DualShock 4 / DualSense read +// over HID. Several pads are merged into one. +struct PadState { + bool connected{}; + std::uint16_t buttons{}; // XINPUT_GAMEPAD_* bits + std::uint8_t left_trigger{}, right_trigger{}; + std::int16_t left_x{}, left_y{}, right_x{}, right_y{}; +}; + +// Turns a pad into the launcher's ImGui input: +// - D-pad and left stick move the focus, A (Cross) presses the focused item; +// - B (Circle) closes an open combo or menu and leaves a text field, and is +// Escape otherwise, which the launcher's pages and modals already go back on; +// - the right stick moves a pointer and R3 clicks with it. Under Steam Input's +// gamepad layouts the Steam Deck's right trackpad is the right stick and its +// click is R3, so this is what makes the trackpad a mouse in the launcher. +class PadFeed { +public: + // Call once a frame after the backends' NewFrame and before + // ImGui::NewFrame(), which takes the events in. Returns where the OS + // cursor should move (client area pixels) when the pad moved the pointer. + std::optional update(ImGuiIO& io, const PadState& pad, float scale); + // A mouse move or a touch the window received (WM_MOUSEMOVE, client area + // pixels). One that is not the echo of our own cursor move takes the + // pointer back from the pad. + void mouse_moved(ImVec2 position); + // The right stick owns the pointer; ImGui draws it, since the Steam + // Deck's Game Mode may not show the system cursor. + bool pointing() const { return pointing_; } + +private: + std::uint16_t buttons_{}; + ImGuiKey back_key_{ImGuiKey_Escape}; + bool connected_{}; + bool pointing_{}; + ImVec2 pointer_{}; +}; + +} // namespace dingosdk::launcher_gui diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 20277a07..b72f418e 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -6,6 +6,7 @@ #include "Engine/Core/Log/logging.h" #include +#include #include #include @@ -50,7 +51,7 @@ ImVec2 begin_page(const char* id, ImVec2 size) { bool tile_hit(const char* id, ImVec2 position, ImVec2 size, bool enabled, bool& hovered) { ImGui::SetCursorScreenPos(position); ImGui::BeginDisabled(!enabled); - const bool pressed = ImGui::InvisibleButton(id, size); + const bool pressed = ImGui::InvisibleButton(id, size, ImGuiButtonFlags_EnableNav); hovered = enabled && ImGui::IsItemHovered(); ImGui::EndDisabled(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -125,7 +126,7 @@ bool list_row(const char* id, float width, float height, bool ticked) { bool toggle(const char* id, bool* on) { const ImVec2 size(S(42), S(22)); const ImVec2 start = ImGui::GetCursorScreenPos(); - const bool pressed = ImGui::InvisibleButton(id, size); + const bool pressed = ImGui::InvisibleButton(id, size, ImGuiButtonFlags_EnableNav); if (pressed) *on = !*on; const bool hovered = ImGui::IsItemHovered(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -140,7 +141,7 @@ bool toggle(const char* id, bool* on) { bool more_button(const char* id, float size) { const ImVec2 start = ImGui::GetCursorScreenPos(); - const bool pressed = ImGui::InvisibleButton(id, ImVec2(size, size)); + const bool pressed = ImGui::InvisibleButton(id, ImVec2(size, size), ImGuiButtonFlags_EnableNav); const bool hovered = ImGui::IsItemHovered(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); auto* draw = ImGui::GetWindowDrawList(); @@ -176,6 +177,8 @@ LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPAR } if (g_capturing_key && (message == WM_CHAR || message == WM_SYSCHAR || message == WM_KEYUP || message == WM_SYSKEYUP)) return 0; + if (message == WM_MOUSEMOVE && g_pad_feed) + g_pad_feed->mouse_moved(ImVec2(static_cast(GET_X_LPARAM(lparam)), static_cast(GET_Y_LPARAM(lparam)))); if (ImGui_ImplWin32_WndProcHandler(window, message, wparam, lparam)) return 1; switch (message) { case WM_NCHITTEST: { @@ -294,7 +297,8 @@ void apply_style() { colours[ImGuiCol_ModalWindowDimBg] = rgb(rgba(4, 6, 9, 0.72f)); colours[ImGuiCol_Separator] = rgb(color::outline); colours[ImGuiCol_TextSelectedBg] = rgb(rgba(1, 131, 255, 0.45f)); - colours[ImGuiCol_NavHighlight] = ImVec4(0, 0, 0, 0); + // Only shown once a controller moves the focus: the outline of the item A presses. + colours[ImGuiCol_NavCursor] = rgb(color::blue); } @@ -366,6 +370,7 @@ int run(const launcher_app::Session& session, const std::vector& a ImGui::CreateContext(); ImGui::GetIO().IniFilename = nullptr; ImGui::GetIO().LogFilename = nullptr; + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; const auto fonts = load_fonts(); apply_style(); ImGui_ImplWin32_Init(window); @@ -414,6 +419,8 @@ int run(const launcher_app::Session& session, const std::vector& a const auto ui_storage = std::make_unique(); auto& ui = *ui_storage; ModsPanel mods_panel; + PadFeed pad_feed; + g_pad_feed = &pad_feed; bool running = true; HANDLE game{}; DWORD game_id{}; @@ -462,6 +469,16 @@ int run(const launcher_app::Session& session, const std::vector& a if (hidden || IsIconic(window)) { Sleep(hidden ? 100 : 50); continue; } ImGui_ImplDX12_NewFrame(); ImGui_ImplWin32_NewFrame(); + // A pad pressed while another window has the focus is not meant for us; + // it reads as let go, so nothing stays held while we are in the background. + const bool foreground = GetForegroundWindow() == window; + if (const auto pointer = pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale)) { + // The system cursor follows the pad's pointer, so the two agree when the + // mouse or the touch screen is used next. ImGui draws the pointer itself, + // so a cursor the system will not move (Proton, gamescope) costs nothing. + POINT point{static_cast(pointer->x), static_cast(pointer->y)}; + if (ClientToScreen(window, &point)) SetCursorPos(point.x, point.y); + } ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); ImGui::Render(); @@ -471,6 +488,7 @@ int run(const launcher_app::Session& session, const std::vector& a logging::write(logging::Level::info, logging::Channel::launcher, "Launcher drew its first frame."); } } + g_pad_feed = nullptr; ShowWindow(window, SW_HIDE); if (game) CloseHandle(game); launcher.cancel(); diff --git a/Launcher/gui_gamepad.cpp b/Launcher/gui_gamepad.cpp new file mode 100644 index 00000000..937c0355 --- /dev/null +++ b/Launcher/gui_gamepad.cpp @@ -0,0 +1,94 @@ +#include "gui_internal.h" + +#include "Extension/UI/Overlay/playstation_input.h" + +#include + +#include +#include + +namespace dingosdk::launcher_gui::detail { +namespace { + +using GetState = DWORD(WINAPI*)(DWORD, XINPUT_STATE*); +// Loaded on first use like the overlay does: a PC without the XInput runtime +// still gets a launcher, just without Xbox pads. +GetState xinput_get_state() { + 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; + if (const auto entry = GetProcAddress(module, "XInputGetState")) { + GetState function{}; + static_assert(sizeof(function) == sizeof(entry)); + std::memcpy(&function, &entry, sizeof(function)); + return function; + } + FreeLibrary(module); + } + return nullptr; + }(); + return get_state; +} + +// Asking an empty XInput slot is slow (it looks for a device), so only slots +// with a pad are read every frame, and one empty slot is tried a second. +struct Slots { + unsigned connected{(1u << XUSER_MAX_COUNT) - 1}; // all four tried on the first frame + DWORD next_probe{}; + ULONGLONG probe_at{}; +}; + +} // namespace + +PadState read_pad() { + static Slots slots; + PadState pad; + if (const auto get_state = xinput_get_state()) { + const auto now = GetTickCount64(); + unsigned probe{}; + if (now >= slots.probe_at) { + slots.probe_at = now + 1000; + for (DWORD tried = 0; tried < XUSER_MAX_COUNT && !probe; ++tried) { + const DWORD slot = (slots.next_probe + tried) % XUSER_MAX_COUNT; + if (!(slots.connected & (1u << slot))) probe = 1u << slot; + } + slots.next_probe = (slots.next_probe + 1) % XUSER_MAX_COUNT; + } + // Steam, DS4Windows and ViGEm put virtual pads in any slot, and an idle + // one may sit ahead of the pad in use, so every pad is merged. + for (DWORD slot = 0; slot < XUSER_MAX_COUNT; ++slot) { + const unsigned bit = 1u << slot; + if (!((slots.connected | probe) & bit)) continue; + XINPUT_STATE sample{}; + if (get_state(slot, &sample) != ERROR_SUCCESS) { slots.connected &= ~bit; continue; } + slots.connected |= bit; + const auto& gamepad = sample.Gamepad; + pad.connected = true; + pad.buttons |= gamepad.wButtons; + pad.left_trigger = std::max(pad.left_trigger, gamepad.bLeftTrigger); + pad.right_trigger = std::max(pad.right_trigger, gamepad.bRightTrigger); + const auto magnitude = [](SHORT x, SHORT y) { return std::abs(int(x)) + std::abs(int(y)); }; + if (magnitude(gamepad.sThumbLX, gamepad.sThumbLY) > magnitude(pad.left_x, pad.left_y)) { + pad.left_x = gamepad.sThumbLX; pad.left_y = gamepad.sThumbLY; + } + if (magnitude(gamepad.sThumbRX, gamepad.sThumbRY) > magnitude(pad.right_x, pad.right_y)) { + pad.right_x = gamepad.sThumbRX; pad.right_y = gamepad.sThumbRY; + } + } + } + // XInput wins whenever a pad is there: DS4Windows and Steam Input leave the + // PlayStation pad visible beside their XInput twin, and both would double up. + if (pad.connected) return pad; + const auto playstation = overlay::detail::read_playstation_pads(); + if (!playstation.available) return {}; + const auto& sony = playstation.pad; + pad.connected = true; + pad.buttons = sony.buttons; + pad.left_trigger = sony.left_trigger; pad.right_trigger = sony.right_trigger; + pad.left_x = sony.left_x; pad.left_y = sony.left_y; + pad.right_x = sony.right_x; pad.right_y = sony.right_y; + return pad; +} + +} // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 07ad623f..efb2f721 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -1,5 +1,6 @@ #pragma once +#include "gamepad_input.h" #include "launch.h" #include "text_encoding.h" #include "thunderstore.h" @@ -162,6 +163,11 @@ inline std::atomic g_captured_key{}; // Paths dropped on the window, picked up by the next frame. inline std::mutex g_dropped_mutex; inline std::vector g_dropped; +// Every connected controller merged into one: XInput pads (Xbox, and Steam +// Input's virtual pad on a Steam Deck), else a DualShock 4 / DualSense over HID. +PadState read_pad(); +// The window's controller input, told about mouse moves by the window procedure. +inline PadFeed* g_pad_feed{}; // ---------------------------------------------------------------- settings diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 116df32c..1e6d8ee3 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -712,7 +712,8 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst save(panel); } ImGui::SameLine(extent.x - S(28) - S(110)); - if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close(); + // Escape too, and so a controller's B: the modal has no other way out but CLOSE. + if (ImGui::Button("CLOSE", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close(); ImGui::EndPopup(); } @@ -1033,10 +1034,12 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo } ImGui::EndChild(); + // Read before the overviews: one closing on Escape must not also leave the page. + const bool popup_open = ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); if (panel.tab == 1) package_overview(fonts, panel, frame, installing); else mod_overview(fonts, panel, installed, frame, installing); if (leave) close(); - if (!installing && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && + if (!installing && !popup_open && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { // Escape lets go of the ticked mods first, and leaves the page after that. if (panel.tab == 0 && !panel.marked.empty()) panel.marked.clear(); @@ -1072,7 +1075,11 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); - if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { panel.confirm_remove.clear(); ImGui::CloseCurrentPopup(); } + // Escape (and a controller's B) answers Cancel, never the destructive choice. + if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + panel.confirm_remove.clear(); + ImGui::CloseCurrentPopup(); + } ImGui::SameLine(); push_primary_button(); if (ImGui::Button("UNINSTALL", ImVec2(S(110), 0))) { @@ -1117,7 +1124,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo panel.conflict_name.c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); - if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { + if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { panel.conflict_name.clear(); ImGui::CloseCurrentPopup(); } diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 827ed9d7..4b6afe60 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -676,7 +676,8 @@ void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool in if (ImGui::Checkbox("Install with the others I tick", &ticked)) pick(store, package.full_name, ticked); ImGui::EndDisabled(); ImGui::SameLine(extent.x - S(28) - S(110)); - if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close(); + // Escape too, and so a controller's B: the modal has no other way out but CLOSE. + if (ImGui::Button("CLOSE", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close(); ImGui::EndPopup(); } diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 35c239cb..e57d5b53 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -2,9 +2,10 @@ if(WIN32) add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp + Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs - dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui winhttp shell32 dwmapi windowscodecs ole32) + dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32) set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher") dingosdk_version_info(dingosdk_launcher "ReSkate launcher" "ReSkateLauncher.exe" VFT_APP) endif() @@ -19,6 +20,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) + add_executable(dingosdk_gamepad_input_tests Launcher/Test/gamepad_input_tests.cpp Launcher/gamepad_input.cpp) + target_link_libraries(dingosdk_gamepad_input_tests PRIVATE dingosdk_imgui) + target_include_directories(dingosdk_gamepad_input_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME launcher_gamepad_input COMMAND dingosdk_gamepad_input_tests) add_executable(dingosdk_content_catalogs_tests Engine/Vfs/Test/content_catalogs_tests.cpp) target_link_libraries(dingosdk_content_catalogs_tests PRIVATE dingosdk_content_cache) add_test(NAME content_catalogs COMMAND dingosdk_content_catalogs_tests) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index a1dedab8..378c9a2f 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -7,7 +7,10 @@ if(WIN32) "${PROJECT_SOURCE_DIR}/External/imgui/backends/imgui_impl_dx12.cpp" "${PROJECT_SOURCE_DIR}/External/imgui/backends/imgui_impl_win32.cpp") target_include_directories(dingosdk_imgui PUBLIC "${PROJECT_SOURCE_DIR}/External/imgui") - target_compile_definitions(dingosdk_imgui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX UNICODE _UNICODE) + # The backend's own gamepad reads only XInput slot 0. The launcher feeds every pad + # itself (Launcher/gamepad_input.cpp), and the in-game menu does not use ImGui's gamepad keys. + target_compile_definitions(dingosdk_imgui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX UNICODE _UNICODE + IMGUI_IMPL_WIN32_DISABLE_GAMEPAD) target_link_libraries(dingosdk_imgui PUBLIC d3d12 dxgi d3dcompiler dwmapi) add_library(dingosdk_detours STATIC diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 4679520a..2df4cc16 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -1,3 +1,7 @@ +# DualShock 4 / DualSense read straight from HID, for the in-game menu and the launcher. +add_library(dingosdk_playstation_input STATIC Extension/UI/Overlay/playstation_input.cpp) +target_link_libraries(dingosdk_playstation_input PUBLIC dingosdk_logging PRIVATE hid cfgmgr32) + add_library(dingosdk_overlay STATIC Extension/UI/Overlay/overlay.cpp @@ -23,7 +27,6 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/park_previews.cpp Extension/UI/Overlay/gpu_diagnostics.cpp Extension/UI/Overlay/input_capture.cpp - Extension/UI/Overlay/playstation_input.cpp Extension/UI/Overlay/cursor.cpp Extension/UI/Overlay/progression_menu.cpp Extension/UI/Overlay/atmosphere_menu.cpp @@ -34,7 +37,7 @@ add_library(dingosdk_overlay STATIC Extension/Trainer/trainer_page.cpp Extension/Trainer/trainer_view.cpp ) -target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE hid cfgmgr32 shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) +target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE dingosdk_playstation_input shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) # The window shown from the moment ReSkate loads until the game's own window appears. add_library(dingosdk_startup_window STATIC Extension/UI/Startup/startup_window.cpp) From 8c7c30e3bb11306fd45e577258587ce48930e473 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 14:38:48 +0000 Subject: [PATCH 004/119] Launcher: controller reaches every panel; pad pointer without cursor warps - Settings' and Mods' rails, pages and lists are NavFlattened children, so the D-pad crosses between them instead of treating each as one item that only B could leave. - The pad's pointer no longer moves the system cursor. Under Proton that move came back late as a mouse move and dropped the pointer; now a mouse move counts only when it lands somewhere new. - The first R3 only shows the pointer, so a stray click does not press whatever the mouse last rested on. --- Launcher/Test/gamepad_input_tests.cpp | 84 ++++++++++++++++++--------- Launcher/gamepad_input.cpp | 35 +++++------ Launcher/gamepad_input.h | 16 ++--- Launcher/gui.cpp | 8 +-- Launcher/gui_mods.cpp | 14 +++-- Launcher/gui_mods_browse.cpp | 5 +- Launcher/gui_settings.cpp | 7 ++- 7 files changed, 96 insertions(+), 73 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index bdc62a53..063f0d65 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -31,12 +31,11 @@ struct Seen { // One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. // Two buttons stacked at the top left, and a menu popup opened on request. -Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::optional* warp = nullptr) { +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false) { auto& io = ImGui::GetIO(); io.DisplaySize = ImVec2(800, 600); io.DeltaTime = 1.0f / 60.0f; - const auto moved = feed.update(io, pad, 1.0f); - if (warp) *warp = moved; + feed.update(io, pad, 1.0f); ImGui::NewFrame(); Seen seen; ImGui::SetNextWindowPos(ImVec2(0, 0)); @@ -46,6 +45,17 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::opti if (ImGui::InvisibleButton("first", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "first"; ImGui::SetCursorScreenPos(ImVec2(100, 200)); if (ImGui::InvisibleButton("second", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "second"; + if (panels) { + // A page's rail and content, side by side, as Settings and Mods lay them out. + ImGui::SetCursorScreenPos(ImVec2(400, 200)); + ImGui::BeginChild("##rail", ImVec2(150, 200), ImGuiChildFlags_NavFlattened); + if (ImGui::Button("tab", ImVec2(120, 40))) seen.pressed = "tab"; + ImGui::EndChild(); + ImGui::SetCursorScreenPos(ImVec2(580, 200)); + ImGui::BeginChild("##content", ImVec2(200, 200), ImGuiChildFlags_NavFlattened); + if (ImGui::Button("option", ImVec2(120, 40))) seen.pressed = "option"; + ImGui::EndChild(); + } if (open_menu) ImGui::OpenPopup("##menu"); if (ImGui::BeginPopup("##menu")) { seen.menu_open = true; @@ -145,38 +155,55 @@ int main() { check(ImGui::IsKeyDown(ImGuiKey_Escape), "An idle pad does not let go of the keyboard's Escape"); } + { + fresh(); + PadFeed feed; + constexpr std::uint16_t dpad_right = 0x0008; + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, true); + const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, true); frame(feed, pad(), false, true); }; + tap(dpad_down); // onto "first" + tap(dpad_down); // "second", level with the panels + tap(dpad_right); // the rail + tap(dpad_right); // the content + std::string pressed = frame(feed, pad(button_a), false, true).pressed; + const auto released = frame(feed, pad(), false, true).pressed; + check((pressed.empty() ? released : pressed) == "option", + "The D-pad crosses from a page's rail into its content"); + } + // ------------------------------------------------ trackpad pointer { fresh(); PadFeed feed; - ImGui::GetIO().AddMousePosEvent(400, 300); + auto& io = ImGui::GetIO(); + io.AddMousePosEvent(400, 300); + feed.mouse_moved(ImVec2(400, 300)); frame(feed, pad()); - std::optional warp; - frame(feed, pad(0, 3000, 0), false, &warp); - check(!warp && !feed.pointing(), "A thumb resting near the middle does not move the pointer"); + frame(feed, pad(0, 3000, 0)); + check(!feed.pointing() && io.MousePos.x == 400, "A thumb resting near the middle does not move the pointer"); - frame(feed, pad(0, 16000, 0), false, &warp); - check(feed.pointing() && ImGui::GetIO().MouseDrawCursor, "Tilting takes the pointer and draws it"); - check(warp && warp->x > 400 && warp->y == 300, "Tilting right moves the pointer right, from the mouse's place"); - for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767), false, &warp); - check(warp && warp->x == 799 && warp->y == 0, "The pointer stays inside the window"); + frame(feed, pad(0, 16000, 0)); frame(feed, pad()); - check(ImGui::GetIO().MousePos.x == 799 && ImGui::GetIO().MousePos.y == 0, "ImGui's mouse is where the pad left it"); + check(feed.pointing() && io.MouseDrawCursor, "Tilting takes the pointer and draws it"); + check(io.MousePos.x > 400 && io.MousePos.y == 300, "Tilting right moves the pointer right, from the mouse's place"); + for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767)); + frame(feed, pad()); + check(io.MousePos.x == 799 && io.MousePos.y == 0, "The pointer stays inside the window"); - // The system cursor reporting a stale place (Proton, gamescope) does not take the pointer back. - ImGui::GetIO().AddMousePosEvent(10, 10); - frame(feed, pad(), false, &warp); + // The system cursor stays where the mouse left it, and is reported every frame. + io.AddMousePosEvent(400, 300); frame(feed, pad()); - check(!warp && ImGui::GetIO().MousePos.x == 799, "Without tilt the pointer stays put"); + frame(feed, pad()); + check(io.MousePos.x == 799, "The system cursor's old place does not take the pointer back"); + feed.mouse_moved(ImVec2(400, 300)); // the same place, repeated by Windows + check(feed.pointing(), "A mouse move repeated in the same place keeps the pointer"); - feed.mouse_moved(ImVec2(799, 0)); // the echo of our own cursor move - check(feed.pointing(), "The echo of our own cursor move keeps the pointer"); - feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen - ImGui::GetIO().AddMousePosEvent(50, 60); + feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen + io.AddMousePosEvent(50, 60); frame(feed, pad()); frame(feed, pad()); - check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "The mouse takes the pointer back"); - check(ImGui::GetIO().MousePos.x == 50, "The mouse's place wins once it moved"); + check(!feed.pointing() && !io.MouseDrawCursor, "The mouse takes the pointer back"); + check(io.MousePos.x == 50, "The mouse's place wins once it moved"); } { fresh(); @@ -185,14 +212,15 @@ int main() { frame(feed, pad()); frame(feed, pad()); std::string pressed = frame(feed, pad(right_thumb)).pressed; - check(ImGui::GetIO().MouseDown[0], "The trackpad's click (R3) holds the left button"); - const auto released = frame(feed, pad()).pressed; - if (pressed.empty()) pressed = released; + pressed += frame(feed, pad()).pressed; + check(feed.pointing() && pressed.empty() && !ImGui::GetIO().MouseDown[0], + "The first trackpad click (R3) only shows the pointer"); + pressed = frame(feed, pad(right_thumb)).pressed; + check(ImGui::GetIO().MouseDown[0], "The next click holds the left button"); + pressed += frame(feed, pad()).pressed; check(pressed == "second", "The trackpad's click presses what the pointer is over"); check(!ImGui::GetIO().MouseDown[0], "Letting go of R3 lets go of the left button"); - frame(feed, pad(0, 20000, 0)); - check(feed.pointing(), "The pointer is the pad's"); press(feed, dpad_down); check(!feed.pointing() && ImGui::GetIO().NavVisible, "The D-pad hands the screen back to the focus"); } diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index 8380948a..fc167c4d 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -29,8 +29,7 @@ constexpr float left_dead_zone = 7849.0f / 32767.0f; constexpr float right_dead_zone = 0.2f; // Pointer speed at full tilt, in design pixels a second. constexpr float pointer_speed = 1400.0f; -// A mouse move this close to the pad's pointer is the echo of moving the -// system cursor there; one further away is the mouse or a finger. +// A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } @@ -52,13 +51,13 @@ bool imgui_cancels() { return top && !(top->Flags & ImGuiWindowFlags_Modal); } -bool moved_away(ImVec2 position, ImVec2 pointer) { - return std::abs(position.x - pointer.x) > pointer_slack || std::abs(position.y - pointer.y) > pointer_slack; +bool moved_away(ImVec2 position, ImVec2 before) { + return std::abs(position.x - before.x) > pointer_slack || std::abs(position.y - before.y) > pointer_slack; } } // namespace -std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { +void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { // A pad that went away lets go of everything it held. const PadState pad = input.connected ? input : PadState{}; const auto changed = static_cast(pad.buttons ^ buttons_); @@ -90,12 +89,13 @@ std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) pointing_ = false; - bool warp = false; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); + // The first R3 only shows the pointer: a stray click must not press + // whatever the mouse last rested on. + const bool click = pointing_ && (pressed & right_thumb); if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) { pointing_ = true; - warp = true; // From wherever the mouse left it, which may be outside the window. pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos : ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); @@ -107,31 +107,28 @@ std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt; pointer_.x += x * step; pointer_.y += y * step; - warp = true; } if (pointing_) { pointer_.x = std::clamp(pointer_.x, 0.0f, std::max(0.0f, io.DisplaySize.x - 1)); pointer_.y = std::clamp(pointer_.y, 0.0f, std::max(0.0f, io.DisplaySize.y - 1)); - // Every frame, after the backend's own: it may report a system cursor - // that Proton or gamescope did not move along with ours. + // Every frame, after the backend's own report of the system cursor, + // which stays where the mouse left it. io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); io.AddMousePosEvent(pointer_.x, pointer_.y); } - // The release is sent even when the mouse took the pointer meanwhile: the - // press was ours. - if (changed & right_thumb) { + if (click || (clicking_ && (changed & right_thumb))) { + clicking_ = click; io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); - io.AddMouseButtonEvent(ImGuiMouseButton_Left, (pad.buttons & right_thumb) != 0); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, click); } io.MouseDrawCursor = pointing_; - if (warp) return pointer_; - return std::nullopt; } void PadFeed::mouse_moved(ImVec2 position) { - // Our own cursor move comes back as a mouse move too; only one that lands - // somewhere else is the mouse or a finger. - if (pointing_ && moved_away(position, pointer_)) pointing_ = false; + // Windows repeats a mouse move where the mouse already was when a window + // changes under it; only a move to a new place is the mouse or a finger. + if (pointing_ && moved_away(position, mouse_)) pointing_ = false; + mouse_ = position; } } // namespace dingosdk::launcher_gui diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 45904a95..3a9dc332 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -3,7 +3,6 @@ #include #include -#include // Controller input for the launcher window. Kept free of Windows headers so // the mapping can be tested anywhere; gui_gamepad.cpp reads the pads. @@ -29,15 +28,14 @@ struct PadState { class PadFeed { public: // Call once a frame after the backends' NewFrame and before - // ImGui::NewFrame(), which takes the events in. Returns where the OS - // cursor should move (client area pixels) when the pad moved the pointer. - std::optional update(ImGuiIO& io, const PadState& pad, float scale); + // ImGui::NewFrame(), which takes the events in. + void update(ImGuiIO& io, const PadState& pad, float scale); // A mouse move or a touch the window received (WM_MOUSEMOVE, client area - // pixels). One that is not the echo of our own cursor move takes the - // pointer back from the pad. + // pixels). A move to a new place takes the pointer back from the pad. void mouse_moved(ImVec2 position); - // The right stick owns the pointer; ImGui draws it, since the Steam - // Deck's Game Mode may not show the system cursor. + // The right stick owns the pointer. ImGui draws it and the system cursor + // stays put: under Proton a moved system cursor comes back late as a + // mouse move, which would look like the mouse taking over. bool pointing() const { return pointing_; } private: @@ -45,7 +43,9 @@ private: ImGuiKey back_key_{ImGuiKey_Escape}; bool connected_{}; bool pointing_{}; + bool clicking_{}; // R3 holds the left button ImVec2 pointer_{}; + ImVec2 mouse_{-1, -1}; // where the last mouse move was }; } // namespace dingosdk::launcher_gui diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index b72f418e..f29efac4 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -472,13 +472,7 @@ int run(const launcher_app::Session& session, const std::vector& a // A pad pressed while another window has the focus is not meant for us; // it reads as let go, so nothing stays held while we are in the background. const bool foreground = GetForegroundWindow() == window; - if (const auto pointer = pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale)) { - // The system cursor follows the pad's pointer, so the two agree when the - // mouse or the touch screen is used next. ImGui draws the pointer itself, - // so a cursor the system will not move (Proton, gamescope) costs nothing. - POINT point{static_cast(pointer->x), static_cast(pointer->y)}; - if (ClientToScreen(window, &point)) SetCursorPos(point.x, point.y); - } + pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale); ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); ImGui::Render(); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 1e6d8ee3..9c26f679 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -571,7 +571,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co // ------------------------------------------------ the list, and under it how its order works const float hint = ImGui::GetTextLineHeightWithSpacing(); const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top) - hint); - ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders); + ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); const auto note = [](const char* first, const char* second = nullptr) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -670,7 +670,8 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst ImGui::Spacing(); ImGui::BeginChild("##mod_overview_body", - ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight()))); + ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())), + ImGuiChildFlags_NavFlattened); ImGui::PushTextWrapPos(0); if (!mod.outdated.empty()) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), @@ -850,7 +851,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeight() + S(56); ImGui::BeginChild("##broken_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer), - ImGuiChildFlags_Borders); + ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); for (const auto& problem : problems) { ImGui::PushFont(fonts.bold); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); @@ -934,7 +935,7 @@ void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, U const float footer = ImGui::GetFrameHeight() + S(44); ImGui::BeginChild("##outdated_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer), - ImGuiChildFlags_Borders); + ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); for (const auto* package : pending) { const auto found = installed.find(thunderstore::folder_for(package->full_name)); ImGui::PushFont(fonts.bold); @@ -1013,12 +1014,13 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo const float rail_top = top + title_size * 2 + S(16); ImGui::SetCursorPos(ImVec2(rail_x, rail_top)); - ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top)); + // Flattened, so a controller's D-pad crosses from the rail into the list and back. + ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened); const bool leave = rail(fonts, panel, window, rail_width, pending, installing); ImGui::EndChild(); ImGui::SetCursorPos(ImVec2(content_x, top)); - ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top)); + ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top), ImGuiChildFlags_NavFlattened); // With nothing to say, the list runs all the way down to BACK's bottom // edge; a message takes two lines off it until it is gone. const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 4b6afe60..35b17ad7 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -474,7 +474,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float const auto packages = visible_packages(store, installed); // ------------------------------------------------ the list - ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders); + ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); const auto note = [](const char* text) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -653,7 +653,8 @@ void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool in ImGui::Spacing(); ImGui::BeginChild("##overview_body", - ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight()))); + ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())), + ImGuiChildFlags_NavFlattened); ImGui::PushTextWrapPos(0); if (package.deprecated) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 394d5eb4..ae1ef4c3 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -123,7 +123,8 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui // ------------------------------------------------ rail const float rail_top = top + title_size + S(20); ImGui::SetCursorPos(ImVec2(rail_x, rail_top)); - ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top)); + // Flattened, so a controller's D-pad crosses from the rail into the page and back. + ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened); static constexpr std::array names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; ImGui::BeginDisabled(binding); for (int i = 0; i < static_cast(names.size()); ++i) @@ -140,9 +141,9 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui // Kept to a readable column: settings are sentences, not a table. ImGui::SetCursorPos(ImVec2(content_x, top)); ImGui::BeginChild("##settings_content", - ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top)); + ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top), ImGuiChildFlags_NavFlattened); const float footer = ImGui::GetTextLineHeight() + S(16); - ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer)); + ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer), ImGuiChildFlags_NavFlattened); switch (ui.settings_tab) { case 0: { section_caption(fonts, "GAME FOLDER"); From deaa041f24f7f08c994f8b8b07e844c5f3fe02e5 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 15:20:22 +0000 Subject: [PATCH 005/119] Launcher: controller focus stays inside pages; mouse cursor hides while it moves begin_panel and begin_page focused their window again every frame. With a controller that pulled the focus out of the page's child panels, so in Settings and the Mod manager (GET MODS included) the D-pad and left stick could reach nothing. The window is now focused only when the focus is not already inside it. While the pad moves the focus the mouse cursor hides, and the next mouse move brings it back. --- Launcher/gui.cpp | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index f29efac4..5b82c64d 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -9,6 +9,8 @@ #include #include +#include + #include #include @@ -21,6 +23,21 @@ extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, namespace dingosdk::launcher_gui { namespace detail { +namespace { + +// Keeps a panel or page in front, but not over its own open combo or popup, and +// not while the focus is already inside it: focusing the window again every +// frame would pull a controller's focus out of its child panels. +void keep_in_front(const char* id) { + if (ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return; + const ImGuiWindow* window = ImGui::FindWindowByName(id); + const ImGuiWindow* focused = GImGui->NavWindow; + if (window && focused && focused->RootWindow == window) return; + ImGui::SetNextWindowFocus(); +} + +} // namespace + void panel_title(const Fonts& fonts, const char* text) { ImGui::PushFont(fonts.tile); ImGui::TextUnformatted(text); @@ -30,8 +47,7 @@ void panel_title(const Fonts& fonts, const char* text) { ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel) { ImGui::SetNextWindowPos(ImVec2((size.x - panel.x) * 0.5f, (size.y - panel.y) * 0.5f)); ImGui::SetNextWindowSize(panel); - // Keep panels in front, but not over their own open combo or popup. - if (!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) ImGui::SetNextWindowFocus(); + keep_in_front(id); ImGui::Begin(id, nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); return panel; } @@ -39,7 +55,7 @@ ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel) { ImVec2 begin_page(const char* id, ImVec2 size) { ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(size); - if (!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) ImGui::SetNextWindowFocus(); + keep_in_front(id); // Opaque, and darker than a tile: a page covers the window, so the main // screen must not show through, and its tiles need something to sit on. ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(rgba(14, 15, 18))); @@ -475,6 +491,10 @@ int run(const launcher_app::Session& session, const std::vector& a pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale); ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); + // While the pad moves the focus, the mouse cursor (the Deck's trackpad in its + // desktop layout) hides; the next mouse move hides the focus and brings it back. + if (GImGui->NavCursorVisible && GImGui->NavHighlightItemUnderNav && !pad_feed.pointing()) + ImGui::SetMouseCursor(ImGuiMouseCursor_None); ImGui::Render(); renderer.render(); if (!drew) { From 21d5a0ba4bdfd6e588a5c92043b19a68764a7f42 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 15:26:41 +0000 Subject: [PATCH 006/119] Launcher: the left stick scrolls under the focus or the pointer ImGui 1.91 moves the focus with the D-pad only; its left stick scrolls the focused window, which under a pointer is usually the page itself. The stick also handed the screen from the pointer to the focus, so scrolling flipped between the two. The launcher now scrolls itself: the nearest scrollable panel around the focus while the focus is shown, else the panel under the pointer or the mouse (as wheel notches). Scrolling no longer switches between them. --- Launcher/Test/gamepad_input_tests.cpp | 64 +++++++++++++++++++++++++-- Launcher/gamepad_input.cpp | 38 ++++++++++------ Launcher/gamepad_input.h | 4 +- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 063f0d65..736e0fa4 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -3,6 +3,7 @@ #include "Launcher/gamepad_input.h" #include +#include #include #include @@ -21,17 +22,18 @@ void check(bool condition, const char* what) { ++failures; } -constexpr std::uint16_t dpad_down = 0x0002, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; +constexpr std::uint16_t dpad_down = 0x0002, dpad_right = 0x0008, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; struct Seen { std::string pressed; // the button that reported a press this frame bool escape{}; bool menu_open{}; + float list_scroll{}; }; // One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. // Two buttons stacked at the top left, and a menu popup opened on request. -Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false) { +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false, bool list = false) { auto& io = ImGui::GetIO(); io.DisplaySize = ImVec2(800, 600); io.DeltaTime = 1.0f / 60.0f; @@ -56,6 +58,18 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane if (ImGui::Button("option", ImVec2(120, 40))) seen.pressed = "option"; ImGui::EndChild(); } + if (list) { + // A long list in a panel, like GET MODS. + ImGui::SetCursorScreenPos(ImVec2(400, 100)); + ImGui::BeginChild("##list", ImVec2(300, 200), ImGuiChildFlags_NavFlattened); + for (int row = 0; row < 30; ++row) { + ImGui::PushID(row); + if (ImGui::Selectable("##row", false, 0, ImVec2(280, 40))) seen.pressed = "row " + std::to_string(row); + ImGui::PopID(); + } + seen.list_scroll = ImGui::GetScrollY(); + ImGui::EndChild(); + } if (open_menu) ImGui::OpenPopup("##menu"); if (ImGui::BeginPopup("##menu")) { seen.menu_open = true; @@ -68,8 +82,9 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane return seen; } -PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0) { +PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0, std::int16_t left_y = 0) { PadState state; + state.left_y = left_y; state.connected = true; state.buttons = buttons; state.right_x = right_x; @@ -158,7 +173,6 @@ int main() { { fresh(); PadFeed feed; - constexpr std::uint16_t dpad_right = 0x0008; for (int i = 0; i < 2; ++i) frame(feed, pad(), false, true); const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, true); frame(feed, pad(), false, true); }; tap(dpad_down); // onto "first" @@ -171,6 +185,48 @@ int main() { "The D-pad crosses from a page's rail into its content"); } + // ------------------------------------------------ scrolling + { + fresh(); + PadFeed feed; + const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, false, true); frame(feed, pad(), false, false, true); }; + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + tap(dpad_right); // from nothing onto the list's first row + tap(dpad_right); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel holding the focus"); + check(!feed.pointing() && GImGui->NavCursorVisible && GImGui->NavHighlightItemUnderNav, + "Scrolling stays with the focus, not the mouse"); + for (int i = 0; i < 60; ++i) frame(feed, pad(0, 0, 0, 32767), false, false, true); + check(frame(feed, pad(), false, false, true).list_scroll == 0, "Pushing up scrolls back to the top"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(500, 200); // over the list + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + frame(feed, pad(0, 0, 0, 0), false, false, true); + frame(feed, pad(right_thumb), false, false, true); // the pointer shows, without a click + frame(feed, pad(), false, false, true); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel under the pointer"); + check(feed.pointing(), "Scrolling keeps the pointer"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(500, 200); // the mouse resting over the list + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel under the mouse"); + } + // ------------------------------------------------ trackpad pointer { fresh(); diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index fc167c4d..0e551cb8 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -29,15 +29,32 @@ constexpr float left_dead_zone = 7849.0f / 32767.0f; constexpr float right_dead_zone = 0.2f; // Pointer speed at full tilt, in design pixels a second. constexpr float pointer_speed = 1400.0f; +// Scrolling at full tilt: design pixels a second for the focused panel, and +// mouse wheel notches a second (ImGui scrolls five lines a notch) under a pointer. +constexpr float scroll_speed = 1200.0f; +constexpr float wheel_speed = 16.0f; // A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } -void stick(ImGuiIO& io, ImGuiKey negative, ImGuiKey positive, float value) { - const auto amount = [](float tilt) { return std::clamp((tilt - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f); }; - io.AddKeyAnalogEvent(negative, -value > left_dead_zone, amount(-value)); - io.AddKeyAnalogEvent(positive, value > left_dead_zone, amount(value)); +// The left stick scrolls whatever the user is looking at: the panel holding +// the pad's focus while the focus is shown, else the one under the pointer. +// ImGui's own stick scrolling only ever moves the focused window, which under a +// pointer is usually the page itself, with nothing to scroll. +void scroll(ImGuiIO& io, float tilt, float scale) { + const float amount = std::copysign(std::clamp((std::abs(tilt) - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f), tilt); + if (amount == 0.0f) return; + const ImGuiContext& g = *GImGui; + if (g.NavCursorVisible && g.NavHighlightItemUnderNav) { + // The nearest panel around the focus that can scroll. + ImGuiWindow* window = g.NavWindow; + while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow; + if (window && window->ScrollMax.y > 0) + ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime); + return; + } + io.AddMouseWheelEvent(0, amount * wheel_speed * io.DeltaTime); } // B goes to ImGui's own cancel while ImGui has something to cancel: a combo or @@ -75,19 +92,12 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { if (down) back_key_ = imgui_cancels() ? ImGuiKey_GamepadFaceRight : ImGuiKey_Escape; io.AddKeyEvent(back_key_, down); } - const float left_x = axis(pad.left_x), left_y = axis(pad.left_y); - if (pad.connected || connected_) { - // Analog values ImGui already has are dropped by ImGui itself. - stick(io, ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, left_x); - stick(io, ImGuiKey_GamepadLStickDown, ImGuiKey_GamepadLStickUp, left_y); - } - connected_ = pad.connected; + scroll(io, axis(pad.left_y), scale); buttons_ = pad.buttons; // Moving the focus hands the screen back to it: ImGui hides the focus while - // a pointer moves, so the pointer goes away instead. - if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) - pointing_ = false; + // a pointer moves, so the pointer goes away instead. Scrolling keeps both. + if (pressed & focus_bits) pointing_ = false; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 3a9dc332..ea1d7ac2 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -19,7 +19,8 @@ struct PadState { }; // Turns a pad into the launcher's ImGui input: -// - D-pad and left stick move the focus, A (Cross) presses the focused item; +// - the D-pad moves the focus, A (Cross) presses the focused item; +// - the left stick scrolls, under the focus or under the pointer; // - B (Circle) closes an open combo or menu and leaves a text field, and is // Escape otherwise, which the launcher's pages and modals already go back on; // - the right stick moves a pointer and R3 clicks with it. Under Steam Input's @@ -41,7 +42,6 @@ public: private: std::uint16_t buttons_{}; ImGuiKey back_key_{ImGuiKey_Escape}; - bool connected_{}; bool pointing_{}; bool clicking_{}; // R3 holds the left button ImVec2 pointer_{}; From 1715dd38d049e6961624f6e8f59036f20a466c56 Mon Sep 17 00:00:00 2001 From: ComputerKWasTaken Date: Mon, 5 Oct 2026 15:44:58 -0500 Subject: [PATCH 007/119] Add controller park randomization and cancel guest startup rolls --- .../World/Test/park_randomization_tests.cpp | 4 ++ Engine/Game/World/park_randomization.h | 3 +- Extension/Multiplayer/Test/mesh_tests.cpp | 63 ++++++++++++++++- Extension/Multiplayer/Test/protocol_tests.cpp | 19 ++++++ Extension/Profile/local_profile_world.cpp | 3 + .../Test/native_tools_view_tests.cpp | 67 ++++++++++++++++++- Extension/UI/NativeMenu/native_tools_view.h | 16 ++++- README.md | 7 +- 8 files changed, 173 insertions(+), 9 deletions(-) diff --git a/Engine/Game/World/Test/park_randomization_tests.cpp b/Engine/Game/World/Test/park_randomization_tests.cpp index b9904529..bc55f205 100644 --- a/Engine/Game/World/Test/park_randomization_tests.cpp +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -46,6 +46,10 @@ int main() { launch.pending = true; check(!launch.consume(true, true) && !launch.pending, "guests never randomize host-controlled parks"); check(!launch.consume(true, false), "leaving a host does not defer startup randomization"); + launch.pending = true; + check(!launch.consume(false, true) && !launch.pending, + "joining a host cancels startup randomization before park content is ready"); + check(!launch.consume(true, false), "leaving an unready host session cannot trigger a deferred roll"); if (failures) return 1; std::cout << "park randomization: ok\n"; return 0; diff --git a/Engine/Game/World/park_randomization.h b/Engine/Game/World/park_randomization.h index 5c0ad538..912f2964 100644 --- a/Engine/Game/World/park_randomization.h +++ b/Engine/Game/World/park_randomization.h @@ -30,8 +30,9 @@ struct ParkLaunchRandomization { // Waiting for content does not consume the launch. Joining a host does: // leaving that session or reconstructing the controller must not reroll. bool consume(bool ready, bool controlled_by_host) { + if (controlled_by_host) { pending = false; return false; } if (!ready || !std::exchange(pending, false)) return false; - return !controlled_by_host; + return true; } }; } diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index e0895a89..732e8844 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -6,19 +6,25 @@ #include "Extension/Multiplayer/Session/session_receive.cpp" #include "Extension/Multiplayer/Session/session_commands.cpp" #include "Extension/Multiplayer/Session/session_party.cpp" +#include "Engine/Game/World/park_randomization.h" #include #include namespace dingosdk { bool read_local_camera_transform(std::uintptr_t, std::uintptr_t, std::array&) noexcept { return false; } -ParksModel local_profile_parks() { return {}; } +ParksModel simulated_parks; +std::vector>* simulated_park_receives{}; +std::uint64_t simulated_park_receiver{}; +ParksModel local_profile_parks() { return simulated_parks; } void set_lobby_park_mode(bool, bool) {} bool simulated_object_guest{}; void set_lobby_object_guest(bool guest) { simulated_object_guest = guest; } WorldLayersModel simulated_host_layers; WorldLayersModel local_profile_world_layers() { return simulated_host_layers; } void apply_host_world_layers(bool, const WorldLayerChoices &) {} -void apply_host_park_choices(const ParkChoices &) {} +void apply_host_park_choices(const ParkChoices& choices) { + if (simulated_park_receives) simulated_park_receives->emplace_back(simulated_park_receiver, choices); +} std::optional capture_local_network_objects() { return {}; } void set_remote_network_objects(std::string_view, std::span) {} void clear_remote_network_objects() {} @@ -520,6 +526,7 @@ struct Simulation { local.pose.root.position[0] = static_cast(now - 10000000) / 1000000; local.pose.root.position[2] = n < positions.size() ? positions[n] : static_cast(n); refresh_host_choices(s, now); + simulated_park_receiver = s.transport.status().local_id; networking(s, local, now); if (s.mode != Mode::off) { sync_objects(s, local, now); @@ -2053,6 +2060,54 @@ void mesh_checks() { for (const auto &s : sim.nodes) check(s->mode == Mode::off, "Guest remained after host departure"); } +void random_park_sync_checks() { + std::mt19937 generator{7821}; + std::vector> received; + simulated_park_receives = &received; + simulated_parks.choices = random_park_choices(generator); + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + const auto all_received = [&] { + return !received.empty() && std::all_of(received.begin(), received.end(), [](const auto& entry) { + return entry.second == simulated_parks.choices; + }); + }; + const auto received_by = [&](const Session& node) { + return std::any_of(received.begin(), received.end(), [&](const auto& entry) { + return entry.first == node.transport.status().local_id && entry.second == simulated_parks.choices; + }); + }; + check(received_by(*sim.nodes[1]) && received_by(*sim.nodes[2]) && all_received(), + "Guests did not receive the host's initial random park choices"); + received.clear(); + simulated_parks.choices = random_park_choices(generator); + sim.run(12); sim.fresh(3); + check(received_by(*sim.nodes[1]) && received_by(*sim.nodes[2]) && all_received(), + "A new host roll did not reach existing guests intact"); + received.clear(); + sim.add(); sim.run(30); sim.fresh(4); + check(received_by(*sim.nodes.back()) && all_received(), + "A late joiner did not receive the host's current random parks"); + + auto& host = *sim.nodes[0]; + auto& sender = *sim.nodes[2]; + auto forged = packet(host, PacketKind::roster, sim.now); + forged.parks = random_park_choices(generator); + check(forged.parks != simulated_parks.choices, "Spoof fixture must use different park choices"); + forged.source = sender.transport.status().local_id; + forged.epoch = sender.epoch; + for (const auto& node : sim.nodes) + forged.members.push_back({node->transport.status().local_id, node->epoch, "Player"}); + received.clear(); + check(send_packet(sender, sim.nodes[1]->transport.status().local_id, forged, true, false), "Guest park spoof fixture failed to send"); + sim.run(2); sim.fresh(4); + check(std::all_of(received.begin(), received.end(), [&](const auto& entry) { return entry.second == simulated_parks.choices; }) && + host.parks == simulated_parks.choices, "A guest's forged roster replaced the host's park choices"); + simulated_park_receives = nullptr; + simulated_parks = {}; + std::cout << "Random parks: host rolls, current guests, late join and guest spoof rejection passed.\n"; +} } // namespace } // namespace dingosdk::multiplayer int main(int argc, char **argv) { @@ -2069,6 +2124,9 @@ int main(int argc, char **argv) { dingosdk::simulated_host_layers = {}; dingosdk::multiplayer::join_tick_checks(); dingosdk::multiplayer::tick_settings_checks(); + if (argc == 2 && std::string_view(argv[1]) == "--parks-only") { + dingosdk::multiplayer::random_park_sync_checks(); return 0; + } if (argc == 2 && std::string_view(argv[1]) == "--objects-only") { dingosdk::multiplayer::object_sync_checks(); return 0; } @@ -2110,6 +2168,7 @@ int main(int argc, char **argv) { dingosdk::multiplayer::session_controls_checks(); dingosdk::multiplayer::guest_building_checks(); dingosdk::multiplayer::world_layer_sync_checks(); + dingosdk::multiplayer::random_park_sync_checks(); std::cout << "Mesh admission, 2-8 players, relay fallback, route loss, packet loss, reconnect and " "password checks passed.\n"; return 0; diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 223296db..a8839706 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Net/protocol.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Extension/Throwdowns/throwdown_wire.h" +#include "Engine/Game/World/park_randomization.h" #include #include #include @@ -47,6 +48,23 @@ void tick_rates_codec() { } } } +void random_parks_codec() { + std::mt19937 generator{9147}; + auto p = frame(1, 0); + p.kind = PacketKind::roster; + p.members = {{76561198000000001ULL, p.epoch, "Host"}}; + for (unsigned roll = 0; roll < 1000; ++roll) { + p.parks = dingosdk::random_park_choices(generator); + const auto decoded = decode_wire(encode_wire(p)); + check(decoded && decoded->parks == p.parks, "A random park selection changed in the host roster"); + } + // This variant exists at Historic, but not Construction: lot-specific + // validation must also hold when sharing randomized layouts. + p.parks[0] = "flumppark_10"; + bool rejected{}; + try { (void)encode(p); } catch (const std::invalid_argument&) { rejected = true; } + check(rejected, "A park layout for another lot was shared"); +} void compressed_codec() { auto p = frame(1, 125); p.time_us = 1000000; @@ -909,6 +927,7 @@ int main() { try { codec(); tick_rates_codec(); + random_parks_codec(); compressed_codec(); sender_timeline(); greetings(); diff --git a/Extension/Profile/local_profile_world.cpp b/Extension/Profile/local_profile_world.cpp index 11f3d1f9..4d24bea1 100644 --- a/Extension/Profile/local_profile_world.cpp +++ b/Extension/Profile/local_profile_world.cpp @@ -279,6 +279,9 @@ void set_lobby_park_mode(bool active, bool guest) { std::lock_guard lock(s.native_mutex); if (r.lobby_active == active && r.model.controlled_by_host == (active && guest)) return; if (!s.active.load(std::memory_order_acquire) || !r.active.load(std::memory_order_acquire)) return; + // Joining a host cancels this launch's roll even before a park controller + // can tick. Leaving the lobby must restore saved choices without rerolling. + if (active && guest) r.launch_randomization.pending = false; auto choices = r.model.choices; if (!active) choices = profile::park_choices(*s.store->shared_snapshot()); else if (!guest) { diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 3b64308e..8de62445 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -96,9 +96,9 @@ int main() { model.world_controls.choices.traffic = 3; activate(state, model, cb, "traffic", ""); check(capture.command == "environment traffic -1", "Population cycles back to authored default"); - const auto parks = render(state, model, cb, parks_section); - const auto load = std::find_if(parks.side.begin(), parks.side.end(), [](const auto& row) { return row.id == "load-park"; }); - check(load != parks.side.end() && load->command.empty(), "Host-owned park action is visibly unavailable"); + const auto locked_parks = render(state, model, cb, parks_section); + const auto load = std::find_if(locked_parks.side.begin(), locked_parks.side.end(), [](const auto& row) { return row.id == "load-park"; }); + check(load != locked_parks.side.end() && load->command.empty(), "Host-owned park action is visibly unavailable"); cb.queue_debug = [](void*, const overlay::DebugRequest&, char* result, std::size_t size) { std::snprintf(result, size, "Queued for the game update thread."); return true; }; @@ -122,6 +122,67 @@ int main() { model.player_card.custom_name.clear(); const auto steam_page = render(state, model, cb, player_section); check(find(steam_page.side, "card-name-reset")->command.empty(), "Steam name reset is idle without a custom name"); + { + State parks; + overlay::Model m; + m.world.map = WorldMap::bam; + m.parks.available = true; + m.parks.choices.fill("flumppark_01"); + Capture log; + auto controls = cb; + controls.user = &log; + const auto control = [&](const Page& page, std::string_view id) -> const Row& { + const auto found = find(page.main, id); + check(found != page.main.end() && found->button, "Random park controls are controller-focusable buttons"); + return *found; + }; + auto page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").title == "Load Random Parks" && + control(page, "load-random-parks").command == "load-random-parks", "Controller exposes random loading for all lots"); + check(control(page, "park-random-on-launch").title == "Randomize on Launch: Off", "Launch toggle reads the shared preference"); + parks.parks.fill("megapark_01"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 1 && log.command == "park random", "Random loading queues one shared command, not three separate rolls"); + check(parks.parks == ParkChoices{}, "Accepted random loading discards every staged layout"); + sync(parks, m); + check(parks.parks == m.parks.choices, "A reroll of the same saved layouts still replaces staged selections"); + + m.parks.controlled_by_host = true; + page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").command.empty(), "Guests cannot activate random loading"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 1, "A stale random button cannot load after joining a host"); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 2 && log.command == "park random-on-launch 1", "Guests can save their own next-launch preference"); + m.parks.randomize_on_launch = true; + page = render(parks, m, controls, parks_section); + check(control(page, "park-random-on-launch").title == "Randomize on Launch: On", "Controller reflects changes from the other menu"); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 3 && log.command == "park random-on-launch 0", "Launch toggle uses the latest shared state"); + + m.multiplayer.server_admin = true; + page = render(parks, m, controls, parks_section); + check(!control(page, "load-random-parks").command.empty(), "Dedicated-server admins keep random park controls"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 4 && log.command == "park random", "Server admin sends one shared command for server-side routing"); + m.world.map = WorldMap::grom; + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 4, "Random loading is unavailable on maps without these park lots"); + m.world.map = WorldMap::bam; + m.parks.available = false; + activate(parks, m, controls, "load-random-parks", {}); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 4, "Unavailable park controls cannot queue actions"); + m.parks.available = true; + controls.queue_console_command = nullptr; + page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").command.empty() && control(page, "park-random-on-launch").command.empty(), + "Missing command callbacks disable both controls"); + controls.queue_console_command = [](void*, const char*, char*, std::size_t) { return false; }; + parks.parks.fill("megapark_01"); + activate(parks, m, controls, "load-random-parks", {}); + check(parks.parks[0] == "megapark_01" && !parks.feedback.empty(), "A rejected queue retains staged choices and reports failure"); + } { // Time of day forces one of a map's seven time layers, others off. overlay::Model world; diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index 49d93e70..59b3308f 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -137,7 +137,9 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks s.feedback = accepted ? "" : "Couldn't apply that change. Try again when the game is ready."; }; const auto console = [&](const std::string& text, bool enabled) { - report(enabled && cb.queue_console_command && cb.queue_console_command(cb.user, text.c_str(), result.data(), result.size())); + const bool accepted = enabled && cb.queue_console_command && cb.queue_console_command(cb.user, text.c_str(), result.data(), result.size()); + report(accepted); + return accepted; }; if (command == "select-level") { if (multiplayer_controls_level(m.multiplayer)) { s.feedback = "Only the lobby host can change levels."; return; } @@ -170,6 +172,14 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks s.parks[s.lot] = family == park_families.size() ? "empty" : park_id(family, 1); } else if (command == "load-park") { console("park " + std::string(park_lots[s.lot].key) + " " + s.parks[s.lot], can_park(m, cb) && valid_park(s.lot, s.parks[s.lot])); + } else if (command == "load-random-parks") { + // Use the shared command path: hosts load locally, server admins request + // one server roll, and guests cannot replace the host's layouts. + if (console("park random", can_park(m, cb))) s.parks = {}; + } else if (command == "park-random-on-launch") { + // This is a personal preference, including while following a host. + console(m.parks.randomize_on_launch ? "park random-on-launch 0" : "park random-on-launch 1", + m.parks.available); } else if (command == "noclip" || command == "no-bail" || command == "freecam" || command == "speed") { const auto& d = m.debug; overlay::DebugRequest request; @@ -264,6 +274,10 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 for (unsigned i = 0; i < park_lots.size(); ++i) button(p.main, "park-lot-" + std::to_string(i), std::string(park_lots[i].label) + (s.lot == i ? " / SELECTED" : ""), "park-lot", true, std::string(park_lots[i].key)); + text(p.main, "park-random-title", "ALL THREE PARK LOCATIONS"); + button(p.main, "load-random-parks", "Load Random Parks", "load-random-parks", can_park(m, cb)); + toggle(p.main, "park-random-on-launch", "Randomize on Launch", m.parks.randomize_on_launch, + m.parks.available && cb.queue_console_command); text(p.side, "park-options", "PARK LAYOUT"); const bool available = can_park(m, cb); std::string family = "Empty lot"; diff --git a/README.md b/README.md index b603bca1..91e25c11 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,16 @@ ReSkate supports one game build at a time (Steam build `25414733`). The menu and console keys can be changed in the launcher's Settings. -In **MAP → ROTATING PARKS**, **Load Random Parks** chooses and loads one random layout for each of +In **MAP → ROTATING PARKS** and the controller-friendly **CUSTOM STUFF → PARKS** screen, +**Load Random Parks** chooses and loads one random layout for each of San Vansterdam's three slots. Each supported layout has an equal chance; empty lots are excluded and a roll can pick a layout already loaded. Choices save with your profile and are shared when you host. **Randomize on Launch** is off by default. Turn it on to pick fresh layouts once on each subsequent game launch, when the park controller is ready. Changing maps does not roll again. Guests follow the host's layouts; a dedicated server admin can use the button to randomize the server's parks for everyone. -The launch preference only affects your own parks, including when you host a lobby. +Both menus share the same launch preference. It only affects your own parks, including when you host +a lobby. Joining a host cancels any pending startup roll, so leaving the lobby restores your saved +layouts without unexpectedly randomizing them. The console equivalents are `park random` and `park random-on-launch 0|1`. A dedicated server also accepts `park random` from its console or an admin. From 650ff2db724e41a6ea42d5bcb08e96caac91a2f2 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 19:30:30 -0600 Subject: [PATCH 008/119] Accept a byte-order mark in reskate-levels.json The launcher and the server already skip one, so a map saved with a BOM was listed everywhere but left out in game. --- Engine/Game/World/custom_level_manifest.cpp | 4 ++-- Engine/Game/World/custom_level_manifest.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Engine/Game/World/custom_level_manifest.cpp b/Engine/Game/World/custom_level_manifest.cpp index fb87f2a2..782b0d24 100644 --- a/Engine/Game/World/custom_level_manifest.cpp +++ b/Engine/Game/World/custom_level_manifest.cpp @@ -86,9 +86,9 @@ void exact_fields(const Json& object, std::initializer_list ex CustomLevelManifest parse(std::string_view text) { if (text.size() > custom_level_manifest_max_bytes) throw std::runtime_error("custom-level manifest exceeds 64 KiB"); - if (text.starts_with("\xef\xbb\xbf")) - throw std::runtime_error("custom-level manifest must not contain a UTF-8 BOM"); + // Json::parse skips a byte-order mark, which several Windows editors still + // write. The launcher and the server accept one, so the game must too. const auto root = Json::parse(text, JsonLimits{ custom_level_manifest_max_bytes, 8, 8192}); exact_fields(root, {"schema", "levels"}, "custom-level manifest"); diff --git a/Engine/Game/World/custom_level_manifest.h b/Engine/Game/World/custom_level_manifest.h index 759a7377..1ac866b2 100644 --- a/Engine/Game/World/custom_level_manifest.h +++ b/Engine/Game/World/custom_level_manifest.h @@ -23,7 +23,7 @@ struct CustomLevelManifest { std::string issue; }; -// Parse the Studio-authored Patch/dingosdk-levels.json contract. Invalid input +// Parse a mod's or the Patch folder's reskate-levels.json. Invalid input // fails closed: no partial set of destinations is returned. CustomLevelManifest parse_custom_level_manifest(std::string_view text) noexcept; CustomLevelManifest read_custom_level_manifest(const std::filesystem::path& path) noexcept; From a0c1224df2770395204575b9411a087559fdb99a Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 22:28:48 -0600 Subject: [PATCH 009/119] Leave out a mod whose archive number does not fit in 16 bits The launch merge truncated cas_65537 to archive 1 while a live apply rejected it, and any misnamed archive stopped the whole merge instead of just that mod. --- Engine/Vfs/game_archives.cpp | 9 ++++++--- Engine/Vfs/mod_merge.cpp | 11 ++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Engine/Vfs/game_archives.cpp b/Engine/Vfs/game_archives.cpp index 0eca63f8..4b553cb8 100644 --- a/Engine/Vfs/game_archives.cpp +++ b/Engine/Vfs/game_archives.cpp @@ -1,6 +1,7 @@ #include "game_archives.h" #include "Engine/Resource/cas_codec.h" #include "Engine/Core/Platform/path_text.h" +#include #include #include #include @@ -151,8 +152,10 @@ const std::string& GameArchives::directory(std::uint32_t installChunk) const { std::optional GameArchives::archive_index(const std::string& stem) { const auto digits = stem.find_last_not_of("0123456789"); if (digits == std::string::npos || digits + 1 >= stem.size()) return {}; - const auto value = std::stoul(stem.substr(digits + 1)); - if (value > 0xFFFF) return {}; - return static_cast(value); + std::uint16_t value{}; + const auto end = stem.data() + stem.size(); + const auto [last, error] = std::from_chars(stem.data() + digits + 1, end, value); // range error past 65535, never throws + if (error != std::errc{} || last != end) return {}; + return value; } } diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 605fc938..5a316793 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -217,11 +217,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con } for (const auto& relative : files.archives) { const auto path = fs::path(relative); - const auto stem = path.stem().string(); - const auto digits = stem.find_last_not_of("0123456789"); - if (digits == std::string::npos || digits + 1 >= stem.size()) - throw std::runtime_error("Unexpected archive name: " + relative); - const auto number = static_cast(std::stoul(stem.substr(digits + 1))); + const auto index = vfs::GameArchives::archive_index(path.stem().string()); + if (!index) { // the mod's problem: the catalog merges again without it + report.problems[mod->name].push_back("Unexpected archive name: " + relative); + continue; + } + const auto number = *index; // CasStore keys its directories relative to Win32, so the mod's // own Win32/ prefix comes off before the two are matched up. auto directory = lower(path.parent_path().generic_string()); From be92c9d14c756762ebecd16399ef316154f88798 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 22:28:48 -0600 Subject: [PATCH 010/119] Sort the scoring check's asset list before removing duplicates --- Engine/Vfs/mod_scoring.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/Engine/Vfs/mod_scoring.cpp b/Engine/Vfs/mod_scoring.cpp index 8a36d161..ee8d6746 100644 --- a/Engine/Vfs/mod_scoring.cpp +++ b/Engine/Vfs/mod_scoring.cpp @@ -201,6 +201,7 @@ ScoringCheck check_scoring(const Catalog& catalog, std::vector* not result.assets.push_back(key.substr(4)); for (const auto& sha1 : versions) hash = fnv(hash, key + ' ' + hex(sha1) + '\n'); } + std::ranges::sort(result.assets); // "ebx "/"res " keys sort apart once the prefix is gone result.assets.erase(std::unique(result.assets.begin(), result.assets.end()), result.assets.end()); result.fingerprint = hash ? hash : 1; } catch (const std::exception& failure) { From de20b8e1ae4e4d2126a696e0d279ab057f7a7374 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 23:59:31 -0600 Subject: [PATCH 011/119] Check a mod's table counts against the file before sizing anything by them A corrupt count was trusted for allocations, so a damaged mod was left out with a vague "bad allocation" or briefly committed gigabytes. --- Engine/Resource/binary_bundle.cpp | 2 ++ Engine/Resource/toc.cpp | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Engine/Resource/binary_bundle.cpp b/Engine/Resource/binary_bundle.cpp index d6a6c8c5..b199ce05 100644 --- a/Engine/Resource/binary_bundle.cpp +++ b/Engine/Resource/binary_bundle.cpp @@ -167,6 +167,8 @@ BinaryBundle read_binary_bundle(const std::span data) { const auto metadataOffset = reader.u32(endian); const auto metadataSize = reader.u32(endian); if (strings > data.size()) throw std::out_of_range("Binary bundle string table is out of range"); + const auto tables = std::uint64_t{ebxCount} * 28 + std::uint64_t{resourceCount} * 56 + std::uint64_t{chunkCount} * 44; // SHA-1 plus entry bytes + if (tables > reader.remaining()) throw std::out_of_range("Binary bundle asset counts exceed the file"); std::vector hashes(total); for (auto& hash : hashes) hash = reader.sha1(); diff --git a/Engine/Resource/toc.cpp b/Engine/Resource/toc.cpp index d5479caa..93917b47 100644 --- a/Engine/Resource/toc.cpp +++ b/Engine/Resource/toc.cpp @@ -133,6 +133,10 @@ std::string c_string_at(const std::span data, const std::size_t throw std::runtime_error("TOC name is unterminated"); } +void expect_table(const BinaryReader& reader, std::size_t count, std::size_t entrySize, const char* what) { // before sizing by a file's count + if (count > reader.remaining() / entrySize) throw std::out_of_range(what); +} + class HuffmanNames final { public: HuffmanNames(const std::span data, @@ -142,6 +146,7 @@ public: const std::size_t tableCount) { BinaryReader reader(data); reader.seek(namesOffset); + expect_table(reader, namesCount, 4, "TOC name count exceeds the file"); words_.reserve(namesCount); for (std::size_t index = 0; index < namesCount; ++index) words_.push_back(reader.u32(Endian::big)); reader.seek(tableOffset); @@ -364,9 +369,11 @@ TocDocument read_toc(const std::span data, TocDocument result; result.flags = flags; reader.seek(bundleHashOffset); + expect_table(reader, bundlesSize, 4, "TOC bundle count exceeds the file"); result.bundleHashMap.reserve(bundlesSize); for (std::size_t index = 0; index < bundlesSize; ++index) result.bundleHashMap.push_back(reader.i32(Endian::big)); reader.seek(bundleDataOffset); + expect_table(reader, bundlesSize, 16, "TOC bundle count exceeds the file"); result.bundles.reserve(bundlesSize); for (std::size_t index = 0; index < bundlesSize; ++index) { const auto nameOffset = reader.u32(Endian::big); @@ -389,12 +396,15 @@ TocDocument read_toc(const std::span data, } if (chunksSize) { reader.seek(chunkHashOffset); + expect_table(reader, chunksSize, 4, "TOC chunk count exceeds the file"); result.chunkHashMap.reserve(chunksSize); for (std::size_t index = 0; index < chunksSize; ++index) result.chunkHashMap.push_back(reader.i32(Endian::big)); - std::vector words(static_cast(dataCount)); reader.seek(chunkDataOffset); + expect_table(reader, static_cast(dataCount), 4, "TOC chunk data count exceeds the file"); + std::vector words(static_cast(dataCount)); for (auto& value : words) value = reader.u32(Endian::big); reader.seek(chunkGuidOffset); + expect_table(reader, chunksSize, 20, "TOC chunk count exceeds the file"); result.chunks.reserve(chunksSize); for (std::size_t index = 0; index < chunksSize; ++index) { const auto guidBytes = reader.view(16); From 84ade917b3eb48972fb6b80b21276e8d0ab7fa56 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 23:59:31 -0600 Subject: [PATCH 012/119] Flush a merged file before publishing it --- Engine/Vfs/mod_merge_files.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index a169b9ee..591b5028 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -40,7 +40,7 @@ void write_file(const fs::path& path, std::span bytes) { { std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output || !output.write(reinterpret_cast(bytes.data()), - static_cast(bytes.size()))) + static_cast(bytes.size())) || !output.flush()) // ~ofstream ignores a failed flush throw std::runtime_error("Cannot write " + path_utf8(temporary)); } if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { From ed979c45afb012cf515f5466d7cdef6814d91860 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 23:59:32 -0600 Subject: [PATCH 013/119] Rebuild the patch when the last merge round leaves a mod out --- Engine/Vfs/mod_catalog.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index ff670f7f..cd8cc945 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -225,9 +225,9 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver // Merge, and whenever a mod could not be merged cleanly, merge again // without it. Removing one mod cannot break another, but a few rounds // are allowed in case two damaged mods hid each other's problems. - for (int round = 0; round < 4; ++round) { + for (int round = 0;; ++round) { merge = merge_mods(result, observe); - if (!merge.issue.empty()) break; + if (!merge.issue.empty() || round == 4) break; // a fifth merge only rebuilds without round four's removals bool removed = false; for (std::size_t i = result.mods.size(); i-- > 0;) { const auto found = merge.problems.find(result.mods[i].name); From 0acfae0c883126e18f76281c2bbf4685149d4b33 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 23:59:32 -0600 Subject: [PATCH 014/119] Refuse a build prefix longer than the buffer it is read into --- Extension/Assets/loose_files.cpp | 5 +++-- Extension/Assets/mod_layers.cpp | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Extension/Assets/loose_files.cpp b/Extension/Assets/loose_files.cpp index 03e414b5..932958b2 100644 --- a/Extension/Assets/loose_files.cpp +++ b/Extension/Assets/loose_files.cpp @@ -47,7 +47,7 @@ struct State { // Hooks and the native mount live until process exit, like the pinned runtime. State& state() { static auto* value = new State; return *value; } -// Supported September 8 build, checked after runtime image validation. +// Supported game build, checked after runtime image validation. bool validate_contract(std::uintptr_t base) { struct Contract { std::uintptr_t rva; std::span bytes; }; const Contract contracts[]{ @@ -58,7 +58,8 @@ bool validate_contract(std::uintptr_t base) { for (const auto& contract : contracts) { std::array actual{}; SIZE_T count{}; - if (!ReadProcessMemory(GetCurrentProcess(), reinterpret_cast(base + contract.rva), + if (contract.bytes.size() > actual.size() || // a longer prefix would overrun actual + !ReadProcessMemory(GetCurrentProcess(), reinterpret_cast(base + contract.rva), actual.data(), contract.bytes.size(), &count) || count != contract.bytes.size() || std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size())) return false; } diff --git a/Extension/Assets/mod_layers.cpp b/Extension/Assets/mod_layers.cpp index 840d7be2..b3f4e1f8 100644 --- a/Extension/Assets/mod_layers.cpp +++ b/Extension/Assets/mod_layers.cpp @@ -126,7 +126,7 @@ bool read_memory(std::uintptr_t address, void* destination, std::size_t size) no destination, size, &read) && read == size; } -// Supported September 8 build, checked after runtime image validation. +// Supported game build, checked after runtime image validation. bool validate_contract(std::uintptr_t base) noexcept { struct Contract { std::uintptr_t rva; std::span bytes; }; const Contract contracts[]{ @@ -134,7 +134,8 @@ bool validate_contract(std::uintptr_t base) noexcept { {layers::layout_bootstrap, layers::layout_bootstrap_prefix}}; for (const auto& contract : contracts) { std::array actual{}; - if (!read_memory(base + contract.rva, actual.data(), contract.bytes.size()) || + if (contract.bytes.size() > actual.size() || // a longer prefix would overrun actual + !read_memory(base + contract.rva, actual.data(), contract.bytes.size()) || std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size())) return false; } std::uintptr_t slot{}; From e1e09e438ebdec6b0390e619ccadf53008667283 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Tue, 6 Oct 2026 10:20:12 -0600 Subject: [PATCH 015/119] Leave out a mod whose TOC cannot be read instead of every mod The merge read each mod's superbundle TOC without a catch, so one damaged file stopped the whole merge and the game started with no mods at all. --- Engine/Vfs/mod_merge.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 5a316793..29d8dcc3 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -340,7 +340,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con Source source; source.root = mod->directory; source.placement = &placements[mod]; - source.toc = fb::read_toc(read_file(mod->directory / fs::path(relative))); + try { + source.toc = fb::read_toc(read_file(mod->directory / fs::path(relative))); + } catch (const std::exception& failure) { // the mod's problem: the catalog merges again without it + report.problems[mod->name].push_back(relative + " could not be read: " + failure.what()); + continue; + } sources.push_back(std::move(source)); } auto merged = combine(baseRoot / fs::path(relative), baseRoot, sources, report, From e3ec512878a080a83dd62e7fd63e4c10cacbf6ed Mon Sep 17 00:00:00 2001 From: Vebjorhk <77414086+Vebjorhk@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:58:11 +0200 Subject: [PATCH 016/119] Keep the game off EA's online services Skate.exe contacts EA on every launch, offline mode included: error and session reports (collector.errors.ea.com), SDK telemetry (freeform-river.data.ea.com) and remote configuration (gcs.ea.com). - Block the game's lookups of ea.com and tnt-ea.com names inside the process (getaddrinfo and the other Winsock resolvers). Installed at bootstrap, and fails closed like the EA app launch block - Turn EA's error reporting (EadpErrorsData) off in game, next to the existing DingoTelemetry settings; the game ignores them on its command line - Leave EA's image CDN (dingo-dev-assets.akamaized.net) reachable for the fast-travel artwork, which only downloads images - Add a hostname test, contrib/ea-watch.ps1 to check the traffic, and a short note in the README's privacy section Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 + .../Boot/Test/ea_service_block_tests.cpp | 34 ++ Extension/Boot/ea_service_block.cpp | 154 +++++ Extension/Boot/ea_service_block.h | 44 ++ README.md | 5 + Runtime/bootstrap.cpp | 3 + Runtime/client_tick.cpp | 8 +- cmake/Runtime.cmake | 3 +- contrib/ea-watch.ps1 | 569 ++++++++++++++++++ 9 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 Extension/Boot/Test/ea_service_block_tests.cpp create mode 100644 Extension/Boot/ea_service_block.cpp create mode 100644 Extension/Boot/ea_service_block.h create mode 100644 contrib/ea-watch.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eb4ff81..aa828702 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -172,6 +172,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests) add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp) add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests) + add_executable(dingosdk_ea_service_block_tests Extension/Boot/Test/ea_service_block_tests.cpp) + target_include_directories(dingosdk_ea_service_block_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME ea_service_block COMMAND dingosdk_ea_service_block_tests) endif() # The route and logging tests live in the local, untracked tests/ folder. option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF) diff --git a/Extension/Boot/Test/ea_service_block_tests.cpp b/Extension/Boot/Test/ea_service_block_tests.cpp new file mode 100644 index 00000000..99361e54 --- /dev/null +++ b/Extension/Boot/Test/ea_service_block_tests.cpp @@ -0,0 +1,34 @@ +#include "Extension/Boot/ea_service_block.h" +#include +#include +#include +#include +using namespace dingosdk; +void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); } +bool blocked(std::string_view host) { return ea_service_host(host); } +bool blocked(std::wstring_view host) { return ea_service_host(host); } +static_assert(ea_service_host(std::string_view("collector.errors.ea.com")), "The match is usable at compile time"); +int main() { + try { + // Every EA service the game contacted in a recorded session, and a sample of the others it names. + for (const auto* host : {"collector.errors.ea.com", "a-collector.errors.ea.com", "freeform-river.data.ea.com", + "gcs.ea.com", "pin-river-grpc.data.ea.com", "pin-em.data.ea.com", "spring25.client.blazeredirector.ea.com", + "spring18.gosredirector.ea.com", "accounts.ea.com", "gateway.grpc.ea.com", "experimentation-grpc.data.ea.com", + "adtech-runtime-rest-internal.data.ea.com", "river-grpc-lt.eadpdata.ddns.ea.com", "pn.tnt-ea.com", + "rtm.tnt-ea.com", "ea.com", "tnt-ea.com"}) + check(blocked(host), std::string("EA service must be blocked: ") + host); + check(blocked("COLLECTOR.Errors.EA.com"), "Host names compare without case"); + check(blocked("gcs.ea.com."), "A trailing root dot is the same name"); + check(blocked(L"freeform-river.data.ea.com"), "Wide lookups are blocked too"); + check(blocked(L"GCS.EA.COM."), "Wide lookups compare without case and root dot"); + // What ReSkate and the game still need, and names that only look like EA's. + for (const auto* host : {"dingo-dev-assets.akamaized.net", "api.reskate.dev", "api.github.com", + "raw.githubusercontent.com", "thunderstore.io", "api.steampowered.com", "idea.com", "notea.com", + "xtnt-ea.com", "ea.com.example.net", "ea.co", "eacom", "com", ".", "", "159.153.1.1"}) + check(!blocked(host), std::string("Host must stay reachable: ") + host); + check(!blocked(L"dingo-dev-assets.akamaized.net"), "The fast-travel artwork CDN stays reachable"); + check(!blocked(L"gcs.ea.cöm"), "Characters outside ASCII never match a domain letter"); + check(!blocked("gcs.ea.com.."), "Only one root dot is stripped"); + std::cout << "EA service block checks passed.\n"; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/Extension/Boot/ea_service_block.cpp b/Extension/Boot/ea_service_block.cpp new file mode 100644 index 00000000..8947c55e --- /dev/null +++ b/Extension/Boot/ea_service_block.cpp @@ -0,0 +1,154 @@ +#include +#include +#include +#include "ea_service_block.h" + +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" + +#include +#include +#include +#include +#include +#include + +namespace dingosdk { +namespace { + +using GetAddrInfoAFn = INT (WSAAPI*)(PCSTR, PCSTR, const ADDRINFOA*, PADDRINFOA*); +using GetAddrInfoWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, const ADDRINFOW*, PADDRINFOW*); +using GetAddrInfoExAFn = INT (WSAAPI*)(PCSTR, PCSTR, DWORD, LPGUID, const ADDRINFOEXA*, PADDRINFOEXA*, timeval*, + LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE); +using GetAddrInfoExWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, DWORD, LPGUID, const ADDRINFOEXW*, PADDRINFOEXW*, timeval*, + LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE); +using GetHostByNameFn = hostent* (WSAAPI*)(const char*); + +std::atomic original_get_addr_info_a{}; +std::atomic original_get_addr_info_w{}; +std::atomic original_get_addr_info_ex_a{}; +std::atomic original_get_addr_info_ex_w{}; +std::atomic original_get_host_by_name{}; + +// A refused name is logged once at info level; a service that cannot connect keeps retrying, so +// repeats are logged at debug level only. Never destroyed, because lookups can still arrive while +// the process exits. +struct RefusedNames { + std::mutex mutex; + std::set names; +}; +RefusedNames& refused_names() { + static auto* value = new RefusedNames; + return *value; +} + +template +bool refuse(const Char* name) noexcept { + if (!name) return false; + const std::basic_string_view host(name); + if (!ea_service_host(host)) return false; + try { + std::wstring wide; + wide.reserve(host.size()); + for (const auto ch : host) wide.push_back(static_cast(static_cast>(ch))); + bool first{}; + { + auto& refused = refused_names(); + const std::lock_guard lock(refused.mutex); + first = refused.names.insert(wide).second; + } + if (first) + logging::write(logging::Level::info, logging::Channel::runtime, + L"Blocked the game's lookup of " + wide + L": ReSkate keeps it off EA's online services"); + else if (logging::enabled(logging::Level::debug)) + logging::write(logging::Level::debug, logging::Channel::runtime, L"Blocked the game's lookup of " + wide + L" again"); + } catch (...) {} + return true; +} + +// What the resolver itself answers for a name that does not exist. +template +INT not_found(Result* result) noexcept { + if (result) *result = nullptr; + WSASetLastError(WSAHOST_NOT_FOUND); + return WSAHOST_NOT_FOUND; +} + +INT WSAAPI get_addr_info_a(PCSTR name, PCSTR service, const ADDRINFOA* hints, PADDRINFOA* result) { + if (refuse(name)) return not_found(result); + return original_get_addr_info_a.load()(name, service, hints, result); +} + +INT WSAAPI get_addr_info_w(PCWSTR name, PCWSTR service, const ADDRINFOW* hints, PADDRINFOW* result) { + if (refuse(name)) return not_found(result); + return original_get_addr_info_w.load()(name, service, hints, result); +} + +// Failing before the lookup starts is a valid answer for an overlapped call too: the completion +// routine and event are only used once the function has returned WSA_IO_PENDING. +INT WSAAPI get_addr_info_ex_a(PCSTR name, PCSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXA* hints, + PADDRINFOEXA* result, timeval* timeout, LPOVERLAPPED overlapped, + LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) { + if (refuse(name)) { + if (handle) *handle = nullptr; + return not_found(result); + } + return original_get_addr_info_ex_a.load()(name, service, name_space, provider, hints, result, timeout, overlapped, + completion, handle); +} + +INT WSAAPI get_addr_info_ex_w(PCWSTR name, PCWSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXW* hints, + PADDRINFOEXW* result, timeval* timeout, LPOVERLAPPED overlapped, + LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) { + if (refuse(name)) { + if (handle) *handle = nullptr; + return not_found(result); + } + return original_get_addr_info_ex_w.load()(name, service, name_space, provider, hints, result, timeout, overlapped, + completion, handle); +} + +hostent* WSAAPI get_host_by_name(const char* name) { + if (refuse(name)) { + WSASetLastError(WSAHOST_NOT_FOUND); + return nullptr; + } + return original_get_host_by_name.load()(name); +} + +template +void hook(const char* name, void* replacement, std::atomic& original) { + const auto module = LoadLibraryExW(L"ws2_32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + const auto target = module ? reinterpret_cast(GetProcAddress(module, name)) : nullptr; + if (!target) throw std::runtime_error(std::string("Missing ") + name); + void* relay{}; + const auto create = hook_prepare(target, replacement, &relay); + if (create != HookOk) throw std::runtime_error(std::string("Cannot hook ") + name + ": " + hook_status_string(create)); + original.store(reinterpret_cast(relay)); + const auto enable = hook_enable(target); + if (enable != HookOk) throw std::runtime_error(std::string("Cannot enable ") + name + ": " + hook_status_string(enable)); +} + +} // namespace + +bool start_ea_service_block(std::string& error) noexcept { + try { + // Skate.exe resolves names through getaddrinfo alone; the other entry points close the + // same door for anything else loaded into the process. +#pragma warning(push) +#pragma warning(disable: 4191) + hook("getaddrinfo", reinterpret_cast(&get_addr_info_a), original_get_addr_info_a); + hook("GetAddrInfoW", reinterpret_cast(&get_addr_info_w), original_get_addr_info_w); + hook("GetAddrInfoExA", reinterpret_cast(&get_addr_info_ex_a), original_get_addr_info_ex_a); + hook("GetAddrInfoExW", reinterpret_cast(&get_addr_info_ex_w), original_get_addr_info_ex_w); + hook("gethostbyname", reinterpret_cast(&get_host_by_name), original_get_host_by_name); +#pragma warning(pop) + error.clear(); + return true; + } catch (const std::exception& exception) { + error = exception.what(); + return false; + } +} + +} // namespace dingosdk diff --git a/Extension/Boot/ea_service_block.h b/Extension/Boot/ea_service_block.h new file mode 100644 index 00000000..7285be97 --- /dev/null +++ b/Extension/Boot/ea_service_block.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace dingosdk { + +// EA's online services, as Skate.exe names them. ea.com holds the Blaze redirectors, accounts and +// gateway, telemetry (pin-*, freeform-river), error reports (collector.errors), remote +// configuration (gcs), experimentation, leaderboards, social and ad tech; tnt-ea.com holds push +// notifications and real-time messaging. Not listed on purpose: dingo-dev-assets.akamaized.net, +// the CDN the fast-travel artwork comes from (Engine/Game/World/location_travel.h). It only serves +// images, and the travel menu would lose its icons without it. +inline constexpr std::array ea_service_domains{"ea.com", "tnt-ea.com"}; + +// Whether a host name is one of ea_service_domains or a name under one, ignoring ASCII case and a +// trailing root dot. Address literals never match: Skate.exe reaches EA by name only. +template +constexpr bool ea_service_host(std::basic_string_view host) noexcept { + using Unit = std::make_unsigned_t; + if (!host.empty() && host.back() == Char('.')) host.remove_suffix(1); + for (const auto domain : ea_service_domains) { + if (host.size() < domain.size()) continue; + const auto start = host.size() - domain.size(); + if (start != 0 && host[start - 1] != Char('.')) continue; + bool same = true; + for (std::size_t i = 0; same && i < domain.size(); ++i) { + auto unit = static_cast(host[start + i]); + if (unit >= Unit('A') && unit <= Unit('Z')) unit = static_cast(unit + Unit('a' - 'A')); + same = unit == static_cast(domain[i]); + } + if (same) return true; + } + return false; +} + +// Makes every name lookup inside Skate.exe for an EA service fail as if the name did not exist, so +// the game cannot reach EA's servers: no error reports, telemetry or remote configuration. Always on. +bool start_ea_service_block(std::string& error) noexcept; + +} // namespace dingosdk diff --git a/README.md b/README.md index ad3c5e0e..22304f3c 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,11 @@ login name. To turn it off, untick **Send crash reports** in the launcher's Settings (ADVANCED), or set the environment variable `RESKATE_CRASH_REPORTING=0`. +ReSkate also keeps the game off EA's online services: the game's own crash reports, telemetry and +remote configuration requests are turned off and blocked. Fast-travel artwork still comes from EA's +image CDN (`dingo-dev-assets.akamaized.net`), which only downloads images. +[contrib/ea-watch.ps1](contrib/ea-watch.ps1) shows what the game connects to, if you want to check. + ## Third-party code ReSkate uses Dear ImGui, Microsoft Detours, RapidJSON, spdlog, SQLite, LZ4, Zstandard, miniz, bcdec and diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index d04e5383..ef29d198 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -6,6 +6,7 @@ #include "Extension/Boot/steam_restart_guard.h" #include "Extension/Boot/user_data_redirect.h" #include "Extension/Boot/ea_app_block.h" +#include "Extension/Boot/ea_service_block.h" #include "Extension/Assets/native_patch_support.h" #include "Extension/Assets/mod_layers.h" #include "Extension/Assets/loose_files.h" @@ -61,6 +62,8 @@ bool initialize_bootstrap(std::uintptr_t base) { if (!stage(Channel::runtime, "Separate game user data", ready, error)) return false; error.clear(); ready = start_ea_app_block(error); if (!stage(Channel::runtime, "EA app launch block", ready, error)) return false; + error.clear(); ready = start_ea_service_block(error); + if (!stage(Channel::runtime, "EA online services block", ready, error)) return false; error.clear(); ready = start_steam_restart_guard(error); return stage(Channel::runtime, launcher::offline_mode() ? "Offline Steam" : "Steam restart guard", ready, error); } diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 7d3f17a7..2a5b0856 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -93,6 +93,9 @@ void refresh_interactive_model(std::uintptr_t client, DWORD state, DWORD game_ty // Engine settings that cost simulation time for nothing here, set once through the named // setter (retried until the settings registry answers): // - telemetry and the performance tracker only feed EA's backend, absent offline; +// - EA's error reporting (EadpErrorsData) only builds reports for EA's servers, which +// ea_service_block keeps unreachable. Set here because the same values given on the game's +// command line do not take; // - the world-transform update, which grows with every remote player's entities, is split // over more jobs than Game.cfg's 2. void apply_performance_settings() { @@ -104,11 +107,14 @@ void apply_performance_settings() { next_attempt = now + 500; try { const auto jobs = std::to_string(std::clamp(std::thread::hardware_concurrency() / 2, 2u, 8u)); - const std::array, 7> settings{{ + const std::array, 10> settings{{ {"DingoTelemetry.Enable", "0"}, {"DingoTelemetry.EnablePlayerTickEvents", "0"}, {"DingoTelemetry.EnablePerformanceEvents", "0"}, {"DingoTelemetry.EnableCPUBenchmark", "0"}, + {"EadpErrorsData.DisableAllEventsReporting", "1"}, + {"EadpErrorsData.EnableReportCrashes", "0"}, + {"EadpErrorsData.AutoSendEventFromPersistence", "0"}, {"PerformanceTracker.Enabled", "0"}, {"PerformanceTracker.JuiceLogPerformance", "0"}, {"EcsWorldTransform.ParallelWorldTransformUpdateJobCount", jobs}, diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d8458a68..fdbcd741 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -173,7 +173,8 @@ add_library(dingosdk_runtime SHARED Extension/Progression/fixed_stop_entitlement_observation.cpp Extension/Progression/neighborhood_unlock_override.cpp Extension/Boot/user_data_redirect.cpp - Extension/Boot/ea_app_block.cpp) + Extension/Boot/ea_app_block.cpp + Extension/Boot/ea_service_block.cpp) target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt diff --git a/contrib/ea-watch.ps1 b/contrib/ea-watch.ps1 new file mode 100644 index 00000000..0357f8ea --- /dev/null +++ b/contrib/ea-watch.ps1 @@ -0,0 +1,569 @@ +<# +.SYNOPSIS + Records which programs on this PC look up and talk to EA's servers, so you can check what + skate. and ReSkate send to EA. + +.DESCRIPTION + Uses two event-tracing providers built into Windows; nothing is installed and the game is not + touched: + Microsoft-Windows-DNS-Client every DNS lookup, with the program that made it + Microsoft-Windows-Kernel-Network every TCP/UDP connect, send and receive, with the program, + the remote address and the size in bytes + The traffic itself is encrypted; the report shows which EA hosts were looked up, by which + program, and how many bytes went to and came from them. + + The game keeps the fast-travel artwork it downloads in its own cache and only fetches it from + EA's image CDN when it is missing. To see those downloads, close the game and rename + %LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording; the game makes a new one. + The report also warns about hosts-file entries and Windows Firewall rules that would hide or + change what the game sends. + + Each recording gets its own folder, %LOCALAPPDATA%\ReSkate\ea-watch\-