Merge pull request #148 from Vebjorhk/feat/r3-first-person-camera-cycle

First person as a step in the R3 camera height cycle (opt-in)
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-11 11:21:26 -05:00
commit f4292d6b73
10 files changed
+210 -1

No files matched your search

+3
View File
@@ -136,6 +136,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)
@@ -0,0 +1,73 @@
#include "Engine/Game/Skater/first_person_cycle.h"
#include <cstdio>
#include <string_view>
// 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<int>(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;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <cstdint>
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};
}
}
+2
View File
@@ -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
@@ -100,6 +100,11 @@ void set_local_values(const std::vector<std::pair<std::string, Json>>& 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.
@@ -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 <atomic>
#include <cstring>
#include <optional>
#include <string_view>
@@ -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<CameraHeightWrite> 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<std::uintptr_t>(key), text, sizeof(text)) !=
static_cast<std::ptrdiff_t>(camera_height_key.size()) || camera_height_key != text) return false;
NativeSettingValue native{};
std::uint8_t byte{};
if (!memory::peek(reinterpret_cast<std::uintptr_t>(value), native) ||
!memory::peek(reinterpret_cast<std::uintptr_t>(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 {
+63 -1
View File
@@ -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 <atomic>
#include <cmath>
#include <optional>
#include <utility>
@@ -255,6 +257,7 @@ constexpr std::array<const char*, 4> 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<const char*, 4> 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<float, 4> 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<double>(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<float*, 4> 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<std::uint8_t> 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<ULONGLONG> 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<std::uint8_t>(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<first_person::CycleStep>(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<std::uint8_t>((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);
@@ -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{};
+1
View File
@@ -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.
@@ -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.");
}