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.