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.