stan hai 3 semanas
pai
achega
b27248892b
Modificáronse 28 ficheiros con 252 adicións e 206 borrados
  1. 4 0
      CMakeLists.txt
  2. 5 5
      Sunrise/Sunrise.vcxproj
  3. 3 3
      Sunrise/resources/default_settings.json
  4. 2 2
      Sunrise/resources/sunrise.rc
  5. 6 3
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  6. 38 34
      Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp
  7. 0 6
      Sunrise/src/client/hooks/noclip/runtime.h
  8. 4 4
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  9. 10 6
      Sunrise/src/client/movement/movement_settings_store.cpp
  10. 4 4
      Sunrise/src/client/movement/movement_settings_store.h
  11. 3 6
      Sunrise/src/client/patterns/registry.cpp
  12. 4 27
      Sunrise/src/client/runtime/client_hook_activation.cpp
  13. 4 4
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  14. 1 4
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h
  15. 24 29
      Sunrise/src/client/ui/movement/movement_panel.cpp
  16. 8 0
      Sunrise/src/client/ui/movement/movement_panel.h
  17. 7 7
      Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp
  18. 0 8
      Sunrise/src/client/ui/teleport/teleport_panel.h
  19. 30 0
      Sunrise/src/core/logging/log.cpp
  20. 13 0
      Sunrise/src/core/logging/log.h
  21. 2 27
      Sunrise/src/core/runtime/core_runtime.cpp
  22. 3 3
      Sunrise/src/core/settings/client/definition.h
  23. 2 1
      Sunrise/src/core/settings/server/gameplay/definition.h
  24. 3 3
      Sunrise/src/core/settings/settings.h
  25. 27 5
      Sunrise/src/core/settings/settings_upgrade.cpp
  26. 35 15
      Sunrise/src/core/settings/steam/steam_settings_parser.cpp
  27. 7 0
      Sunrise/src/state/activity/forced/activity_forced_destination.cpp
  28. 3 0
      Sunrise/src/state/activity/forced/activity_forced_destination.h

+ 4 - 0
CMakeLists.txt

@@ -44,6 +44,10 @@ endif()
 target_include_directories(steam_api64
     PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/src"
 )
+# sunrise.rc names its payloads relative to itself, so its directory has to be on the include path.
+target_include_directories(steam_api64
+    PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/resources"
+)
 target_include_directories(steam_api64 SYSTEM PRIVATE
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/detours"
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui"

+ 5 - 5
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -188,8 +188,8 @@
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_datagram_transmission.cpp" />
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_message_transmission.cpp" />
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
-    <ClCompile Include="src\client\ui\teleport\teleport_panel.cpp" />
-    <ClCompile Include="src\client\teleport\teleport_settings_store.cpp" />
+    <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
+    <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
@@ -723,11 +723,11 @@
     <ClInclude Include="src\core\ui\layout\navigation\ui_layout_navigation.h" />
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
-    <ClInclude Include="src\client\teleport\teleport_settings_store.h" />
+    <ClInclude Include="src\client\movement\movement_settings_store.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
-    <ClInclude Include="src\client\ui\teleport\teleport_panel.h" />
+    <ClInclude Include="src\client\ui\movement\movement_panel.h" />
     <ClInclude Include="src\client\runtime\internal.h" />
     <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
     <ClInclude Include="src\client\hooks\graphics\graphics_hook_lifecycle.h" />

+ 3 - 3
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 3,
+  "version": 4,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -32,7 +32,7 @@
     },
     "fade_release": true,
     "force_join_request_ready": true,
-    "region_private": true,
+    "region_private": false,
     "pin_replicated_record": true,
     "hold_spawn": true,
     "spawn_hold_ms": 30000
@@ -40,7 +40,7 @@
   "server": {
     "bap_port": 30974,
     "gameplay": {
-      "topology": "disabled",
+      "topology": "embedded",
       "bind_address": "127.0.0.1",
       "advertised_address": "127.0.0.1",
       "transport_address": "127.0.0.1",

+ 2 - 2
Sunrise/resources/sunrise.rc

@@ -3,8 +3,8 @@
 #include "resource.h"
 
 IDR_DEFAULT_SETTINGS RCDATA "default_settings.json"
-IDR_IMGUI_LICENSE RCDATA "LICENSE.txt"
-IDR_DETOURS_LICENSE RCDATA "LICENSE.md"
+IDR_IMGUI_LICENSE RCDATA "..\\vendor\\imgui\\LICENSE.txt"
+IDR_DETOURS_LICENSE RCDATA "..\\vendor\\detours\\LICENSE.md"
 
 VS_VERSION_INFO VERSIONINFO
 FILEVERSION SUNRISE_VER_MAJOR, SUNRISE_VER_MINOR, SUNRISE_VER_PATCH, SUNRISE_VER_BUILD

+ 6 - 3
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -8,6 +8,7 @@
 
 #include "../../../core/logging/log.h"
 #include "../../../core/settings/settings.h"
+#include "../../../state/activity/forced/activity_forced_destination.h"
 #include "../../hooking/detour.h"
 #include "internal.h"
 
@@ -111,8 +112,8 @@ void report(std::uint32_t sliceSet, bool forced) noexcept {
 
 /**
  * Reports a bubble as private, for the region transition's own call only.
- * A public region holds its slice-set switch until a public activity host connects.
- * `client.region_private` off keeps the public answer, so the citizen join can run.
+ * A public region holds its slice-set switch until a public activity host connects. The answer
+ * is public unless `client.region_private` is on, or a destination is forced.
  * @return False on the starter's call, otherwise the reader's own answer.
  */
 __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
@@ -128,7 +129,9 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     if (caller != g_returnSite.load(std::memory_order_acquire)) {
         return true;
     }
-    const bool forced = core::settings::get().client.regionPrivate;
+    // No public host serves a forced destination, so that run waits forever. It must load solo.
+    const bool forced =
+        core::settings::get().client.regionPrivate || state::activity::forced::override_active();
     report(sliceSet, forced);
     return !forced;
 }

+ 38 - 34
Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp

@@ -18,8 +18,8 @@
 #include "../../../core/logging/log.h"
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../hooking/detour.h"
+#include "../../movement/movement_settings_store.h"
 #include "../../patterns/image_scan.h"
-#include "../../teleport/teleport_settings_store.h"
 #include "runtime.h"
 
 namespace sunrise::client::hooks::noclip {
@@ -29,6 +29,7 @@ namespace {
 constexpr std::string_view kHavokStepText =
     "40 53 48 83 EC 20 83 79 38 01 48 8B D9 77 06 48 8B 01 FF 50 20 F7 43 38 FD FF FF FF "
     "75 09 48 8B 03 48 8B CB FF 50 28";
+/** Masked form of the step text. This is the form the image scan takes. */
 constexpr auto kHavokStep =
     patterns::signature<patterns::signature_length(kHavokStepText)>(kHavokStepText);
 
@@ -80,10 +81,11 @@ struct HavokArray {
     std::uint32_t capacityAndFlags{};
 };
 
-static_assert(sizeof(HavokArray) == 16);
+/** hkArray packs its pointer, size and capacity into 16 bytes with no tail padding. */
+constexpr std::size_t kHavokArrayBytes = 16;
+static_assert(sizeof(HavokArray) == kHavokArrayBytes);
 
 std::atomic_bool g_installed{false};
-std::atomic_bool g_active{false};
 std::atomic_bool g_toggleDown{false};
 std::atomic_bool g_targetValid{false};
 /** The two target floats are one atomic publication, so readers never observe mixed coordinates. */
@@ -158,45 +160,52 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
     return std::bit_cast<std::array<float, 2>>(value);
 }
 
-/** Polls the configured edge-triggered toggle on the physics thread. */
-void poll_toggle() noexcept {
-    const client::teleport::Settings settings = client::teleport::get();
-    const bool usable =
-        settings.noclipEnabled && settings.noclipToggleKey != client::teleport::kNoKey;
-    const bool down =
-        usable && (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
-    if (!usable) {
+/**
+ * Polls the bound key on the physics thread and flips the stored switch when it goes down.
+ * The key and the interface toggle write the same stored value, so there is one on/off state.
+ * @return True while noclip is on.
+ */
+[[nodiscard]] bool poll_toggle() noexcept {
+    const client::movement::Settings settings = client::movement::get();
+    if (settings.noclipToggleKey == client::movement::kNoKey) {
         g_toggleDown.store(false, std::memory_order_relaxed);
-        if (g_active.exchange(false, std::memory_order_acq_rel)) {
-            invalidate_target();
-        }
-        return;
+        return settings.noclipEnabled;
     }
+    const bool down = (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
+    // An open interface owns the keyboard, so the bound key only tracks the press, it never flips.
     if (core::ui::runtime::snapshot().visible) {
         g_toggleDown.store(down, std::memory_order_relaxed);
-        return;
+        return settings.noclipEnabled;
     }
     if (down && !g_toggleDown.exchange(true, std::memory_order_acq_rel)) {
-        const bool enabled = !g_active.load(std::memory_order_acquire);
-        g_active.store(enabled, std::memory_order_release);
+        client::movement::Settings updated = settings;
+        updated.noclipEnabled = !settings.noclipEnabled;
+        if (!client::movement::publish(updated)) {
+            return settings.noclipEnabled;
+        }
         invalidate_target();
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
-                         enabled ? "ev=noclip stage=toggle active=1 mode=rigid_body_position"
-                                 : "ev=noclip stage=toggle active=0 mode=rigid_body_position");
-        return;
+                         updated.noclipEnabled
+                             ? "ev=noclip stage=toggle enabled=1 mode=rigid_body_position"
+                             : "ev=noclip stage=toggle enabled=0 mode=rigid_body_position");
+        return updated.noclipEnabled;
     }
     if (!down) {
         g_toggleDown.store(false, std::memory_order_release);
     }
+    return settings.noclipEnabled;
+}
+
+/** @return True while the stored switch has noclip on. */
+[[nodiscard]] bool enabled() noexcept {
+    return client::movement::get().noclipEnabled;
 }
 
 /** Runs Havok normally, then replaces collision-resolved horizontal position for the character. */
 std::int32_t __fastcall havok_step(std::byte* simulation, float deltaTime) noexcept {
-    poll_toggle();
-
     std::array<float, kVectorLanes> nativeVelocity{};
-    const bool enabledBeforeStep = active();
+    const bool enabledBeforeStep = poll_toggle();
     std::byte* const before = enabledBeforeStep ? character_body(simulation) : nullptr;
     const bool hasVelocity = before != nullptr;
     if (hasVelocity) {
@@ -206,7 +215,8 @@ std::int32_t __fastcall havok_step(std::byte* simulation, float deltaTime) noexc
     const HavokStep next = reinterpret_cast<HavokStep>(g_stepHandle.original);
     const std::int32_t result = next != nullptr ? next(simulation, deltaTime) : 0;
 
-    if (!enabledBeforeStep || !active()) {
+    // Re-read after the step, so a toggle from the interface thread lands before a position write.
+    if (!enabledBeforeStep || !enabled()) {
         return result;
     }
     std::byte* const body = character_body(simulation);
@@ -274,6 +284,7 @@ void report_install_failure(const char* reason) noexcept {
 
 } // namespace
 
+/** Resolves the Havok targets and attaches the simulation-step detour. */
 bool install() noexcept {
     if (g_installed.load(std::memory_order_acquire)) {
         return true;
@@ -303,6 +314,7 @@ bool install() noexcept {
     return true;
 }
 
+/** Detaches the simulation-step detour, then clears the toggle and the horizontal target. */
 void uninstall() noexcept {
     if (!g_installed.exchange(false, std::memory_order_acq_rel)) {
         return;
@@ -310,19 +322,11 @@ void uninstall() noexcept {
     (void)hooking::detour::uninstall(g_stepHandle);
     g_stepHandle = {};
     g_characterMotionVtable = 0;
-    reset();
-}
-
-void reset() noexcept {
-    g_active.store(false, std::memory_order_release);
+    // The switch is a stored setting, so detaching clears only the key state and the target.
     g_toggleDown.store(false, std::memory_order_release);
     invalidate_target();
 }
 
-bool active() noexcept {
-    return g_active.load(std::memory_order_acquire);
-}
-
 void invalidate_target() noexcept {
     g_targetValid.store(false, std::memory_order_release);
 }

+ 0 - 6
Sunrise/src/client/hooks/noclip/runtime.h

@@ -11,12 +11,6 @@ namespace sunrise::client::hooks::noclip {
 /** Detaches the simulation-step detour and clears runtime state. */
 void uninstall() noexcept;
 
-/** Clears the active toggle and horizontal target. */
-void reset() noexcept;
-
-/** @return True while horizontal noclip is toggled on. */
-[[nodiscard]] bool active() noexcept;
-
 /** Invalidates the horizontal target after another feature changes rigid-body position. */
 void invalidate_target() noexcept;
 

+ 4 - 4
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -16,7 +16,7 @@
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../../state/account/account_state.h"
 #include "../../../state/runtime/runtime.h"
-#include "../../teleport/teleport_settings_store.h"
+#include "../../movement/movement_settings_store.h"
 #include "../noclip/runtime.h"
 #include "../polled_input/runtime.h"
 #include "internal.h"
@@ -318,7 +318,7 @@ void set_vertical_velocity(std::byte* body, float value) noexcept {
     }
     report_gates(component, body);
     set_vertical_velocity(body, 0.0F);
-    if (!move_body(body, client::teleport::get().distance)) {
+    if (!move_body(body, client::movement::get().distance)) {
         return false;
     }
     begin_press();
@@ -366,8 +366,8 @@ void capture_forward(std::uint32_t playerIndex) noexcept {
 void poll_request() noexcept {
     end_press();
     expire_request();
-    const client::teleport::Settings settings = client::teleport::get();
-    const bool usable = settings.enabled && settings.virtualKey != client::teleport::kNoKey;
+    const client::movement::Settings settings = client::movement::get();
+    const bool usable = settings.enabled && settings.virtualKey != client::movement::kNoKey;
     g_active.store(usable, std::memory_order_relaxed);
     if (!usable) {
         g_keyDown.store(false, std::memory_order_relaxed);

+ 10 - 6
Sunrise/src/client/teleport/teleport_settings_store.cpp → Sunrise/src/client/movement/movement_settings_store.cpp

@@ -1,6 +1,10 @@
-// Runtime-safe movement settings persisted beside the module after every interface change.
+/**
+ * The movement configuration store. It is separate from Core settings because the interface
+ * changes these values while the game runs and saves each change at once. Core settings are
+ * read once and never change after that.
+ */
 
-#include "teleport_settings_store.h"
+#include "movement_settings_store.h"
 
 #include <Windows.h>
 
@@ -13,11 +17,11 @@
 #include "../../core/filesystem/path.h"
 #include "../../core/logging/log.h"
 
-namespace sunrise::client::teleport {
+namespace sunrise::client::movement {
 namespace {
 
 /** The module-owned configuration file, beside the generated settings and logs. */
-constexpr std::wstring_view kFileSuffix = L"\\teleport.json";
+constexpr std::wstring_view kFileSuffix = L"\\movement.json";
 /** The document is a few scalars, so one small buffer covers both reading and writing. */
 constexpr std::size_t kFileCapacity = 512;
 /** Longest scalar accepted from the file. Anything longer is malformed rather than large. */
@@ -41,7 +45,7 @@ bool g_pathResolved{};
 void report_fail(const char* reason) noexcept {
     std::array<char, 96> line{};
     const int written = std::snprintf(
-        line.data(), line.size(), "ev=teleport stage=store result=fail reason=%s", reason);
+        line.data(), line.size(), "ev=movement stage=store result=fail reason=%s", reason);
     if (written > 0) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
@@ -245,4 +249,4 @@ bool publish(const Settings& settings) noexcept {
     return true;
 }
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 4 - 4
Sunrise/src/client/teleport/teleport_settings_store.h → Sunrise/src/client/movement/movement_settings_store.h

@@ -2,7 +2,7 @@
 
 #include <cstdint>
 
-namespace sunrise::client::teleport {
+namespace sunrise::client::movement {
 
 /** Default distance, in world units along the camera's forward vector. */
 inline constexpr float kDefaultDistance = 10.0F;
@@ -10,10 +10,10 @@ inline constexpr float kDefaultDistance = 10.0F;
 inline constexpr float kMinimumDistance = 1.0F;
 /** Largest offered distance. Past this a press reliably lands through a wall or the floor. */
 inline constexpr float kMaximumDistance = 100.0F;
-/** No key is bound until one is picked, so a fresh install cannot teleport by accident. */
+/** No key is bound until one is picked, so a fresh install cannot fire a movement feature. */
 inline constexpr std::uint32_t kNoKey = 0;
 
-/** Runtime teleport configuration. This module owns it; Core settings do not carry it. */
+/** Runtime movement configuration. This module owns it; Core settings do not carry it. */
 struct Settings {
     bool enabled{false};
     float distance{kDefaultDistance};
@@ -41,4 +41,4 @@ void shutdown() noexcept;
  */
 bool publish(const Settings& settings) noexcept;
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 3 - 6
Sunrise/src/client/patterns/registry.cpp

@@ -10,9 +10,7 @@ constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
 
 /**
  * The one exact byte a pattern's candidate search keys on.
- * Only exact bytes can be searched for, so a pattern without one cannot be scanned at all. That
- * is the same condition the sweep already rejected patterns on, so the anchor doubles as the
- * validity check.
+ * A pattern with no exact byte cannot be scanned, so the anchor doubles as the validity check.
  */
 struct Anchor {
     /** Position of the anchor byte inside the pattern. */
@@ -49,9 +47,8 @@ struct Anchor {
 [[nodiscard]] bool matches_at(std::span<const std::byte> image,
                               std::size_t offset,
                               std::span<const PatternByte> pattern) noexcept {
-    // The offset is checked before the subtraction, so this holds for any caller value rather
-    // than only for the bounded offsets next_candidate produces.
-    if (offset >= image.size() || pattern.size() > image.size() - offset) {
+    // next_candidate never returns an offset past the last whole match, so this cannot wrap.
+    if (pattern.size() > image.size() - offset) {
         return false;
     }
     for (std::size_t index = 0; index < pattern.size(); ++index) {

+ 4 - 27
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -103,31 +103,6 @@ void report_resolve_failure() noexcept {
         core::log::Channel::client, core::log::Level::error, std::string_view(line.data(), length));
 }
 
-/**
- * Reports how long main activation took, for the debug channel only.
- * Timing is diagnostic, so it never appears at the levels a normal run uses.
- * @param event Event and phase text the duration is appended to.
- * @param startedTick Tick count taken when activation began.
- * @param result Outcome text for the log line.
- */
-void report_elapsed(const char* event, std::uint64_t startedTick, const char* result) noexcept {
-    const std::uint64_t elapsed = GetTickCount64() - startedTick;
-    std::array<char, 96> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "%s ms=%llu result=%s",
-                                      event,
-                                      static_cast<unsigned long long>(elapsed),
-                                      result);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
-}
-
 /** Clears both main-image target groups while no game hook owns their entries. */
 void clear_game_targets() noexcept {
     targets::game::content::clear();
@@ -223,8 +198,10 @@ bool activate_main_once() noexcept {
     // presents. That wait belongs to the overlay, not to the work being measured.
     const std::uint64_t startedTick = GetTickCount64();
     const bool active = runtime::activate_required_main_locked();
-    runtime::report_elapsed(
-        "ev=activate stage=main phase=complete", startedTick, active ? "ok" : "fail");
+    core::log::write_elapsed(core::log::Channel::client,
+                             "ev=activate stage=main phase=complete",
+                             startedTick,
+                             active ? "ok" : "fail");
     core::ui::busy::end(core::ui::busy::Task::initialization);
     if (!active) {
         // A failed sweep latches too: repeating it stalls the frame loop for nothing.

+ 4 - 4
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -13,9 +13,9 @@
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
+#include "../movement/movement_settings_store.h"
 #include "../targets/game.h"
 #include "../targets/steam_targets.h"
-#include "../teleport/teleport_settings_store.h"
 #include "../ui/runtime/client_ui_module_runtime.h"
 #include "internal.h"
 #include "runtime.h"
@@ -24,8 +24,8 @@ namespace sunrise::client {
 
 /** Initializes Client-owned process state without installing hooks. */
 bool initialize(void* module) noexcept {
-    // Loaded before the pages register, so the teleport page draws saved values on its first frame.
-    teleport::initialize(module);
+    // Loaded before the pages register, so the movement page draws saved values on its first frame.
+    movement::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -91,7 +91,7 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
-    teleport::shutdown();
+    movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     return true;

+ 1 - 4
Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h

@@ -7,15 +7,12 @@
 
 namespace sunrise::client::targets::game::assert_handler {
 
-// clang-format off
 /**
- * Derives the assert handler setter and slot from the setter copies and the assert sites that read
- * it.
+ * Derives the assert handler setter and slot from the setter copies and the reading sites.
  * @param image Executable ranges from the main game image.
  * @param output Receives the setter, slot and handler currently installed in it.
  * @return True when one candidate wins the vote by the required margin.
  */
-// clang-format on
 [[nodiscard]] bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexcept;
 
 /** @param targets Validated assert table published without failure. */

+ 24 - 29
Sunrise/src/client/ui/teleport/teleport_panel.cpp → Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -1,6 +1,9 @@
-// Movement controls whose changes are persisted immediately.
+/**
+ * The movement module's interface. Every control saves to disk at once, so a change made here
+ * survives the next launch with no settings edit.
+ */
 
-#include "teleport_panel.h"
+#include "movement_panel.h"
 
 #include <Windows.h>
 
@@ -9,10 +12,9 @@
 #include <imgui.h>
 
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
-#include "../../hooks/noclip/runtime.h"
-#include "../../teleport/teleport_settings_store.h"
+#include "../../movement/movement_settings_store.h"
 
-namespace sunrise::client::ui::teleport {
+namespace sunrise::client::ui::movement {
 namespace {
 
 /** Lowest and highest virtual keys the picker scans. Zero is not a key. */
@@ -37,7 +39,7 @@ CaptureTarget g_capturing{CaptureTarget::none};
  * @param output Receives the name.
  */
 void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
-    if (virtualKey == client::teleport::kNoKey) {
+    if (virtualKey == client::movement::kNoKey) {
         (void)std::snprintf(output.data(), output.size(), "None");
         return;
     }
@@ -69,7 +71,7 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
  */
 [[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
     if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
-        picked = client::teleport::kNoKey;
+        picked = client::movement::kNoKey;
         return true;
     }
     for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
@@ -86,14 +88,11 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
 
 /**
  * Draws one key picker while keeping capture ownership exclusive.
- * @param id ImGui identity
- * for the button.
+ * @param id ImGui identity for the button.
  * @param target Binding this picker captures.
- * @param virtualKey Binding value
- * to display and update.
+ * @param virtualKey Binding value to display and update.
  * @param width Button width.
- * @return True when a new binding was
- * captured.
+ * @return True when a new binding was captured.
  */
 [[nodiscard]] bool
 key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, float width) noexcept {
@@ -103,7 +102,7 @@ key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, floa
             g_capturing = CaptureTarget::none;
         }
         ImGui::PopID();
-        std::uint32_t picked = client::teleport::kNoKey;
+        std::uint32_t picked = client::movement::kNoKey;
         if (capture_key(picked)) {
             virtualKey = picked;
             g_capturing = CaptureTarget::none;
@@ -123,9 +122,9 @@ key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, floa
 
 } // namespace
 
-/** Draws the teleport module inside the active Core UI frame. */
+/** Draws the movement module inside the active Core UI frame. */
 void draw() noexcept {
-    client::teleport::Settings settings = client::teleport::get();
+    client::movement::Settings settings = client::movement::get();
     bool changed = false;
 
     ImGui::TextUnformatted("Teleport");
@@ -134,7 +133,8 @@ void draw() noexcept {
                        "Cancels vertical momentum.");
     ImGui::Spacing();
 
-    changed = core::ui::components::toggle::control("Enabled", settings.enabled) || changed;
+    changed =
+        core::ui::components::toggle::control("Enabled##teleport", settings.enabled) || changed;
 
     ImGui::Spacing();
     // One label column and one control column, so the slider and key buttons share both edges.
@@ -149,8 +149,8 @@ void draw() noexcept {
     float distance = settings.distance;
     if (ImGui::SliderFloat("##distance",
                            &distance,
-                           client::teleport::kMinimumDistance,
-                           client::teleport::kMaximumDistance,
+                           client::movement::kMinimumDistance,
+                           client::movement::kMaximumDistance,
                            "%.0f units")) {
         settings.distance = distance;
         changed = true;
@@ -168,16 +168,11 @@ void draw() noexcept {
     ImGui::TextUnformatted("Noclip");
     ImGui::Separator();
     ImGui::TextWrapped("Uses native horizontal rigid-body velocity while preserving the game's "
-                       "vertical movement.");
+                       "vertical movement. The bound key turns it on and off in game.");
     ImGui::Spacing();
 
-    changed = core::ui::components::toggle::control("Available", settings.noclipEnabled) || changed;
-
-    ImGui::Spacing();
-    ImGui::AlignTextToFramePadding();
-    ImGui::TextUnformatted("Active");
-    ImGui::SameLine(labelWidth);
-    ImGui::TextUnformatted(client::hooks::noclip::active() ? "Yes" : "No");
+    changed =
+        core::ui::components::toggle::control("Enabled##noclip", settings.noclipEnabled) || changed;
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
@@ -187,10 +182,10 @@ void draw() noexcept {
         key_picker("noclip_key", CaptureTarget::noclip, settings.noclipToggleKey, controlWidth)
         || changed;
 
-    if (changed && !client::teleport::publish(settings)) {
+    if (changed && !client::movement::publish(settings)) {
         ImGui::Spacing();
         ImGui::TextUnformatted("value out of range, not saved");
     }
 }
 
-} // namespace sunrise::client::ui::teleport
+} // namespace sunrise::client::ui::movement

+ 8 - 0
Sunrise/src/client/ui/movement/movement_panel.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::ui::movement {
+
+/** Draws the movement module inside the active Core UI frame. */
+void draw() noexcept;
+
+} // namespace sunrise::client::ui::movement

+ 7 - 7
Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp

@@ -4,29 +4,29 @@
 
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
-#include "../teleport/teleport_panel.h"
+#include "../movement/movement_panel.h"
 
 namespace sunrise::client::ui::runtime {
 namespace {
 
 /** Namespaced stable ID prevents Client modules from colliding with Server modules. */
-constexpr std::string_view kTeleportStableId = "client.teleport";
+constexpr std::string_view kMovementStableId = "client.movement";
 /** Short menu label for the shared teleport and noclip page. */
-constexpr std::string_view kTeleportDisplayName = "Movement";
+constexpr std::string_view kMovementDisplayName = "Movement";
 
-core::ui::modules::registry::PageRegistration g_teleportPage;
+core::ui::modules::registry::PageRegistration g_movementPage;
 
 } // namespace
 
 /** @return True when the Client module owns its Core UI registry slot. */
 bool initialize() noexcept {
-    return g_teleportPage.acquire(
-        core::ui::modules::Owner::client, kTeleportStableId, kTeleportDisplayName, &teleport::draw);
+    return g_movementPage.acquire(
+        core::ui::modules::Owner::client, kMovementStableId, kMovementDisplayName, &movement::draw);
 }
 
 /** Removes the Client module from the Core UI registry. */
 void shutdown() noexcept {
-    g_teleportPage.release();
+    g_movementPage.release();
 }
 
 } // namespace sunrise::client::ui::runtime

+ 0 - 8
Sunrise/src/client/ui/teleport/teleport_panel.h

@@ -1,8 +0,0 @@
-#pragma once
-
-namespace sunrise::client::ui::teleport {
-
-/** Draws the teleport module inside the active Core UI frame. */
-void draw() noexcept;
-
-} // namespace sunrise::client::ui::teleport

+ 30 - 0
Sunrise/src/core/logging/log.cpp

@@ -2,6 +2,7 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <atomic>
 #include <cstdio>
@@ -28,6 +29,8 @@ constexpr std::wstring_view kPreviousLogSuffix = L".old";
 constexpr std::string_view kLineEnding = "\r\n";
 /** One trailing null byte is kept for the debugger sink. */
 constexpr std::size_t kLineTerminatorBytes = 1;
+/** An elapsed line is one event, a duration and an outcome, so it needs less than a full line. */
+constexpr std::size_t kElapsedCapacity = 128;
 /** Event text stops before the CRLF and the trailing null. */
 constexpr std::size_t kEventTextCapacity =
     kLineCapacity - kLineEnding.size() - kLineTerminatorBytes;
@@ -244,6 +247,33 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
     ReleaseSRWLockShared(&g_log.lock);
 }
 
+/** Formats and emits one debug event carrying a duration in the ms field. */
+void write_elapsed(Channel channel,
+                   std::string_view event,
+                   unsigned long long startedTick,
+                   std::string_view result) noexcept {
+    if (!accepts(channel, Level::debug)) {
+        return;
+    }
+    const unsigned long long elapsed = GetTickCount64() - startedTick;
+    std::array<char, kElapsedCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "%.*s ms=%llu result=%.*s",
+                                      static_cast<int>(event.size()),
+                                      event.data(),
+                                      elapsed,
+                                      static_cast<int>(result.size()),
+                                      result.data());
+    if (written <= 0) {
+        return;
+    }
+    // snprintf reports the length before truncation, so the emitted view is clamped to the buffer.
+    const auto length =
+        std::min(static_cast<std::size_t>(written), line.size() - kLineTerminatorBytes);
+    write(channel, Level::debug, {line.data(), length});
+}
+
 /** @return True while a sink write is in progress. */
 bool writers_active() noexcept {
     return g_writers.load(std::memory_order_acquire) != 0;

+ 13 - 0
Sunrise/src/core/logging/log.h

@@ -50,6 +50,19 @@ void shutdown() noexcept;
 /** Emits one structured event when allowed by the channel threshold. */
 void write(Channel channel, Level level, std::string_view event) noexcept;
 
+/**
+ * Emits one debug event carrying a duration in the ms field.
+ * Timing is diagnostic, so it stays off at the levels a normal run uses.
+ * @param channel Channel owning the measured boundary.
+ * @param event Event and phase text the duration is appended to.
+ * @param startedTick GetTickCount64 value taken when the boundary began.
+ * @param result Outcome text for the log line.
+ */
+void write_elapsed(Channel channel,
+                   std::string_view event,
+                   unsigned long long startedTick,
+                   std::string_view result) noexcept;
+
 /**
  * Reports whether any thread is inside a sink write.
  * A sink holds an operating system lock this process shares, so a thread suspended there

+ 2 - 27
Sunrise/src/core/runtime/core_runtime.cpp

@@ -78,31 +78,6 @@ void report_stage_failure(const char* stage) noexcept {
     log::write(log::Channel::core, log::Level::error, event);
 }
 
-/**
- * Reports how long one boot boundary took, for the debug channel only.
- * Timing is diagnostic, so it never appears at the levels a normal run uses.
- * @param event Event and phase text the duration is appended to.
- * @param startedTick Tick count taken when the boundary began.
- * @param result Outcome text for the log line.
- */
-void report_elapsed(const char* event, std::uint64_t startedTick, const char* result) noexcept {
-    const std::uint64_t elapsed = GetTickCount64() - startedTick;
-    std::array<char, 96> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "%s ms=%llu result=%s",
-                                      event,
-                                      static_cast<unsigned long long>(elapsed),
-                                      result);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    log::write(log::Channel::core, log::Level::debug, {line.data(), length});
-}
-
 } // namespace
 
 /** Initializes every runtime layer in dependency order. */
@@ -154,7 +129,7 @@ bool initialize(void* module) noexcept {
         // Reported before the unwind, so this measures initialization alone and stays comparable
         // with the success line. The unwind's own quiesce waits would otherwise be counted here.
         // A logging-stage failure has no sinks left to carry it, and reports nothing.
-        report_elapsed("ev=initialize phase=complete", startedTick, "fail");
+        log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "fail");
         // Reverse every stage because the failing expression may have completed earlier stages.
         (void)client::shutdown();
         server::shutdown();
@@ -173,7 +148,7 @@ bool initialize(void* module) noexcept {
     }
     g_initialized.store(true, std::memory_order_release);
     log::write(log::Channel::core, log::Level::info, "ev=initialize result=ok");
-    report_elapsed("ev=initialize phase=complete", startedTick, "ok");
+    log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "ok");
     ReleaseSRWLockExclusive(&g_runtimeLock);
     return true;
 }

+ 3 - 3
Sunrise/src/core/settings/client/definition.h

@@ -31,10 +31,10 @@ struct Settings {
     bool forceJoinRequestReady{true};
     /**
      * Reports a public region as private to the region transition.
-     * On, a public region loads solo. Off, it waits for a public activity host.
-     * That host is the only route to the citizen join and the gameplay channel.
+     * On, a public region loads solo. Off, it waits for a public activity host, which is the
+     * route to the citizen join. A forced destination loads solo either way.
      */
-    bool regionPrivate{true};
+    bool regionPrivate{false};
     /**
      * Pins the participation record to the replicated snapshot at `comp + 496`.
      * Off, the record is the local one at `comp + 1256`, whose spawn-gate byte no wire field

+ 2 - 1
Sunrise/src/core/settings/server/gameplay/definition.h

@@ -39,7 +39,8 @@ inline constexpr std::uint16_t kClientLeaseMinimum = 4096;
  * datagram lands after the Client's egress rewrite. Both carry the same port.
  */
 struct Settings {
-    Topology topology{Topology::disabled};
+    /** Embedded by default. Public activities reach each other over the peer protocol. */
+    Topology topology{Topology::embedded};
     /** Local interface the embedded endpoint binds. */
     std::array<unsigned char, kAddressOctets> bindAddress{127, 0, 0, 1};
     /** Address written into the published descriptor. */

+ 3 - 3
Sunrise/src/core/settings/settings.h

@@ -15,10 +15,10 @@ namespace sunrise::core::settings {
 
 /**
  * Layout version of the settings file this build writes and expects.
- * Raise it when a key is renamed, removed, or changes meaning. Adding a key needs no raise,
- * because a missing key already takes its default. An older file is upgraded in place at load.
+ * Raise it when a key is renamed, removed, changes meaning, or must take a new default.
+ * Adding a key needs no raise, because a missing key already takes its default.
  */
-inline constexpr std::uint32_t kSettingsVersion = 3;
+inline constexpr std::uint32_t kSettingsVersion = 4;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 27 - 5
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -17,8 +17,24 @@ namespace {
 
 /** The layout version member, quoted so a value string cannot match it. */
 constexpr std::string_view kVersionMember = "\"version\"";
-/** Members replaced with the bundled default because their value form changed. */
-constexpr std::array<std::string_view, 1> kReplacedMembers{"\"key_bindings\""};
+
+/** One replaced member, and the layout version that changed it. */
+struct ReplacedMember {
+    /** Quoted member name, so a value string cannot match it. */
+    std::string_view name;
+    /** Replaced only while the file is older than this version. */
+    std::uint32_t version;
+};
+
+/**
+ * Members replaced with the bundled default, each with the version that changed it.
+ * A member is listed because its value form changed, or because its default changed.
+ */
+constexpr std::array<ReplacedMember, 3> kReplacedMembers{{
+    {"\"key_bindings\"", 3},
+    {"\"region_private\"", 4},
+    {"\"topology\"", 4},
+}};
 /** One splice per replaced member, plus the version member itself. */
 constexpr std::size_t kSpliceCapacity = kReplacedMembers.size() + 1;
 /** Room for the version member and its digits when the file predates versioning. */
@@ -195,11 +211,17 @@ bool apply(std::string_view document,
             root + 1, root + 1, {versionText.data(), static_cast<std::size_t>(length)}};
     }
 
-    for (const std::string_view member : kReplacedMembers) {
+    const std::uint32_t from = document_version(document);
+    for (const ReplacedMember& member : kReplacedMembers) {
+        // A file at or past that version keeps its own value, so an upgrade never overwrites a
+        // choice the user made against the current layout.
+        if (from >= member.version) {
+            continue;
+        }
         std::size_t replacementStart = 0;
         std::size_t replacementEnd = 0;
-        if (!value_span(document, member, start, end)
-            || !value_span(bundled, member, replacementStart, replacementEnd)) {
+        if (!value_span(document, member.name, start, end)
+            || !value_span(bundled, member.name, replacementStart, replacementEnd)) {
             // A member the file never carried needs no replacement.
             continue;
         }

+ 35 - 15
Sunrise/src/core/settings/steam/steam_settings_parser.cpp

@@ -1,15 +1,17 @@
 #include <algorithm>
 #include <array>
+#include <cstdio>
 #include <string_view>
 
+#include "../../logging/log.h"
 #include "../parser.h"
 
 namespace sunrise::core::settings::parser {
 namespace {
 
-/** Space is the first printable ASCII value accepted by persona storage. */
+/** Space is the first printable ASCII value accepted by fixed string storage. */
 constexpr unsigned int kMinimumPrintableAscii = 0x20;
-/** Tilde is the last printable ASCII value accepted by persona storage. */
+/** Tilde is the last printable ASCII value accepted by fixed string storage. */
 constexpr unsigned int kMaximumPrintableAscii = 0x7E;
 /** Hex letter digits start at the value 10. */
 constexpr unsigned int kHexadecimalAlphaOffset = 10;
@@ -97,16 +99,14 @@ decode_escape(std::string_view encoded, std::size_t& position, char& output) noe
 }
 
 /**
- * Decodes a nonempty printable-ASCII JSON string into fixed storage. Shared by every settings
- * field that stores a short bounded string, so each caller only names its own byte limit.
+ * Decodes a nonempty printable-ASCII JSON string into fixed storage.
+ * The storage reserves its last byte for the trailing null, so that byte bounds the decode.
  * @param encoded Borrowed encoded JSON string bytes.
- * @param maxBytes Largest accepted decoded length, excluding the trailing null.
  * @param output Receives the decoded bytes and a trailing null only on success.
- * @return True for 1 to maxBytes printable ASCII bytes.
+ * @return True for 1 to Capacity minus 1 printable ASCII bytes.
  */
 template <std::size_t Capacity>
 [[nodiscard]] bool decode_bounded_string(std::string_view encoded,
-                                         std::size_t maxBytes,
                                          std::array<char, Capacity>& output) noexcept {
     std::array<char, Capacity> candidate{};
     std::size_t decodedCount = 0;
@@ -117,7 +117,7 @@ template <std::size_t Capacity>
         }
         const auto byte = static_cast<unsigned char>(value);
         if (byte < kMinimumPrintableAscii || byte > kMaximumPrintableAscii
-            || decodedCount >= maxBytes) {
+            || decodedCount + 1 >= Capacity) {
             return false;
         }
         candidate[decodedCount++] = value;
@@ -129,6 +129,9 @@ template <std::size_t Capacity>
     return true;
 }
 
+/** Longest rejected token copied into the fallback line, so that line stays short. */
+constexpr std::size_t kReportedTokenBytes = 32;
+
 /** Destiny 2 only supports these languages; anything else falls back to English. */
 constexpr std::array<std::string_view, 13> kSupportedLanguages{
     "english",
@@ -153,20 +156,38 @@ constexpr std::array<std::string_view, 13> kSupportedLanguages{
 }
 
 /**
- * Resolves an authored Steam API language token, falling back to English for anything that
- * isn't exactly one language this build ships. This also catches a comma-separated list — a
- * shape only GetAvailableGameLanguages should ever receive — which would otherwise leave
- * GetCurrentGameLanguage answering with a value the Client cannot parse as one language.
+ * Names a replaced language token. Settings are read before the log sinks exist, so this early
+ * line is the only report.
+ * @param encoded Borrowed encoded token that was not accepted.
+ */
+void report_language_fallback(std::string_view encoded) noexcept {
+    std::array<char, 96> line{};
+    // The token comes from the file, so its length is capped here rather than trusted.
+    const std::string_view token = encoded.substr(0, std::min(encoded.size(), kReportedTokenBytes));
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=settings stage=language result=fallback token=%.*s",
+                                      static_cast<int>(token.size()),
+                                      token.data());
+    if (written > 0) {
+        log::early({line.data(), std::min(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
+/**
+ * Resolves an authored Steam API language token, falling back to English for anything else.
+ * One token is the only accepted shape, because GetCurrentGameLanguage answers with this value.
  * @param encoded Borrowed encoded JSON string bytes.
  * @return The matching supported code, or the English fallback.
  */
 [[nodiscard]] std::array<char, steam::kLanguageCapacity>
 resolve_language(std::string_view encoded) noexcept {
     std::array<char, steam::kLanguageCapacity> decoded{};
-    if (decode_bounded_string(encoded, steam::kMaximumLanguageBytes, decoded)
+    if (decode_bounded_string(encoded, decoded)
         && is_supported_language(std::string_view(decoded.data()))) {
         return decoded;
     }
+    report_language_fallback(encoded);
     return {"english"};
 }
 
@@ -231,8 +252,7 @@ bool Parser::steam_user_settings(steam::User& output) noexcept {
         if (key == "persona_name") {
             std::string_view value;
             if (hasPersonaName || !string(value)
-                || !decode_bounded_string(
-                    value, steam::kMaximumPersonaNameBytes, candidate.personaName)) {
+                || !decode_bounded_string(value, candidate.personaName)) {
                 return false;
             }
             hasPersonaName = true;

+ 7 - 0
Sunrise/src/state/activity/forced/activity_forced_destination.cpp

@@ -87,6 +87,13 @@ void clear() noexcept {
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 }
 
+/** @return True while the stored selection is complete and its switch is on. */
+bool override_active() noexcept {
+    ForcedDestination value{};
+    snapshot(value);
+    return active(value);
+}
+
 /** Overwrites one committed destination with the forced one. */
 bool apply(destination::DestinationSelection& selection) noexcept {
     ForcedDestination value{};

+ 3 - 0
Sunrise/src/state/activity/forced/activity_forced_destination.h

@@ -21,6 +21,9 @@ void snapshot(ForcedDestination& value) noexcept;
 /** Drops the selection and the switch, the same as the interface's clear action. */
 void clear() noexcept;
 
+/** @return True while the stored selection is complete and its switch is on. */
+[[nodiscard]] bool override_active() noexcept;
+
 /**
  * Overwrites one committed destination with the forced one. The descriptor bits are dropped:
  * they carry the client's chosen name, and one outbound message replays them as-is. The