diff --git a/Engine/Core/Platform/memory.h b/Engine/Core/Platform/memory.h index edc53d7..589416b 100644 --- a/Engine/Core/Platform/memory.h +++ b/Engine/Core/Platform/memory.h @@ -40,8 +40,10 @@ inline std::ptrdiff_t peek_cstring(std::uintptr_t address, char* destination, st if (!destination || !capacity || address < 0x10000 || address > highest_user_address - capacity) return -1; __try { const auto* source = reinterpret_cast(address); - for (std::size_t i = 0; i < capacity; ++i) - if (!(destination[i] = source[i])) return static_cast(i); + for (std::size_t i = 0; i < capacity; ++i) { + destination[i] = source[i]; + if (destination[i] == '\0') return static_cast(i); + } return -1; } __except (EXCEPTION_EXECUTE_HANDLER) { return -1; diff --git a/Engine/Game/Build/20260929/offboard_flight.h b/Engine/Game/Build/20260929/offboard_flight.h index c0d6540..5d2c731 100644 --- a/Engine/Game/Build/20260929/offboard_flight.h +++ b/Engine/Game/Build/20260929/offboard_flight.h @@ -35,4 +35,16 @@ inline constexpr std::array offboard_flight_states{{ 0xe3,0x71,0x21,0x4f,0x18,0x20,0xc5,0xe8,0x58,0xc1,0xc5,0xf8,0x11,0x06,0x4c,0x8b}}}, }}; inline constexpr std::uintptr_t offboard_flight_vtable = 0x65e8f40; +// FollowRagdoll / FollowAnimatedRagdoll / FollowSimulatedRagdoll also keep +// world velocity at +10. Their shared update derives it from displacement +// (RVA 48380DE), including Y at +14. Boosts must update this cached velocity +// as well as the skeleton bodies when diving or gliding. +inline constexpr std::array, 3> offboard_ragdoll_states{{ + {0x720, 0x65e8df8, 0x4832c80}, + {0x8e0, 0x65e8e60, 0x4830a70}, + {0xaa0, 0x65e8ed0, 0x48332f0}, +}}; +inline constexpr Fingerprint ragdoll_velocity_write{0x48380de, { + 0xc4,0xe2,0x79,0x18,0x80,0xec,0x17,0x00,0x00,0xc5,0xe8,0x5e,0xc8,0xc5,0xf8,0x11, + 0x4f,0x10,0xc5,0xf8,0x10,0x43,0x30,0xc5,0xf8,0x11,0x87,0x60,0x01,0x00,0x00,0x4c}}; } diff --git a/Engine/Game/Input/controller_bindings.h b/Engine/Game/Input/controller_bindings.h index f833dac..cffcf00 100644 --- a/Engine/Game/Input/controller_bindings.h +++ b/Engine/Game/Input/controller_bindings.h @@ -1,5 +1,7 @@ #pragma once +#include #include +#include #include #include #include @@ -7,10 +9,69 @@ namespace dingosdk { // XInput button bits, plus two synthetic trigger bits (above the dead zone). inline constexpr std::uint32_t controller_button_mask = 0x3f3ff; +// Single keys retain their saved format. Chords pack up to five sorted key IDs +// into the remaining 30 bits of a distinct tag in the same profile field. +inline constexpr std::uint32_t keyboard_binding_tag = 0x80000000u; +inline constexpr std::uint32_t keyboard_chord_tag = 0x40000000u; +inline constexpr std::uint8_t keyboard_key_id(std::uint32_t key) noexcept { + if (key >= '0' && key <= '9') return static_cast(key - '0' + 1); + if (key >= 'A' && key <= 'Z') return static_cast(key - 'A' + 11); + if (key == 0x20) return 37; // Space + if (key >= 0x70 && key <= 0x7b) return static_cast(key - 0x70 + 38); // F1-F12 + if (key == 0x11) return 50; // Ctrl + if (key == 0x10) return 51; // Shift + if (key == 0x12) return 52; // Alt + return 0; +} +inline constexpr std::uint8_t keyboard_key_from_id(std::uint32_t id) noexcept { + if (id >= 1 && id <= 10) return static_cast('0' + id - 1); + if (id >= 11 && id <= 36) return static_cast('A' + id - 11); + if (id == 37) return 0x20; + if (id >= 38 && id <= 49) return static_cast(0x70 + id - 38); + if (id == 50) return 0x11; + if (id == 51) return 0x10; + if (id == 52) return 0x12; + return 0; +} +inline constexpr bool bindable_keyboard_key(std::uint32_t key) noexcept { + return keyboard_key_id(key) != 0; +} +inline constexpr bool keyboard_binding(std::uint32_t value) noexcept { + if ((value & ~0xffu) == keyboard_binding_tag) return bindable_keyboard_key(value & 0xffu); + if ((value & 0xc0000000u) != keyboard_chord_tag) return false; + unsigned count = 0, previous = 0; + bool ended = false; + for (unsigned slot = 0; slot < 5; ++slot) { + const unsigned id = (value >> (slot * 6)) & 0x3fu; + if (!id) { ended = true; continue; } + if (ended || !keyboard_key_from_id(id) || id <= previous) return false; + previous = id; + ++count; + } + return count >= 2; +} +inline std::uint32_t keyboard_binding_from_keys(const std::array& keys) noexcept { + std::uint32_t value = keyboard_chord_tag; + unsigned count = 0; + for (unsigned id = 1; id <= 52; ++id) { + const auto key = keyboard_key_from_id(id); + if (!(keys[key / 64] & (std::uint64_t{1} << (key % 64)))) continue; + if (count == 5) return 0; + value |= id << (count++ * 6); + } + if (count == 1) { + const auto id = value & 0x3fu; + return keyboard_binding_tag | keyboard_key_from_id(id); + } + return count >= 2 ? value : 0; +} inline constexpr bool valid_controller_combo(std::uint32_t value) noexcept { return (value & ~controller_button_mask) == 0; } -// Button names shown to the player. Bindings are always stored as XInput bits. +inline constexpr bool valid_action_binding(std::uint32_t value) noexcept { + return valid_controller_combo(value) || keyboard_binding(value); +} +// Button names shown to the player. Controller chords keep their XInput bits. enum class ControllerStyle : std::uint8_t { xbox, dualshock4, dualsense }; struct ControllerInput { bool available{}; @@ -19,16 +80,46 @@ struct ControllerInput { // (a pad connecting, disconnecting or switching source) resets latches. unsigned device{}; ControllerStyle style{}; + std::array keys{}; }; struct ControllerBindingsModel { bool available{}; + bool freecam_controller{}; + std::uint32_t freecam_controller_combo{}; + std::uint32_t freecam_combo{}; + std::uint32_t tp_to_freecam_combo{}; std::uint32_t noclip_combo{}; std::uint32_t forward_velocity_combo{}; std::uint32_t up_velocity_combo{}; + std::uint32_t offboard_up_velocity_combo{}; std::string status; }; inline std::string controller_combo_label(std::uint32_t value, ControllerStyle style = ControllerStyle::xbox) { if (!value) return "Not bound"; + if (keyboard_binding(value)) { + const auto key_name = [](std::uint32_t key) { + if (key == 0x20) return std::string("Space"); + if (key == 0x11) return std::string("Ctrl"); + if (key == 0x10) return std::string("Shift"); + if (key == 0x12) return std::string("Alt"); + if (key >= 0x70) return "F" + std::to_string(key - 0x6fu); + return std::string(1, static_cast(key)); + }; + if ((value & 0xc0000000u) == keyboard_binding_tag) return key_name(value & 0xffu); + std::string label; + const auto append = [&](unsigned wanted) { + for (unsigned slot = 0; slot < 5; ++slot) { + const auto id = (value >> (slot * 6)) & 0x3fu; + if (!id) break; + if (id != wanted) continue; + if (!label.empty()) label += " + "; + label += key_name(keyboard_key_from_id(id)); + } + }; + for (unsigned id = 50; id <= 52; ++id) append(id); + for (unsigned id = 1; id < 50; ++id) append(id); + return label; + } const bool playstation = style != ControllerStyle::xbox; struct Button { std::uint32_t bit; const char* xbox; const char* playstation; }; constexpr Button buttons[]{ @@ -46,18 +137,34 @@ inline std::string controller_combo_label(std::uint32_t value, ControllerStyle s return label; } // A held combo never repeats. Returning from menus, reconnecting, changing a -// binding, and changing level all require releasing every bound button first. +// binding, and changing level all require releasing the bound inputs first. struct ControllerComboLatch { std::uint32_t combo{}; unsigned device{}; bool armed{}; bool update(std::uint32_t binding, ControllerInput input, bool inhibited) noexcept { - if (binding != combo || input.device != device) { - combo = binding; device = input.device; armed = false; + const bool keyboard = keyboard_binding(binding); + const unsigned source = keyboard ? 0 : input.device; + if (binding != combo || source != device) { + combo = binding; device = source; armed = false; } - if (inhibited || !input.available || !combo || !valid_controller_combo(combo)) { + if (inhibited || (!keyboard && !input.available) || !combo || !valid_action_binding(combo)) { armed = false; return false; } + if (keyboard) { + bool any = false, all = true; + for (unsigned slot = 0; slot < 5; ++slot) { + const auto key = (combo & 0xc0000000u) == keyboard_binding_tag ? + (slot ? 0u : combo & 0xffu) : keyboard_key_from_id((combo >> (slot * 6)) & 0x3fu); + if (!key) break; + const bool held = (input.keys[key / 64] & (std::uint64_t{1} << (key % 64))) != 0; + any |= held; all &= held; + } + if (!any) armed = true; + if (!armed || !all) return false; + armed = false; + return true; + } const auto held = input.buttons & combo; if (!held) armed = true; if (!armed || held != combo) return false; @@ -71,13 +178,30 @@ struct ControllerComboCapture { std::uint32_t chord{}; unsigned device{}; bool ready{}; - std::optional update(ControllerInput input) noexcept { - if (!input.available || input.device != device) { - chord = 0; ready = false; device = input.device; + std::array keyboard_chord{}; + std::optional update(ControllerInput input, bool allow_keyboard = false) noexcept { + if (input.device != device || (!input.available && !allow_keyboard)) { + chord = 0; ready = false; keyboard_chord = {}; device = input.device; + } + if (!input.available && !allow_keyboard) return {}; + const auto buttons = input.buttons & controller_button_mask; + const bool keys_held = std::any_of(input.keys.begin(), input.keys.end(), [](auto word) { return word != 0; }); + if (!ready) { ready = buttons == 0 && (!allow_keyboard || !keys_held); return {}; } + if (allow_keyboard && keys_held && !chord) { + if (std::popcount(input.keys[0]) + std::popcount(input.keys[1]) + + std::popcount(input.keys[2]) + std::popcount(input.keys[3]) > + std::popcount(keyboard_chord[0]) + std::popcount(keyboard_chord[1]) + + std::popcount(keyboard_chord[2]) + std::popcount(keyboard_chord[3])) keyboard_chord = input.keys; + } + if (std::any_of(keyboard_chord.begin(), keyboard_chord.end(), [](auto word) { return word != 0; })) { + if (!keys_held) { + const auto result = keyboard_binding_from_keys(keyboard_chord); + keyboard_chord = {}; ready = false; + if (result) return result; + } + return {}; } if (!input.available) return {}; - const auto buttons = input.buttons & controller_button_mask; - if (!ready) { ready = buttons == 0; return {}; } if (std::popcount(buttons) > std::popcount(chord)) chord = buttons; if (!buttons && chord) { const auto result = chord; chord = 0; ready = false; diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index dc6b4cf..2824a87 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -981,8 +981,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, for (const auto& [index, guids] : left) { // Name the mod whose asset took the item's name, when that is why. const Asset* taken{}; - for (const auto& guid : guids) - if ((taken = held.replacement(guid))) break; + for (const auto& guid : guids) { + taken = held.replacement(guid); + if (taken) break; + } report.notes.push_back(mod_of(index) + ": " + entry.asset.name + ": " + std::to_string(guids.size()) + (itemList ? " item(s)" : " entry(ies)") + " left out of the list because the merged " diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 23d4e29..aec449a 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -112,9 +112,14 @@ ParkChoices park_choices(const Snapshot&); WorldLayerChoices world_layer_choices(const Snapshot&); WorldControls world_controls(const Snapshot&); GraphicsControls graphics_controls(const Snapshot&); +std::uint32_t freecam_controller_binding(const Snapshot&); +bool freecam_controller(const Snapshot&); +std::uint32_t freecam_binding(const Snapshot&); +std::uint32_t tp_to_freecam_binding(const Snapshot&); std::uint32_t noclip_binding(const Snapshot&); std::uint32_t forward_velocity_binding(const Snapshot&); std::uint32_t up_velocity_binding(const Snapshot&); +std::uint32_t offboard_up_velocity_binding(const Snapshot&); std::vector challenge_completed_criteria(const Snapshot&, std::string_view); std::string encode(const Snapshot&); Snapshot decode(std::string_view); @@ -177,12 +182,22 @@ public: void save_park_choice(unsigned lot, std::string_view id); void save_world_controls(const WorldControls&); void save_graphics_controls(const GraphicsControls&); + bool freecam_controller() const; + void save_freecam_controller(bool); + std::uint32_t freecam_controller_binding() const; + void save_freecam_controller_binding(std::uint32_t); + std::uint32_t freecam_binding() const; + void save_freecam_binding(std::uint32_t); + std::uint32_t tp_to_freecam_binding() const; + void save_tp_to_freecam_binding(std::uint32_t); std::uint32_t noclip_binding() const; void save_noclip_binding(std::uint32_t); std::uint32_t forward_velocity_binding() const; void save_forward_velocity_binding(std::uint32_t); std::uint32_t up_velocity_binding() const; void save_up_velocity_binding(std::uint32_t); + std::uint32_t offboard_up_velocity_binding() const; + void save_offboard_up_velocity_binding(std::uint32_t); void save_world_layer_choice(unsigned layer, std::string_view mode); void restore_world_layers(WorldMap map); std::uint64_t begin_challenge(std::string_view id); diff --git a/Extension/Profile/local_profile_preferences.cpp b/Extension/Profile/local_profile_preferences.cpp index 7ef08ab..a970120 100644 --- a/Extension/Profile/local_profile_preferences.cpp +++ b/Extension/Profile/local_profile_preferences.cpp @@ -9,15 +9,20 @@ ControllerBindingsModel local_profile_controller_bindings() { ControllerBindingsModel result; result.status = binding_feedback; if (!s.active || !s.store) return result; + result.freecam_controller = s.store->freecam_controller(); + result.freecam_controller_combo = s.store->freecam_controller_binding(); + result.freecam_combo = s.store->freecam_binding(); + result.tp_to_freecam_combo = s.store->tp_to_freecam_binding(); result.noclip_combo = s.store->noclip_binding(); result.forward_velocity_combo = s.store->forward_velocity_binding(); result.up_velocity_combo = s.store->up_velocity_binding(); + result.offboard_up_velocity_combo = s.store->offboard_up_velocity_binding(); result.available = true; return result; } bool set_local_noclip_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_noclip_binding(combo); binding_feedback = combo ? "Noclip binding saved." : "Noclip binding cleared."; @@ -26,9 +31,55 @@ bool set_local_noclip_binding(std::uint32_t combo) { } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } } +bool local_freecam_controller() { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + return s.active && s.store && s.store->freecam_controller(); +} +bool set_local_freecam_controller(bool value) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store) return false; + try { + s.store->save_freecam_controller(value); + return true; + } catch (...) { return false; } +} + +bool set_local_freecam_controller_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_freecam_controller_binding(combo); + binding_feedback = combo ? "Freecam Controller binding saved." : "Freecam Controller binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","freecam_controller"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + +bool set_local_freecam_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_freecam_binding(combo); + binding_feedback = combo ? "Freecam binding saved." : "Freecam binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","freecam"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + +bool set_local_tp_to_freecam_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_tp_to_freecam_binding(combo); + binding_feedback = combo ? "TP to Freecam binding saved." : "TP to Freecam binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","tp_to_freecam"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + bool set_local_forward_velocity_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_forward_velocity_binding(combo); binding_feedback = combo ? "Forward Boost binding saved." : "Forward Boost binding cleared."; @@ -37,9 +88,20 @@ bool set_local_forward_velocity_binding(std::uint32_t combo) { } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } } +bool set_local_offboard_up_velocity_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_offboard_up_velocity_binding(combo); + binding_feedback = combo ? "Off-board Up Boost binding saved." : "Off-board Up Boost binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","offboard_up_velocity"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + bool set_local_up_velocity_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_up_velocity_binding(combo); binding_feedback = combo ? "Up Boost binding saved." : "Up Boost binding cleared."; diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 1758638..caecaae 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -60,9 +60,15 @@ bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); void tick_local_park_editor() noexcept; ControllerBindingsModel local_profile_controller_bindings(); +bool set_local_freecam_controller(bool); +bool local_freecam_controller(); +bool set_local_freecam_controller_binding(std::uint32_t); +bool set_local_freecam_binding(std::uint32_t); +bool set_local_tp_to_freecam_binding(std::uint32_t); bool set_local_noclip_binding(std::uint32_t); bool set_local_forward_velocity_binding(std::uint32_t); bool set_local_up_velocity_binding(std::uint32_t); +bool set_local_offboard_up_velocity_binding(std::uint32_t); bool set_local_object_persistence(bool enabled); bool clear_local_persisted_objects(std::string_view map); bool delete_local_placed_object(std::string_view map, std::uint64_t token); diff --git a/Extension/Profile/local_profile_store.cpp b/Extension/Profile/local_profile_store.cpp index a040226..2fc5d77 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -146,12 +146,58 @@ void Store::save_district_rank(std::string_view id, std::uint32_t rank) { } +bool Store::freecam_controller() const { + std::lock_guard lock(mutex_); + return profile::freecam_controller(value_); +} +void Store::save_freecam_controller(bool value) { + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"options", "freecam_controller"}) = value; + update.commit(); +} +std::uint32_t Store::freecam_controller_binding() const { + std::lock_guard lock(mutex_); + return profile::freecam_controller_binding(value_); +} +void Store::save_freecam_controller_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "freecam_controller"}) = combo; + update.commit(); +} + +std::uint32_t Store::freecam_binding() const { + std::lock_guard lock(mutex_); + return profile::freecam_binding(value_); +} +void Store::save_freecam_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "freecam"}) = combo; + update.commit(); +} + +std::uint32_t Store::tp_to_freecam_binding() const { + std::lock_guard lock(mutex_); + return profile::tp_to_freecam_binding(value_); +} +void Store::save_tp_to_freecam_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "tp_to_freecam"}) = combo; + update.commit(); +} + std::uint32_t Store::noclip_binding() const { std::lock_guard lock(mutex_); return profile::noclip_binding(value_); } void Store::save_noclip_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "noclip"}) = combo; @@ -164,19 +210,31 @@ std::uint32_t Store::forward_velocity_binding() const { return profile::forward_velocity_binding(value_); } void Store::save_forward_velocity_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "forward_velocity"}) = combo; update.commit(); } +std::uint32_t Store::offboard_up_velocity_binding() const { + std::lock_guard lock(mutex_); + return profile::offboard_up_velocity_binding(value_); +} +void Store::save_offboard_up_velocity_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "offboard_up_velocity"}) = combo; + update.commit(); +} + std::uint32_t Store::up_velocity_binding() const { std::lock_guard lock(mutex_); return profile::up_velocity_binding(value_); } void Store::save_up_velocity_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "up_velocity"}) = combo; diff --git a/Extension/Profile/profile_update.cpp b/Extension/Profile/profile_update.cpp index e731a97..8af0be0 100644 --- a/Extension/Profile/profile_update.cpp +++ b/Extension/Profile/profile_update.cpp @@ -67,9 +67,10 @@ void Store::Update::encode(Json& patch) { if (settings) { detail::validate_settings(value); (void)park_choices(value); (void)world_layer_choices(value); - (void)world_controls(value); (void)graphics_controls(value); (void)profile::noclip_binding(value); + (void)world_controls(value); (void)graphics_controls(value); (void)profile::freecam_controller(value); (void)profile::freecam_controller_binding(value); (void)profile::freecam_binding(value); (void)profile::tp_to_freecam_binding(value); (void)profile::noclip_binding(value); (void)profile::forward_velocity_binding(value); (void)profile::up_velocity_binding(value); + (void)profile::offboard_up_velocity_binding(value); remove("player_settings", [](const Json& row) { return row[0] != "options"; }); auto residual = value.settings; const auto flatten = [&](auto&& self, Json& node, const std::string& scope) -> void { diff --git a/Extension/Settings/input_bindings.cpp b/Extension/Settings/input_bindings.cpp index a5abc6d..55d7f35 100644 --- a/Extension/Settings/input_bindings.cpp +++ b/Extension/Settings/input_bindings.cpp @@ -5,6 +5,54 @@ namespace dingosdk::profile { using namespace detail; +std::uint32_t freecam_controller_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("freecam_controller"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Freecam Controller binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Freecam Controller binding"); + return static_cast(mask); +} +bool freecam_controller(const Snapshot& s) { + const auto options = s.settings.find("options"); + if (options == s.settings.end()) return false; + require(options->is_object(), "Options must be an object"); + const auto value = options->find("freecam_controller"); + if (value == options->end()) return false; + require(value->is_boolean(), "freecam_controller must be a boolean"); + return value->get(); +} +std::uint32_t freecam_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("freecam"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Freecam binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Freecam binding"); + return static_cast(mask); +} +std::uint32_t tp_to_freecam_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("tp_to_freecam"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "TP to Freecam binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in TP to Freecam binding"); + return static_cast(mask); +} std::uint32_t noclip_binding(const Snapshot& s) { const auto bindings = s.settings.find("bindings"); if (bindings == s.settings.end()) return 0; @@ -14,8 +62,8 @@ std::uint32_t noclip_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Noclip binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Noclip binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Noclip binding"); return static_cast(mask); } @@ -28,11 +76,24 @@ std::uint32_t forward_velocity_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Forward velocity binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Forward Boost binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Forward Boost binding"); return static_cast(mask); } +std::uint32_t offboard_up_velocity_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("offboard_up_velocity"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Off-board Up Boost binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Off-board Up Boost binding"); + return static_cast(mask); +} std::uint32_t up_velocity_binding(const Snapshot& s) { const auto bindings = s.settings.find("bindings"); if (bindings == s.settings.end()) return 0; @@ -42,8 +103,8 @@ std::uint32_t up_velocity_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Up velocity binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Up Boost binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Up Boost binding"); return static_cast(mask); } diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index 251b984..ed828c3 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -1,6 +1,7 @@ #include "client_source_spawn.h" #include "client_source_spawn_internal.h" #include "no_bail.h" +#include "offboard_flight.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/launcher_support.h" #include "Engine/Game/Build/20260929/engine.h" @@ -242,6 +243,7 @@ constexpr const char* no_bail = "NoBail"; constexpr const char* flight_speed = "FlightSpeed"; constexpr const char* forward_velocity = "ForwardVelocity"; constexpr const char* up_velocity = "UpVelocity"; +constexpr const char* offboard_up_velocity = "OffboardUpVelocity"; constexpr const char* first_person_fov = "FirstPerson.Fov"; constexpr const char* free_camera_fov = "FreeCamera.Fov"; constexpr const char* spring = "FirstPerson.Spring"; @@ -272,6 +274,7 @@ void save_debug(InteractiveDebug& debug) noexcept { {saved::flight_speed, static_cast(debug.flight_speed)}, {saved::forward_velocity, static_cast(debug.forward_velocity_speed)}, {saved::up_velocity, static_cast(debug.up_velocity_speed)}, + {saved::offboard_up_velocity, static_cast(debug.offboard_up_velocity_speed)}, {saved::first_person_fov, static_cast(debug.first_person_fov)}, {saved::free_camera_fov, static_cast(debug.free_camera_fov)}, {saved::spring, arm.enabled}}; @@ -300,6 +303,8 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { debug.forward_velocity_speed = *speed; if (const auto speed = saved::number(saved::up_velocity); speed && *speed >= 1.0f && *speed <= 25.0f) debug.up_velocity_speed = *speed; + if (const auto speed = saved::number(saved::offboard_up_velocity); speed && *speed >= 1.0f && *speed <= 25.0f) + debug.offboard_up_velocity_speed = *speed; if (const auto fov = saved::number(saved::first_person_fov); fov && *fov >= first_person_fov_min && *fov <= first_person_fov_max) { debug.first_person_fov = *fov; @@ -372,6 +377,7 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b source_require(!debug.park_editor || debug.editor_transition || request.action == overlay::DebugAction::restore_debug || request.action == overlay::DebugAction::set_camera_speed || request.action == overlay::DebugAction::set_forward_velocity_speed || request.action == overlay::DebugAction::set_up_velocity_speed || + request.action == overlay::DebugAction::set_offboard_up_velocity_speed || request.action == overlay::DebugAction::set_first_person_fov || request.action == overlay::DebugAction::set_free_camera_fov || (request.action >= overlay::DebugAction::set_first_person_spring && request.action <= overlay::DebugAction::reset_first_person_arm) || @@ -424,13 +430,21 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b return; } if (request.action == overlay::DebugAction::set_forward_velocity_speed || - request.action == overlay::DebugAction::set_up_velocity_speed) { - const bool up = request.action == overlay::DebugAction::set_up_velocity_speed; - source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= (up ? 25.0f : 300.0f), - up ? "Up Boost speed must be between 1 and 25." : "Forward Boost speed must be between 1 and 300."); - (up ? debug.up_velocity_speed : debug.forward_velocity_speed) = request.value; + request.action == overlay::DebugAction::set_up_velocity_speed || + request.action == overlay::DebugAction::set_offboard_up_velocity_speed) { + if (request.action == overlay::DebugAction::set_offboard_up_velocity_speed) { + source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= 25.0f, + "Off-board Up Boost speed must be between 1 and 25."); + debug.offboard_up_velocity_speed = request.value; + debug.status = "Off-board Up Boost speed updated."; + } else { + const bool up = request.action == overlay::DebugAction::set_up_velocity_speed; + source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= (up ? 25.0f : 300.0f), + up ? "Up Boost speed must be between 1 and 25." : "Forward Boost speed must be between 1 and 300."); + (up ? debug.up_velocity_speed : debug.forward_velocity_speed) = request.value; + debug.status = up ? "Up Boost speed updated." : "Forward Boost speed updated."; + } mark_debug_changed(debug); - debug.status = up ? "Up Boost speed updated." : "Forward Boost speed updated."; return; } if (request.action == overlay::DebugAction::restore_debug) { @@ -443,7 +457,9 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b debug.forward_velocity_speed = 20.0f; debug.forward_velocity.valid = false; debug.up_velocity_speed = 20.0f; + debug.offboard_up_velocity_speed = 20.0f; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; debug.no_bail = false; debug_stop_noclip(debug); clear_no_bail(); @@ -475,29 +491,63 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b return; } source_require(can_control, "Wait for an active local level before changing debug settings."); - if (request.action == overlay::DebugAction::add_forward_velocity || request.action == overlay::DebugAction::add_up_velocity) { + if (request.action == overlay::DebugAction::add_forward_velocity || request.action == overlay::DebugAction::add_up_velocity || request.action == overlay::DebugAction::add_offboard_up_velocity) { source_require(session_boosts_allowed(), "The host has turned off boosts in this session."); - const bool up = request.action == overlay::DebugAction::add_up_velocity; + const bool offboard_boost = request.action == overlay::DebugAction::add_offboard_up_velocity; + const bool up = request.action == overlay::DebugAction::add_up_velocity || offboard_boost; source_require(!debug.noclip, "Disable Noclip before using velocity boosts."); source_require(source_state().velocity_guard_active.load(std::memory_order_acquire), "Velocity boosts are unavailable for this game build."); overlay::DebugModel skater; const auto transform = debug_skater(trial.base, client, skater); const auto bodies = debug_noclip_bodies(trial.base, client, skater.skater_identity); - source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + if (offboard_boost) { + source_require(bodies.offboard || bodies.wipeout, "Off-board Up Boost requires walking, falling or gliding."); + } else { + source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + } SourceReader reader; source_require(reader.value(skater.skater_identity, 0x7e0) == 0, "Wait for the current teleport before using velocity boosts."); const auto board_velocity = up ? std::array{} : reader.value>(bodies.parts[0], 0x70); reader.verify(); - const auto velocity = velocity_boost_delta(transform, up ? debug.up_velocity_speed : debug.forward_velocity_speed, + const float speed = offboard_boost ? debug.offboard_up_velocity_speed : (up ? debug.up_velocity_speed : debug.forward_velocity_speed); + const auto velocity = velocity_boost_delta(transform, speed, up ? VelocityBoostDirection::up : VelocityBoostDirection::forward, board_velocity); source_require(velocity.has_value(), "Velocity boost direction or speed is invalid."); - auto& boost = up ? debug.up_velocity : debug.forward_velocity; - source_require(!boost.valid, "Velocity boost is already queued."); + auto& boost = offboard_boost ? debug.offboard_up_velocity : (up ? debug.up_velocity : debug.forward_velocity); + source_require(offboard_boost || !boost.valid, "Velocity boost is already queued."); boost = {client, skater.skater_identity, bodies.core, GetTickCount64() + 1000, *velocity, true}; - debug.status = up ? "Up Boost queued." : "Forward Boost queued."; + if (offboard_boost) source_state().offboard_boost_core.store(bodies.core, std::memory_order_release); + if (offboard_boost) { + // Give the player the impulse on this client tick. The motion hook + // keeps its upward speed briefly if native off-board drive rewrites it. + const float current_up = reader.value(bodies.parts[9], 0x74); + const float target_up = std::max(speed, current_up + speed); + source_require(std::isfinite(target_up) && target_up <= 100000, + "Off-board boost produced an invalid upward speed."); + std::array, 32> velocities{}; + std::array flags{}; + for (std::size_t i = 9; i < bodies.parts.size(); ++i) { + velocities[i] = reader.value>(bodies.parts[i], 0x70); + flags[i] = reader.value(bodies.parts[i], 0x60); + velocities[i][1] = std::max(velocities[i][1], target_up); + } + reader.verify(); + if (bodies.offboard) + source_require(ensure_offboard_up_velocity(trial.base, bodies.core, bodies.context, + bodies.rig_wrapper, target_up), "Off-board boost motion ownership changed."); + for (std::size_t i = 9; i < bodies.parts.size(); ++i) { + debug_write(bodies.parts[i] + 0x70, velocities[i]); + debug_write(bodies.parts[i] + 0x60, flags[i] | 8u); + } + boost.velocity[1] = target_up; + boost.applied = true; + boost.expires = GetTickCount64() + 180; + ++debug.offboard_up_velocity_updates; + debug.status = "Off-board up velocity added."; + } else debug.status = up ? "Up Boost queued." : "Forward Boost queued."; return; } if (request.action == overlay::DebugAction::set_no_bail) { @@ -532,6 +582,7 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b source_require(session_noclip_allowed(), "The host has turned off noclip in this session."); debug.forward_velocity.valid = false; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; source_require(no_bail_available(), "Noclip requires the native No Bail hooks."); source_require(source_state().velocity_guard_active.load(std::memory_order_acquire), "Velocity flight is unavailable for this game build."); @@ -671,6 +722,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli SourceLastError error; overlay::DebugModel model; auto& state = source_state(); + model.free_camera = state.free_camera_active.load(std::memory_order_acquire); if (!state.initialized.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire)) return model; SourceBusyScope scope{state.busy}; auto& trial = state.trial; @@ -689,6 +741,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli if (!can_control) { debug.forward_velocity.valid = false; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; } if (debug.park_editor && !lobby_object_placement_allowed() && can_control && camera_phase_observed) { try { @@ -710,6 +763,10 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli catch (const SourceGuard& guard) { debug.status = guard.message; } catch (...) { debug.status = "Debug action failed; inspect the current state before retrying."; } } + // Camera ownership survives a busy tick or a failed presentation snapshot. + // Input capture must follow the lease, not those transient model failures. + state.free_camera_active.store(debug.camera_owned && !debug.first_person, std::memory_order_release); + model.free_camera = debug.camera_owned && !debug.first_person; std::optional flight_camera; try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); } catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; } @@ -769,6 +826,11 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli debug.noclip ? "Disable Noclip before using Up Boost." : !state.velocity_guard_active.load(std::memory_order_acquire) ? "Up Boost is unavailable for this game build." : "Local skater physics could not be read."; + model.offboard_up_velocity_unavailable = !can_control ? "Waiting for active local controls." : + debug.park_editor ? "Close Park Editor before using Off-board Up Boost." : + debug.noclip ? "Disable Noclip before using Off-board Up Boost." : + !state.velocity_guard_active.load(std::memory_order_acquire) ? "Off-board Up Boost is unavailable for this game build." : + "Local skater physics could not be read."; try { (void)debug_skater(base, client, model); const auto bodies = debug_noclip_bodies(base, client, model.skater_identity); @@ -800,6 +862,22 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli } else if (debug.up_velocity.valid) { model.up_velocity_unavailable = "Up Boost is being applied."; } + if (debug.offboard_up_velocity.valid && (GetTickCount64() >= debug.offboard_up_velocity.expires || + debug.offboard_up_velocity.entity != model.skater_identity || debug.offboard_up_velocity.core != bodies.core || + (!debug.offboard_up_velocity.applied && !bodies.offboard && !bodies.wipeout))) { + const bool applied = debug.offboard_up_velocity.applied; + debug.offboard_up_velocity.valid = false; + if (!applied) debug.status = "Off-board Up Boost cancelled because skater physics changed."; + } + if (can_control && !debug.park_editor && !debug.noclip && + state.velocity_guard_active.load(std::memory_order_acquire) && (bodies.offboard || bodies.wipeout)) { + model.offboard_up_velocity_available = true; + model.offboard_up_velocity_unavailable.clear(); + } else if (!bodies.offboard && !bodies.wipeout) { + model.offboard_up_velocity_unavailable = "Off-board Up Boost requires walking, falling or gliding."; + } else if (debug.offboard_up_velocity.valid) { + model.offboard_up_velocity_unavailable = "Off-board Up Boost is being applied."; + } model.no_bail_available = can_control && update_no_bail(client, model.skater_identity, debug.no_bail && no_bail_allowed, debug.noclip && debug.noclip_velocity.valid, debug.noclip_velocity.expires); if (model.camera_available) { @@ -816,14 +894,16 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli if (model.camera_available) model.noclip_unavailable = guard.message; model.forward_velocity_unavailable = guard.message; model.up_velocity_unavailable = guard.message; + model.offboard_up_velocity_unavailable = guard.message; } catch (...) { if (model.camera_available) model.noclip_unavailable = "Local skater physics could not be read."; model.forward_velocity_unavailable = "Local skater physics could not be read."; model.up_velocity_unavailable = "Local skater physics could not be read."; + model.offboard_up_velocity_unavailable = model.up_velocity_unavailable; } if (!session_boosts_allowed()) { - model.forward_velocity_available = model.up_velocity_available = false; - model.forward_velocity_unavailable = model.up_velocity_unavailable = "The host has turned off boosts in this session."; + model.forward_velocity_available = model.up_velocity_available = model.offboard_up_velocity_available = false; + model.forward_velocity_unavailable = model.up_velocity_unavailable = model.offboard_up_velocity_unavailable = "The host has turned off boosts in this session."; } if (!model.no_bail_available) { clear_no_bail(); @@ -850,8 +930,15 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli model.forward_velocity_updates = debug.forward_velocity_updates; model.up_velocity_speed = debug.up_velocity_speed; model.up_velocity_updates = debug.up_velocity_updates; + model.offboard_up_velocity_speed = debug.offboard_up_velocity_speed; + model.offboard_up_velocity_updates = debug.offboard_up_velocity_updates; + state.offboard_boost_core.store(debug.offboard_up_velocity.valid ? debug.offboard_up_velocity.core : 0, + std::memory_order_release); return model; } +bool client_free_camera_active() noexcept { + return source_state().free_camera_active.load(std::memory_order_acquire); +} bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed) { const overlay::DebugRequest request{overlay::DebugAction::restore_debug}; const auto model = on_client_debug_tick(base, client, false, camera_phase_observed, &request); diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index ff42da6..2ceaaa5 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -3,6 +3,7 @@ #include "no_bail.h" #include "offboard_flight.h" #include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/no_bail.h" #include "Engine/Game/Build/20260929/offboard_flight.h" @@ -77,6 +78,11 @@ NoclipBodies debug_noclip_bodies(std::uintptr_t base, std::uintptr_t client, std result.seconds = reader.value(result.context, 0x17ec); source_require(std::isfinite(result.seconds) && result.seconds >= 0 && result.seconds <= .1f, "Invalid physics timestep."); result.offboard = reader.pointer(reader.pointer(result.core, 0x3b0)) == base + addr::offboard_flight::offboard_flight_vtable; + // Read the committed native state, not the selector's proposed next state + // (which is remapped by its caller and may be stale between selections). + const auto physics = reader.value(result.context, 0x1414); + result.wipeout = physics == addr::no_bail::wipeout_physics_state || + physics == addr::no_bail::offboard_physics_state; source_require(reader.pointer(board) == base + spawn::board_physics_vtable && reader.pointer(rig) == base + spawn::rig_physics_vtable, "Unsupported board or skeleton physics."); const auto board_parts = reader.pointer(board, 0x20), rig_parts = reader.pointer(rig, 0x20); @@ -104,41 +110,76 @@ void noclip_apply_velocity(std::uintptr_t core) noexcept { state.busy.test_and_set(std::memory_order_acquire)) return; SourceBusyScope scope{state.busy}; auto& debug = state.trial.debug; - const auto apply_boost = [&](InteractiveDebug::VelocityRequest& boost, std::uint64_t& updates, bool up) { + const auto apply_boost = [&](InteractiveDebug::VelocityRequest& boost, std::uint64_t& updates, bool up, bool offboard = false) { if (!boost.valid || boost.core != core) return; try { + if (offboard && boost.applied && GetTickCount64() >= boost.expires) { + boost.valid = false; + state.offboard_boost_core.store(0, std::memory_order_release); + return; + } source_require(GetTickCount64() < boost.expires, "Velocity boost timed out."); const auto bodies = debug_noclip_bodies(state.trial.base, boost.client, boost.entity); source_require(bodies.core == core, "Skater physics was replaced before the velocity boost could apply."); - source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + source_require(offboard ? (boost.applied || bodies.offboard || bodies.wipeout) : !bodies.offboard, + offboard ? "Off-board Up Boost requires walking, falling or gliding." : + "Velocity boosts require the skater to be on the board."); source_require(!debug.noclip && !debug.park_editor, "Velocity boost cancelled while editing or flying."); SourceReader reader; source_require(reader.value(boost.entity, 0x7e0) == 0, "Wait for the current teleport before using velocity boosts."); std::array, 32> velocities{}; std::array flags{}; - for (std::size_t i = 0; i < bodies.parts.size(); ++i) { + const std::size_t first = offboard ? 9 : 0; // Leave the carried/detached board alone. + if (offboard && !boost.applied) { + const auto current_up = reader.value(bodies.parts[9], 0x74); + boost.velocity[1] = std::max(boost.velocity[1], current_up + boost.velocity[1]); + } + for (std::size_t i = first; i < bodies.parts.size(); ++i) { velocities[i] = reader.value>(bodies.parts[i], 0x70); flags[i] = reader.value(bodies.parts[i], 0x60); for (std::size_t axis = 0; axis < 3; ++axis) { - velocities[i][axis] += boost.velocity[axis]; + if (offboard && axis == 1) velocities[i][axis] = std::max(velocities[i][axis], boost.velocity[1]); + else velocities[i][axis] += boost.velocity[axis]; source_require(std::isfinite(velocities[i][axis]) && std::abs(velocities[i][axis]) <= 100000, "Velocity boost produced an invalid physics velocity."); } } reader.verify(); - boost.valid = false; - for (std::size_t i = 0; i < bodies.parts.size(); ++i) { + if (offboard && bodies.offboard) + source_require(ensure_offboard_up_velocity(state.trial.base, core, bodies.context, + bodies.rig_wrapper, boost.velocity[1]), "Off-board boost motion ownership changed."); + for (std::size_t i = first; i < bodies.parts.size(); ++i) { body_write(bodies.parts[i] + 0x70, velocities[i]); body_write(bodies.parts[i] + 0x60, flags[i] | 8u); } - ++updates; - debug.status = up ? "Up velocity added." : "Forward velocity added."; - } catch (const SourceGuard& issue) { boost.valid = false; debug.status = issue.message; } - catch (...) { boost.valid = false; debug.status = "Velocity boost failed during the physics update."; } + if (offboard) { + if (!boost.applied) { + ++updates; + boost.applied = true; + boost.expires = GetTickCount64() + 180; + debug.status = "Off-board up velocity added."; + } + } else { + boost.valid = false; + ++updates; + debug.status = up ? "Up velocity added." : "Forward velocity added."; + } + } catch (const SourceGuard& issue) { + boost.valid = false; + if (offboard) state.offboard_boost_core.store(0, std::memory_order_release); + debug.status = issue.message; + if (offboard) logging::write(logging::Level::warning, logging::Channel::skater, issue.message); + } catch (...) { + boost.valid = false; + if (offboard) state.offboard_boost_core.store(0, std::memory_order_release); + debug.status = "Velocity boost failed during the physics update."; + if (offboard) logging::write(logging::Level::warning, logging::Channel::skater, debug.status); + } }; apply_boost(debug.forward_velocity, debug.forward_velocity_updates, false); apply_boost(debug.up_velocity, debug.up_velocity_updates, true); + apply_boost(debug.offboard_up_velocity, debug.offboard_up_velocity_updates, true, true); const auto& request = debug.noclip_velocity; if (!debug.noclip || !request.valid || request.core != core) return; try { @@ -318,6 +359,36 @@ bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, state.busy.test_and_set(std::memory_order_acquire)) return false; SourceBusyScope scope{state.busy}; auto& debug = state.trial.debug; + auto& boost = debug.offboard_up_velocity; + if (!debug.noclip && boost.valid && boost.applied) { + try { + SourceReader reader; + if (reader.pointer(boost.core, 0x438) != rig || reader.pointer(boost.core, 0x3c0) != context) + return false; + if (GetTickCount64() >= boost.expires) return false; + const auto bodies = debug_noclip_bodies(state.trial.base, boost.client, boost.entity); + source_require(bodies.core == boost.core && bodies.rig_wrapper == rig && bodies.context == context, + "Off-board boost skater changed."); + source_require(!debug.park_editor && reader.value(boost.entity, 0x7e0) == 0, + "Off-board boost interrupted by editing or teleporting."); + source_require(reader.raw(reinterpret_cast(supplied), target.data(), sizeof(target)) && + valid_flight_transform(target), "Invalid off-board motion target."); + // Native off-board movement drives the skeleton toward this target. + // Body velocity alone is undone by that drive on the same step. + // Lead only Y for the launch; keep native X/Z, rotation and collision flags. + target[13] = std::max(target[13], bodies.root[1] + boost.velocity[1] * bodies.seconds); + source_require(valid_flight_transform(target), "Off-board boost target is out of bounds."); + reader.verify(); + if (bodies.offboard) + source_require(ensure_offboard_up_velocity(state.trial.base, bodies.core, context, rig, boost.velocity[1]), + "Off-board boost motion ownership changed."); + return bodies.seconds > 0; + } catch (const SourceGuard& issue) { debug.status = issue.message; } + catch (...) { debug.status = "Off-board boost motion failed."; } + boost.valid = false; + state.offboard_boost_core.store(0, std::memory_order_release); + return false; + } const auto& request = debug.noclip_velocity; if (!debug.noclip || !request.valid) return false; try { @@ -364,6 +435,12 @@ void noclip_skater_motion(std::uintptr_t rig, std::uintptr_t context, alignas(16) std::array target{}; const auto* motion = noclip_motion_target(rig, context, supplied, target) ? &target : supplied; if (original) original(rig, context, motion, flags); + if (source_state().offboard_boost_core.load(std::memory_order_acquire)) { + std::uintptr_t core{}; + if (memory::peek(rig + 0x4630, core) && + core == source_state().offboard_boost_core.load(std::memory_order_acquire)) + noclip_apply_velocity(core); + } } } } diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 4941f74..186323b 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -30,6 +30,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, const overlay::FlightInput* flight_input = nullptr); bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed); +bool client_free_camera_active() noexcept; // Native camera lease for the party's Spectate action. Null restores only the // camera acquired by this function; it never changes another debug camera. A positive // `fov` is applied to the spectate camera while it is held; its own comes back after. diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index a1efea7..918e533 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -123,14 +123,18 @@ struct InteractiveDebug { ULONGLONG expires{}; std::array velocity{}; bool valid{}; + bool applied{}; } noclip_velocity; VelocityRequest forward_velocity; VelocityRequest up_velocity; + VelocityRequest offboard_up_velocity; std::uint64_t noclip_velocity_updates{}, noclip_motion_updates{}; std::uint64_t forward_velocity_updates{}; float forward_velocity_speed = 20.0f; std::uint64_t up_velocity_updates{}; + std::uint64_t offboard_up_velocity_updates{}; float up_velocity_speed = 20.0f; + float offboard_up_velocity_speed = 20.0f; float noclip_altitude{}; bool noclip_altitude_valid{}, noclip_altitude_offboard{}; // Player choices kept in the local profile (see load_saved_debug): @@ -158,6 +162,8 @@ struct SourceState { std::atomic_flag busy = ATOMIC_FLAG_INIT; bool velocity_guard_attempted{}; // Protected by initialization_mutex. std::atomic velocity_guard_active{}; + std::atomic free_camera_active{}; + std::atomic offboard_boost_core{}; std::atomic velocity_update_original{}; std::atomic motion_original{}; SourceTrial trial; @@ -172,6 +178,7 @@ struct NoclipBodies { std::uintptr_t core{}, context{}, rig_wrapper{}; float seconds{}, board_height{}; bool offboard{}; + bool wipeout{}; // Spread-eagle/torpedo use the native wipeout physics path. std::array root{}; // Entity root: the camera target and the idle motion target. std::array parts{}; // Board 0..8, native skeleton velocity parts 1..23. }; diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 5a7fb97..141406e 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -135,11 +135,67 @@ void register_movement_commands(Commands ®istry) { request_debug(Debug::add_forward_velocity); }; registry.add(std::move(boost)); - auto mask = argument("controller_mask", Type::unsigned_integer); + auto mask = argument("button_or_key", Type::unsigned_integer); mask.minimum = 0; mask.maximum = UINT32_MAX; + auto freecamcontroller = + action("freecam_controller", "Block input and use controller for Freecam", Group::movement, {argument("enabled", Type::boolean)}); + freecamcontroller.inspect = [](const Model &m) { + return boolean_state(m.bindings.available, m.bindings.freecam_controller, "Controller bindings are unavailable."); + }; + freecamcontroller.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_controller(std::get(args[0])); + out(saved ? "" : "error: Invalid setting or save failed."); + }; + registry.add(std::move(freecamcontroller)); + + auto freecamcontroller_bind = + action("bind freecamcontroller", "Bind a controller combo or keyboard key to freecam controller toggle; 0 clears the binding", Group::movement, {mask}); + freecamcontroller_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + freecamcontroller_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_controller_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(freecamcontroller_bind)); + + auto tp_to_freecam = + action("tp_to_freecam", "Teleport to freecam position and disable freecam", Group::movement); + tp_to_freecam.inspect = [](const Model &m) { + return boolean_state(m.debug.camera_available && m.debug.camera_position_valid, m.debug.free_camera, + !m.debug.camera_position_valid ? "Camera position unavailable" : "Camera controls unavailable"); + }; + tp_to_freecam.run = [](const Model &m, const Values &, const Output &) { + if (!m.debug.camera_position_valid) return; + teleport_local_skater(m.debug.camera_position); + if (m.debug.free_camera) + request_debug(overlay::DebugAction::set_free_camera, false); + }; + registry.add(std::move(tp_to_freecam)); + + auto tp_to_freecam_bind = + action("bind tptofreecam", "Bind a controller combo or keyboard key to TP to Freecam; 0 clears the binding", Group::movement, {mask}); + tp_to_freecam_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + tp_to_freecam_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_tp_to_freecam_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(tp_to_freecam_bind)); + auto freecam_bind = + action("bind freecam", "Bind a controller combo or keyboard key to freecam; 0 clears the binding", Group::movement, {mask}); + freecam_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + freecam_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(freecam_bind)); auto bind = - action("bind noclip", "Bind controller buttons to noclip; 0 clears the binding", Group::movement, {mask}); + action("bind noclip", "Bind a controller combo or keyboard key to noclip; 0 clears the binding", Group::movement, {mask}); bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -149,7 +205,7 @@ void register_movement_commands(Commands ®istry) { }; registry.add(std::move(bind)); auto boost_bind = - action("bind forwardvelocity", "Bind controller buttons to Forward Boost; 0 clears the binding", Group::movement, {mask}); + action("bind forwardvelocity", "Bind a controller combo or keyboard key to Forward Boost; 0 clears the binding", Group::movement, {mask}); boost_bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -159,7 +215,7 @@ void register_movement_commands(Commands ®istry) { }; registry.add(std::move(boost_bind)); auto up_bind = - action("bind upvelocity", "Bind controller buttons to Up Boost; 0 clears the binding", Group::movement, {mask}); + action("bind upvelocity", "Bind a controller combo or keyboard key to Up Boost; 0 clears the binding", Group::movement, {mask}); up_bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -168,6 +224,16 @@ void register_movement_commands(Commands ®istry) { out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); }; registry.add(std::move(up_bind)); + auto offboard_up_bind = + action("bind offboardupvelocity", "Bind a controller combo or keyboard key to Off-board Up Boost; 0 clears the binding", Group::movement, {mask}); + offboard_up_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + offboard_up_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_offboard_up_velocity_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(offboard_up_bind)); } void register_ai_commands(Commands ®istry) { const auto ready = [](const Model &m) { diff --git a/Extension/Skater/offboard_flight.cpp b/Extension/Skater/offboard_flight.cpp index 7ce0306..8a8cda7 100644 --- a/Extension/Skater/offboard_flight.cpp +++ b/Extension/Skater/offboard_flight.cpp @@ -4,6 +4,7 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/no_bail.h" #include "Engine/Game/Build/20260929/offboard_flight.h" +#include #include namespace dingosdk { @@ -33,6 +34,11 @@ bool offboard_flight_compatible(std::uintptr_t base) noexcept { if (link(base + state.vtable_rva, 0x18) != base + state.update_rva || !memory::read(base + state.velocity_write.rva, bytes) || bytes != state.velocity_write.bytes) return false; } + for (const auto& state : offboard_ragdoll_states) + if (link(base + state[1], 0x18) != base + state[2]) return false; + std::array ragdoll_bytes{}; + if (!memory::read(base + ragdoll_velocity_write.rva, ragdoll_bytes) || + ragdoll_bytes != ragdoll_velocity_write.bytes) return false; return true; } bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, @@ -55,6 +61,48 @@ bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::u } return false; } +bool ensure_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float minimum_up_speed) noexcept { + FlightLastError error; + if (!object(base) || !object(context) || !object(rig) || !std::isfinite(minimum_up_speed) || + minimum_up_speed <= 0 || minimum_up_speed > 100000 || + link(core) != base + addr::no_bail::bail_core_vtable || link(core, 0x3c0) != context || link(core, 0x438) != rig) + return false; + const auto parent = link(core, 0x3b0); + if (link(parent) != base + offboard_flight_vtable || link(parent, 8) != context || + link(parent, 0x10) != link(core, 0x430) || link(parent, 0x18) != rig || + link(rig) != context || link(rig, 0x4630) != core) return false; + const auto active = link(parent, 0x48); + for (const auto& state : offboard_flight_states) { + if (active != parent + state.offset) continue; + std::array velocity{}; + if (link(active) != base + state.vtable_rva || !memory::read(active + 0x10, velocity)) return false; + velocity[1] = std::max(velocity[1], minimum_up_speed); + return sync_offboard_flight_velocity(base, core, context, rig, velocity); + } + for (const auto& state : offboard_ragdoll_states) { + if (active != parent + state[0]) continue; + if (link(active) != base + state[1]) return false; + float vertical{}; + const auto address = active + 0x14; + if (!memory::read(address, vertical) || !std::isfinite(vertical) || std::abs(vertical) > 100000) + return false; + // Replace a fall with the positive launch speed, rather than adding + // an impulse to a cached negative velocity. Preserve horizontal motion. + const float launched = std::max(vertical, minimum_up_speed); + MEMORY_BASIC_INFORMATION page{}; + if (!VirtualQuery(reinterpret_cast(address), &page, sizeof(page)) || + page.State != MEM_COMMIT || (page.Protect & (PAGE_GUARD | PAGE_NOACCESS)) || + !(page.Protect & (PAGE_READWRITE | PAGE_EXECUTE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY))) + return false; + SIZE_T written{}; + return WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(address), &launched, + sizeof(launched), &written) && written == sizeof(launched) && + memory::read(address, vertical) && vertical == launched; + } + // Scripted animation/trajectory states have different motion rules. + return true; +} bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept { FlightLastError error; diff --git a/Extension/Skater/offboard_flight.h b/Extension/Skater/offboard_flight.h index ebbe8b3..389e5e8 100644 --- a/Extension/Skater/offboard_flight.h +++ b/Extension/Skater/offboard_flight.h @@ -3,9 +3,12 @@ #include namespace dingosdk { +// Keep a short boost's minimum rise speed through native off-board motion updates. +bool ensure_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float minimum_up_speed) noexcept; bool offboard_flight_compatible(std::uintptr_t base) noexcept; -// Only call during a freshly validated local noclip motion update, with live -// input. Synchronize the active falling/ground state, preserving its fourth lane. +// Only call during a freshly validated local motion/physics update. +// Synchronize the active falling/ground state, preserving its fourth lane. bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept; // Multiplies the upward part of the active off-board state's velocity, once (a jump that has diff --git a/Extension/UI/Overlay/input_capture.cpp b/Extension/UI/Overlay/input_capture.cpp index 82c2e10..d5c9f70 100644 --- a/Extension/UI/Overlay/input_capture.cpp +++ b/Extension/UI/Overlay/input_capture.cpp @@ -2,6 +2,8 @@ #include "overlay_internal.h" #include "input_capture.h" #include "held_input.h" +#include "Engine/Game/Input/playstation_report.h" +#include #include #include #include @@ -19,7 +21,7 @@ thread_local unsigned overlay_input_access = 0; thread_local HRAWINPUT noted_raw_input = nullptr; bool block_polled_input() { const auto error = GetLastError(); - const bool capture = !overlay_input_access && owns_menu_cursor(state()); + const bool capture = !overlay_input_access && (owns_menu_cursor(state()) || state().freecam_controller_active.load(std::memory_order_relaxed)); SetLastError(error); return capture; } @@ -28,6 +30,11 @@ void note_input_record(const RAWINPUT& record) noexcept; HeldInput& held_input() { static auto* value = new HeldInput; return *value; } struct InputCaptureHooks { + Hook hid_attributes, read_file, overlapped, overlapped_ex, completion, completion_ex, close_handle; + struct HidRead { HANDLE file; void* buffer; DWORD capacity; PlayStationPad kind; }; + std::mutex hid_mutex; + std::map hid_devices; + std::map hid_reads; Hook register_raw; Hook async_key, key, keyboard, raw_data, raw_buffer, capture, release_capture, cursor, show_cursor, physical_cursor, cursor_position; std::array xinput_state, xinput_extended, xinput_keystroke; @@ -43,6 +50,139 @@ struct InputCaptureHooks { }; InputCaptureHooks& input_hooks() { static auto* hooks = new InputCaptureHooks; return *hooks; } +BOOLEAN __stdcall captured_hid_attributes(HANDLE file, PHIDD_ATTRIBUTES attributes) { + const auto result = original(input_hooks().hid_attributes)(file, attributes); + const auto error = GetLastError(); + if (result && attributes) { + const auto kind = playstation_pad(attributes->VendorID, attributes->ProductID); + if (kind != PlayStationPad::none) { + std::lock_guard lock(input_hooks().hid_mutex); + input_hooks().hid_devices[file] = kind; + } + } + SetLastError(error); + return result; +} +void neutral_hid(const InputCaptureHooks::HidRead& read, DWORD size) { + if (!block_polled_input() || !read.buffer || size > read.capacity) return; + auto* bytes = static_cast(read.buffer); + if (!parse_playstation_report(read.kind, playstation_bluetooth(read.capacity), bytes, size)) return; + const bool sense_full = read.kind == PlayStationPad::dualsense && + (bytes[0] == 0x31 || (bytes[0] == 1 && !playstation_bluetooth(read.capacity))); + const unsigned offset = bytes[0] == 0x31 ? 2 : bytes[0] == 0x11 ? 3 : 1; + auto* data = bytes + offset; + std::fill_n(data, 4, std::uint8_t{128}); + if (sense_full) { + data[4] = data[5] = 0; // triggers; retain report sequence at +6 + data[7] = 8; data[8] = data[9] = 0; + } else { + data[4] = 8; data[5] = 0; data[6] &= 0xfc; // retain DS4 sequence bits + data[7] = data[8] = 0; + } + // Full Bluetooth reports have a CRC over the 0xA1 input prefix and payload. + // A stale checksum makes the native reader discard the neutral report. + if (size == 78 && (bytes[0] == 0x31 || bytes[0] == 0x11)) { + std::uint32_t crc = 0xffffffffu; + const auto feed = [&](std::uint8_t byte) { + crc ^= byte; + for (unsigned bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + }; + feed(0xa1); + for (DWORD i = 0; i < size - 4; ++i) feed(bytes[i]); + crc = ~crc; + std::memcpy(bytes + size - 4, &crc, sizeof(crc)); + } + static std::atomic reported{}; + if (!reported.exchange(true)) logging::write(logging::Level::info, logging::Channel::input, + "Native PlayStation HID report neutralized; overlay retains its independent input stream."); +} +void complete_hid(OVERLAPPED* request, DWORD bytes, bool success, bool finished) { + auto& h = input_hooks(); + InputCaptureHooks::HidRead read{}; + { + std::lock_guard lock(h.hid_mutex); + const auto found = h.hid_reads.find(request); + if (found == h.hid_reads.end()) return; + read = found->second; + if (finished) h.hid_reads.erase(found); + } + if (success) neutral_hid(read, bytes); +} +BOOL WINAPI captured_read_file(HANDLE file, LPVOID buffer, DWORD size, LPDWORD bytes, LPOVERLAPPED request) { + auto& h = input_hooks(); + InputCaptureHooks::HidRead read{file, buffer, size, PlayStationPad::none}; + if (!overlay_input_access) { + // Some readers inspect a temporary enumeration handle, then open a + // second handle for reports. Identify that handle as well. + bool known{}; + { + std::lock_guard lock(h.hid_mutex); + known = h.hid_devices.contains(file); + } + if (!known && size >= 10 && size <= 512) { + const auto error = GetLastError(); + HIDD_ATTRIBUTES attributes{sizeof(attributes)}; + const auto okay = original(h.hid_attributes)(file, &attributes); + const auto kind = okay ? playstation_pad(attributes.VendorID, attributes.ProductID) : PlayStationPad::none; + { + std::lock_guard lock(h.hid_mutex); + h.hid_devices[file] = kind; + } + SetLastError(error); + } + std::lock_guard lock(h.hid_mutex); + const auto found = h.hid_devices.find(file); + if (found != h.hid_devices.end() && found->second != PlayStationPad::none) { + read.kind = found->second; + if (request) h.hid_reads[request] = read; + } + } + const auto result = original(h.read_file)(file, buffer, size, bytes, request); + const auto error = GetLastError(); + if (read.kind != PlayStationPad::none) { + if (result) neutral_hid(read, bytes ? *bytes : request ? static_cast(request->InternalHigh) : 0); + if (!result && error != ERROR_IO_PENDING && request) complete_hid(request, 0, false, true); + } + SetLastError(error); + return result; +} +BOOL WINAPI captured_overlapped(HANDLE file, LPOVERLAPPED request, LPDWORD bytes, BOOL wait) { + const auto result = original(input_hooks().overlapped)(file, request, bytes, wait); + const auto error = GetLastError(); + complete_hid(request, result && bytes ? *bytes : 0, result != FALSE, result || error != ERROR_IO_INCOMPLETE); + SetLastError(error); return result; +} +BOOL WINAPI captured_overlapped_ex(HANDLE file, LPOVERLAPPED request, LPDWORD bytes, DWORD timeout, BOOL alertable) { + const auto result = original(input_hooks().overlapped_ex)(file, request, bytes, timeout, alertable); + const auto error = GetLastError(); + complete_hid(request, result && bytes ? *bytes : 0, result != FALSE, + result || (error != ERROR_IO_INCOMPLETE && error != WAIT_TIMEOUT && error != WAIT_IO_COMPLETION)); + SetLastError(error); return result; +} +BOOL WINAPI captured_completion(HANDLE port, LPDWORD bytes, PULONG_PTR key, LPOVERLAPPED* request, DWORD timeout) { + const auto result = original(input_hooks().completion)(port, bytes, key, request, timeout); + const auto error = GetLastError(); + if (request && *request) complete_hid(*request, result && bytes ? *bytes : 0, result != FALSE, true); + SetLastError(error); return result; +} +BOOL WINAPI captured_completion_ex(HANDLE port, LPOVERLAPPED_ENTRY entries, ULONG count, PULONG removed, DWORD timeout, BOOL alertable) { + const auto result = original(input_hooks().completion_ex)(port, entries, count, removed, timeout, alertable); + const auto error = GetLastError(); + if (result && entries && removed) for (ULONG i = 0; i < *removed; ++i) + complete_hid(entries[i].lpOverlapped, entries[i].dwNumberOfBytesTransferred, + static_cast(entries[i].Internal) >= 0, true); + SetLastError(error); return result; +} +BOOL WINAPI captured_close_handle(HANDLE file) { + auto& h = input_hooks(); + { + std::lock_guard lock(h.hid_mutex); + if (h.hid_devices.erase(file)) + std::erase_if(h.hid_reads, [&](const auto& entry) { return entry.second.file == file; }); + } + return original(h.close_handle)(file); +} + SHORT WINAPI captured_async_key(int key) { const auto value = original(input_hooks().async_key)(key); return block_polled_input() ? 0 : value; @@ -354,6 +494,7 @@ HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID DWORD neutral = 0; if (object.dwType & DIDFT_POV) neutral = 0xffffffffu; else if (object.dwType & DIDFT_ABSAXIS) { + neutral = 32767; // Standard DirectInput axis midpoint if range lookup fails. DIPROPRANGE range{}; range.diph = {sizeof(range), sizeof(range.diph), object.dwOfs, DIPH_BYOFFSET}; auto** vtable = *reinterpret_cast(device); @@ -363,6 +504,17 @@ HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID } else continue; std::memcpy(static_cast(data) + object.dwOfs, &neutral, 4); } + } else if (size == sizeof(DIJOYSTATE) || size == sizeof(DIJOYSTATE2)) { + // A zeroed DirectInput axis is full travel, not the center. Some + // controllers use the standard joystick layout without a format + // record we can inspect; keep those pads neutral while captured. + auto* bytes = static_cast(data); + const DWORD center = 32767; + const DWORD released_pov = 0xffffffffu; + for (std::size_t offset = 0; offset < 8 * sizeof(DWORD); offset += sizeof(DWORD)) + std::memcpy(bytes + offset, ¢er, sizeof(center)); + for (std::size_t offset = 8 * sizeof(DWORD); offset < 12 * sizeof(DWORD); offset += sizeof(DWORD)) + std::memcpy(bytes + offset, &released_pov, sizeof(released_pov)); } } return result; @@ -523,6 +675,19 @@ void sync_raw_mouse_registration(bool overlay_owns_pointer) { bool install_input_capture() { auto& h = input_hooks(); + const auto hid = LoadLibraryExW(L"hid.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + const auto kernel = GetModuleHandleW(L"kernel32.dll"); + if (!hid || !install(h.hid_attributes, reinterpret_cast(GetProcAddress(hid, "HidD_GetAttributes")), + reinterpret_cast(captured_hid_attributes))) return false; + const auto kernel_hook = [&](Hook& hook, const char* name, void* detour) { + return install(hook, reinterpret_cast(GetProcAddress(kernel, name)), detour); + }; + if (!kernel_hook(h.read_file, "ReadFile", reinterpret_cast(captured_read_file)) || + !kernel_hook(h.overlapped, "GetOverlappedResult", reinterpret_cast(captured_overlapped)) || + !kernel_hook(h.overlapped_ex, "GetOverlappedResultEx", reinterpret_cast(captured_overlapped_ex)) || + !kernel_hook(h.completion, "GetQueuedCompletionStatus", reinterpret_cast(captured_completion)) || + !kernel_hook(h.completion_ex, "GetQueuedCompletionStatusEx", reinterpret_cast(captured_completion_ex)) || + !kernel_hook(h.close_handle, "CloseHandle", reinterpret_cast(captured_close_handle))) return false; const auto user32 = GetModuleHandleW(L"user32.dll"); const auto add = [&](Hook& hook, const char* name, void* detour) { const bool okay = install(hook, reinterpret_cast(GetProcAddress(user32, name)), detour); @@ -575,7 +740,7 @@ bool install_input_capture() { if (!install(h.direct_create, reinterpret_cast(GetProcAddress(direct, "DirectInput8Create")), reinterpret_cast(captured_direct_create))) return false; } - dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Menu input capture installed: keyboard, raw input, XInput, DirectInput and cursor ownership."); + dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Menu input capture installed: keyboard, raw input, XInput, DirectInput, PlayStation HID and cursor ownership."); return true; } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 16a9d52..85f2856 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -52,6 +52,8 @@ enum class DebugAction { set_park_editor, add_up_velocity, set_up_velocity_speed, + add_offboard_up_velocity, + set_offboard_up_velocity_speed, set_first_person, set_first_person_fov, set_first_person_spring, @@ -100,20 +102,24 @@ struct DebugModel { bool noclip = false, noclip_available = false; bool forward_velocity_available = false; bool up_velocity_available = false; + bool offboard_up_velocity_available = false; std::string camera_unavailable = "Waiting for local controls."; std::string noclip_unavailable = "Waiting for local controls."; std::string forward_velocity_unavailable = "Waiting for local controls."; std::string up_velocity_unavailable = "Waiting for local controls."; + std::string offboard_up_velocity_unavailable = "Waiting for local controls."; bool no_bail = false, no_bail_available = false, no_bail_active = false; std::uint64_t noclip_velocity_updates = 0, noclip_motion_updates = 0; std::uint64_t forward_velocity_updates = 0; std::uint64_t up_velocity_updates = 0; + std::uint64_t offboard_up_velocity_updates = 0; bool game_ui_hidden = false; bool settings_owned = false; // ReSkate free-flight speed in world units per second. float camera_speed = 15.0f; float forward_velocity_speed = 20.0f; float up_velocity_speed = 20.0f; + float offboard_up_velocity_speed = 20.0f; bool camera_position_valid = false; bool skater_position_valid = false; std::array camera_position{}; @@ -391,6 +397,7 @@ extern "C" __declspec(dllexport) void DingoSDKOverlayGetStatus(dingosdk::overlay // Poll only on the client update thread. Returns no input while either overlay // surface is open, the game is unfocused, or the overlay has stopped. extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* input, bool flight_active, bool player_flight = false); +extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active); // allow_menu is used only by the Binds page while recording. Focus and lifetime // gates still apply. Gameplay callers leave it false. extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* input, bool allow_menu = false); diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index ebcf842..95ee5c3 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -241,7 +241,9 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { } } const bool capture = owns_pointer(); - if (capture && is_input(message)) { + const bool freecam_capture = s.freecam_controller_active.load(std::memory_order_relaxed); + if (freecam_capture) release_game_buttons(window, previous); + if ((capture || freecam_capture) && is_input(message)) { // Foreground raw-input packets still need DefWindowProc cleanup. return message == WM_INPUT ? DefWindowProcW(window, message, wp, lp) : 0; } @@ -390,11 +392,27 @@ dingosdk::overlay::FlightInput read_player_flight_controller() { const auto sample = read_controller_sample(); if (!sample.device) return {}; const auto& pad = sample.pad; - return dingosdk::controller_flight_input(pad.sThumbLX, pad.sThumbLY, pad.bLeftTrigger, pad.bRightTrigger, + auto input = dingosdk::controller_flight_input(pad.sThumbLX, pad.sThumbLY, pad.bLeftTrigger, pad.bRightTrigger, (pad.wButtons & XINPUT_GAMEPAD_LEFT_THUMB) != 0); + const float rx = static_cast(pad.sThumbRX), ry = static_cast(pad.sThumbRY); + const float magnitude = std::sqrt(rx * rx + ry * ry); + constexpr float deadzone = 8689.0f; + if (magnitude > deadzone) { + const float scale = std::clamp((magnitude - deadzone) / (32767.0f - deadzone), 0.0f, 1.0f) / magnitude; + input.look_x = rx * scale * 25.0f; + input.look_y = ry * scale * -25.0f; + } + return input; } } +extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) { + const bool previous = state().freecam_controller_active.exchange(active, std::memory_order_relaxed); + if (previous != active) + dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, + "Freecam controller input capture %s.", active ? "enabled" : "disabled"); +} + extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* output, bool allow_menu) { if (!output) return; struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error; @@ -403,6 +421,15 @@ extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* ou const HWND window = s.window.load(); if (!window || s.stop.load() || s.failed.load() || (!allow_menu && interactive_visible(s)) || !game_window_foreground(window)) return; + // Read through the overlay's bypass: freecam capture hides keys from the game. + { + OverlayInputAccess access; + for (unsigned key = 0; key < 256; ++key) { + if (dingosdk::bindable_keyboard_key(key) && (GetAsyncKeyState(key) & 0x8000)) { + output->keys[key / 64] |= std::uint64_t{1} << (key % 64); + } + } + } const auto sample = read_controller_sample(); output->style = sample.style; if (!sample.device) return; @@ -455,6 +482,8 @@ extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* o output->forward = std::clamp(output->forward + controller.forward, -1.0f, 1.0f); output->up = std::clamp(output->up + controller.up, -1.0f, 1.0f); output->boost = output->boost || controller.boost; + output->look_x = controller.look_x; + output->look_y = controller.look_y; looking = false; return; // Native camera owns look input; do not sample or recenter the cursor. } diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 324adc3..ab7d27f 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -138,6 +138,7 @@ struct State { std::atomic failed{false}; bool win32_ready = false; bool dx12_ready = false; + std::atomic freecam_controller_active{false}; bool show_on_ready = false; bool force_windowed = false; HANDLE stop_event = nullptr; diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 894e742..e344a3c 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -380,7 +380,6 @@ void draw_menu() { // than copying every retained log string a second time each poll. ingest_console_log(std::move(next.console_log)); s.model = std::move(next); - } } // Nothing but the chat: the held model may be old, so it must not reopen the editor. diff --git a/Extension/UI/Overlay/playstation_input.cpp b/Extension/UI/Overlay/playstation_input.cpp index 2c477f1..551a5ad 100644 --- a/Extension/UI/Overlay/playstation_input.cpp +++ b/Extension/UI/Overlay/playstation_input.cpp @@ -1,4 +1,5 @@ #include "playstation_input.h" +#include "input_capture.h" #include "Engine/Core/Log/logging.h" #include #include @@ -96,6 +97,7 @@ std::unique_ptr open_pad(const std::wstring& path) { // Drains every queued report so the newest one wins. Returns false once the // device is gone. bool drain(Pad& pad) { + OverlayInputAccess access; // This independent HID stream belongs to freecam/menu. for (int reports = 0; reports < 256; ++reports) { if (!pad.pending) { ResetEvent(pad.event); diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index 36479e1..a146353 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -67,12 +67,12 @@ void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) } void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "controller-binds", "CONTROLLER BINDS"); + begin_card(menu, "controller-binds", "ACTION BINDS"); const bool available = model.bindings.available && callbacks.queue_console_command; const auto save = [&](int action, std::uint32_t combo) { std::array result{}; const auto command = std::string("bind ") + - (action == 1 ? "noclip " : action == 2 ? "forwardvelocity " : "upvelocity ") + std::to_string(combo); + (action == 1 ? "freecamcontroller " : action == 2 ? "freecam " : action == 3 ? "noclip " : action == 4 ? "forwardvelocity " : action == 5 ? "upvelocity " : action == 6 ? "tptofreecam " : "offboardupvelocity ") + std::to_string(combo); const bool queued = callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size()); result.back() = '\0'; feedback(menu, result[0] ? result.data() : queued ? "Saving binding..." : "Could not queue binding."); @@ -83,7 +83,7 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback if (!available || ImGui::GetTime() >= menu.bind_capture_until) { menu.recording_bind = 0; feedback(menu, "Recording cancelled. Your binding is unchanged."); - } else if (const auto combo = menu.bind_capture.update(controller)) { + } else if (const auto combo = menu.bind_capture.update(controller, true)) { const auto action = menu.recording_bind; menu.recording_bind = 0; save(action, *combo); @@ -92,7 +92,7 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback ImGui::BeginDisabled(!available); if (ImGui::BeginTable("controller-binds", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, px(130)); - ImGui::TableSetupColumn("Controller combo", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Controller combo / key", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##controls", ImGuiTableColumnFlags_WidthFixed, px(170)); ImGui::TableHeadersRow(); const auto row = [&](int action, const char* name, std::uint32_t combo) { @@ -120,19 +120,22 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback } ImGui::PopID(); }; - row(1, "Noclip", model.bindings.noclip_combo); - row(2, "Forward Boost", model.bindings.forward_velocity_combo); - row(3, "Up Boost", model.bindings.up_velocity_combo); + row(1, "Freecam Controller", model.bindings.freecam_controller_combo); + row(2, "Freecam", model.bindings.freecam_combo); + row(3, "Noclip", model.bindings.noclip_combo); + row(4, "Forward Boost", model.bindings.forward_velocity_combo); + row(5, "Up Boost", model.bindings.up_velocity_combo); + row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo); + row(7, "Off-board Up Boost", model.bindings.offboard_up_velocity_combo); ImGui::EndTable(); } ImGui::EndDisabled(); if (menu.recording_bind) { - warn(!controller.available ? "Connect a controller and keep the game focused to record. PlayStation pads work directly or through DS4Windows / Steam Input." : - !menu.bind_capture.ready ? "Release all controller buttons first." : - "Hold the buttons you want together, then release them to save."); + warn(!menu.bind_capture.ready ? "Release controller buttons and keyboard keys first." : + "Hold up to five keys together, then release them. A-Z, 0-9, Space, F1-F12, Ctrl, Shift and Alt work; controller combos work too."); } else { - note(("Record a button or combo, such as " + controller_combo_label(0x300, controller.style) + - ". Noclip toggles; Forward Boost and Up Boost add their velocity once per press. Use a different combo for each.").c_str()); + note(("Record a key, keyboard chord or controller combo, such as Ctrl + F5 or " + controller_combo_label(0x300, controller.style) + + ". Freecam, Freecam Controller, and Noclip toggle; boosts add velocity once per press. Off-board Up Boost also works while falling or gliding; release and press again to repeat. On-board and off-board boosts can share a combo. Give toggles different combos.").c_str()); note("Saved to your profile."); if (!model.bindings.status.empty()) note(model.bindings.status.c_str()); if (!model.bindings.available) warn("Waiting for the local profile."); diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 6c6e7d1..736ec69 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -18,6 +18,19 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (toggle_row(menu, "Freecam", "Detach the camera and explore.", flight, debug.available && debug.camera_available && callbacks.queue_debug)) debug_request(menu, callbacks, {DebugAction::set_free_camera, flight}); + bool freecam_controller = model.bindings.freecam_controller; + if (toggle_row(menu, "Block Input(Enable Controller Support)", "Block player input and use the controller for the Freecam.", freecam_controller, + model.bindings.available && callbacks.queue_debug)) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, freecam_controller ? "freecam_controller true" : "freecam_controller false", result.data(), result.size()); + } + field(menu, "Teleport to Freecam"); + ImGui::BeginDisabled(!debug.free_camera || !debug.camera_position_valid || !callbacks.queue_console_command); + if (ImGui::Button("Teleport", ImVec2(-1, 0))) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, "tp_to_freecam", result.data(), result.size()); + } + ImGui::EndDisabled(); { field(menu, "Field of view"); const bool custom = debug.free_camera_fov > 0; @@ -126,6 +139,10 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c float up_velocity = debug.up_velocity_speed; if (ImGui::SliderFloat("##up-velocity", &up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {DebugAction::set_up_velocity_speed, false, up_velocity}); + field(menu, "Off-board up boost"); + float offboard_up_velocity = debug.offboard_up_velocity_speed; + if (ImGui::SliderFloat("##offboard-up-velocity", &offboard_up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, offboard_up_velocity}); ImGui::EndDisabled(); note("Controller: left stick moves, right stick looks, RT / LT rise and fall, click the left stick to boost."); note("Keyboard: WASD / Q E, Shift to boost. Close the menu to fly."); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 97c24e2..acefc1e 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -375,18 +375,57 @@ void update_model(std::uintptr_t client, TickState& frame) { const auto camera_phase_ready = [&] { return camera_phase_issue() == nullptr; }; std::optional debug_request; bool debug_busy{}, controller_busy{}; - std::uint32_t noclip_combo{}, forward_velocity_combo{}, up_velocity_combo{}; + std::uint32_t freecam_controller_combo{}, freecam_combo{}, tp_to_freecam_combo{}, noclip_combo{}, forward_velocity_combo{}, up_velocity_combo{}, offboard_up_velocity_combo{}; + bool freecam_controller = dingosdk::local_freecam_controller(); { std::lock_guard lock(r.mutex); debug_busy = r.requests.loading(); controller_busy = !r.requests.idle(); + freecam_controller_combo = r.model.bindings.available ? r.model.bindings.freecam_controller_combo : 0; + freecam_combo = r.model.bindings.available ? r.model.bindings.freecam_combo : 0; + tp_to_freecam_combo = r.model.bindings.available ? r.model.bindings.tp_to_freecam_combo : 0; noclip_combo = r.model.bindings.available ? r.model.bindings.noclip_combo : 0; forward_velocity_combo = r.model.bindings.available ? r.model.bindings.forward_velocity_combo : 0; up_velocity_combo = r.model.bindings.available ? r.model.bindings.up_velocity_combo : 0; + offboard_up_velocity_combo = r.model.bindings.available ? r.model.bindings.offboard_up_velocity_combo : 0; debug_request = r.requests.take(GetCurrentThreadId()); } + // Capture belongs to the freecam state itself. Keep it active across brief + // request/loading phases instead of opening a path back to player input. + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); dingosdk::ControllerInput controller; - if (noclip_combo || forward_velocity_combo || up_velocity_combo) DingoSDKOverlayReadControllerInput(&controller); + if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo) DingoSDKOverlayReadControllerInput(&controller); + + if (r.freecam_controller_bind_latch.update(freecam_controller_combo, controller, r.observer_failed || controller_busy || !r.debug_model.free_camera)) { + if (dingosdk::set_local_freecam_controller(!freecam_controller)) + freecam_controller = !freecam_controller; + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); + record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam_controller\"}"); + } + + if (r.tp_to_freecam_bind_latch.update(tp_to_freecam_combo, controller, r.observer_failed || controller_busy || !r.debug_model.free_camera || !r.debug_model.camera_position_valid || debug_request.has_value())) { + if (dingosdk::teleport_local_skater(r.debug_model.camera_position)) { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::set_free_camera, false}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) + debug_request = r.requests.take(GetCurrentThreadId()); + } + record("{\"event\":\"controller_binding_triggered\",\"action\":\"tp_to_freecam\"}"); + } + + const bool freecam_bind_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (state != 13 && state != 21) || !r.debug_model.camera_available || + (freecam_combo && !camera_phase_ready()); + if (r.freecam_bind_latch.update(freecam_combo, controller, freecam_bind_blocked)) { + { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::set_free_camera, !r.debug_model.free_camera}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) + debug_request = r.requests.take(GetCurrentThreadId()); + } + record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam\"}"); + } + const bool bind_blocked = r.observer_failed || controller_busy || debug_request.has_value() || (state != 13 && state != 21) || !r.debug_model.noclip_available || (noclip_combo && !camera_phase_ready()); @@ -401,6 +440,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"noclip\"}"); } const bool forward_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.offboard_up_velocity_available && forward_velocity_combo == offboard_up_velocity_combo) || + (r.debug_model.free_camera && freecam_controller) || (state != 13 && state != 21) || !r.debug_model.forward_velocity_available || (forward_velocity_combo && forward_velocity_combo == noclip_combo); if (r.forward_velocity_bind_latch.update(forward_velocity_combo, controller, forward_velocity_blocked)) { @@ -413,6 +454,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"forward_velocity\"}"); } const bool up_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.offboard_up_velocity_available && up_velocity_combo == offboard_up_velocity_combo) || + (r.debug_model.free_camera && freecam_controller) || (state != 13 && state != 21) || !r.debug_model.up_velocity_available || (up_velocity_combo && (up_velocity_combo == noclip_combo || up_velocity_combo == forward_velocity_combo)); if (r.up_velocity_bind_latch.update(up_velocity_combo, controller, up_velocity_blocked)) { @@ -423,6 +466,19 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"up_velocity\"}"); } } + + const bool offboard_up_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.free_camera && freecam_controller) || + (state != 13 && state != 21) || !r.debug_model.offboard_up_velocity_available || + (offboard_up_velocity_combo && offboard_up_velocity_combo == noclip_combo); + if (r.offboard_up_velocity_bind_latch.update(offboard_up_velocity_combo, controller, offboard_up_velocity_blocked)) { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::add_offboard_up_velocity}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) { + debug_request = r.requests.take(GetCurrentThreadId()); + record("{\"event\":\"controller_binding_triggered\",\"action\":\"offboard_up_velocity\"}"); + } + } if (debug_request || r.debug_model.free_camera || r.debug_model.first_person || r.debug_model.noclip || now >= r.next_debug) { ScheduledWorkScope work{r, debug_request.has_value()}; @@ -430,11 +486,13 @@ void update_model(std::uintptr_t client, TickState& frame) { const bool debug_ready = !r.observer_failed && !debug_busy && (state == 13 || state == 21) && native_context_ready(); dingosdk::overlay::FlightInput flight_input; DingoSDKOverlayReadFlightInput(&flight_input, (r.debug_model.free_camera || r.debug_model.noclip) && debug_ready, - r.debug_model.noclip); + r.debug_model.noclip || (r.debug_model.free_camera && freecam_controller)); const auto phase_issue = camera_phase_issue(); auto debug = dingosdk::on_client_debug_tick(r.base, client, debug_ready, phase_issue == nullptr, debug_request ? &*debug_request : nullptr, &flight_input); + // Input ownership must follow the live camera even with every overlay closed. + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); // A debug action, or a mode the tick ended by itself (host policy, lost // camera), can have replaced the native camera: validate it again. if (debug_request || debug.free_camera != r.debug_model.free_camera || @@ -470,6 +528,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"forward_velocity_applied\",\"count\":" + std::to_string(debug.forward_velocity_updates) + "}"); if (debug.up_velocity_updates != r.debug_model.up_velocity_updates) record("{\"event\":\"up_velocity_applied\",\"count\":" + std::to_string(debug.up_velocity_updates) + "}"); + if (debug.offboard_up_velocity_updates != r.debug_model.offboard_up_velocity_updates) + record("{\"event\":\"offboard_up_velocity_applied\",\"count\":" + std::to_string(debug.offboard_up_velocity_updates) + "}"); if (r.debug_model.noclip && !debug.noclip && !debug_request) { std::ostringstream stopped; stopped << "{\"event\":\"noclip_stopped\",\"reason\":" << std::quoted(debug.status) << '}'; @@ -911,6 +971,7 @@ void update_model(std::uintptr_t client, TickState& frame) { // Validated afresh for the restore, which may itself change the camera. frame.camera_issue.reset(); const bool restored = dingosdk::restore_client_debug(r.base, client, camera_phase_ready()); + DingoSDKOverlaySetFreecamInputCapture(false); frame.camera_issue.reset(); if (!restored) { std::lock_guard lock(r.mutex); diff --git a/Runtime/runtime_internal.h b/Runtime/runtime_internal.h index 6ab359a..a511c00 100644 --- a/Runtime/runtime_internal.h +++ b/Runtime/runtime_internal.h @@ -69,9 +69,13 @@ struct Runtime { std::vector patch_level_manifests; ULONGLONG next_debug{}, flight_unavailable_since{}; std::string flight_unavailable_issue, flight_unavailable_logged; + dingosdk::ControllerComboLatch freecam_controller_bind_latch; + dingosdk::ControllerComboLatch freecam_bind_latch; + dingosdk::ControllerComboLatch tp_to_freecam_bind_latch; dingosdk::ControllerComboLatch noclip_bind_latch; dingosdk::ControllerComboLatch forward_velocity_bind_latch; dingosdk::ControllerComboLatch up_velocity_bind_latch; + dingosdk::ControllerComboLatch offboard_up_velocity_bind_latch; ULONGLONG next_offline{}; bool transition_seen{}, initialized{}, catalog_logged{}, native_tick_ready{}, native_loading_logging{}, fixed_stop_entitlement_route_ready{}, menu_load_queued{}, startup_load_queued{}; diff --git a/contrib/setup-linux-server-libs.sh b/contrib/setup-linux-server-libs.sh old mode 100755 new mode 100644