From 76ba5a173988c724c4fc374e562111a53e362b75 Mon Sep 17 00:00:00 2001 From: Vebjorhk <77414086+Vebjorhk@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:02:01 +0200 Subject: [PATCH] First person as a step in the R3 camera height cycle (opt-in) With the game's Camera height control on (Gameplay > Advanced Camera), R3 on the board switches between the high and the low camera by writing the opposite of the UseHighCam option. A new switch in SKATER > CAMERA > FIRST PERSON, "First person in the camera cycle" (off by default), makes it high -> low -> first person -> high. - The existing native option hook passes UseHighCam writes to a listener, so the settings code does not depend on the skater code - Low -> first person keeps the low camera and turns first person on; first person -> high turns it off and keeps the high camera - First person is switched through the menu toggle's own action on the next client tick, so the player's first-person settings apply as they are; it only starts on the board, outside Freecam, Noclip and the Park Editor - Saved as FirstPerson.CameraCycle; Reset arm settings keeps it - Add a ctest (first_person_cycle) for the cycle's steps Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 + .../Skater/Test/first_person_cycle_tests.cpp | 73 +++++++++++++++++++ Engine/Game/Skater/first_person_cycle.h | 26 +++++++ Engine/Game/Skater/first_person_spring.h | 2 + Extension/Profile/local_profile_runtime.h | 5 ++ Extension/Settings/local_native_settings.cpp | 29 ++++++++ Extension/Skater/client_debug.cpp | 64 +++++++++++++++- .../Skater/client_source_spawn_internal.h | 3 + Extension/UI/Overlay/overlay.h | 1 + Extension/UI/Overlay/skate_menu_skater.cpp | 5 ++ 10 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 Engine/Game/Skater/Test/first_person_cycle_tests.cpp create mode 100644 Engine/Game/Skater/first_person_cycle.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a0e7729e..9897473e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) 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_first_person_cycle_tests Engine/Game/Skater/Test/first_person_cycle_tests.cpp) + target_include_directories(dingosdk_first_person_cycle_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME first_person_cycle COMMAND dingosdk_first_person_cycle_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_cycle_tests.cpp b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp new file mode 100644 index 00000000..ea2f4748 --- /dev/null +++ b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp @@ -0,0 +1,73 @@ +#include "Engine/Game/Skater/first_person_cycle.h" + +#include +#include + +// First person in the camera cycle (first_person::cycle_height_write): the game's camera height +// button flips UseHighCam; the option turns that into high -> low -> first person -> high. +namespace { +using namespace dingosdk::first_person; +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()); +} +bool same(const CycleWrite& write, bool high, CycleStep step) { return write.high == high && write.step == step; } + +void off() { + for (const bool first_person : {false, true}) + for (const bool high : {false, true}) + expect(same(cycle_height_write({false, first_person, true}, high), high, CycleStep::none), + "with the option off the game's height passes through and first person is left alone"); +} + +void steps() { + expect(same(cycle_height_write({true, false, true}, false), false, CycleStep::none), "high -> low is the game's own step"); + expect(same(cycle_height_write({true, false, true}, true), false, CycleStep::enter), + "low -> first person keeps the low camera behind it"); + expect(same(cycle_height_write({true, true, false}, true), true, CycleStep::leave), + "first person (low behind it) -> the high camera"); + expect(same(cycle_height_write({true, true, false}, false), true, CycleStep::leave), + "first person switched on in the menu over the high camera still lands on high"); + expect(same(cycle_height_write({true, false, false}, true), true, CycleStep::none), + "where first person cannot start (on foot, Freecam) low -> high is the game's own step"); +} + +// The game reads the height it keeps and writes the opposite on every press. +void presses() { + for (const bool start_high : {true, false}) { + bool high = start_high, first_person = false; + const auto press = [&] { + const auto write = cycle_height_write({true, first_person, !first_person}, !high); + high = write.high; + if (write.step == CycleStep::enter) first_person = true; + if (write.step == CycleStep::leave) first_person = false; + }; + if (!start_high) press(); // low -> first person + if (!start_high) press(); // first person -> high + for (int round = 0; round < 3; ++round) { + expect(high && !first_person, "the cycle starts each round on the high camera"); + press(); + expect(!high && !first_person, "first press: low camera"); + press(); + expect(!high && first_person, "second press: first person over the low camera"); + press(); + } + expect(high && !first_person, "the third press of each round returns to the high camera"); + } +} +} // namespace + +int main() { + off(); + steps(); + presses(); + if (failures) { + std::printf("%d first-person cycle test(s) failed\n", failures); + return 1; + } + std::printf("first-person cycle tests passed\n"); + return 0; +} diff --git a/Engine/Game/Skater/first_person_cycle.h b/Engine/Game/Skater/first_person_cycle.h new file mode 100644 index 00000000..8ee30393 --- /dev/null +++ b/Engine/Game/Skater/first_person_cycle.h @@ -0,0 +1,26 @@ +#pragma once +#include + +namespace dingosdk::first_person { +// "First person in the camera cycle". With the game's Camera height control on, its camera +// height button (R3) writes the profile option UseHighCam as the opposite of the height it reads +// (true is the high camera). With the option on, ReSkate turns that into high -> low -> first +// person -> high by changing the value written: the step into first person keeps the low camera +// behind it, and the step out of first person always lands on the high camera. +enum class CycleStep : std::uint8_t { none, enter, leave }; +struct CycleState { + bool enabled{}; // the option is on and the local skater is in control + bool first_person{}; // first person has the camera + bool can_enter{}; // first person can take the camera now: on the board, no Freecam +}; +struct CycleWrite { + bool high{}; // the height the game keeps + CycleStep step{}; // what first person does +}; +constexpr CycleWrite cycle_height_write(const CycleState& state, bool high) noexcept { + if (!state.enabled) return {high, CycleStep::none}; + if (state.first_person) return {true, CycleStep::leave}; + if (high && state.can_enter) return {false, CycleStep::enter}; + return {high, CycleStep::none}; +} +} diff --git a/Engine/Game/Skater/first_person_spring.h b/Engine/Game/Skater/first_person_spring.h index 4d771137..36a76b3f 100644 --- a/Engine/Game/Skater/first_person_spring.h +++ b/Engine/Game/Skater/first_person_spring.h @@ -18,6 +18,8 @@ struct Settings { 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; + // Put first person into the game's camera height cycle (first_person_cycle.h, opt-in). + bool camera_cycle = 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 diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 6f728984..af4fc4ad 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -99,6 +99,11 @@ void set_local_values(const std::vector>& values) n // read it every frame without touching the database. float local_menu_scale() noexcept; bool set_local_menu_scale(float value) noexcept; +// The game's camera height option (UseHighCam, true is the high camera) as the game writes it, +// which its camera height button does: the listener returns the height to keep instead. Runs on +// the thread that writes the option. +using CameraHeightWrite = bool (*)(bool high) noexcept; +void set_camera_height_write_listener(CameraHeightWrite listener) noexcept; } PlayerCardModel local_profile_player_card(); // Renames only the local RIP Card. An empty name restores the Steam name. diff --git a/Extension/Settings/local_native_settings.cpp b/Extension/Settings/local_native_settings.cpp index 64c8f80b..c0aaf817 100644 --- a/Extension/Settings/local_native_settings.cpp +++ b/Extension/Settings/local_native_settings.cpp @@ -7,6 +7,7 @@ #include "Engine/Game/Build/20260929/named_settings.h" #include "local_user_settings.h" #include "Extension/Profile/local_profile_runtime.h" +#include #include #include #include @@ -132,6 +133,30 @@ void apply_own_default(std::uintptr_t group, const char* key, const NativeSettin return; } } + +// The game's camera height button writes UseHighCam (a cloud option, true is the high camera) +// as the opposite of the height it reads. The listener may keep another height +// (first person in the camera cycle, Extension/Skater/client_debug.cpp). +std::atomic height_write_listener{}; +// True when the game's write of `value` is to be replaced by `high`. +bool replace_camera_height(const char* key, std::uintptr_t type, const NativeSettingValue* value, bool& high) noexcept { + constexpr std::string_view camera_height_key = "UseHighCam"; + const auto listener = height_write_listener.load(std::memory_order_acquire); + if (!listener || type != local_runtime().base + native_types::native_bool) return false; + char text[camera_height_key.size() + 1]; + if (memory::peek_cstring(reinterpret_cast(key), text, sizeof(text)) != + static_cast(camera_height_key.size()) || camera_height_key != text) return false; + NativeSettingValue native{}; + std::uint8_t byte{}; + if (!memory::peek(reinterpret_cast(value), native) || + !memory::peek(reinterpret_cast(native.data), byte) || byte > 1) return false; + high = listener(byte != 0); + return high != (byte != 0); +} +} + +void set_camera_height_write_listener(CameraHeightWrite listener) noexcept { + height_write_listener.store(listener, std::memory_order_release); } bool restore_native_setting(std::uintptr_t group, const char* key, std::uintptr_t type, @@ -181,6 +206,10 @@ const NativeSettingValue* native_setting_get(std::uintptr_t group, const char* k } bool native_setting_set(std::uintptr_t group, const char* key, std::uintptr_t type, const NativeSettingValue* value) { + bool high{}; + const NativeSettingValue kept{type, &high}; + // The original setter clones the boxed value, so `kept` only needs to outlive the call. + if (replace_camera_height(key, type, value, high)) value = &kept; const bool changed = native_settings().set(group, key, type, value); PreserveError preserve; try { diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index e0e24c9c..c89f5b3d 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -7,10 +7,12 @@ #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "free_flight.h" +#include "Engine/Game/Skater/first_person_cycle.h" #include "Engine/Game/Skater/velocity_boost.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include "Extension/Profile/local_profile_runtime.h" +#include #include #include #include @@ -255,6 +257,7 @@ constexpr std::array strength{"FirstPerson.Up", "FirstPerson.Dow constexpr const char* stabilize = "FirstPerson.Stabilize"; constexpr const char* follow_flips = "FirstPerson.FollowFlips"; constexpr const char* board_only = "FirstPerson.ThirdPersonOnFoot"; +constexpr const char* camera_cycle = "FirstPerson.CameraCycle"; constexpr std::array steady{"FirstPerson.Smoothing", "FirstPerson.HeadPitch", "FirstPerson.HeadRoll", "FirstPerson.Bob"}; constexpr ULONGLONG delay_ms = 750; @@ -293,6 +296,7 @@ void save_debug(InteractiveDebug& debug) noexcept { 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); + values.emplace_back(saved::camera_cycle, arm.camera_cycle); 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); @@ -336,6 +340,7 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { 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; + if (const auto enabled = profile_runtime::local_preference(saved::camera_cycle)) arm.camera_cycle = *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; @@ -423,7 +428,9 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b 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; + case Action::set_first_person_camera_cycle: settings.camera_cycle = request.enabled; break; + // Resets the view; which steps the camera height button goes through is kept. + case Action::reset_first_person_arm: settings = {.camera_cycle = settings.camera_cycle}; break; default: break; } source_require(first_person::valid(settings), "First-person arm setting is outside its supported range."); @@ -438,6 +445,8 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b ? (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.") + : request.action == Action::set_first_person_camera_cycle + ? (settings.camera_cycle ? "Camera height button: high, low, first person." : "Camera height button: high and low only.") : "First-person settings updated."; return; } @@ -776,6 +785,57 @@ void first_person_board_tick(SourceTrial& trial, std::uintptr_t client, bool can debug.first_person_retry_after = now + 500; } } +// "First person in the camera cycle" (Engine/Game/Skater/first_person_cycle.h). The game writes +// its camera height option on the thread that handles the button, so each tick publishes what +// that write needs, and the next tick carries out the step it asked for. +namespace { +enum CycleFlag : std::uint8_t { cycle_enabled = 1, cycle_first_person = 2, cycle_can_enter = 4 }; +std::atomic cycle_flags{}, cycle_request{}; +// When the flags were published: a tick that stopped running (loading, no local skater) leaves +// the game's own height alone. +std::atomic cycle_published{}; +constexpr ULONGLONG cycle_flags_lifetime_ms = 250; +} +bool first_person_camera_height_write(bool high) noexcept { + const auto published = cycle_published.load(std::memory_order_acquire); + const auto flags = cycle_flags.load(std::memory_order_acquire); + const auto now = GetTickCount64(); + if (now < published || now - published > cycle_flags_lifetime_ms) return high; + const auto write = first_person::cycle_height_write( + {(flags & cycle_enabled) != 0, (flags & cycle_first_person) != 0, (flags & cycle_can_enter) != 0}, high); + if (write.step != first_person::CycleStep::none) + cycle_request.store(static_cast(write.step), std::memory_order_release); + return write.high; +} +void first_person_cycle_tick(SourceTrial& trial, std::uintptr_t client, bool can_control, bool phase, DWORD error) { + auto& debug = trial.debug; + const auto now = GetTickCount64(); + const bool enabled = debug.first_person_settings.camera_cycle && can_control && phase && + !debug.park_editor && !debug.noclip && !debug.editor_transition; + const auto step = static_cast(cycle_request.exchange(0, std::memory_order_acq_rel)); + if (enabled && step != first_person::CycleStep::none) { + // The player's own First person choice, so it replaces an on-foot hand-back. + debug.first_person_paused = false; + try { + debug_action(trial, client, can_control, phase, + {overlay::DebugAction::set_first_person, step == first_person::CycleStep::enter}, error); + } catch (const SourceGuard& guard) { + debug.status = guard.message; + // The press kept the low camera; the next ones switch height as the game does. + debug.first_person_cycle_retry_after = now + 2000; + } catch (...) { + debug.first_person_cycle_retry_after = now + 2000; + } + } + bool can_enter = false; + if (enabled && !debug.first_person && !debug.camera_owned && now >= debug.first_person_cycle_retry_after) { + const auto on_foot = first_person_on_foot(trial.base, client); + can_enter = on_foot && !*on_foot; + } + cycle_flags.store(static_cast((enabled ? cycle_enabled : 0) | (debug.first_person ? cycle_first_person : 0) | + (can_enter ? cycle_can_enter : 0)), std::memory_order_release); + cycle_published.store(now, std::memory_order_release); +} } } @@ -797,6 +857,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli if (!debug.saved_loaded) { debug.saved_loaded = true; load_saved_debug(debug); + profile_runtime::set_camera_height_write_listener(&first_person_camera_height_write); } if (debug.save_pending && GetTickCount64() >= debug.save_due) save_debug(debug); if (debug.no_bail_restore && can_control && !debug.park_editor && GetTickCount64() >= debug.no_bail_restore_after) { @@ -832,6 +893,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli 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); + first_person_cycle_tick(trial, client, can_control, camera_phase_observed, error.value); // Camera ownership survives a busy tick or a failed presentation snapshot. // Input capture must follow the lease, not those transient model failures. state.free_camera_active.store(debug.camera_owned && !debug.first_person, std::memory_order_release); diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index 2dfd7d7d..4282cfde 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -107,6 +107,9 @@ struct InteractiveDebug { // 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{}; + // "First person in the camera cycle": after first person could not start, the camera height + // button switches height as the game does until this time. + ULONGLONG first_person_cycle_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{}; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 3e4b6892..1dd5ec40 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -74,6 +74,7 @@ enum class DebugAction { set_first_person_head_roll, set_first_person_bob, set_first_person_board_only, + set_first_person_camera_cycle, 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 5609442b..e1f6e165 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -97,6 +97,11 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal "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}); + if (toggle_row(menu, "First person in the camera cycle", + "Clicking the right stick (R3) on the board goes high camera, low camera, first person, then high again. " + "Needs Camera height control on in the game's settings (Gameplay > Advanced Camera).", + settings.camera_cycle, can_edit)) + debug_request(menu, callbacks, {DebugAction::set_first_person_camera_cycle, settings.camera_cycle}); 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."); }