From 51dddf3d91546a265d8fed4f90c81f14e153746c Mon Sep 17 00:00:00 2001 From: samitakkula Date: Sat, 17 Oct 2026 06:34:46 -0700 Subject: [PATCH] Keyframe smoothing for the replay editor's free camera The game plays the replay camera's framing keys as cubic Hermite segments whose speed changes at every key (the acceleration jumps there, and it brakes where a channel slows or turns back), so a free-camera shot surges and settles around each key. keysmooth on rewrites each segment as the game builds it, so that the game's own player plays the natural cubic spline through every key: speed and acceleration carry on through the keys, and every key is still met exactly, at its time. - keysmooth on|off (off by default), keysmooth amount 0..1 (1 by default; 0 is the game's curve) and keysmooth status; kept in %LOCALAPPDATA%\ReSkate\KeySmooth.settings. Only the free camera (Behaviour 1) is smoothed; the other camera modes keep the game's curve. - A switch in Mod Options > Visuals and in the overlay's Skater > Freecam. - The curve apart from the game, with tests (DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS); the hooked functions are fingerprinted, and a build that differs gets no hook. - contrib/keyframe-smoothing.md: what it does, how, and what was seen in game. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 10 + Engine/Game/Build/20260929/replay_keyframes.h | 77 +++++ Extension/Console/commands.cpp | 1 + Extension/Console/commands.h | 1 + .../Test/replay_keyframes_tests.cpp | 243 ++++++++++++++ Extension/ReplayKeyframes/replay_curve.cpp | 146 ++++++++ Extension/ReplayKeyframes/replay_curve.h | 51 +++ .../ReplayKeyframes/replay_keyframes.cpp | 317 ++++++++++++++++++ Extension/ReplayKeyframes/replay_keyframes.h | 33 ++ .../replay_keyframes_commands.cpp | 52 +++ Extension/ReplayKeyframes/replay_settings.cpp | 46 +++ Extension/ReplayKeyframes/replay_settings.h | 15 + .../Test/native_tools_view_tests.cpp | 14 + Extension/UI/NativeMenu/native_tools_view.h | 5 + Extension/UI/Overlay/hub_page.cpp | 2 + Extension/UI/Overlay/overlay.h | 6 + Extension/UI/Overlay/skate_menu_skater.cpp | 10 + Runtime/client_tick.cpp | 2 + Runtime/runtime_init.cpp | 3 + cmake/Runtime.cmake | 4 + contrib/keyframe-smoothing.md | 77 +++++ 21 files changed, 1115 insertions(+) create mode 100644 Engine/Game/Build/20260929/replay_keyframes.h create mode 100644 Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp create mode 100644 Extension/ReplayKeyframes/replay_curve.cpp create mode 100644 Extension/ReplayKeyframes/replay_curve.h create mode 100644 Extension/ReplayKeyframes/replay_keyframes.cpp create mode 100644 Extension/ReplayKeyframes/replay_keyframes.h create mode 100644 Extension/ReplayKeyframes/replay_keyframes_commands.cpp create mode 100644 Extension/ReplayKeyframes/replay_settings.cpp create mode 100644 Extension/ReplayKeyframes/replay_settings.h create mode 100644 contrib/keyframe-smoothing.md diff --git a/CMakeLists.txt b/CMakeLists.txt index b6b222dc..30164a32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -356,6 +356,16 @@ if(DINGOSDK_BUILD_BOARD_BREAK_TESTS) target_include_directories(dingosdk_board_break_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME board_break COMMAND dingosdk_board_break_tests) endif() +option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF) +if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS) + enable_testing() + # The smoother curve: stock at 0, every key met, C2 at 1, and a framing segment the game's spline plays as + # meant; the saved settings read back as written. + add_executable(dingosdk_replay_keyframes_tests Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp + Extension/ReplayKeyframes/replay_curve.cpp Extension/ReplayKeyframes/replay_settings.cpp) + target_include_directories(dingosdk_replay_keyframes_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME replay_keyframes COMMAND dingosdk_replay_keyframes_tests) +endif() option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS) enable_testing() diff --git a/Engine/Game/Build/20260929/replay_keyframes.h b/Engine/Game/Build/20260929/replay_keyframes.h new file mode 100644 index 00000000..3d2a1785 --- /dev/null +++ b/Engine/Game/Build/20260929/replay_keyframes.h @@ -0,0 +1,77 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +#include + +// The replay editor's camera keyframes (Extension/ReplayKeyframes/replay_keyframes.h): framing keys +// (Behaviour, Subject, offsets, FOV), the free camera's among them, played as cubic Hermite segments +// between neighbouring keys. (The camera's other track, whole transforms played by 0x6222e0, isn't +// used by the free camera and is left alone.) +namespace dingosdk::game::build::v20260929::replay_keyframes { +// ---- Framing track: segments built when keys change ---- +// rebuild(manager, session): any key edit ends here. It builds a segment for every framing key of the +// session with segment_build and sends the lot, with the transform keys, to the camera object. The +// manager sends one package at a time (its handle is at manager + 0x40): the session it rebuilt last +// is the one the camera plays. +inline constexpr Fingerprint session_rebuild{0x62c500, { + 0x89,0x54,0x24,0x10,0x48,0x89,0x4c,0x24,0x08,0x55,0x53,0x57,0x41,0x57,0x48,0x8d, + 0xac,0x24,0x08,0xfe,0xff,0xff,0x48,0x81,0xec,0xf8,0x02,0x00,0x00,0x4c,0x8b,0xf9}}; +// Segment* segment_build(manager, Segment* out, Node* keys_end, Node** key): the segment from a key +// to the next. keys_end is the anchor of the session's key map (session node + 0x60: [1] is the first +// key); the session's id is the int at session node + 0x20. +inline constexpr Fingerprint segment_build{0x60c7a0, { + 0x40,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0xf0, + 0x00,0x00,0x00,0x4c,0x8b,0xe1,0x4d,0x8b,0xf9,0x48,0x8b,0xca,0x4d,0x8b,0xe8,0x48}}; +inline constexpr std::size_t session_id_before_keys = 0x60 - 0x20; +// void slot_build(manager, const void* value, double time, int behaviour, int subject, Slot* out): +// one key as a segment slot, under a Behaviour and Subject (segment_build passes the segment's own). +inline constexpr Fingerprint slot_build{0x60ec20, { + 0x4c,0x8b,0xdc,0x49,0x89,0x73,0x10,0x57,0x48,0x81,0xec,0x60,0x01,0x00,0x00,0x48, + 0x8b,0x82,0xf0,0x00,0x00,0x00,0x48,0x8b,0xfa,0x48,0x8b,0xb4,0x24,0x98,0x01,0x00}}; +// void slot_unwrap(Slot* adjust, const Slot* reference, int behaviour): adjust's angles to within π +// of the reference's. +inline constexpr Fingerprint slot_unwrap{0x61abd0, { + 0x48,0x83,0xec,0x48,0x45,0x85,0xc0,0x75,0x6d,0xc5,0xfa,0x10,0x52,0x48,0xc5,0xfa, + 0x10,0x49,0x48,0xc5,0xf2,0x5c,0xc2,0xc5,0xf8,0x54,0x05,0xf1,0xb9,0xa5,0x05,0xc5}}; +// bool segment_eval(camera, double time, const Segment*, Out*): plays a framing segment, every lane +// through the game's spline (p0, p1, p2, p3, s, k1, k2): +// m1 = (1 − k1)·0.5·(p2 − p0), m2 = (1 − k2)·0.5·(p3 − p1), Hermite from p1 to p2. +inline constexpr Fingerprint segment_eval{0x6220d0, { + 0x48,0x8b,0xc4,0x48,0x89,0x58,0x08,0x57,0x48,0x81,0xec,0x50,0x01,0x00,0x00,0xc5, + 0xfc,0x10,0x05,0xe9,0x15,0xdb,0x05,0xc5,0xfc,0x10,0x15,0x01,0x16,0xdb,0x05,0x41}}; +// A segment this short or shorter plays as its end (s = 1). +inline constexpr double tiny_gap = 1.0 / 65536.0; +// The manager singleton. +inline constexpr std::uintptr_t manager = 0x71f2130; +inline constexpr std::size_t manager_sessions = 0x10; // EASTL map: anchor; root at +0x20 + +// A key's node in a session's framing map: time at + 0x20, value at + 0x30. +inline constexpr std::size_t framing_key_time = 0x20; +inline constexpr std::size_t framing_key_value = 0x30; + +// The segment (0x1e8 bytes): four slots of 0x60 bytes, two blocks of twelve k, Behaviour, Subject. +inline constexpr std::size_t slot_size = 0x60; +inline constexpr std::size_t slot_floats = slot_size / sizeof(float); +inline constexpr std::size_t slot_time = 0x40; // double +inline constexpr std::size_t segment_next = 0x000; // p3, rescaled to one segment's time away +inline constexpr std::size_t segment_to = 0x060; // p2 +inline constexpr std::size_t segment_previous = 0x0c0; // p0, rescaled likewise +inline constexpr std::size_t segment_from = 0x120; // p1 +inline constexpr std::size_t segment_k_to = 0x180; // twelve floats: the k of the to key +inline constexpr std::size_t segment_k_from = 0x1b0; // and of the from key +inline constexpr std::size_t segment_k_count = 12; +inline constexpr std::size_t segment_behaviour = 0x1e0; +// The free camera's keys play under this Behaviour (with Subject 0): seen in game, 2026-10-16. +inline constexpr int free_camera_behaviour = 1; +inline constexpr std::size_t segment_subject = 0x1e4; +inline constexpr std::size_t segment_size = 0x1e8; + +// Which slot floats each Behaviour plays, and with which k (segment_eval's evaluators 0x609a50, +// 0x608e10 and 0x609fe0, plus the FOV lane segment_eval plays itself). +struct Lane { + unsigned char slot_float, k; +}; +inline constexpr Lane behaviour_0_lanes[]{{0, 0}, {1, 1}, {2, 2}, {8, 4}, {9, 5}, {10, 6}, {18, 8}, {19, 9}, {21, 10}}; +inline constexpr Lane behaviour_1_lanes[]{{12, 0}, {13, 1}, {14, 2}, {4, 4}, {5, 5}, {6, 6}, {21, 10}}; +inline constexpr Lane behaviour_2_lanes[]{{0, 0}, {1, 1}, {2, 2}, {4, 4}, {5, 5}, {6, 6}, {18, 8}, {19, 9}, {21, 10}}; +} diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 5cf3ad2c..b449231c 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -232,6 +232,7 @@ const Commands &game_commands() { register_road_rash_commands(*result); register_board_break_commands(*result); register_grind_exit_commands(*result); + register_replay_keyframes_commands(*result); return result; }(); return *registry; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 0caee4c7..828ecd15 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -30,6 +30,7 @@ void register_hall_of_meat_commands(Commands &); void register_road_rash_commands(Commands &); void register_board_break_commands(Commands &); void register_grind_exit_commands(Commands &); +void register_replay_keyframes_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. void request_debug(overlay::DebugAction, bool enabled = false, float value = 0); void request_feature(overlay::OfflineFeatureGroup, bool enabled); diff --git a/Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp b/Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp new file mode 100644 index 00000000..192bb1b7 --- /dev/null +++ b/Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp @@ -0,0 +1,243 @@ +// Replay keyframe smoothing, apart from the game: at smoothness 0 the camera plays the game's own curve, +// at any smoothness it meets every key, at 1 its speed and acceleration carry on through the keys, and a +// framing segment rewritten for the game's own spline plays the tangents it was given. +// (The game's spline is copied here term for term, so these run without it.) +#include "Extension/ReplayKeyframes/replay_curve.h" +#include "Extension/ReplayKeyframes/replay_settings.h" +#include +#include +#include +#include +#include +#include + +using namespace dingosdk::replay_keyframes; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +bool near(double a, double b, double within) { return std::abs(a - b) <= within * (1.0 + std::abs(b)); } + +// Keys a second, two seconds and half a second apart: uneven, as the user places them. +const std::vector times{0.0, 1.0, 3.0, 3.5, 6.0}; +// The game's spline (0x602a20). +float game_spline(float p0, float p1, float p2, float p3, float s, float k1, float k2) { + const float m1 = (1 - k1) * 0.5f * (p2 - p0), m2 = (1 - k2) * 0.5f * (p3 - p1); + return (2 * s * s * s - 3 * s * s + 1) * p1 + (3 * s * s - 2 * s * s * s) * p2 + (s * s * s - 2 * s * s + s) * m1 + (s * s * s - s * s) * m2; +} +// Acceleration per second² at the start (s = 0) and end (s = 1) of a segment with velocities v per second. +double acceleration_at_start(double p1, double p2, double v1, double v2, double h) { return (6 * (p2 - p1) - (4 * v1 + 2 * v2) * h) / (h * h); } +double acceleration_at_end(double p1, double p2, double v1, double v2, double h) { return (-6 * (p2 - p1) + (2 * v1 + 4 * v2) * h) / (h * h); } + +void blends_run_from_the_games_tangent_to_the_smooth_one() { + check(blend(0.25f, 99.0, 0.0f) == 0.25f, "At smoothness 0 a tangent is the game's, exactly"); + check(blend(0.25f, 99.0, 1.0f) == 99.0f, "at 1 the smooth curve's"); + check(near(blend(1.0f, 3.0, 0.25f), 1.5, 1e-6), "and in between a mix"); +} + +void at_one_speed_and_acceleration_carry_on() { + const std::vector values{0.0, 4.0, -2.0, 1.0, 7.0}; + std::vector v(values.size()), work; + check(c2_velocities(times, values, v, work), "The spline is solved"); + for (std::size_t i = 1; i + 1 < values.size(); ++i) { + const double before = times[i] - times[i - 1], after = times[i + 1] - times[i]; + check(near(acceleration_at_end(values[i - 1], values[i], v[i - 1], v[i], before), + acceleration_at_start(values[i], values[i + 1], v[i], v[i + 1], after), 1e-9), + "The acceleration is the same either side of every inner key"); + } + const auto last = values.size() - 1; + check(near(acceleration_at_start(values[0], values[1], v[0], v[1], times[1]), 0, 1e-9) && + near(acceleration_at_end(values[last - 1], values[last], v[last - 1], v[last], times[last] - times[last - 1]), 0, 1e-9), + "It starts and ends without accelerating"); + check(std::abs(v[0]) > 0.1 && std::abs(v.back()) > 0.1, "(the keys don't start or end at rest by themselves)"); +} + +void straight_lines_stay_straight() { + std::vector values; + for (const double t : times) values.push_back(2.5 * t - 1.0); + std::vector v(values.size()), work; + check(c2_velocities(times, values, v, work), "The spline is solved"); + for (const double velocity : v) check(near(velocity, 2.5, 1e-12), "Keys on a line at uneven times move along it evenly"); + const std::vector one{2.0}, value{5.0}; + std::vector still(1, 9.0); + check(c2_velocities(one, value, still, work) && still[0] == 0, "One key holds still"); + const std::vector backwards{0.0, 1.0, 1.0}, three{0.0, 1.0, 2.0}; + std::vector none(3, 9.0); + check(!c2_velocities(backwards, three, none, work) && none[0] == 9.0, "Keys that don't move on in time are refused"); +} + +// A framing segment as the game builds it (0x60c7a0) from four of the keys, starting at key `from`. +struct Framing { + std::vector slots; + std::vector key_times; + alignas(16) unsigned char segment[layout::segment_size]{}; +}; +Framing framing_keys(std::size_t from) { + constexpr auto floats = layout::slot_floats; + Framing result; + result.key_times = times; + result.slots.assign(times.size() * floats, 0.0f); + for (std::size_t key = 0; key < times.size(); ++key) { + float* slot = result.slots.data() + key * floats; + for (std::size_t lane = 0; lane < floats; ++lane) + slot[lane] = static_cast(std::cos(0.9 * static_cast(lane) + 1.7 * static_cast(key)) * 5.0); + std::memcpy(reinterpret_cast(slot) + layout::slot_time, ×[key], sizeof(double)); + } + const auto slot = [&](std::size_t key) { return reinterpret_cast(result.slots.data() + key * floats); }; + const auto last = times.size() - 1; + const auto previous = from ? from - 1 : from, to = from < last ? from + 1 : from, next = to < last ? to + 1 : to; + std::memcpy(result.segment + layout::segment_from, slot(from), layout::slot_size); + std::memcpy(result.segment + layout::segment_to, slot(to), layout::slot_size); + std::memcpy(result.segment + layout::segment_previous, slot(previous), layout::slot_size); + std::memcpy(result.segment + layout::segment_next, slot(next), layout::slot_size); + // The neighbours moved to one segment's time away (0x624e50), and some braking. + const double gap = times[to] - times[from]; + auto* const p0 = reinterpret_cast(result.segment + layout::segment_previous); + auto* const p1 = reinterpret_cast(result.segment + layout::segment_from); + auto* const p2 = reinterpret_cast(result.segment + layout::segment_to); + auto* const p3 = reinterpret_cast(result.segment + layout::segment_next); + for (std::size_t lane = 0; lane < floats; ++lane) { + if (lane * sizeof(float) >= layout::slot_time && lane * sizeof(float) < layout::slot_time + sizeof(double)) continue; + if (previous != from) p0[lane] = static_cast(p1[lane] + (p0[lane] - p1[lane]) * gap / (times[from] - times[previous])); + if (next != to) p3[lane] = static_cast(p2[lane] + (p3[lane] - p2[lane]) * gap / (times[next] - times[to])); + } + auto* const k_to = reinterpret_cast(result.segment + layout::segment_k_to); + auto* const k_from = reinterpret_cast(result.segment + layout::segment_k_from); + for (std::size_t k = 0; k < layout::segment_k_count; ++k) { + k_to[k] = 0.1f * static_cast(k % 4); + k_from[k] = k == 4 ? 1.0f : 0.05f * static_cast(k); + } + const int behaviour = 1; + std::memcpy(result.segment + layout::segment_behaviour, &behaviour, sizeof(behaviour)); + return result; +} +float segment_float(const unsigned char* segment, std::size_t offset) { + float value; + std::memcpy(&value, segment + offset, sizeof(value)); + return value; +} +// What the game's player makes of a lane of the segment at s. +float play(const unsigned char* segment, layout::Lane lane, float s) { + const auto f = [&](std::size_t slot) { return segment_float(segment, slot + lane.slot_float * sizeof(float)); }; + return game_spline(f(layout::segment_previous), f(layout::segment_from), f(layout::segment_to), f(layout::segment_next), s, + segment_float(segment, layout::segment_k_from + lane.k * sizeof(float)), + segment_float(segment, layout::segment_k_to + lane.k * sizeof(float))); +} + +void a_framing_segment_plays_as_meant() { + const auto lanes_played = framing_lanes(1); + check(lanes_played.size() == 7 && framing_lanes(7).empty(), "Behaviour 1 plays seven lanes, an unknown one none"); + Work work; + for (std::size_t from = 0; from + 1 < times.size(); ++from) { + // At smoothness 0 the game plays the segment as it built it. + auto stock = framing_keys(from); + const auto built = stock; + check(smooth_segment(stock.segment, {stock.key_times, stock.slots, from}, lanes_played, 0.0f, work) == SegmentResult::smoothed, + "A segment is rewritten"); + for (const auto lane : lanes_played) + for (float s = 0; s <= 1.0f; s += 0.125f) + check(near(play(stock.segment, lane, s), play(built.segment, lane, s), 1e-4), "At 0 it plays as the game built it"); + + // Above 0 the game's spline plays the blended tangents (the C2 ones at 1), and still starts at one + // key and ends at the next. + const double gap = times[from + 1] - times[from]; + for (const float smoothness : {0.3f, 1.0f}) { + auto smooth = framing_keys(from); + check(smooth_segment(smooth.segment, {smooth.key_times, smooth.slots, from}, lanes_played, smoothness, work) == + SegmentResult::smoothed, + "A segment is rewritten"); + for (const auto lane : lanes_played) { + std::vector values, v(times.size()), solver; + for (std::size_t key = 0; key < times.size(); ++key) values.push_back(smooth.slots[key * layout::slot_floats + lane.slot_float]); + c2_velocities(times, values, v, solver); + const auto f = [&](const unsigned char* segment, std::size_t slot) { + return segment_float(segment, slot + lane.slot_float * sizeof(float)); + }; + const auto k = [&](const unsigned char* segment, std::size_t block) { return segment_float(segment, block + lane.k * sizeof(float)); }; + const float game_m1 = (1 - k(built.segment, layout::segment_k_from)) * 0.5f * + (f(built.segment, layout::segment_to) - f(built.segment, layout::segment_previous)); + const float game_m2 = (1 - k(built.segment, layout::segment_k_to)) * 0.5f * + (f(built.segment, layout::segment_next) - f(built.segment, layout::segment_from)); + check(near(0.5 * (f(smooth.segment, layout::segment_to) - f(smooth.segment, layout::segment_previous)), + blend(game_m1, v[from] * gap, smoothness), 1e-4) && + near(0.5 * (f(smooth.segment, layout::segment_next) - f(smooth.segment, layout::segment_from)), + blend(game_m2, v[from + 1] * gap, smoothness), 1e-4), + "The game's spline plays the blended tangents"); + check(play(smooth.segment, lane, 0.0f) == values[from] && near(play(smooth.segment, lane, 1.0f), values[from + 1], 1e-5), + "and still goes from key to key"); + } + } + + // At 1, the two segments the game plays either side of a key have the same speed per second there. + if (from > 0) { + auto into = framing_keys(from - 1), out = framing_keys(from); + check(smooth_segment(into.segment, {into.key_times, into.slots, from - 1}, lanes_played, 1.0f, work) == + SegmentResult::smoothed && + smooth_segment(out.segment, {out.key_times, out.slots, from}, lanes_played, 1.0f, work) == + SegmentResult::smoothed, + "Both segments at a key are rewritten"); + const double before = times[from] - times[from - 1]; + for (const auto lane : lanes_played) { + const auto f = [&](const unsigned char* segment, std::size_t slot) { + return static_cast(segment_float(segment, slot + lane.slot_float * sizeof(float))); + }; + const double arriving = 0.5 * (f(into.segment, layout::segment_next) - f(into.segment, layout::segment_from)) / before; + const double leaving = 0.5 * (f(out.segment, layout::segment_to) - f(out.segment, layout::segment_previous)) / gap; + check(near(arriving, leaving, 1e-4), "The speed carries on through a key"); + } + } + auto smooth = framing_keys(from); + check(smooth_segment(smooth.segment, {smooth.key_times, smooth.slots, from}, lanes_played, 1.0f, work) == SegmentResult::smoothed, + "A segment is rewritten"); + // Lanes the Behaviour doesn't play, and their k, are left as they were. + for (std::size_t offset = 0; offset < layout::segment_size; offset += sizeof(float)) { + bool played = false; + for (const auto lane : lanes_played) + for (const auto slot : {layout::segment_previous, layout::segment_next}) + played = played || offset == slot + lane.slot_float * sizeof(float); + for (const auto lane : lanes_played) + for (const auto block : {layout::segment_k_from, layout::segment_k_to}) + played = played || offset == block + lane.k * sizeof(float); + if (!played) + check(std::memcmp(smooth.segment + offset, built.segment + offset, sizeof(float)) == 0 || + offset == layout::segment_behaviour, + "Nothing else in the segment changes"); + } + } + auto last = framing_keys(times.size() - 1); + check(smooth_segment(last.segment, {last.key_times, last.slots, times.size() - 1}, lanes_played, 1.0f, work) == + SegmentResult::stock, + "The last key's segment holds still and is left alone"); + auto odd = framing_keys(1); + reinterpret_cast(odd.segment + layout::segment_from)[12] += 1.0f; + const auto changed = odd; + check(smooth_segment(odd.segment, {odd.key_times, odd.slots, 1}, lanes_played, 1.0f, work) == SegmentResult::mismatch && + std::memcmp(odd.segment, changed.segment, layout::segment_size) == 0, + "A segment that doesn't match its keys is left as the game built it"); +} + +void settings_are_kept_as_left() { + const Settings fresh = from_text(""); + check(!fresh.on && fresh.amount == 1.0f, "With nothing saved, smoothing is off (on, it would be the smoothest)"); + const Settings left{true, 0.3f}; + const Settings read = from_text(to_text(left)); + check(read.on && read.amount == 0.3f, "Settings read back as they were written"); + const Settings odd = from_text("on = 1\r\namount=7\nease=0.5\nonly=0\nwho=me\n"); + check(odd.on && odd.amount == 1.0f, + "Values out of range, and lines it doesn't know (an earlier build's ease and only too), keep the defaults"); + check(from_text("amount=1.5").amount == 1.0f && from_text("amount=0").amount == 0.0f, "The amount runs from 0 to 1"); +} +} // namespace + +int main() { + try { + blends_run_from_the_games_tangent_to_the_smooth_one(); + at_one_speed_and_acceleration_carry_on(); + straight_lines_stay_straight(); + a_framing_segment_plays_as_meant(); + settings_are_kept_as_left(); + std::cout << "replay keyframes tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "replay keyframes test failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/Extension/ReplayKeyframes/replay_curve.cpp b/Extension/ReplayKeyframes/replay_curve.cpp new file mode 100644 index 00000000..fa995dd5 --- /dev/null +++ b/Extension/ReplayKeyframes/replay_curve.cpp @@ -0,0 +1,146 @@ +#include "replay_curve.h" +#include +#include +#include +#include + +namespace dingosdk::replay_keyframes { +namespace { +float read_float(const unsigned char* at) noexcept { + float value; + std::memcpy(&value, at, sizeof(value)); + return value; +} +void write_float(unsigned char* at, float value) noexcept { std::memcpy(at, &value, sizeof(value)); } +double read_double(const unsigned char* at) noexcept { + double value; + std::memcpy(&value, at, sizeof(value)); + return value; +} +// The same float, give or take rounding: a key the game and this code both built from one keyframe. +bool same(float a, float b) noexcept { return std::abs(a - b) <= 1e-4f * (1.0f + std::abs(a)); } + +// The natural spline through two or more keys at rising times. One equation per key, +// a·v[i−1] + b·v[i] + c·v[i+1] = r: the acceleration either side of an inner key is the same, and at +// the two ends it is zero. Solved by the Thomas algorithm (the matrix is diagonally dominant): the +// forward sweep leaves c' in work and d' in velocities, and the back substitution turns d' into v. +void solve(std::span times, std::span values, std::span velocities, + std::span work) { + const auto n = times.size(); + const auto equation = [&](std::size_t i, double& a, double& b, double& c, double& r) { + a = c = 0; + if (i == 0) { + b = 2; + c = 1; + r = 3 * (values[1] - values[0]) / (times[1] - times[0]); + } else if (i == n - 1) { + a = 1; + b = 2; + r = 3 * (values[i] - values[i - 1]) / (times[i] - times[i - 1]); + } else { + const double before = times[i] - times[i - 1], after = times[i + 1] - times[i]; + a = after; + b = 2 * (before + after); + c = before; + r = 3 * (after * (values[i] - values[i - 1]) / before + before * (values[i + 1] - values[i]) / after); + } + }; + for (std::size_t i = 0; i < n; ++i) { + double a{}, b{}, c{}, r{}; + equation(i, a, b, c, r); + const double pivot = i ? b - a * work[i - 1] : b; + work[i] = c / pivot; + velocities[i] = (r - (i ? a * velocities[i - 1] : 0.0)) / pivot; + } + for (std::size_t i = n - 1; i-- > 0;) + velocities[i] -= work[i] * velocities[i + 1]; +} +} // namespace + +bool c2_velocities(std::span times, std::span values, std::span velocities, + std::vector& work) { + const auto n = times.size(); + if (!n || values.size() != n || velocities.size() != n) return false; + for (std::size_t i = 0; i + 1 < n; ++i) + if (!(times[i + 1] - times[i] > 1e-9)) return false; + if (n == 1) { + velocities[0] = 0; + return true; + } + work.resize(n); + solve(times, values, velocities, work); + return true; +} + +float blend(float stock, double c2, float smoothness) noexcept { + if (!(smoothness > 0)) return stock; + if (smoothness >= 1) return static_cast(c2); + return static_cast((1.0 - smoothness) * stock + smoothness * c2); +} + +SegmentResult smooth_segment(unsigned char* segment, const FramingKeys& keys, std::span lanes, + float smoothness, Work& work) { + constexpr auto slot_floats = layout::slot_floats; + const auto n = keys.times.size(), from = keys.from; + if (!segment || lanes.empty() || from + 1 >= n || keys.slots.size() != n * slot_floats) + return SegmentResult::stock; + const double gap = keys.times[from + 1] - keys.times[from]; + if (!(gap > layout::tiny_gap)) return SegmentResult::stock; + if (read_double(segment + layout::segment_from + layout::slot_time) != keys.times[from] || + read_double(segment + layout::segment_to + layout::slot_time) != keys.times[from + 1]) + return SegmentResult::mismatch; + const auto lane_at = [segment](std::size_t slot, std::size_t index) { return segment + slot + index * sizeof(float); }; + const auto key_at = [&](std::size_t key, std::size_t index) { return keys.slots[key * slot_floats + index]; }; + + struct Change { + std::size_t index, k; + float previous, next; + }; + std::array changes{}; + std::size_t count{}; + work.lane.resize(n); + work.velocities.resize(n); + for (const auto lane : lanes) { + const std::size_t index = lane.slot_float, k = lane.k; + if (index >= slot_floats || k >= layout::segment_k_count || count == changes.size()) + return SegmentResult::stock; + const float p0 = read_float(lane_at(layout::segment_previous, index)); + const float p1 = read_float(lane_at(layout::segment_from, index)); + const float p2 = read_float(lane_at(layout::segment_to, index)); + const float p3 = read_float(lane_at(layout::segment_next, index)); + const float k1 = read_float(segment + layout::segment_k_from + k * sizeof(float)); + const float k2 = read_float(segment + layout::segment_k_to + k * sizeof(float)); + if (!same(p1, key_at(from, index)) || !same(p2, key_at(from + 1, index))) + return SegmentResult::mismatch; + for (std::size_t key = 0; key < n; ++key) { + const float value = key_at(key, index); + if (!std::isfinite(value)) return SegmentResult::stock; + work.lane[key] = value; + } + if (!c2_velocities(keys.times, work.lane, work.velocities, work.solver)) return SegmentResult::stock; + // The tangents the game's spline makes of the segment as it was built. + const float m1 = blend((1.0f - k1) * 0.5f * (p2 - p0), work.velocities[from] * gap, smoothness); + const float m2 = blend((1.0f - k2) * 0.5f * (p3 - p1), work.velocities[from + 1] * gap, smoothness); + const Change change{index, k, p2 - 2.0f * m1, p1 + 2.0f * m2}; + if (!std::isfinite(change.previous) || !std::isfinite(change.next)) return SegmentResult::stock; + changes[count++] = change; + } + for (std::size_t i = 0; i < count; ++i) { + const auto& change = changes[i]; + write_float(lane_at(layout::segment_previous, change.index), change.previous); + write_float(lane_at(layout::segment_next, change.index), change.next); + write_float(segment + layout::segment_k_from + change.k * sizeof(float), 0.0f); + write_float(segment + layout::segment_k_to + change.k * sizeof(float), 0.0f); + } + return SegmentResult::smoothed; +} + +std::span framing_lanes(int behaviour) noexcept { + switch (behaviour) { + case 0: return layout::behaviour_0_lanes; + case 1: return layout::behaviour_1_lanes; + case 2: return layout::behaviour_2_lanes; + default: return {}; + } +} +} // namespace dingosdk::replay_keyframes diff --git a/Extension/ReplayKeyframes/replay_curve.h b/Extension/ReplayKeyframes/replay_curve.h new file mode 100644 index 00000000..76fe59ce --- /dev/null +++ b/Extension/ReplayKeyframes/replay_curve.h @@ -0,0 +1,51 @@ +#pragma once +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/replay_keyframes.h" +#include +#include +#include + +// The replay keyframes' smoother curve, apart from the game (Extension/ReplayKeyframes/replay_keyframes.h +// says where it goes in). The game's framing track plays a cubic Hermite segment between each pair of +// keys, from the tangents it chooses at the two keys. Smoothness σ chooses other tangents: +// - at 0, the game's own; +// - at 1, those of the natural cubic spline through every key, at the keys' real times: position, +// speed and acceleration all carry on through a key, and the camera never brakes at one; +// - in between, the two blended. +// Each segment still starts at one key and ends at the next: tangents never move the keys. +namespace dingosdk::replay_keyframes { +namespace layout = addr::replay_keyframes; + +// The velocity (per second) at every key of the natural cubic spline through (times[i], values[i]): +// the camera starts and ends without accelerating. One key holds still. False, and velocities +// untouched, when the sizes differ, there are no keys or the times don't rise. +bool c2_velocities(std::span times, std::span values, std::span velocities, + std::vector& work); + +// A segment tangent at smoothness σ: the game's at 0 (exactly), the C2 one at 1, blended between. +float blend(float stock, double c2, float smoothness) noexcept; + +// Scratch space, kept between calls so that a rebuild allocates little. +struct Work { + std::vector lane, velocities, solver; +}; + +// The framing track's keys as one segment's Behaviour sees them: every key of the session as a slot +// (slot_floats floats each, key after key) and its slot time, angles unwrapped in sequence, and which +// key the segment starts at. +struct FramingKeys { + std::span times; + std::span slots; + std::size_t from; +}; +enum class SegmentResult { smoothed, stock, mismatch }; +// Rewrites a segment the game built (layout::segment_size bytes) so that the game's own player plays +// it with σ's tangents on every lane the Behaviour plays: each tangent goes into the neighbour slots +// (p0 = p2 − 2·m1, p3 = p1 + 2·m2) and its k to 0, so (1 − k)·0.5·(p2 − p0) is m1 again. Left as it +// was (stock) for the last key's segment, a segment too short to play, a lane that isn't finite, or +// (mismatch) when the segment's own from and to don't match the keys. +SegmentResult smooth_segment(unsigned char* segment, const FramingKeys& keys, std::span lanes, + float smoothness, Work& work); +// The lanes a Behaviour plays; empty for one the game doesn't have. +std::span framing_lanes(int behaviour) noexcept; +} // namespace dingosdk::replay_keyframes diff --git a/Extension/ReplayKeyframes/replay_keyframes.cpp b/Extension/ReplayKeyframes/replay_keyframes.cpp new file mode 100644 index 00000000..53a465f3 --- /dev/null +++ b/Extension/ReplayKeyframes/replay_keyframes.cpp @@ -0,0 +1,317 @@ +#include "replay_keyframes.h" +#include "replay_curve.h" +#include "replay_settings.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/replay_keyframes.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::replay_keyframes { +namespace { +namespace native = addr::replay_keyframes; +using SegmentBuild = void* (*)(void* manager, void* segment, void* keys_end, void** key); +using SegmentEval = bool (*)(void* camera, double time, const void* segment, void* out); +using SlotBuild = void (*)(void* manager, const void* value, double time, int behaviour, int subject, void* slot); +using SlotUnwrap = void (*)(void* adjust, const void* reference, int behaviour); +using Rebuild = void (*)(void* manager, int session); + +// More keys than any replay has: a walk that goes on longer has lost its way. +constexpr std::size_t most_keys = 4096; +static_assert(native::slot_size % 16 == 0, "slots hold vec4s, one after another in a vector"); + +std::atomic started{}, framing_hooked{}; +std::atomic build_original{}; +std::atomic eval_original{}; +SlotBuild slot_build{}; +SlotUnwrap slot_unwrap{}; +Rebuild rebuild{}; +std::uintptr_t image{}; + +// The player's settings (replay_settings.h), loaded at start and saved at every change. +std::atomic enabled{}; +std::atomic smooth{1.0f}; +// The session whose framing keys the game built last (the one the camera plays): 1 << 32 | its id. +std::atomic last_session{}; +// Segments by Behaviour (0, 1, 2, other) and Subject (0..7, other). +constexpr std::size_t behaviours = 4, subjects = 9; +std::size_t behaviour_index(int behaviour) noexcept { + return behaviour >= 0 && behaviour < static_cast(behaviours) - 1 ? static_cast(behaviour) : behaviours - 1; +} +std::size_t subject_index(int subject) noexcept { + return subject >= 0 && subject < static_cast(subjects) - 1 ? static_cast(subject) : subjects - 1; +} +struct Counters { + std::array, behaviours> played; + std::array, subjects>, behaviours> built; + std::atomic smoothed, stock, mismatch, filtered; +} counters; + +std::uintptr_t at(std::uintptr_t address) noexcept { return *reinterpret_cast(address); } +// The next node of an EASTL map, as the game steps through one (0x2714d50): node [0] right, [1] left, +// [2] parent. After the last node comes the map's anchor. +std::uintptr_t successor(std::uintptr_t node) noexcept { + auto next = at(node); + if (!next) { + next = at(node + 16); + if (node == at(next)) { + std::uintptr_t climbed; + do { + climbed = next; + next = at(climbed + 16); + } while (climbed == at(next)); + if (at(climbed) == next) next = climbed; + return next; + } + if (!next) next = node; + } else if (at(next + 8)) { + do next = at(next + 8); + while (at(next + 8)); + } + return next; +} + +bool matches(std::uintptr_t base, const game::build::Fingerprint& contract) noexcept { + std::array bytes{}; + return memory::peek(base + contract.rva, bytes) && bytes == contract.bytes; +} +template +bool hook(std::uintptr_t base, const game::build::Fingerprint& contract, Function replacement, std::atomic& original) noexcept { + auto* const target = reinterpret_cast(base + contract.rva); + void* previous{}; + if (hook_prepare(target, reinterpret_cast(replacement), &previous) != HookOk) return false; + original = reinterpret_cast(previous); + if (hook_enable(target) != HookOk) { + hook_remove(target); + original = nullptr; + return false; + } + return true; +} + +int segment_int(const void* segment, std::size_t offset) noexcept { + int value{}; + std::memcpy(&value, static_cast(segment) + offset, sizeof(value)); + return value; +} + +SegmentResult smooth_framing(void* manager, unsigned char* segment, std::uintptr_t keys_end, std::uintptr_t key, + int behaviour, int subject, float amount) { + thread_local std::vector nodes; + thread_local std::vector slots; + thread_local std::vector times; + thread_local Work work; + const auto lanes = framing_lanes(behaviour); + if (lanes.empty()) return SegmentResult::stock; + + nodes.clear(); + std::size_t from = most_keys; + for (auto node = at(keys_end + sizeof(std::uintptr_t)); node != keys_end; node = successor(node)) { + if (!node || nodes.size() == most_keys) return SegmentResult::stock; + if (node == key) from = nodes.size(); + nodes.push_back(node); + } + if (from == most_keys) return SegmentResult::mismatch; + const auto n = nodes.size(); + // Every key as a slot, the way the game builds the segment's neighbours: under this segment's + // Behaviour and Subject, then each key's angles unwrapped against the key before it (going on from + // the segment's first key) or after it (going back). + slots.assign(n * native::slot_floats, 0.0f); + const auto slot = [](std::size_t index) { return slots.data() + index * native::slot_floats; }; + for (std::size_t i = 0; i < n; ++i) + slot_build(manager, reinterpret_cast(nodes[i] + native::framing_key_value), + *reinterpret_cast(nodes[i] + native::framing_key_time), behaviour, subject, slot(i)); + for (std::size_t i = from + 1; i < n; ++i) slot_unwrap(slot(i), slot(i - 1), behaviour); + for (std::size_t i = from; i-- > 0;) slot_unwrap(slot(i), slot(i + 1), behaviour); + times.resize(n); + for (std::size_t i = 0; i < n; ++i) + std::memcpy(×[i], reinterpret_cast(slot(i)) + native::slot_time, sizeof(double)); + const FramingKeys keys{times, slots, from}; + return smooth_segment(segment, keys, lanes, amount, work); +} +void* segment_build(void* manager, void* segment, void* keys_end, void** key) { + void* const result = build_original.load(std::memory_order_relaxed)(manager, segment, keys_end, key); + if (!segment || !keys_end || !key || !*key) return result; + const int behaviour = segment_int(segment, native::segment_behaviour), subject = segment_int(segment, native::segment_subject); + counters.built[behaviour_index(behaviour)][subject_index(subject)].fetch_add(1, std::memory_order_relaxed); + const auto end = reinterpret_cast(keys_end); + std::int32_t session{}; + std::memcpy(&session, reinterpret_cast(end - native::session_id_before_keys), sizeof(session)); + last_session.store(1ull << 32 | static_cast(session), std::memory_order_relaxed); + if (!enabled.load(std::memory_order_relaxed)) return result; + const float amount = smooth.load(std::memory_order_relaxed); + if (!(amount > 0)) return result; + if (behaviour != native::free_camera_behaviour) { + counters.filtered.fetch_add(1, std::memory_order_relaxed); + return result; + } + auto outcome = SegmentResult::stock; + try { + outcome = smooth_framing(manager, static_cast(segment), end, reinterpret_cast(*key), + behaviour, subject, amount); + } catch (...) {} + auto& counter = outcome == SegmentResult::smoothed ? counters.smoothed + : outcome == SegmentResult::mismatch ? counters.mismatch : counters.stock; + counter.fetch_add(1, std::memory_order_relaxed); + return result; +} +bool segment_eval(void* camera, double time, const void* segment, void* out) { + if (segment) + counters.played[behaviour_index(segment_int(segment, native::segment_behaviour))].fetch_add(1, std::memory_order_relaxed); + return eval_original.load(std::memory_order_relaxed)(camera, time, segment, out); +} + +// Whether the manager still has the session (its map: anchor at manager_sessions, root two pointers +// in, the int id at node + 0x20, ordered by it). +bool has_session(std::uintptr_t manager, std::int32_t id) noexcept { + std::uintptr_t node{}; + if (!memory::peek(manager + native::manager_sessions + 2 * sizeof(std::uintptr_t), node)) return false; + for (int depth = 0; node && depth < 64; ++depth) { + std::int32_t key{}; + if (!memory::peek(node + 0x20, key)) return false; + if (key == id) return true; + if (!memory::peek(node + (id < key ? sizeof(std::uintptr_t) : 0), node)) return false; + } + return false; +} +// The game's rebuild of the session the camera plays, so that its framing segments are built with +// the new setting. Game thread. +std::string rebuild_framing() { + if (!framing_hooked.load()) return ""; + const auto seen = last_session.load(std::memory_order_relaxed); + if (!seen) return " Framing keys: none built yet; they follow when the game builds them."; + const auto id = static_cast(static_cast(seen)); + std::uintptr_t manager{}; + if (!memory::peek(image + native::manager, manager) || !manager || !has_session(manager, id)) + return " Framing keys: the replay they were built for is gone; they follow at the next key edit."; + rebuild(reinterpret_cast(manager), id); + return std::format(" Framing keys of session {} rebuilt.", id); +} +std::string number(float value) { return std::format("{:g}", value); } +std::string behaviour_name(std::size_t index) { return index < behaviours - 1 ? std::to_string(index) : "other"; } +std::string subject_name(std::size_t index) { return index < subjects - 1 ? std::to_string(index) : "other"; } + +std::filesystem::path settings_path() { + PWSTR raw{}; + std::filesystem::path result; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw) + result = std::filesystem::path(raw) / L"ReSkate" / L"KeySmooth.settings"; + if (raw) CoTaskMemFree(raw); + return result; +} +void load_settings() { + try { + std::ifstream in(settings_path()); + const auto loaded = from_text(std::string(std::istreambuf_iterator(in), {})); + enabled.store(loaded.on, std::memory_order_relaxed); + smooth.store(loaded.amount, std::memory_order_relaxed); + } catch (...) {} +} +// Written on a thread of its own, as Board Break does, so that a slow disk never holds up the frame; +// the writes are kept in order by their own lock. +void save_settings() { + try { + const Settings now{enabled.load(), smooth.load()}; + std::thread([text = to_text(now)] { + static std::mutex writing; + std::lock_guard lock(writing); + try { + const auto path = settings_path(); + if (path.empty()) return; + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::trunc); + file << text; + } catch (...) {} + }).detach(); + } catch (...) {} +} +// A changed setting: saved, and the keys in play rebuilt with it. +std::string changed(std::string said) { + save_settings(); + return said + rebuild_framing(); +} +} // namespace + +bool start(std::uintptr_t base) noexcept { + if (started.exchange(true)) return available(); + try { + if (!base) return false; + image = base; + load_settings(); + if (matches(base, native::segment_build) && matches(base, native::slot_build) && + matches(base, native::slot_unwrap) && matches(base, native::session_rebuild)) { + slot_build = reinterpret_cast(base + native::slot_build.rva); + slot_unwrap = reinterpret_cast(base + native::slot_unwrap.rva); + rebuild = reinterpret_cast(base + native::session_rebuild.rva); + framing_hooked = hook(base, native::segment_build, &segment_build, build_original); + } + const bool counted = matches(base, native::segment_eval) && + hook(base, native::segment_eval, &segment_eval, eval_original); + logging::log(available() ? logging::Level::info : logging::Level::warning, logging::Channel::graphics, + "Replay keyframe smoothing: framing track {}, framing player count {}; smoothing {}.", + framing_hooked ? "hooked" : "not hooked (game code differs)", counted ? "on" : "off", on() ? "on" : "off"); + return available(); + } catch (...) { + return false; + } +} + +bool available() noexcept { return framing_hooked.load(); } +bool on() noexcept { return enabled.load(std::memory_order_relaxed); } +float smoothness() noexcept { return smooth.load(std::memory_order_relaxed); } + +std::string set_on(bool value) { + enabled.store(value, std::memory_order_relaxed); + return changed(value ? "Keyframe smoothing on (smoothness " + number(smoothness()) + ")." + : std::string("Keyframe smoothing off: the game's own curve.")); +} +std::string set_smoothness(float value) { + smooth.store(std::isfinite(value) ? std::clamp(value, 0.0f, 1.0f) : 1.0f, std::memory_order_relaxed); + return changed("Keyframe smoothness " + number(smoothness()) + (on() ? "." : " (smoothing is off).")); +} + +std::string status() { + const auto take = [](std::atomic& counter) { return counter.exchange(0, std::memory_order_relaxed); }; + std::uint64_t played{}, built{}; + std::string played_by, built_by; + const auto add = [](std::string& list, const std::string& name, std::uint64_t count) { + if (count) list += std::format("{}{}: {}", list.empty() ? "" : ", ", name, count); + }; + for (std::size_t b = 0; b < behaviours; ++b) { + const auto count = take(counters.played[b]); + played += count; + add(played_by, behaviour_name(b), count); + for (std::size_t s = 0; s < subjects; ++s) { + const auto built_here = take(counters.built[b][s]); + built += built_here; + add(built_by, behaviour_name(b) + "/" + subject_name(s), built_here); + } + } + const auto seen = last_session.load(std::memory_order_relaxed); + return std::format( + "Keyframe smoothing {}: smoothness {}, free camera (Behaviour {}) only. Since the last status: framing " + "segments played {} times (by Behaviour: {}), built {} (by Behaviour/Subject: {}; smoothed {}, left as the " + "game built them {}, other Behaviour {}, not matching {}){}.", + on() ? "on" : "off", number(smoothness()), native::free_camera_behaviour, + played, played_by.empty() ? "none" : played_by, built, built_by.empty() ? "none" : built_by, take(counters.smoothed), + take(counters.stock), take(counters.filtered), take(counters.mismatch), + !framing_hooked.load() ? " (not hooked)" + : seen ? std::format("; last built for session {}", static_cast(static_cast(seen))) + : std::string{}); +} +} // namespace dingosdk::replay_keyframes diff --git a/Extension/ReplayKeyframes/replay_keyframes.h b/Extension/ReplayKeyframes/replay_keyframes.h new file mode 100644 index 00000000..909b0de1 --- /dev/null +++ b/Extension/ReplayKeyframes/replay_keyframes.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +// Replay keyframes: a smoothness slider for the replay editor's camera. The game plays framing keys +// (the free camera's among them) as cubic Hermite segments whose tangents are only as smooth as the +// keys allow: it brakes where a channel turns or slows sharply, and the acceleration jumps at every +// key. Smoothness σ (replay_curve.h) gives every segment other tangents, up to those of the natural +// cubic spline through all keys at σ = 1; every key is still met exactly. +// +// The game builds the segments whenever a key changes (Engine/Game/Build//replay_keyframes.h), +// and each is rewritten as it is built, so that the game's own player plays σ's tangents. A change of +// setting builds the segments of the session the camera plays again (the game's own rebuild, on the +// game thread). Only the free camera's Behaviour (the evaluator a key's camera mode plays through) is +// smoothed; `keysmooth status` counts segments by Behaviour and Subject. +// The editor's preview of framing keys (the values it shows at the playhead) is the game's, and at +// σ > 0 it plays smoothed keys without its braking. +// +// Smoothing is off until the player turns it on (`keysmooth on`, or Keyframe smoothing in the menus): +// then σ is the amount (1 unless changed). The settings are kept between game runs (replay_settings.h). +namespace dingosdk::replay_keyframes { +// Checks this build's functions, loads the settings and hooks the framing track; once. False when it can't. +bool start(std::uintptr_t image_base) noexcept; +bool available() noexcept; +// Any thread. +bool on() noexcept; +float smoothness() noexcept; // the amount when on +// Game thread: the new setting, saved, with the framing keys of the session in play rebuilt; what was done. +std::string set_on(bool value); +std::string set_smoothness(float value); +// The settings, and how many framing segments were played and built since the last status. +std::string status(); +} // namespace dingosdk::replay_keyframes diff --git a/Extension/ReplayKeyframes/replay_keyframes_commands.cpp b/Extension/ReplayKeyframes/replay_keyframes_commands.cpp new file mode 100644 index 00000000..376bf73e --- /dev/null +++ b/Extension/ReplayKeyframes/replay_keyframes_commands.cpp @@ -0,0 +1,52 @@ +#include "replay_keyframes.h" +#include "replay_settings.h" +#include "Extension/Console/commands.h" +#include +#include + +// The replay editor camera's keyframe smoothing (replay_keyframes.h); the menus' switches (Mod Options > +// Visuals, and the overlay's Skater > Freecam) send `keysmooth on|off` too. +namespace dingosdk::console { +void register_replay_keyframes_commands(Commands ®istry) { + const auto unavailable = "Keyframe smoothing is unavailable for this game build."; + auto on = variable("keysmooth", "Smooth the replay editor's camera through its keyframes: it still passes through " + "every key at its time, without the game's changes of speed at them. Off is the game's own curve. " + "Kept between runs", + Group::graphics, argument("on|off", Type::boolean)); + on.inspect = [unavailable](const Model &) { + return boolean_state(replay_keyframes::available(), replay_keyframes::on(), unavailable); + }; + on.run = [](const Model &, const Values &args, const Output &out) { + out(replay_keyframes::set_on(std::get(args[0]))); + }; + on.reset = [](const Model &, const Output &out) { out(replay_keyframes::set_on(false)); }; + registry.add(std::move(on)); + + const auto fraction = [](const char *name) { + auto value = argument(name, Type::number); + value.minimum = 0.0; + value.maximum = 1.0; + return value; + }; + auto smooth = variable("keysmooth amount", "How smooth the camera is when keysmooth is on: 1 (the default) is the " + "smoothest that still passes through every key, 0 the game's own curve", + Group::graphics, fraction("0..1")); + smooth.inspect = [unavailable](const Model &) { + return State{replay_keyframes::available(), std::format("{:g}", replay_keyframes::smoothness()), unavailable, {}, + replay_keyframes::smoothness() != replay_keyframes::Settings{}.amount}; + }; + smooth.run = [](const Model &, const Values &args, const Output &out) { + out(replay_keyframes::set_smoothness(static_cast(std::get(args[0])))); + }; + smooth.reset = [](const Model &, const Output &out) { + out(replay_keyframes::set_smoothness(replay_keyframes::Settings{}.amount)); + }; + registry.add(std::move(smooth)); + + auto status = action("keysmooth status", "Keyframe smoothing's settings, and how many framing segments were played and " + "built, by Behaviour, since the last status", Group::graphics); + status.execution = Execution::local; + status.run = [](const Model &, const Values &, const Output &out) { out(replay_keyframes::status()); }; + registry.add(std::move(status)); +} +} // namespace dingosdk::console diff --git a/Extension/ReplayKeyframes/replay_settings.cpp b/Extension/ReplayKeyframes/replay_settings.cpp new file mode 100644 index 00000000..8b929f15 --- /dev/null +++ b/Extension/ReplayKeyframes/replay_settings.cpp @@ -0,0 +1,46 @@ +#include "replay_settings.h" +#include +#include +#include +#include + +namespace dingosdk::replay_keyframes { +namespace { +std::string_view trimmed(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return text; +} +template bool number(std::string_view text, T& out) { + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), out); + return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(); +} +bool flag(std::string_view text, bool& out) { + if (text != "0" && text != "1") return false; + out = text == "1"; + return true; +} +} // namespace + +std::string to_text(const Settings& settings) { + return std::format("on={}\namount={:g}\n", settings.on ? 1 : 0, settings.amount); +} + +Settings from_text(std::string_view text) { + Settings settings; + while (!text.empty()) { + const auto end = text.find('\n'); + const auto line = text.substr(0, end); + text = end == std::string_view::npos ? std::string_view{} : text.substr(end + 1); + const auto eq = line.find('='); + if (eq == std::string_view::npos) continue; + const auto key = trimmed(line.substr(0, eq)), value = trimmed(line.substr(eq + 1)); + if (key == "on") (void)flag(value, settings.on); + else if (key == "amount") { + float amount{}; + if (number(value, amount) && std::isfinite(amount) && amount >= 0 && amount <= 1) settings.amount = amount; + } + } + return settings; +} +} // namespace dingosdk::replay_keyframes diff --git a/Extension/ReplayKeyframes/replay_settings.h b/Extension/ReplayKeyframes/replay_settings.h new file mode 100644 index 00000000..c600a562 --- /dev/null +++ b/Extension/ReplayKeyframes/replay_settings.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +// Keyframe smoothing's settings as the player left them, kept between game runs +// (%LOCALAPPDATA%\ReSkate\KeySmooth.settings, a line each: on=1, amount=1). +namespace dingosdk::replay_keyframes { +struct Settings { + bool on = false; // off: the game's own curve, whatever the rest says + float amount = 1.0f; // smoothness σ when on, 0..1 +}; +std::string to_text(const Settings& settings); +// Lines it doesn't know, or values out of range, keep the defaults. +Settings from_text(std::string_view text); +} // namespace dingosdk::replay_keyframes diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index a4f03e4d..4fbeef8f 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -187,6 +187,20 @@ int main() { model.grind_exit.strength = 25; activate(state, model, cb, "grind-exit-strength", ""); check(capture.command == "grindexit strength 100", "and the lowest wraps to the full exit"); + // Keyframe smoothing sits with what you see, under the replay editor. + const auto smoothing_row = [&] { + const auto page = render(state, model, cb, visuals_section); + const auto row = find(page.main, "keyframe-smoothing"); + return row == page.main.end() ? Row{} : *row; + }; + check(smoothing_row().title == "Keyframe smoothing: Off" && smoothing_row().command.empty(), + "Keyframe smoothing waits until it started"); + model.keyframe_smoothing = {true, false}; + activate(state, model, cb, "keyframe-smoothing", ""); + check(smoothing_row().command == "keyframe-smoothing" && capture.command == "keysmooth on", "Keyframe smoothing turns on"); + model.keyframe_smoothing = {true, true}; + activate(state, model, cb, "keyframe-smoothing", ""); + check(smoothing_row().title == "Keyframe smoothing: On" && capture.command == "keysmooth off", "and off again"); model.multiplayer.local_name = "steam_name"; model.player_card = {true, "Card Name", {}}; const auto card_page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index b9778cb4..621523c4 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -228,6 +228,8 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks const int strength = m.grind_exit.strength; const int next = strength > 75 ? 75 : strength > 50 ? 50 : strength > 25 ? 25 : 100; console("grindexit strength " + std::to_string(next), m.grind_exit.available); + } else if (command == "keyframe-smoothing") { + console(m.keyframe_smoothing.enabled ? "keysmooth off" : "keysmooth on", m.keyframe_smoothing.available); } else if (command == "board-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -372,6 +374,9 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 "graphics", m.graphics.available && cb.queue_console_command, graphics_keys[i]); } button(p.main, "graphics-reset", "Restore default effects", "graphics-reset", m.graphics.available && cb.queue_console_command); + text(p.main, "replay-title", "REPLAY EDITOR"); + toggle(p.main, "keyframe-smoothing", "Keyframe smoothing", m.keyframe_smoothing.enabled, + m.keyframe_smoothing.available && cb.queue_console_command); text(p.side, "hud-title", "CHALLENGES"); button(p.side, "challenges", m.progression.challenges_hidden ? "Challenges: Hidden" : "Challenges: Shown", "challenges", m.progression.challenges_enabled && cb.queue_console_command); diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index 7584b2d1..66394d9a 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1309,6 +1309,8 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { if (list.setting(names[i], on_off(on) + (choice < 0 ? " (default)" : ""), m.graphics.available && console)) act("graphics", std::string(graphics_keys[i])); } if (list.setting("Restore default effects", {}, m.graphics.available && console)) act("graphics-reset"); + if (list.setting("Keyframe smoothing", on_off(m.keyframe_smoothing.enabled), m.keyframe_smoothing.available && console)) + act("keyframe-smoothing"); if (list.setting("Challenges", m.progression.challenges_hidden ? "Hidden" : "Shown", m.progression.challenges_enabled && console)) act("challenges"); { const bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 393574f6..86f1c853 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -251,6 +251,11 @@ struct GrindExitModel { int strength = 100; // pop-out strength, percent (10-100) }; +// Keyframe smoothing's switch (Extension/ReplayKeyframes/replay_keyframes.h): started, and switched on. +struct KeyframeSmoothingModel { + bool available = false, enabled = false; +}; + struct Model { std::string state = "Waiting for native state"; std::string detail; @@ -273,6 +278,7 @@ struct Model { RoadRashModel road_rash; BoardBreakModel board_break; GrindExitModel grind_exit; + KeyframeSmoothingModel keyframe_smoothing; ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 4ecd5e0d..f6e6cdb7 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -62,6 +62,16 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (debug.free_camera) note("Close the menu to fly. WASD / Q E move; hold the right mouse button to look."); if (!debug.camera_available) warn(debug.camera_unavailable.c_str()); end_card(); + + // Keyframe smoothing (Extension/ReplayKeyframes/replay_keyframes.h). + begin_card(menu, "replay-editor", "REPLAY EDITOR"); + bool keysmooth = model.keyframe_smoothing.enabled; + if (toggle_row(menu, "Keyframe smoothing", + "The free camera glides through its keys instead of changing speed at each one. Every key is still met, at its time.", + keysmooth, model.keyframe_smoothing.available && callbacks.queue_console_command)) + send_console(menu, callbacks, keysmooth ? "keysmooth on" : "keysmooth off"); + note("How smooth: keysmooth amount 0..1 in the console (1, the smoothest, by default)."); + end_card(); } void first_person_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 556d831a..3f47f3fc 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -19,6 +19,7 @@ #include "Extension/RoadRash/road_rash.h" #include "Extension/BoardBreak/board_break.h" #include "Extension/BoardBreak/board_break_audio.h" +#include "Extension/ReplayKeyframes/replay_keyframes.h" #include "Extension/Skater/grind_exit_control.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" @@ -1035,6 +1036,7 @@ void update_model(std::uintptr_t client, TickState& frame) { model.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(), dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(), dingosdk::grind_exit_control::strength()}; + model.keyframe_smoothing = {dingosdk::replay_keyframes::available(), dingosdk::replay_keyframes::on()}; model.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 29581f2c..a6066fc8 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -58,6 +58,7 @@ #include "Extension/World/loading_screen.h" #include "Extension/Customization/preset_lookup_guard.h" #include "Extension/Rendering/replay_export.h" +#include "Extension/ReplayKeyframes/replay_keyframes.h" #include "Engine/Vfs/content_catalogs.h" #include "Engine/Vfs/world_layer_scan.h" #include "Engine/Game/World/world_layer_catalog.h" @@ -455,6 +456,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { record("{\"event\":\"preset_lookup_guard_initialized\",\"active\":" + std::string(preset_guard ? "true" : "false") + "}"); const bool replay_export = dingosdk::replay_export::start(r.base); record("{\"event\":\"replay_export_initialized\",\"active\":" + std::string(replay_export ? "true" : "false") + "}"); + const bool replay_keyframes = dingosdk::replay_keyframes::start(r.base); + record("{\"event\":\"replay_keyframes_initialized\",\"active\":" + std::string(replay_keyframes ? "true" : "false") + "}"); void* original{}; auto* address = reinterpret_cast(r.base + rt::client_tick); auto hook_status = dingosdk::hook_prepare(address, reinterpret_cast(&tick), &original); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index ab44fd86..134c04d3 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -129,6 +129,10 @@ add_library(dingosdk_runtime SHARED Extension/World/spatial_cull_ids.cpp Extension/Rendering/display_startup.cpp Extension/Rendering/replay_export.cpp + Extension/ReplayKeyframes/replay_curve.cpp + Extension/ReplayKeyframes/replay_keyframes.cpp + Extension/ReplayKeyframes/replay_keyframes_commands.cpp + Extension/ReplayKeyframes/replay_settings.cpp Engine/Game/World/world_model.cpp Extension/World/level_loading.cpp Extension/World/loading_screen.cpp diff --git a/contrib/keyframe-smoothing.md b/contrib/keyframe-smoothing.md new file mode 100644 index 00000000..50956a44 --- /dev/null +++ b/contrib/keyframe-smoothing.md @@ -0,0 +1,77 @@ +# Keyframe smoothing + +The replay editor's free camera moves through its keys without the changes of speed the game makes +at each one. Every key is still met exactly, at its own time: only the way the camera gets from +one key to the next changes. Off unless the player turns it on; the other camera modes stay on the +game's own curve. + +Code: [replay_curve.cpp](../Extension/ReplayKeyframes/replay_curve.cpp) (the curve, apart from the +game) with [tests](../Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp), +[replay_keyframes.cpp](../Extension/ReplayKeyframes/replay_keyframes.cpp) (the hooks, the rebuild and +the counters), [replay_settings.cpp](../Extension/ReplayKeyframes/replay_settings.cpp) (the saved +settings), [replay_keyframes_commands.cpp](../Extension/ReplayKeyframes/replay_keyframes_commands.cpp) +(console), and the build's addresses in +[replay_keyframes.h](../Engine/Game/Build/20260929/replay_keyframes.h). + +## What the player gets + +- `keysmooth on|off`, also a switch Keyframe smoothing in the pause menu's Mod Options > Visuals and + in the overlay (Insert) under Skater > Freecam > Replay editor. A change applies to the replay open + in the editor at once. +- `keysmooth amount 0..1`: 1 (the default) is the smoothest curve that still meets every key, 0 the + game's own. +- `keysmooth status`: the settings, and counts of the segments built and played, by camera mode. +- Kept in `%LOCALAPPDATA%\ReSkate\KeySmooth.settings`. + +## How it works + +- **The game's curve.** The replay camera's keys are framing keys. Whenever a key changes, the game + builds one segment per key (`segment_build`): the key, the next one and their two neighbours as + slots, plus a factor k per channel. The player (`segment_eval`) plays a segment as a cubic + Hermite from key to key, with the tangent at a key the mean of the velocities into and out of it, + times (1 − k). So the speed carries on through a key, but the acceleration jumps at every one; + k brakes a channel that slows down or turns back at a key (k = 1 stops it there); and the first + and last keys are left and reached at half speed. That is what shows as the camera surging and + settling around each key, and what makes a follow shot's skater drift in frame. +- **The smooth curve.** The curve through the same keys at the same times whose speed and + acceleration both carry on through every key (C2) is the natural cubic spline. With the keys and + their times fixed, it is the one that bends the least, so nothing smoother is possible without + missing a key or moving its time. Its velocity at every key, per channel, comes from one + tridiagonal system (Thomas algorithm), with uneven gaps between keys taken into account. The + amount mixes the game's tangents with these. +- **Written into the game's own segments.** The hook calls the game's builder, then rewrites the + segment it built: for each channel the Behaviour plays, the neighbour slots go to `p2 − 2·m1` and + `p1 + 2·m2` and k to 0, so the game's unchanged player plays the tangents m1 and m2. To have every + key, the hook walks the replay's key map and builds each key's slot with the game's own slot + builder (`slot_build`) and angle unwrapping (`slot_unwrap`), the way the game builds the + neighbours. A segment whose own keys don't match those is left as the game built it and counted. + Nothing runs per frame: the segments are built when keys change. +- **A change applies at once.** A new setting calls the game's own rebuild (`session_rebuild`) for + the replay the builder saw last, after checking the editor still has it. Console commands run on + the game thread. +- **Camera modes.** Each segment carries the Behaviour (the evaluator its camera mode plays through) + and Subject. The free camera is Behaviour 1, Subject 0; the board camera Behaviour 0, Subject 2. + Only Behaviour 1 is smoothed. Behaviour 1 plays the camera's + position, its three rotation angles (so pans, tilts and roll) and its field of view; all of them + are smoothed, each on its own. +- Every function the hook calls or patches is checked against a 32-byte fingerprint first; a game + build that differs gets no hook, and the commands say smoothing is unavailable. + +## Seen working in game + +- The free camera's follow shots lose the surge at each key with smoothing on, by eye. +- The path between keys changes too, not only the speed along it: on a cinematic shot through the + same keys, the game's curve takes the camera through a bush that the smoothed one goes round. +- An export plays like the playback. Smoothing stays on after leaving the editor and when a draft + is reopened in the same run. A key added at the playhead mid-segment doesn't make the camera jump. +- Off by default; on after a restart once turned on. The board camera's keys stay on the game's + curve with smoothing on. +- After a rebuild, every segment the hook rebuilt matched the game's own keys ("not matching 0"). + +## Not tried yet + +- The editor's preview of a key (the values shown at the playhead when stepping between keys) is the + game's own and isn't smoothed; no difference was seen. +- On an out-and-back path the smooth curve swings a little past the turning key, as a spline does. +- A switch inside the replay editor's own menus would sit better than Mod Options, but needs the + editor's menu definition worked out.