diff --git a/CMakeLists.txt b/CMakeLists.txt index 70a0b07..85c792a 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 0000000..393d0f7 --- /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 d20c44a..4d77113 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 db209dc..d5bccb5 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 916ae5c..dc07d25 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 a1efea7..af98fa4 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 e5547d8..deade16 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 6c6e7d1..251953a 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;