From d75d34614b6e34bf8eb402f1c7f404495ee052f9 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 17 Oct 2026 12:29:53 -0500 Subject: [PATCH] Keyframe smoothing: key slots built once per rebuild, no playback hook, smoothness in the menus A rebuild builds one segment per key and each needed every key's slot, so a long replay built them once per segment: the game's rebuild is hooked too and the slots built for its first segment are kept for the rest of it (per Behaviour and Subject). The segment player is no longer hooked (it only counted plays for keysmooth status, every frame). The smoothness is a slider beside the switch in the overlay (sent when let go, as each change rebuilds the open replay's camera) and Keyframe smoothness (100, 75, 50, 25%) in the pause menu and Mod Options > Visuals. The startup line logs on the runtime channel. --- Engine/Game/Build/20260929/replay_keyframes.h | 5 +- .../ReplayKeyframes/replay_keyframes.cpp | 102 +++++++++++------- Extension/ReplayKeyframes/replay_keyframes.h | 2 +- .../replay_keyframes_commands.cpp | 4 +- .../Test/native_tools_view_tests.cpp | 11 ++ Extension/UI/NativeMenu/native_tools_view.h | 9 ++ Extension/UI/Overlay/hub_page.cpp | 3 + Extension/UI/Overlay/overlay.h | 6 +- Extension/UI/Overlay/skate_menu_skater.cpp | 12 ++- Runtime/client_tick.cpp | 3 +- contrib/keyframe-smoothing.md | 8 +- 11 files changed, 120 insertions(+), 45 deletions(-) diff --git a/Engine/Game/Build/20260929/replay_keyframes.h b/Engine/Game/Build/20260929/replay_keyframes.h index 3d2a1785..3e019063 100644 --- a/Engine/Game/Build/20260929/replay_keyframes.h +++ b/Engine/Game/Build/20260929/replay_keyframes.h @@ -33,8 +33,9 @@ inline constexpr Fingerprint slot_build{0x60ec20, { 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): +// bool segment_eval(camera, double time, const Segment*, Out*): plays a framing segment (every frame; +// not hooked: what it plays is decided when the segment is built), 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, diff --git a/Extension/ReplayKeyframes/replay_keyframes.cpp b/Extension/ReplayKeyframes/replay_keyframes.cpp index 53a465f3..72819d29 100644 --- a/Extension/ReplayKeyframes/replay_keyframes.cpp +++ b/Extension/ReplayKeyframes/replay_keyframes.cpp @@ -25,7 +25,6 @@ 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); @@ -36,7 +35,7 @@ static_assert(native::slot_size % 16 == 0, "slots hold vec4s, one after another std::atomic started{}, framing_hooked{}; std::atomic build_original{}; -std::atomic eval_original{}; +std::atomic rebuild_original{}; SlotBuild slot_build{}; SlotUnwrap slot_unwrap{}; Rebuild rebuild{}; @@ -56,11 +55,23 @@ 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; +// A rebuild builds one segment per key, and every segment needs every key of the session as a +// slot: built once per rebuild (per Behaviour and Subject) and kept here for the rest of its +// segments, instead of once per segment. Only inside a rebuild, on its own thread, while nothing +// can change the keys; a segment built anywhere else builds its own. +struct KeySlots { + std::uintptr_t keys_end{}; + int behaviour{}, subject{}; + std::vector nodes; + std::vector slots; // as slot_build made them, before unwrapping +}; +thread_local int rebuilding{}; +thread_local std::vector rebuild_slots; + 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. @@ -112,29 +123,45 @@ int segment_int(const void* segment, std::size_t offset) noexcept { 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 slots; // this segment's: the session's keys, unwrapped from its own 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); + // Behaviour and Subject (once per rebuild, see KeySlots), then each key's angles unwrapped against + // the key before it (going on from the segment's first key) or after it (going back). + const KeySlots* built{}; + if (rebuilding) + for (const auto& kept : rebuild_slots) + if (kept.keys_end == keys_end && kept.behaviour == behaviour && kept.subject == subject) built = &kept; + if (!built) { + nodes.clear(); + 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; + nodes.push_back(node); + } + KeySlots made{keys_end, behaviour, subject, nodes, std::vector(nodes.size() * native::slot_floats, 0.0f)}; + for (std::size_t i = 0; i < nodes.size(); ++i) + slot_build(manager, reinterpret_cast(nodes[i] + native::framing_key_value), + *reinterpret_cast(nodes[i] + native::framing_key_time), behaviour, subject, + made.slots.data() + i * native::slot_floats); + if (rebuilding) { + rebuild_slots.push_back(std::move(made)); + built = &rebuild_slots.back(); + } else { + thread_local KeySlots alone; + alone = std::move(made); + built = &alone; + } + } + const auto found = std::find(built->nodes.begin(), built->nodes.end(), key); + if (found == built->nodes.end()) return SegmentResult::mismatch; + const auto from = static_cast(found - built->nodes.begin()); + const auto n = built->nodes.size(); + slots = built->slots; 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); @@ -169,10 +196,14 @@ void* segment_build(void* manager, void* segment, void* keys_end, void** key) { 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); +// The game's rebuild of a session, which builds all of its segments: the key slots are kept for +// the length of it (KeySlots). +void session_rebuild(void* manager, int session) { + struct Rebuilding { + Rebuilding() { if (rebuilding++ == 0) rebuild_slots.clear(); } + ~Rebuilding() { if (--rebuilding == 0) rebuild_slots.clear(); } + } scope; + rebuild_original.load(std::memory_order_relaxed)(manager, session); } // Whether the manager still has the session (its map: anchor at manager_sessions, root two pointers @@ -257,14 +288,16 @@ bool start(std::uintptr_t base) noexcept { 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); + // (Called through its entry, so a setting's rebuild goes through the hook as the game's do.) rebuild = reinterpret_cast(base + native::session_rebuild.rva); + // The rebuild first: without it each segment builds its own key slots, as before. + hook(base, native::session_rebuild, &session_rebuild, rebuild_original); 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"); + logging::log(available() ? logging::Level::info : logging::Level::warning, logging::Channel::runtime, + "Replay keyframe smoothing: framing track {}, key slots {}; smoothing {}.", + framing_hooked ? "hooked" : "not hooked (game code differs)", + rebuild_original.load() ? "built once per rebuild" : "built per segment", on() ? "on" : "off"); return available(); } catch (...) { return false; @@ -287,15 +320,12 @@ std::string set_smoothness(float value) { 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; + std::uint64_t built{}; + std::string 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; @@ -305,10 +335,10 @@ std::string status() { 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 {}){}.", + "segments 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), + 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))) diff --git a/Extension/ReplayKeyframes/replay_keyframes.h b/Extension/ReplayKeyframes/replay_keyframes.h index 909b0de1..5f56a4b1 100644 --- a/Extension/ReplayKeyframes/replay_keyframes.h +++ b/Extension/ReplayKeyframes/replay_keyframes.h @@ -28,6 +28,6 @@ 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. +// The settings, and how many framing segments were 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 index 376bf73e..2f921930 100644 --- a/Extension/ReplayKeyframes/replay_keyframes_commands.cpp +++ b/Extension/ReplayKeyframes/replay_keyframes_commands.cpp @@ -43,8 +43,8 @@ void register_replay_keyframes_commands(Commands ®istry) { }; 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); + auto status = action("keysmooth status", "Keyframe smoothing's settings, and how many framing segments were 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)); diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 4fbeef8f..18a94b5e 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -201,6 +201,17 @@ int main() { 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"); + // Its smoothness steps down a quarter at a time and wraps to the smoothest; only while it is on. + model.keyframe_smoothing = {true, true, 1.0f}; + activate(state, model, cb, "keyframe-smoothness", ""); + check(capture.command == "keysmooth amount 0.75", "Keyframe smoothness steps from 100% to 75%"); + model.keyframe_smoothing.amount = 0.1f; + activate(state, model, cb, "keyframe-smoothness", ""); + check(capture.command == "keysmooth amount 1", "and from the lowest back to 100%"); + capture.command.clear(); + model.keyframe_smoothing = {true, false, 1.0f}; + activate(state, model, cb, "keyframe-smoothness", ""); + check(capture.command.empty(), "Keyframe smoothness waits until smoothing is on"); 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 621523c4..ec3722d5 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -230,6 +230,12 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks 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 == "keyframe-smoothness") { + // 100 -> 75 -> 50 -> 25 -> 100 (the ReSkate menu's slider sets any value from 0 to 100). + const int percent = overlay::keyframe_smoothness_percent(m.keyframe_smoothing); + const int next = percent > 75 ? 75 : percent > 50 ? 50 : percent > 25 ? 25 : 100; + console("keysmooth amount " + std::string(next == 100 ? "1" : next == 75 ? "0.75" : next == 50 ? "0.5" : "0.25"), + m.keyframe_smoothing.available && m.keyframe_smoothing.enabled); } else if (command == "board-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -377,6 +383,9 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 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); + button(p.main, "keyframe-smoothness", + "Keyframe smoothness: " + std::to_string(overlay::keyframe_smoothness_percent(m.keyframe_smoothing)) + "%", + "keyframe-smoothness", m.keyframe_smoothing.available && m.keyframe_smoothing.enabled && 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 66394d9a..34af2c8f 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1311,6 +1311,9 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { 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("Keyframe smoothness", std::to_string(keyframe_smoothness_percent(m.keyframe_smoothing)) + "%", + m.keyframe_smoothing.available && m.keyframe_smoothing.enabled && console)) + act("keyframe-smoothness"); 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 a2cfa32d..2cbbb179 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -251,10 +251,14 @@ struct GrindExitModel { int strength = 100; // pop-out strength, percent (10-100) }; -// Keyframe smoothing's switch (Extension/ReplayKeyframes/replay_keyframes.h): started, and switched on. +// Keyframe smoothing (Extension/ReplayKeyframes/replay_keyframes.h): started, switched on, and how +// smooth (0..1) when on. struct KeyframeSmoothingModel { bool available = false, enabled = false; + float amount = 1.0f; }; +// The smoothness as the menus show it, in percent. +inline int keyframe_smoothness_percent(const KeyframeSmoothingModel& k) { return static_cast(k.amount * 100.0f + 0.5f); } struct Model { std::string state = "Waiting for native state"; diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index f6e6cdb7..d9d09cf7 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -70,7 +70,17 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal "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)."); + field(menu, "Smoothness", "100% is the smoothest that still meets every key; 0% the game's own curve."); + // Sent once it is let go: every change rebuilds the open replay's camera. + static int dragged = -1; + int smoothness = dragged >= 0 ? dragged : keyframe_smoothness_percent(model.keyframe_smoothing); + ImGui::BeginDisabled(!model.keyframe_smoothing.available || !model.keyframe_smoothing.enabled || !callbacks.queue_console_command); + if (::dingosdk::overlay::menu::slider_int("##keyframe-smoothness", &smoothness, 0, 100, "%d%%", ImGuiSliderFlags_AlwaysClamp)) + dragged = smoothness; + if (ImGui::IsItemDeactivatedAfterEdit() && dragged >= 0) + send_console(menu, callbacks, "keysmooth amount " + std::to_string(dragged / 100.0)); + if (!ImGui::IsItemActive()) dragged = -1; + ImGui::EndDisabled(); end_card(); } diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 30e55401..241a2f29 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -1041,7 +1041,8 @@ 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.keyframe_smoothing = {dingosdk::replay_keyframes::available(), dingosdk::replay_keyframes::on(), + dingosdk::replay_keyframes::smoothness()}; model.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/contrib/keyframe-smoothing.md b/contrib/keyframe-smoothing.md index 50956a44..808874d8 100644 --- a/contrib/keyframe-smoothing.md +++ b/contrib/keyframe-smoothing.md @@ -20,7 +20,9 @@ settings), [replay_keyframes_commands.cpp](../Extension/ReplayKeyframes/replay_k 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. +- `keysmooth status`: the settings, and counts of the segments built, by camera mode. +- The amount is also in the menus: a slider beside the switch in the overlay, and Keyframe + smoothness (100, 75, 50, 25%) beside the switch in Mod Options > Visuals. - Kept in `%LOCALAPPDATA%\ReSkate\KeySmooth.settings`. ## How it works @@ -46,6 +48,10 @@ settings), [replay_keyframes_commands.cpp](../Extension/ReplayKeyframes/replay_k 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. +- **Once per rebuild.** A rebuild builds one segment per key, and each needs every key's slot: the + game's rebuild is hooked too, and the slots built for its first segment are kept for the rest of + it (per Behaviour and Subject), so a rebuild builds each key's slot once instead of once per + segment. A segment built outside a rebuild builds its own. - **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.