Selaa lähdekoodia

sunries 0.3.2.0
- fix hunter load ins
- add logo to menu
- add hud
- add debug overlays

stan 3 viikkoa sitten
vanhempi
commit
4aebb148e9
73 muutettua tiedostoa jossa 2251 lisäystä ja 69 poistoa
  1. 1 1
      .clang-tidy
  2. 25 1
      Sunrise/Sunrise.vcxproj
  3. BIN
      Sunrise/resources/logo_sheet.png
  4. 4 2
      Sunrise/resources/resource.h
  5. 1 0
      Sunrise/resources/sunrise.rc
  6. 8 5
      Sunrise/src/client/content/spawn_sets/spawn_set_build.cpp
  7. 10 0
      Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.cpp
  8. 5 0
      Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.h
  9. 11 0
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.h
  10. 35 3
      Sunrise/src/client/hooks/bootflow/world_step.cpp
  11. 5 2
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp
  12. 3 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp
  13. 4 0
      Sunrise/src/client/hooks/graphics/renderer/state.h
  14. 236 0
      Sunrise/src/client/hooks/graphics/textures/graphics_texture_upload.cpp
  15. 24 0
      Sunrise/src/client/hooks/graphics/textures/graphics_texture_upload.h
  16. 7 0
      Sunrise/src/client/hooks/teleport/runtime.h
  17. 7 0
      Sunrise/src/client/hooks/teleport/teleport_lifecycle.cpp
  18. 5 0
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  19. 113 0
      Sunrise/src/client/player/player_position.cpp
  20. 25 0
      Sunrise/src/client/player/player_position.h
  21. 9 1
      Sunrise/src/client/ui/movement/movement_panel.cpp
  22. 6 0
      Sunrise/src/core/runtime/core_runtime.cpp
  23. 2 0
      Sunrise/src/core/ui/components/components.h
  24. 25 0
      Sunrise/src/core/ui/components/label/ui_label_component.h
  25. 98 0
      Sunrise/src/core/ui/components/logo/ui_logo_component.cpp
  26. 13 0
      Sunrise/src/core/ui/components/logo/ui_logo_component.h
  27. 3 2
      Sunrise/src/core/ui/components/section/ui_section_component.cpp
  28. 3 3
      Sunrise/src/core/ui/components/toggle/ui_toggle_component.cpp
  29. 64 0
      Sunrise/src/core/ui/hud/overlay.h
  30. 41 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_logo_overlay.cpp
  31. 8 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_logo_overlay.h
  32. 139 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.cpp
  33. 8 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.h
  34. 238 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.cpp
  35. 8 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.h
  36. 198 0
      Sunrise/src/core/ui/hud/store/hud_settings_store.cpp
  37. 35 0
      Sunrise/src/core/ui/hud/store/hud_settings_store.h
  38. 219 0
      Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp
  39. 37 3
      Sunrise/src/core/ui/layout/ui_layout_render.cpp
  40. 15 0
      Sunrise/src/core/ui/modules/hud/hud.h
  41. 34 0
      Sunrise/src/core/ui/modules/hud/hud_module_runtime.cpp
  42. 43 0
      Sunrise/src/core/ui/modules/hud/hud_panel_render.cpp
  43. 8 0
      Sunrise/src/core/ui/modules/hud/internal.h
  44. 36 0
      Sunrise/src/core/ui/textures/ui_texture_slots.cpp
  45. 29 0
      Sunrise/src/core/ui/textures/ui_texture_slots.h
  46. 3 2
      Sunrise/src/server/bap/encrypted/internal.h
  47. 34 15
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  48. 2 2
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  49. 2 1
      Sunrise/src/server/gameplay/gameplay_advertisement.cpp
  50. 19 1
      Sunrise/src/server/gameplay/group/group_host.cpp
  51. 20 1
      Sunrise/src/server/gameplay/group/group_host.h
  52. 33 7
      Sunrise/src/server/gameplay/group/group_host_sessions.cpp
  53. 15 0
      Sunrise/src/server/gameplay/group/group_host_sessions.h
  54. 13 0
      Sunrise/src/server/gameplay/peer/peer_transport.cpp
  55. 7 0
      Sunrise/src/server/gameplay/peer/peer_transport.h
  56. 3 0
      Sunrise/src/server/ui/activity_override/activity_override_panel.cpp
  57. 2 1
      Sunrise/src/state/build_data/build_data_runtime.cpp
  58. 2 0
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  59. 6 0
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  60. 3 1
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  61. 19 0
      Sunrise/src/state/build_data/cache/records/cache_spawn_set_records.cpp
  62. 6 0
      Sunrise/src/state/build_data/cache/records/codec.h
  63. 3 0
      Sunrise/src/state/build_data/cache/records/domains.h
  64. 15 2
      Sunrise/src/state/build_data/cache/records/format.h
  65. 2 0
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  66. 1 0
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  67. 18 1
      Sunrise/src/state/build_data/runtime.h
  68. 14 3
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  69. 6 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  70. 1 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  71. 12 0
      Sunrise/src/state/build_data/spawn_sets/definition.h
  72. 105 8
      Sunrise/src/state/build_data/spawn_sets/spawn_set_catalog.cpp
  73. 37 1
      Sunrise/src/state/build_data/spawn_sets/spawn_set_catalog.h

+ 1 - 1
.clang-tidy

@@ -74,5 +74,5 @@ ExtraArgsBefore:
   - -Wdocumentation
 FormatStyle: file
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,new,optional,span,string_view,type_traits,utility,variant,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,new,optional,span,string_view,type_traits,utility,variant,vector'
 ...

+ 25 - 1
Sunrise/Sunrise.vcxproj

@@ -56,7 +56,7 @@
     </ClCompile>
     <Link>
       <SubSystem>Windows</SubSystem>
-      <AdditionalDependencies>kernel32.lib;bcrypt.lib;user32.lib;gdi32.lib;dwmapi.lib;d3dcompiler.lib;shell32.lib;ws2_32.lib;synchronization.lib;%(AdditionalDependencies)</AdditionalDependencies>
+      <AdditionalDependencies>kernel32.lib;bcrypt.lib;user32.lib;gdi32.lib;dwmapi.lib;d3dcompiler.lib;shell32.lib;ws2_32.lib;synchronization.lib;ole32.lib;windowscodecs.lib;%(AdditionalDependencies)</AdditionalDependencies>
     </Link>
   </ItemDefinitionGroup>
   <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
@@ -88,6 +88,7 @@
   </ItemGroup>
   <ItemGroup>
     <None Include="resources\default_settings.json" />
+    <None Include="resources\logo_sheet.png" />
     <None Include="vendor\detours\LICENSE.md" />
     <None Include="vendor\imgui\LICENSE.txt" />
   </ItemGroup>
@@ -147,6 +148,14 @@
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_state.cpp" />
     <ClCompile Include="src\core\ui\notice\ui_notice_overlay.cpp" />
+    <ClCompile Include="src\core\ui\hud\ui_hud_overlay_stack.cpp" />
+    <ClCompile Include="src\core\ui\hud\overlays\ui_hud_logo_overlay.cpp" />
+    <ClCompile Include="src\core\ui\hud\overlays\ui_hud_session_overlay.cpp" />
+    <ClCompile Include="src\core\ui\hud\overlays\ui_hud_status_overlay.cpp" />
+    <ClCompile Include="src\core\ui\hud\store\hud_settings_store.cpp" />
+    <ClCompile Include="src\core\ui\textures\ui_texture_slots.cpp" />
+    <ClCompile Include="src\core\ui\modules\hud\hud_module_runtime.cpp" />
+    <ClCompile Include="src\core\ui\modules\hud\hud_panel_render.cpp" />
     <ClCompile Include="src\core\ui\modules\logs\logs_panel_render.cpp" />
     <ClCompile Include="src\core\ui\modules\logs\filters\logs_filter_controls.cpp" />
     <ClCompile Include="src\core\ui\runtime\ui_visibility_runtime.cpp" />
@@ -155,6 +164,7 @@
     <ClCompile Include="src\core\ui\animation\transition\ui_transition_animation.cpp" />
     <ClCompile Include="src\core\ui\components\card\ui_card_component.cpp" />
     <ClCompile Include="src\core\ui\components\filter\ui_filter_component.cpp" />
+    <ClCompile Include="src\core\ui\components\logo\ui_logo_component.cpp" />
     <ClCompile Include="src\core\ui\components\picker\ui_picker_component.cpp" />
     <ClCompile Include="src\core\ui\components\navigation\ui_navigation_component.cpp" />
     <ClCompile Include="src\core\ui\components\section\ui_section_component.cpp" />
@@ -175,6 +185,7 @@
     <ClCompile Include="src\client\hooks\graphics\graphics_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\graphics_hook_replacements.cpp" />
     <ClCompile Include="src\client\hooks\graphics\input\graphics_window_input.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\textures\graphics_texture_upload.cpp" />
     <ClCompile Include="src\client\hooks\graphics\input\graphics_raw_input_window.cpp" />
     <ClCompile Include="src\client\hooks\polled_input\polled_input_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\polled_input\polled_input_replacements.cpp" />
@@ -207,6 +218,7 @@
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
     <ClCompile Include="src\client\ui\player\player_panel.cpp" />
+    <ClCompile Include="src\client\player\player_position.cpp" />
     <ClCompile Include="src\client\player\player_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\sword_skate\sword_skate.cpp" />
@@ -736,6 +748,14 @@
     <ClInclude Include="src\core\ui\busy\busy.h" />
     <ClInclude Include="src\core\ui\busy\ui_busy_state.h" />
     <ClInclude Include="src\core\ui\notice\ui_notice_overlay.h" />
+    <ClInclude Include="src\core\ui\hud\overlay.h" />
+    <ClInclude Include="src\core\ui\hud\overlays\ui_hud_logo_overlay.h" />
+    <ClInclude Include="src\core\ui\hud\overlays\ui_hud_session_overlay.h" />
+    <ClInclude Include="src\core\ui\hud\overlays\ui_hud_status_overlay.h" />
+    <ClInclude Include="src\core\ui\hud\store\hud_settings_store.h" />
+    <ClInclude Include="src\core\ui\textures\ui_texture_slots.h" />
+    <ClInclude Include="src\core\ui\modules\hud\hud.h" />
+    <ClInclude Include="src\core\ui\modules\hud\internal.h" />
     <ClInclude Include="src\core\ui\modules\logs\internal.h" />
     <ClInclude Include="src\core\ui\modules\logs\filters\logs_filter_controls.h" />
     <ClInclude Include="src\core\ui\runtime\ui_visibility_runtime.h" />
@@ -748,6 +768,8 @@
     <ClInclude Include="src\core\ui\components\card\ui_card_component.h" />
     <ClInclude Include="src\core\ui\components\drawing\drawing.h" />
     <ClInclude Include="src\core\ui\components\filter\ui_filter_component.h" />
+    <ClInclude Include="src\core\ui\components\label\ui_label_component.h" />
+    <ClInclude Include="src\core\ui\components\logo\ui_logo_component.h" />
     <ClInclude Include="src\core\ui\components\picker\ui_picker_component.h" />
     <ClInclude Include="src\core\ui\components\navigation\ui_navigation_component.h" />
     <ClInclude Include="src\core\ui\components\section\ui_section_component.h" />
@@ -765,6 +787,7 @@
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
     <ClInclude Include="src\client\ui\player\player_panel.h" />
+    <ClInclude Include="src\client\player\player_position.h" />
     <ClInclude Include="src\client\player\player_settings_store.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\sword_skate\sword_skate.h" />
@@ -779,6 +802,7 @@
     <ClInclude Include="src\client\hooks\graphics\graphics_hook_lifecycle.h" />
     <ClInclude Include="src\client\hooks\graphics\graphics_hook_replacements.h" />
     <ClInclude Include="src\client\hooks\graphics\input\input.h" />
+    <ClInclude Include="src\client\hooks\graphics\textures\graphics_texture_upload.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\renderer.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\state.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\graphics_renderer_report.h" />

BIN
Sunrise/resources/logo_sheet.png


+ 4 - 2
Sunrise/resources/resource.h

@@ -6,11 +6,13 @@
 #define IDR_IMGUI_LICENSE 102
 /** The next module-local RCDATA identifier embeds the required Microsoft Detours notice. */
 #define IDR_DETOURS_LICENSE 103
+/** The next module-local RCDATA identifier holds the animated logo sprite sheet, as a PNG. */
+#define IDR_LOGO_SHEET 104
 
 /** The four numeric fields of the version resource, in FILEVERSION order. */
 #define SUNRISE_VER_MAJOR 0
 #define SUNRISE_VER_MINOR 3
-#define SUNRISE_VER_PATCH 1
+#define SUNRISE_VER_PATCH 2
 #define SUNRISE_VER_BUILD 0
 /** The same version as display text. Windows shows this string, not the four fields. */
-#define SUNRISE_VER_STRING "0.3.1.0"
+#define SUNRISE_VER_STRING "0.3.2.0"

+ 1 - 0
Sunrise/resources/sunrise.rc

@@ -5,6 +5,7 @@
 IDR_DEFAULT_SETTINGS RCDATA "default_settings.json"
 IDR_IMGUI_LICENSE RCDATA "../vendor/imgui/LICENSE.txt"
 IDR_DETOURS_LICENSE RCDATA "../vendor/detours/LICENSE.md"
+IDR_LOGO_SHEET RCDATA "logo_sheet.png"
 
 VS_VERSION_INFO VERSIONINFO
 FILEVERSION SUNRISE_VER_MAJOR, SUNRISE_VER_MINOR, SUNRISE_VER_PATCH, SUNRISE_VER_BUILD

+ 8 - 5
Sunrise/src/client/content/spawn_sets/spawn_set_build.cpp

@@ -97,11 +97,13 @@ void report(const Storage& storage, const char* result) noexcept {
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=build_data stage=spawn_sets tags=%zu stems=%zu "
-                                      "hashes=%zu bound=%zu components=%zu containers=%zu "
-                                      "read=%zu skipped=%zu ms=%llu result=%s",
+                                      "hashes=%zu points=%zu dropped=%zu bound=%zu components=%zu "
+                                      "containers=%zu read=%zu skipped=%zu ms=%llu result=%s",
                                       storage.entryCount,
                                       storage.stemCount,
                                       storage.nameHashCount,
+                                      storage.pointCount,
+                                      storage.pointsDropped,
                                       storage.spawnComponentCount,
                                       storage.componentCount,
                                       storage.containerCount,
@@ -145,7 +147,7 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
             // A build that installs no spawn set has a whole empty catalogue, not a failed pass.
             report(storage, storage.entryCount == 0 ? "empty" : "sweep");
             reset(storage);
-            return state::build_data::publish_spawn_sets({}, {});
+            return state::build_data::publish_spawn_sets({}, {}, {});
         }
         readDeadlineTick = GetTickCount64() + kReadWindowMs;
         report(storage, "collected");
@@ -163,14 +165,15 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
         // A retry cannot help, and an empty catalogue still finishes the pipeline.
         report(storage, "read");
         reset(storage);
-        return state::build_data::publish_spawn_sets({}, {});
+        return state::build_data::publish_spawn_sets({}, {}, {});
     }
     if (result != AdvanceResult::complete) {
         return false;
     }
     const bool published = state::build_data::publish_spawn_sets(
         std::span(storage.stems).first(storage.stemCount),
-        std::span(storage.nameHashes).first(storage.nameHashCount));
+        std::span(storage.nameHashes).first(storage.nameHashCount),
+        std::span(storage.points).first(storage.pointCount));
     report(storage, published ? "ok" : "publish");
     if (published) {
         bytes.clear();

+ 10 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.cpp

@@ -289,6 +289,13 @@ consume(Storage& storage, const Entry& entry, std::span<const std::byte> blob) n
         if (!record_point(storage, entry.stemIndex, point.nameHash, entryIndex)) {
             return false;
         }
+        // A full point bank costs the closest-spawn query rows, nothing else. So it is counted.
+        if (storage.pointCount < storage.points.size()) {
+            storage.points[storage.pointCount] = {point.position, point.nameHash, entry.stemIndex};
+            ++storage.pointCount;
+        } else {
+            ++storage.pointsDropped;
+        }
         ++recorded;
     }
     storage.stems[entry.stemIndex].pointCount += recorded;
@@ -433,6 +440,9 @@ void reset(Storage& storage) noexcept {
     storage.accumulatedCount = 0;
     storage.nameHashes.fill(spawn_state::NameHash{});
     storage.nameHashCount = 0;
+    storage.points.fill(spawn_state::Point{});
+    storage.pointCount = 0;
+    storage.pointsDropped = 0;
     storage.cursor = 0;
     storage.skipped = 0;
     storage.collected = false;

+ 5 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.h

@@ -104,6 +104,11 @@ struct Storage {
     std::size_t accumulatedCount{};
     std::array<spawn_state::NameHash, spawn_state::kNameHashCapacity> nameHashes{};
     std::size_t nameHashCount{};
+    /** Every point's position and set, in extraction order. The closest-spawn query reads it. */
+    std::array<spawn_state::Point, spawn_state::kPointCapacity> points{};
+    std::size_t pointCount{};
+    /** Points the bank had no room for. The rest of the catalogue is unaffected. */
+    std::size_t pointsDropped{};
     /** Entry the next batch resumes from, so the pass continues across calls. */
     std::size_t cursor{};
     /** Entries whose blob never read inside the window, so the pass moved past them. */

+ 11 - 0
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.h

@@ -15,4 +15,15 @@ void uninstall() noexcept;
 /** @return True while at least one boot-step fix is attached. */
 [[nodiscard]] bool is_installed() noexcept;
 
+/** Publishes the client's own boot-flow step. Call it per frame, on a game thread. */
+void poll_world_step() noexcept;
+
+/**
+ * Reports whether the player is in a loaded destination.
+ * The step is published by a frame poll, so a tick that stops reads as out of world rather than
+ * as the last step for ever.
+ * @return True while the published step is `activity:in_world` and fresh.
+ */
+[[nodiscard]] bool in_world() noexcept;
+
 } // namespace sunrise::client::hooks::bootflow

+ 35 - 3
Sunrise/src/client/hooks/bootflow/world_step.cpp

@@ -1,9 +1,12 @@
+#include <Windows.h>
+
 #include <atomic>
 #include <cstdint>
 #include <string_view>
 
 #include "../../../core/logging/log.h"
 #include "../../../state/activity/runtime.h"
+#include "bootflow_hook_lifecycle.h"
 #include "internal.h"
 
 namespace sunrise::client::hooks::bootflow {
@@ -24,20 +27,49 @@ constexpr auto kStepSignature = signature<signature_length(kStepSignatureText)>(
 constexpr std::int32_t kActivityLoadFirst = 33;
 /** `activity:in_world`. The fade is armed by then, so a spawn now releases it. */
 constexpr std::int32_t kInWorld = 38;
+/** No step has been published. */
+constexpr std::int32_t kNoStep = -1;
+/** A published step older than this says nothing: the tick that publishes it has stopped. */
+constexpr std::uint64_t kStepStaleMs = 1'000;
 
 using GetStep = std::int64_t(__fastcall*)() noexcept;
 
 std::atomic<GetStep> g_step{nullptr};
+/** Last step the frame poll read, for readers that are not on the game thread. */
+std::atomic_int32_t g_publishedStep{kNoStep};
+/** Tick that step was read on. A stale value reads as out of world. */
+std::atomic_uint64_t g_publishedTick{0};
+
+/** @return The current step, or the absent one when the accessor is missing. */
+[[nodiscard]] std::int32_t read_step() noexcept {
+    const GetStep read = g_step.load(std::memory_order_acquire);
+    return read == nullptr ? kNoStep : static_cast<std::int32_t>(read() & 0xFFFFFFFF);
+}
 
 } // namespace
 
+/** Publishes the client's own boot-flow step. */
+void poll_world_step() noexcept {
+    g_publishedStep.store(read_step(), std::memory_order_relaxed);
+    g_publishedTick.store(GetTickCount64(), std::memory_order_release);
+}
+
+/** Reports whether the player is in a loaded destination. */
+bool in_world() noexcept {
+    if (g_publishedStep.load(std::memory_order_relaxed) != kInWorld) {
+        return false;
+    }
+    const std::uint64_t published = g_publishedTick.load(std::memory_order_acquire);
+    return published != 0 && GetTickCount64() - published < kStepStaleMs;
+}
+
 /** Maps the client's own boot-flow step onto the world phase. */
 void observe_world_step() noexcept {
-    const GetStep read = g_step.load(std::memory_order_acquire);
-    if (read == nullptr) {
+    // A missing accessor leaves the phase alone. A step of -1 is a real answer: off a destination.
+    if (g_step.load(std::memory_order_acquire) == nullptr) {
         return;
     }
-    const auto step = static_cast<std::int32_t>(read() & 0xFFFFFFFF);
+    const std::int32_t step = read_step();
     state::activity::WorldPhase phase = state::activity::WorldPhase::idle;
     if (step == kInWorld) {
         phase = state::activity::WorldPhase::arrived;

+ 5 - 2
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp

@@ -7,6 +7,7 @@
 
 #include "../../../../core/ui/busy/busy.h"
 #include "../../../../core/ui/fonts/runtime/ui_runtime_font_lifecycle.h"
+#include "../../../../core/ui/hud/overlay.h"
 #include "../../../../core/ui/layout/layout.h"
 #include "../../../../core/ui/notice/ui_notice_overlay.h"
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
@@ -140,11 +141,13 @@ void render_frame_locked() noexcept {
     ImGui_ImplWin32_NewFrame();
     ImGui::NewFrame();
     // A hidden surface still draws until its close animation ends, so the layout decides. The
-    // running-work overlay draws whether the surface is open or not.
+    // HUD, running-work and notice overlays draw whether the surface is open or not. The HUD
+    // goes first, so the surface stays above it when the two meet.
+    const bool hudDrawn = core::ui::hud::draw(visibility.enabled);
     const bool surfaceDrawn = core::ui::layout::render(visibility.visible);
     const bool busyDrawn = core::ui::busy::draw();
     const bool noticeDrawn = core::ui::notice::draw();
-    if (!surfaceDrawn && !busyDrawn && !noticeDrawn) {
+    if (!hudDrawn && !surfaceDrawn && !busyDrawn && !noticeDrawn) {
         // A frame nobody claimed still drains backend state, and sends no draw data.
         ImGui::EndFrame();
         return;

+ 3 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp

@@ -184,6 +184,8 @@ constexpr std::array<ViewFormat, 6> kTypelessViewFormats{
         return discard_staged(staged);
     }
     staged.dx11BackendInitialized = true;
+    // The interface draws its card without the logo when the sheet cannot be uploaded.
+    (void)textures::upload_logo_sheet(staged.device, staged.logoSheet);
     if (!input::install(staged.window)) {
         report::note(report::Stage::init, report::Reason::windowInput);
         return discard_staged(staged);
@@ -246,6 +248,7 @@ void release_render_target(Resources& resources) noexcept {
 /** @param resources SDK resources freed in an order that respects their dependencies. */
 void release_resources(Resources& resources) noexcept {
     release_render_target(resources);
+    textures::release_logo_sheet(resources.logoSheet);
     release_com(resources.context);
     release_com(resources.device);
     release_com(resources.swapChain);

+ 4 - 0
Sunrise/src/client/hooks/graphics/renderer/state.h

@@ -6,6 +6,8 @@
 #include <d3d11.h>
 #include <dxgi.h>
 
+#include "../textures/graphics_texture_upload.h"
+
 namespace sunrise::client::hooks::graphics::renderer {
 
 /** The SDK objects we own and the started presentation layers, for one swap chain. */
@@ -14,6 +16,8 @@ struct Resources {
     ID3D11Device* device{};
     ID3D11DeviceContext* context{};
     ID3D11RenderTargetView* renderTarget{};
+    /** Bundled logo sheet, uploaded on this device for the interface to draw. */
+    textures::Uploaded logoSheet{};
     HWND window{};
     bool layoutInitialized{};
     bool win32BackendInitialized{};

+ 236 - 0
Sunrise/src/client/hooks/graphics/textures/graphics_texture_upload.cpp

@@ -0,0 +1,236 @@
+/**
+ * Upload of bundled images into renderer textures. The interface draws them, so it owns the
+ * published identifiers, and this layer owns the objects behind them.
+ */
+
+#include "graphics_texture_upload.h"
+
+#include <Windows.h>
+
+#include <string_view>
+#include <wincodec.h>
+
+#include "../../../../../resources/resource.h"
+#include "../../../../core/logging/log.h"
+#include "../../../../core/ui/textures/ui_texture_slots.h"
+
+namespace sunrise::client::hooks::graphics::textures {
+namespace {
+
+/** A PNG holds one image, so the sheet is always the decoder's first frame. */
+constexpr UINT kSheetFrameIndex = 0;
+/** One mip level and one array slice: the sheet is drawn at its own scale. */
+constexpr UINT kSingleLevel = 1;
+/** The texture is not multisampled, which is quality level 0 of one sample. */
+constexpr UINT kSingleSample = 1;
+constexpr UINT kDefaultQuality = 0;
+/** A 2D upload has no slice pitch. */
+constexpr UINT kNoSlicePitch = 0;
+
+/** @param object COM object we own. Released and cleared when set. */
+template <typename Interface> void release_com(Interface*& object) noexcept {
+    if (object != nullptr) {
+        object->Release();
+        object = nullptr;
+    }
+}
+
+/** @param line Whole log line. @return Always false, so callers can return it directly. */
+[[nodiscard]] bool fail(std::string_view line) noexcept {
+    core::log::write(core::log::Channel::client, core::log::Level::warn, line);
+    return false;
+}
+
+/** Balances one COM initialization on the calling thread. */
+class ComScope final {
+public:
+    ComScope() noexcept : result_(CoInitializeEx(nullptr, COINIT_MULTITHREADED)) {}
+
+    /** Undoes only an initialization this scope performed. */
+    ~ComScope() noexcept {
+        if (SUCCEEDED(result_)) {
+            CoUninitialize();
+        }
+    }
+
+    ComScope(const ComScope&) = delete;
+    ComScope& operator=(const ComScope&) = delete;
+    ComScope(ComScope&&) = delete;
+    ComScope& operator=(ComScope&&) = delete;
+
+    /** @return True when COM can be called here, including a thread that already entered it. */
+    [[nodiscard]] bool usable() const noexcept {
+        return SUCCEEDED(result_) || result_ == RPC_E_CHANGED_MODE;
+    }
+
+private:
+    HRESULT result_{};
+};
+
+/** @return The module holding the Sunrise resources, resolved from this code's own address. */
+[[nodiscard]] HMODULE owning_module() noexcept {
+    HMODULE module = nullptr;
+    (void)GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
+                                 | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+                             reinterpret_cast<LPCWSTR>(&owning_module),
+                             &module);
+    return module;
+}
+
+/**
+ * Borrows the bundled sheet bytes out of the module resources.
+ * @param bytes Receives the resource bytes, owned by the module.
+ * @param size Receives the resource size.
+ * @return True when the resource is present and not empty.
+ */
+[[nodiscard]] bool bundled_sheet(const void*& bytes, DWORD& size) noexcept {
+    const HMODULE module = owning_module();
+    const HRSRC resource = module != nullptr
+                               ? FindResourceW(module, MAKEINTRESOURCEW(IDR_LOGO_SHEET), RT_RCDATA)
+                               : nullptr;
+    if (resource == nullptr) {
+        return false;
+    }
+    const DWORD resourceSize = SizeofResource(module, resource);
+    const HGLOBAL loaded = LoadResource(module, resource);
+    const void* resourceBytes = loaded != nullptr ? LockResource(loaded) : nullptr;
+    if (resourceSize == 0 || resourceBytes == nullptr) {
+        return false;
+    }
+    bytes = resourceBytes;
+    size = resourceSize;
+    return true;
+}
+
+/**
+ * Decodes the bundled PNG into one cached bitmap with red, green, blue and alpha bytes.
+ * @param factory Imaging factory used for every decode step.
+ * @param output Receives the decoded bitmap. Null when any step fails.
+ * @return True when the bitmap is decoded.
+ */
+[[nodiscard]] bool decode_sheet(IWICImagingFactory* factory, IWICBitmap*& output) noexcept {
+    const void* bytes = nullptr;
+    DWORD size = 0;
+    if (!bundled_sheet(bytes, size)) {
+        return false;
+    }
+    IWICStream* stream = nullptr;
+    IWICBitmapDecoder* decoder = nullptr;
+    IWICBitmapFrameDecode* frame = nullptr;
+    IWICBitmapSource* converted = nullptr;
+    // The stream only reads the resource, which the module maps read-only.
+    auto* mutableBytes = static_cast<WICInProcPointer>(const_cast<void*>(bytes));
+    const bool decoded =
+        SUCCEEDED(factory->CreateStream(&stream))
+        && SUCCEEDED(stream->InitializeFromMemory(mutableBytes, size))
+        && SUCCEEDED(factory->CreateDecoderFromStream(
+            stream, nullptr, WICDecodeMetadataCacheOnDemand, &decoder))
+        && SUCCEEDED(decoder->GetFrame(kSheetFrameIndex, &frame))
+        && SUCCEEDED(WICConvertBitmapSource(GUID_WICPixelFormat32bppRGBA, frame, &converted))
+        && SUCCEEDED(factory->CreateBitmapFromSource(converted, WICBitmapCacheOnLoad, &output));
+    release_com(converted);
+    release_com(frame);
+    release_com(decoder);
+    release_com(stream);
+    return decoded;
+}
+
+/**
+ * Creates one immutable texture and its view straight from the locked bitmap pixels.
+ * @param device Device that creates and owns both objects.
+ * @param bitmap Decoded bitmap, locked for reading here.
+ * @param output Receives both objects. Left alone when any step fails.
+ * @return True when the texture and its view exist.
+ */
+[[nodiscard]] bool
+create_texture(ID3D11Device* device, IWICBitmap* bitmap, Uploaded& output) noexcept {
+    UINT width = 0;
+    UINT height = 0;
+    if (FAILED(bitmap->GetSize(&width, &height))) {
+        return false;
+    }
+    const WICRect area{0, 0, static_cast<INT>(width), static_cast<INT>(height)};
+    IWICBitmapLock* lock = nullptr;
+    if (FAILED(bitmap->Lock(&area, WICBitmapLockRead, &lock))) {
+        return false;
+    }
+
+    UINT stride = 0;
+    UINT pixelBytes = 0;
+    BYTE* pixels = nullptr;
+    Uploaded created{};
+    bool uploaded = SUCCEEDED(lock->GetStride(&stride))
+                    && SUCCEEDED(lock->GetDataPointer(&pixelBytes, &pixels)) && pixels != nullptr;
+    if (uploaded) {
+        D3D11_TEXTURE2D_DESC description{};
+        description.Width = width;
+        description.Height = height;
+        description.MipLevels = kSingleLevel;
+        description.ArraySize = kSingleLevel;
+        // Byte order matches the decoded format, so the pixels upload with no conversion.
+        description.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
+        description.SampleDesc.Count = kSingleSample;
+        description.SampleDesc.Quality = kDefaultQuality;
+        // The sheet never changes after upload, so the driver may place it where it likes.
+        description.Usage = D3D11_USAGE_IMMUTABLE;
+        description.BindFlags = D3D11_BIND_SHADER_RESOURCE;
+        const D3D11_SUBRESOURCE_DATA initial{pixels, stride, kNoSlicePitch};
+        uploaded =
+            SUCCEEDED(device->CreateTexture2D(&description, &initial, &created.texture))
+            && SUCCEEDED(device->CreateShaderResourceView(created.texture, nullptr, &created.view));
+    }
+    release_com(lock);
+    if (!uploaded) {
+        release_com(created.view);
+        release_com(created.texture);
+        return false;
+    }
+    output = created;
+    return true;
+}
+
+} // namespace
+
+/** Decodes the bundled logo sheet and publishes its view to the Core interface. */
+bool upload_logo_sheet(ID3D11Device* device, Uploaded& output) noexcept {
+    if (device == nullptr) {
+        return false;
+    }
+    const ComScope com;
+    if (!com.usable()) {
+        return fail("ev=texture stage=logo_sheet result=fail reason=com");
+    }
+    IWICImagingFactory* factory = nullptr;
+    if (FAILED(CoCreateInstance(
+            CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)))) {
+        return fail("ev=texture stage=logo_sheet result=fail reason=factory");
+    }
+    IWICBitmap* bitmap = nullptr;
+    const bool decoded = decode_sheet(factory, bitmap);
+    release_com(factory);
+    if (!decoded) {
+        return fail("ev=texture stage=logo_sheet result=fail reason=decode");
+    }
+    const bool created = create_texture(device, bitmap, output);
+    release_com(bitmap);
+    if (!created) {
+        return fail("ev=texture stage=logo_sheet result=fail reason=texture");
+    }
+    core::ui::textures::publish(core::ui::textures::Slot::logoSheet,
+                                reinterpret_cast<ImTextureID>(output.view));
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::debug,
+                     "ev=texture stage=logo_sheet result=ok");
+    return true;
+}
+
+/** Empties the published slot, then releases both objects. */
+void release_logo_sheet(Uploaded& uploaded) noexcept {
+    if (uploaded.view != nullptr) {
+        core::ui::textures::publish(core::ui::textures::Slot::logoSheet, ImTextureID_Invalid);
+    }
+    release_com(uploaded.view);
+    release_com(uploaded.texture);
+}
+
+} // namespace sunrise::client::hooks::graphics::textures

+ 24 - 0
Sunrise/src/client/hooks/graphics/textures/graphics_texture_upload.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <d3d11.h>
+
+namespace sunrise::client::hooks::graphics::textures {
+
+/** One uploaded image: the texture we own and the view the interface draws with. */
+struct Uploaded {
+    ID3D11Texture2D* texture{};
+    ID3D11ShaderResourceView* view{};
+};
+
+/**
+ * Decodes the bundled logo sheet and publishes its view to the Core interface.
+ * @param device Device that creates and owns the texture.
+ * @param output Receives the created objects. Left alone when any step fails.
+ * @return True when the sheet is uploaded and published.
+ */
+[[nodiscard]] bool upload_logo_sheet(ID3D11Device* device, Uploaded& output) noexcept;
+
+/** @param uploaded Objects released and cleared, after the published slot is emptied. */
+void release_logo_sheet(Uploaded& uploaded) noexcept;
+
+} // namespace sunrise::client::hooks::graphics::textures

+ 7 - 0
Sunrise/src/client/hooks/teleport/runtime.h

@@ -95,6 +95,13 @@ void apply_pending(void* component) noexcept;
  */
 [[nodiscard]] bool read_position(void* component, Vector& position) noexcept;
 
+/**
+ * Reports the physics component the local player was last seen driving.
+ * The sync stops for a player at rest, so a frame poll has no other way back to them.
+ * @return That component, or null before the player has been seen. Prove it before use.
+ */
+[[nodiscard]] void* local_player_component() noexcept;
+
 /**
  * Writes the world position of the body a physics component drives.
  * @param component Physics component.

+ 7 - 0
Sunrise/src/client/hooks/teleport/teleport_lifecycle.cpp

@@ -13,6 +13,8 @@
 
 #include "../../../core/logging/log.h"
 #include "../../hooking/detour.h"
+#include "../../player/player_position.h"
+#include "../bootflow/bootflow_hook_lifecycle.h"
 #include "../fly/fly.h"
 #include "../polled_input/runtime.h"
 #include "../sword_skate/sword_skate.h"
@@ -81,6 +83,8 @@ std::int64_t __fastcall camera_transform(std::uint32_t playerIndex) noexcept {
     force_pending();
     // Read here, not on the physics tick: that tick stops for a player who is standing still.
     hooks::fly::poll_toggle();
+    client::player::position::poll();
+    hooks::bootflow::poll_world_step();
     return result;
 }
 
@@ -97,6 +101,8 @@ std::int64_t __fastcall physics_sync(std::byte* component, std::byte* outFlags)
     // is written and read inside this tick, so it has to run here and not on a frame poll.
     hooks::sword_skate::apply(component);
     hooks::fly::apply(component);
+    // This tick is the only one that sees every component, so it is where the player's is found.
+    client::player::position::observe(component);
     const PhysicsSync next = original<PhysicsSync>(kPhysicsSlot);
     return next != nullptr ? next(component, outFlags) : 0;
 }
@@ -195,6 +201,7 @@ void uninstall() noexcept {
     clear_targets();
     clear_action_keys();
     hooks::fly::reset();
+    client::player::position::reset();
     polled_input::release_key();
     (void)hooking::detour::uninstall(g_handles);
     g_handles = {};

+ 5 - 0
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -433,6 +433,11 @@ void force_pending() noexcept {
         core::log::Channel::client, core::log::Level::info, "ev=teleport stage=force result=ok");
 }
 
+/** Reports the physics component the local player was last seen driving. */
+void* local_player_component() noexcept {
+    return g_playerComponent.load(std::memory_order_relaxed);
+}
+
 /** @param component Candidate physics component. @return True when the local player drives it. */
 bool owns_local_player(void* component) noexcept {
     return component != nullptr && g_controlledHandle != nullptr

+ 113 - 0
Sunrise/src/client/player/player_position.cpp

@@ -0,0 +1,113 @@
+/**
+ * The local player's published world position.
+ * The game threads write it and the interface reads it, so a seqlock guards the vector.
+ */
+
+#include "player_position.h"
+
+#include <atomic>
+#include <cstdint>
+
+namespace sunrise::client::player::position {
+namespace {
+
+namespace teleport = hooks::teleport;
+
+/** Odd while a write is in progress, so a reader that sees one retries. */
+std::atomic_uint32_t g_sequence{0};
+/** Written between two sequence bumps, and read between two equal even reads. */
+teleport::Vector g_position{};
+std::atomic_bool g_present{false};
+/**
+ * The player's physics component, found on the sync tick.
+ * It is kept here rather than taken from the teleport hook, because that hook only caches one
+ * while the teleport feature is switched on.
+ */
+std::atomic<void*> g_component{nullptr};
+
+/**
+ * Reads one component's body position and publishes it.
+ * @param component Component already proved to be the player's.
+ * @return True when the body was read. A failed read leaves the last position published.
+ */
+[[nodiscard]] bool publish_from(void* component) noexcept {
+    teleport::Vector position{};
+    // The body is gone at rest and during a load, so the last position stands until a new one
+    // reads back. A player who cannot be read has not moved.
+    if (!teleport::read_position(component, position)) {
+        return false;
+    }
+    g_sequence.fetch_add(1, std::memory_order_acq_rel);
+    g_position = position;
+    g_sequence.fetch_add(1, std::memory_order_release);
+    g_present.store(true, std::memory_order_release);
+    return true;
+}
+
+} // namespace
+
+/** Publishes the position of the component the physics sync is running for. */
+void observe(void* component) noexcept {
+    if (component == nullptr) {
+        return;
+    }
+    void* const known = g_component.load(std::memory_order_relaxed);
+    if (known == component) {
+        (void)publish_from(component);
+        return;
+    }
+    // The ownership test is paid only until the player's component is known. The frame poll drops
+    // a stale one, which is what lets a new destination's component be found.
+    if (known != nullptr || !teleport::owns_local_player(component)) {
+        return;
+    }
+    g_component.store(component, std::memory_order_relaxed);
+    (void)publish_from(component);
+}
+
+/** Refreshes the position for a player at rest, and drops a component that is no longer theirs. */
+void poll() noexcept {
+    void* component = g_component.load(std::memory_order_relaxed);
+    if (component == nullptr) {
+        // The teleport hook keeps one too whenever its own feature is on.
+        component = teleport::local_player_component();
+    }
+    if (component == nullptr) {
+        return;
+    }
+    if (!teleport::owns_local_player(component)) {
+        g_component.store(nullptr, std::memory_order_relaxed);
+        g_present.store(false, std::memory_order_release);
+        return;
+    }
+    g_component.store(component, std::memory_order_relaxed);
+    (void)publish_from(component);
+}
+
+/** Drops the published position. */
+void reset() noexcept {
+    g_component.store(nullptr, std::memory_order_relaxed);
+    g_present.store(false, std::memory_order_release);
+}
+
+/** @return The last published position. */
+Snapshot snapshot() noexcept {
+    Snapshot value{};
+    if (!g_present.load(std::memory_order_acquire)) {
+        return value;
+    }
+    for (;;) {
+        const std::uint32_t before = g_sequence.load(std::memory_order_acquire);
+        if ((before & 1U) != 0U) {
+            continue;
+        }
+        value.position = g_position;
+        if (g_sequence.load(std::memory_order_acquire) == before) {
+            break;
+        }
+    }
+    value.present = true;
+    return value;
+}
+
+} // namespace sunrise::client::player::position

+ 25 - 0
Sunrise/src/client/player/player_position.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include "../hooks/teleport/runtime.h"
+
+namespace sunrise::client::player::position {
+
+/** The local player's world position, or nothing when they have not been seen. */
+struct Snapshot {
+    hooks::teleport::Vector position{};
+    bool present{};
+};
+
+/** Publishes the position of the component the physics sync is running for. */
+void observe(void* component) noexcept;
+
+/** Refreshes the position for a player at rest. Call it per frame, on a game thread. */
+void poll() noexcept;
+
+/** Drops the published position. */
+void reset() noexcept;
+
+/** @return The last published position, which any thread may read. */
+[[nodiscard]] Snapshot snapshot() noexcept;
+
+} // namespace sunrise::client::player::position

+ 9 - 1
Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -11,12 +11,15 @@
 #include <cstdio>
 #include <imgui.h>
 
+#include "../../../core/ui/components/label/ui_label_component.h"
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
 #include "../../movement/movement_settings_store.h"
 
 namespace sunrise::client::ui::movement {
 namespace {
 
+namespace label = core::ui::components::label;
+
 /** Lowest and highest virtual keys the picker scans. Zero is not a key. */
 constexpr int kFirstVirtualKey = 1;
 constexpr int kLastVirtualKey = 254;
@@ -140,10 +143,11 @@ void draw() noexcept {
     ImGui::Spacing();
     // One label column and one control column, so the slider and key buttons share both edges.
     const float labelWidth =
-        ImGui::CalcTextSize("Toggle key").x + ImGui::GetStyle().ItemSpacing.x * 2;
+        label::inset() + ImGui::CalcTextSize("Toggle key").x + ImGui::GetStyle().ItemSpacing.x * 2;
     const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
 
     ImGui::AlignTextToFramePadding();
+    label::align();
     ImGui::TextUnformatted("Distance");
     ImGui::SameLine(labelWidth);
     ImGui::SetNextItemWidth(controlWidth);
@@ -159,6 +163,7 @@ void draw() noexcept {
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
+    label::align();
     ImGui::TextUnformatted("Key");
     ImGui::SameLine(labelWidth);
     changed = key_picker("teleport_key", CaptureTarget::teleport, settings.virtualKey, controlWidth)
@@ -176,6 +181,7 @@ void draw() noexcept {
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
+    label::align();
     ImGui::TextUnformatted("Toggle key");
     ImGui::SameLine(labelWidth);
     changed =
@@ -193,6 +199,7 @@ void draw() noexcept {
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
+    label::align();
     ImGui::TextUnformatted("Toggle key");
     ImGui::SameLine(labelWidth);
     changed =
@@ -200,6 +207,7 @@ void draw() noexcept {
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
+    label::align();
     ImGui::TextUnformatted("Speed");
     ImGui::SameLine(labelWidth);
     ImGui::SetNextItemWidth(controlWidth);

+ 6 - 0
Sunrise/src/core/runtime/core_runtime.cpp

@@ -19,6 +19,7 @@
 #include "../filesystem/path.h"
 #include "../logging/log.h"
 #include "../settings/settings.h"
+#include "../ui/modules/hud/hud.h"
 #include "../ui/modules/logs/logs.h"
 #include "../ui/modules/registry/ui_module_registry.h"
 #include "../ui/runtime/ui_visibility_runtime.h"
@@ -106,6 +107,9 @@ bool initialize(void* module) noexcept {
         log::write(log::Channel::core, log::Level::debug, "ev=initialize phase=begin");
         if (!ui::runtime::initialize(settings::get().client.userInterface)) {
             stage = "ui";
+        } else if (!ui::modules::hud::initialize(module)) {
+            // Registered before logs, which is the order the menu lists the Core pages in.
+            stage = "ui_hud";
         } else if (!ui::modules::logs::initialize()) {
             stage = "ui_logs";
         } else if (!state::entitlements::publish(settings::get().server.entitlements)) {
@@ -138,6 +142,7 @@ bool initialize(void* module) noexcept {
         state::shutdown();
         state::entitlements::clear();
         ui::modules::logs::shutdown();
+        ui::modules::hud::shutdown();
         ui::modules::registry::shutdown();
         ui::runtime::shutdown();
         state::unlocks::clear();
@@ -172,6 +177,7 @@ bool shutdown() noexcept {
     state::shutdown();
     state::entitlements::clear();
     ui::modules::logs::shutdown();
+    ui::modules::hud::shutdown();
     ui::modules::registry::shutdown();
     ui::runtime::shutdown();
     log::write(log::Channel::core, log::Level::info, "ev=shutdown result=ok");

+ 2 - 0
Sunrise/src/core/ui/components/components.h

@@ -2,6 +2,8 @@
 
 #include "card/ui_card_component.h"
 #include "filter/ui_filter_component.h"
+#include "label/ui_label_component.h"
+#include "logo/ui_logo_component.h"
 #include "navigation/ui_navigation_component.h"
 #include "picker/ui_picker_component.h"
 #include "section/ui_section_component.h"

+ 25 - 0
Sunrise/src/core/ui/components/label/ui_label_component.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <imgui.h>
+
+#include "../../scaling/dpi/ui_dpi_scaling.h"
+
+namespace sunrise::core::ui::components::label {
+
+/** 10 authored pixels inset a label from the left edge of its row. */
+constexpr float kInset = 10.0F;
+
+/** @return The shared label inset, scaled for the display. */
+[[nodiscard]] inline float inset() noexcept {
+    return scaling::dpi::pixels(kInset);
+}
+
+/** Moves the cursor so the next item starts at the shared label inset. */
+inline void align() noexcept {
+    if (ImGui::GetCurrentContext() == nullptr) {
+        return;
+    }
+    ImGui::SetCursorPosX(ImGui::GetCursorPosX() + inset());
+}
+
+} // namespace sunrise::core::ui::components::label

+ 98 - 0
Sunrise/src/core/ui/components/logo/ui_logo_component.cpp

@@ -0,0 +1,98 @@
+/**
+ * The animated Sunrise logo. One bundled sprite sheet is uploaded as a single texture, and each
+ * frame is picked out of it with texture coordinates.
+ */
+
+#include "ui_logo_component.h"
+
+#include <cmath>
+#include <imgui.h>
+
+#include "../../textures/ui_texture_slots.h"
+
+namespace sunrise::core::ui::components::logo {
+namespace {
+
+/** Sheet geometry of the bundled logo. It is a square 11 by 11 grid of square frames. */
+constexpr float kSheetExtent = 1408.0F;
+constexpr float kFrameExtent = 128.0F;
+constexpr int kFrameColumns = 11;
+/** The animation uses 112 of the 121 cells. The 9 cells after it are empty. */
+constexpr int kFrameCount = 112;
+/** Every frame is shown for the same 33 ms, so one loop runs 3.696 seconds. */
+constexpr double kFrameSeconds = 0.033;
+/** The art is grayscale, so this gold pair colors it, deep at the top and light at the bottom. */
+constexpr ImU32 kGradientTop = IM_COL32(0xCB, 0x9B, 0x51, 0xFF);
+constexpr ImU32 kGradientBottom = IM_COL32(0xF6, 0xE2, 0x7A, 0xFF);
+/** One quad is two triangles: six indices over four corner vertices. */
+constexpr int kQuadIndexCount = 6;
+constexpr int kQuadVertexCount = 4;
+/** Offsets from the quad's first vertex, which is written top-left first and then clockwise. */
+constexpr ImDrawIdx kTopRightOffset = 1;
+constexpr ImDrawIdx kBottomRightOffset = 2;
+constexpr ImDrawIdx kBottomLeftOffset = 3;
+
+/** @return The sheet cell to show now. */
+[[nodiscard]] int current_frame() noexcept {
+    const double elapsed = ImGui::GetTime() / kFrameSeconds;
+    return static_cast<int>(std::fmod(elapsed, static_cast<double>(kFrameCount)));
+}
+
+/**
+ * Writes one textured quad whose color runs down its vertical axis. Dear ImGui has no
+ * multi-color image call, so the four corner vertices carry the two colors themselves.
+ * @param sheet Texture the quad samples.
+ * @param minimum Top-left screen corner.
+ * @param maximum Bottom-right screen corner.
+ * @param uvMinimum Texture coordinate of the top-left corner.
+ * @param uvMaximum Texture coordinate of the bottom-right corner.
+ */
+void draw_gradient_quad(ImTextureID sheet,
+                        const ImVec2& minimum,
+                        const ImVec2& maximum,
+                        const ImVec2& uvMinimum,
+                        const ImVec2& uvMaximum) noexcept {
+    // Both colors take the style alpha, so the logo fades with the surface that holds it.
+    const ImU32 top = ImGui::GetColorU32(kGradientTop);
+    const ImU32 bottom = ImGui::GetColorU32(kGradientBottom);
+    ImDrawList* drawList = ImGui::GetWindowDrawList();
+    drawList->PushTexture(ImTextureRef(sheet));
+    drawList->PrimReserve(kQuadIndexCount, kQuadVertexCount);
+    const auto first = static_cast<ImDrawIdx>(drawList->_VtxCurrentIdx);
+    drawList->PrimWriteIdx(first);
+    drawList->PrimWriteIdx(static_cast<ImDrawIdx>(first + kTopRightOffset));
+    drawList->PrimWriteIdx(static_cast<ImDrawIdx>(first + kBottomRightOffset));
+    drawList->PrimWriteIdx(first);
+    drawList->PrimWriteIdx(static_cast<ImDrawIdx>(first + kBottomRightOffset));
+    drawList->PrimWriteIdx(static_cast<ImDrawIdx>(first + kBottomLeftOffset));
+    drawList->PrimWriteVtx(minimum, uvMinimum, top);
+    drawList->PrimWriteVtx({maximum.x, minimum.y}, {uvMaximum.x, uvMinimum.y}, top);
+    drawList->PrimWriteVtx(maximum, uvMaximum, bottom);
+    drawList->PrimWriteVtx({minimum.x, maximum.y}, {uvMinimum.x, uvMaximum.y}, bottom);
+    drawList->PopTexture();
+}
+
+} // namespace
+
+/** Draws the current frame of the animated Sunrise logo at the cursor. */
+bool draw(float extent) noexcept {
+    const ImTextureID sheet = textures::get(textures::Slot::logoSheet);
+    if (sheet == ImTextureID_Invalid) {
+        return false;
+    }
+    const int frame = current_frame();
+    const float step = kFrameExtent / kSheetExtent;
+    const float column = static_cast<float>(frame % kFrameColumns) * step;
+    const float row = static_cast<float>(frame / kFrameColumns) * step;
+    const ImVec2 origin = ImGui::GetCursorScreenPos();
+    draw_gradient_quad(sheet,
+                       origin,
+                       {origin.x + extent, origin.y + extent},
+                       {column, row},
+                       {column + step, row + step});
+    // The quad goes straight into the draw list, so the layout is told what space it took.
+    ImGui::Dummy({extent, extent});
+    return true;
+}
+
+} // namespace sunrise::core::ui::components::logo

+ 13 - 0
Sunrise/src/core/ui/components/logo/ui_logo_component.h

@@ -0,0 +1,13 @@
+#pragma once
+
+namespace sunrise::core::ui::components::logo {
+
+/**
+ * Draws the current frame of the animated Sunrise logo at the cursor. The art is grayscale, so
+ * it is tinted with the theme accent.
+ * @param extent Final framebuffer size of the square logo.
+ * @return True when the sheet is uploaded and a frame was drawn. Nothing is drawn otherwise.
+ */
+[[nodiscard]] bool draw(float extent) noexcept;
+
+} // namespace sunrise::core::ui::components::logo

+ 3 - 2
Sunrise/src/core/ui/components/section/ui_section_component.cpp

@@ -3,14 +3,15 @@
 #include <imgui.h>
 
 #include "../../scaling/dpi/ui_dpi_scaling.h"
+#include "../label/ui_label_component.h"
 
 namespace sunrise::core::ui::components::section {
 namespace {
 
 /** 3 authored pixels keep the rail visible beside short and wrapped headers. */
 constexpr float kRailWidth = 3.0F;
-/** 7 authored pixels separate the rail from the first title glyph. */
-constexpr float kRailSpacing = 7.0F;
+/** The rest of the shared inset, so the title starts on the same column as every other label. */
+constexpr float kRailSpacing = label::kInset - kRailWidth;
 /** 2 authored pixels inset the rail ends inside the whole header group. */
 constexpr float kRailVerticalInset = 2.0F;
 /** 3 authored pixels soften the rail without making a large pill. */

+ 3 - 3
Sunrise/src/core/ui/components/toggle/ui_toggle_component.cpp

@@ -6,6 +6,7 @@
 #include "../../animation/transition/ui_transition_animation.h"
 #include "../../scaling/dpi/ui_dpi_scaling.h"
 #include "../drawing/drawing.h"
+#include "../label/ui_label_component.h"
 
 namespace sunrise::core::ui::components::toggle {
 namespace {
@@ -24,8 +25,6 @@ constexpr float kThumbInset = 3.0F;
 constexpr float kThumbRadius = 7.0F;
 /** 8 authored pixels round the row without making it pill-shaped. */
 constexpr float kRowRounding = 8.0F;
-/** 10 authored pixels keep labels away from the row edge. */
-constexpr float kLabelInset = 10.0F;
 /** 8 authored pixels keep long labels from touching the switch track. */
 constexpr float kLabelTrackSpacing = 8.0F;
 /** One authored pixel holds the track shape over active and inactive fills. */
@@ -110,7 +109,8 @@ bool control(const char* label, bool& value, float width) noexcept {
         {thumbX, thumbY}, thumbRadius, ImGui::GetColorU32(style.Colors[ImGuiCol_Text]));
 
     const ImVec2 textSize = ImGui::CalcTextSize(label, nullptr, true);
-    const ImVec2 textPosition{position.x + scaling::dpi::pixels(kLabelInset),
+    // Qualified from the parent namespace, because the label parameter hides the namespace name.
+    const ImVec2 textPosition{position.x + components::label::inset(),
                               position.y + ((controlHeight - textSize.y) * 0.5F)};
     const float textMaximumX = trackX - scaling::dpi::pixels(kLabelTrackSpacing);
     if (textMaximumX > textPosition.x) {

+ 64 - 0
Sunrise/src/core/ui/hud/overlay.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::core::ui::hud {
+
+/** Every HUD overlay, in the order the menu lists them and the corner stacks them. */
+enum class Overlay : std::uint8_t {
+    /** The Sunrise name, version and animated logo. */
+    logoCard,
+    /** Where the player is: activity, bubble, slice set and closest spawn. */
+    currentStatus,
+    /** The instances of the session the player is in. */
+    session,
+    count,
+};
+
+/** Every line of the current-status overlay, each with its own switch. */
+enum class StatusLine : std::uint8_t {
+    activity,
+    bubble,
+    sliceSet,
+    closestSpawn,
+    count,
+};
+
+/**
+ * Resolves the switch file and applies the saved state. It runs at boot, before the first
+ * frame, which is the only point another thread touches the switches.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ */
+void initialize(void* module) noexcept;
+
+/** Drops the switch file path. The switches keep their values. */
+void shutdown() noexcept;
+
+/** @param overlay Overlay to name. @return Its menu label. */
+[[nodiscard]] const char* display_name(Overlay overlay) noexcept;
+
+/** @param overlay Overlay to read. @return True while it draws. */
+[[nodiscard]] bool enabled(Overlay overlay) noexcept;
+
+/** @param overlay Overlay to switch. @param on New switch state. */
+void set_enabled(Overlay overlay, bool on) noexcept;
+
+/** @param line Status line to name. @return Its menu label. */
+[[nodiscard]] const char* display_name(StatusLine line) noexcept;
+
+/** @param line Status line to read. @return True while it draws. */
+[[nodiscard]] bool enabled(StatusLine line) noexcept;
+
+/** @param line Status line to switch. @param on New switch state. */
+void set_enabled(StatusLine line, bool on) noexcept;
+
+/**
+ * Draws every enabled overlay, stacked down the top-left corner. It runs whether the menu is
+ * open or not.
+ * @param interfaceEnabled Core UI enabled state. A disabled interface draws no overlay, because
+ * the menu that switches them off cannot be opened either.
+ * @return True when draw data was built for at least one overlay.
+ */
+[[nodiscard]] bool draw(bool interfaceEnabled) noexcept;
+
+} // namespace sunrise::core::ui::hud

+ 41 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_logo_overlay.cpp

@@ -0,0 +1,41 @@
+#include "ui_hud_logo_overlay.h"
+
+#include <algorithm>
+#include <imgui.h>
+
+#include "../../../../../resources/resource.h"
+#include "../../components/logo/ui_logo_component.h"
+#include "../../scaling/dpi/ui_dpi_scaling.h"
+
+namespace sunrise::core::ui::hud::overlays::logo {
+namespace {
+
+/** 44 authored pixels make the logo as tall as the two text rows beside it. */
+constexpr float kLogoExtent = 44.0F;
+/** The card carries the name and the version, one row each. */
+constexpr float kTextRowCount = 2.0F;
+/** Half a difference centers the shorter column against the taller one. */
+constexpr float kHalfExtent = 2.0F;
+/** The card names the tool with the same wordmark the main surface carries. */
+constexpr char kTitle[] = "SUNRISE";
+
+} // namespace
+
+/** Draws the Sunrise card inside the overlay window the stack has already started. */
+void draw() noexcept {
+    const float extent = scaling::dpi::pixels(kLogoExtent);
+    if (components::logo::draw(extent)) {
+        ImGui::SameLine();
+        // The text column is shorter than the logo, so it starts lower to sit level with it.
+        const float textHeight =
+            (ImGui::GetTextLineHeight() * kTextRowCount) + ImGui::GetStyle().ItemSpacing.y;
+        const float offset = (std::max)(extent - textHeight, 0.0F) / kHalfExtent;
+        ImGui::SetCursorPosY(ImGui::GetCursorPosY() + offset);
+    }
+    ImGui::BeginGroup();
+    ImGui::TextUnformatted(kTitle);
+    ImGui::TextDisabled("%s", SUNRISE_VER_STRING);
+    ImGui::EndGroup();
+}
+
+} // namespace sunrise::core::ui::hud::overlays::logo

+ 8 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_logo_overlay.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::core::ui::hud::overlays::logo {
+
+/** Draws the Sunrise card inside the overlay window the stack has already started. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::hud::overlays::logo

+ 139 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.cpp

@@ -0,0 +1,139 @@
+/**
+ * The session overlay. One row per instance the player is in.
+ * A row names the region, its group session, its activity host, its channel and its join.
+ */
+
+#include "ui_hud_session_overlay.h"
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <imgui.h>
+
+#include "../../../../middleware/content/packages/tables/region_reader.h"
+#include "../../../../server/gameplay/group/group_host.h"
+#include "../../../../server/gameplay/group/group_host_sessions.h"
+#include "../../../../server/gameplay/peer/peer_transport.h"
+#include "../../../../state/gameplay/definition.h"
+
+namespace sunrise::core::ui::hud::overlays::session {
+namespace {
+
+namespace group = server::gameplay::group;
+namespace peer = server::gameplay::peer;
+namespace tables = middleware::content::packages::tables;
+
+/** Instances one snapshot reads. The host table holds no more than this. */
+constexpr std::size_t kRowCapacity = 8;
+/** Admitted peers one snapshot reads. */
+constexpr std::size_t kAdmittedCapacity = 8;
+/** Columns of the instance table, in draw order. */
+constexpr int kColumnCount = 6;
+/** Shown while this host serves no instance. */
+constexpr char kNoInstance[] = "no session instances";
+/** Shown for a row no advertisement gave a region. */
+constexpr char kNoBubble[] = "-";
+
+/** Channel stage names, in PeerStage order. */
+constexpr std::array<const char*, 6> kChannelStages{
+    "absent", "allocated", "teardown", "connecting", "establishing", "connected"};
+
+/** @param stage Link stage. @return Its name, or the absent one for a value out of range. */
+[[nodiscard]] const char* channel_name(state::gameplay::PeerStage stage) noexcept {
+    const auto index = static_cast<std::size_t>(stage);
+    return index < kChannelStages.size() ? kChannelStages[index] : kChannelStages[0];
+}
+
+/** Names how far one join has got. @param count Admitted rows in use. @return The join word. */
+[[nodiscard]] const char*
+join_name(const std::array<group::AdmittedRow, kAdmittedCapacity>& admitted,
+          std::size_t count,
+          std::uint64_t sessionId) noexcept {
+    for (std::size_t index = 0; index < count; ++index) {
+        const group::AdmittedRow& row = admitted[index];
+        if (row.sessionId != sessionId) {
+            continue;
+        }
+        if (!row.joinComplete) {
+            return "joining";
+        }
+        return row.activityHostPublished ? "ready" : "joined";
+    }
+    return "unjoined";
+}
+
+/** Draws one cell of hexadecimal identity. @param value Session id, or zero when there is none. */
+void draw_session_cell(std::uint64_t value) noexcept {
+    if (value == 0) {
+        ImGui::TextDisabled("pending");
+        return;
+    }
+    ImGui::Text("%016llX", static_cast<unsigned long long>(value));
+}
+
+/** Draws the region and bubble cells. @param region Region the advertisement named, or -1. */
+void draw_region_cells(std::int32_t region) noexcept {
+    if (region < 0) {
+        ImGui::TextDisabled("%s", kNoBubble);
+        ImGui::TableNextColumn();
+        ImGui::TextDisabled("%s", kNoBubble);
+        return;
+    }
+    ImGui::Text("%d", static_cast<int>(region));
+    ImGui::TableNextColumn();
+    ImGui::Text("%u", static_cast<unsigned>(region) / tables::kSliceSetIndexFactor);
+}
+
+} // namespace
+
+/** Draws the session instance table inside the overlay window the stack has already started. */
+void draw() noexcept {
+    std::array<group::HostSessionRow, kRowCapacity> rows{};
+    std::size_t rowCount = 0;
+    group::snapshot_host_sessions(rows, rowCount);
+    // A host session outlives the instance on purpose: the peer may rotate back into a region it
+    // never left, and a fresh id there is a hard error. So the link is what says it is still live.
+    std::array<state::gameplay::PeerStage, kRowCapacity> stages{};
+    std::size_t live = 0;
+    for (std::size_t index = 0; index < rowCount; ++index) {
+        if (peer::link_stage(rows[index].groupSessionId, stages[live])) {
+            rows[live] = rows[index];
+            ++live;
+        }
+    }
+    if (live == 0) {
+        ImGui::TextDisabled("%s", kNoInstance);
+        return;
+    }
+    std::array<group::AdmittedRow, kAdmittedCapacity> admitted{};
+    std::size_t admittedCount = 0;
+    group::snapshot_admitted(admitted, admittedCount);
+
+    if (!ImGui::BeginTable("##sunrise_hud_session_table", kColumnCount)) {
+        return;
+    }
+    ImGui::TableSetupColumn("region");
+    ImGui::TableSetupColumn("bubble");
+    ImGui::TableSetupColumn("group session");
+    ImGui::TableSetupColumn("activity host");
+    ImGui::TableSetupColumn("channel");
+    ImGui::TableSetupColumn("join");
+    ImGui::TableHeadersRow();
+    for (std::size_t index = 0; index < live; ++index) {
+        const group::HostSessionRow& row = rows[index];
+        ImGui::TableNextRow();
+        ImGui::TableNextColumn();
+        draw_region_cells(row.regionIndex);
+        ImGui::TableNextColumn();
+        draw_session_cell(row.groupSessionId);
+        ImGui::TableNextColumn();
+        draw_session_cell(row.hostSessionId);
+        ImGui::TableNextColumn();
+        ImGui::TextUnformatted(channel_name(stages[index]));
+        ImGui::TableNextColumn();
+        ImGui::TextUnformatted(join_name(admitted, admittedCount, row.groupSessionId));
+    }
+    ImGui::EndTable();
+}
+
+} // namespace sunrise::core::ui::hud::overlays::session

+ 8 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::core::ui::hud::overlays::session {
+
+/** Draws the session instance table inside the overlay window the stack has already started. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::hud::overlays::session

+ 238 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.cpp

@@ -0,0 +1,238 @@
+/**
+ * The current-status overlay. Each line says where the player is, and each has its own switch.
+ * Every value comes from published State.
+ */
+
+#include "ui_hud_status_overlay.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <imgui.h>
+#include <string_view>
+
+#include "../../../../client/hooks/bootflow/bootflow_hook_lifecycle.h"
+#include "../../../../client/player/player_position.h"
+#include "../../../../middleware/content/packages/tables/region_reader.h"
+#include "../../../../middleware/content/packages/tables/spawn_reader.h"
+#include "../../../../state/activity/destination/activity_destination_snapshot.h"
+#include "../../../../state/activity/membership/activity_membership_query.h"
+#include "../../../../state/activity/runtime.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../overlay.h"
+
+namespace sunrise::core::ui::hud::overlays::status {
+namespace {
+
+namespace activity = state::activity;
+namespace layouts = state::build_data::scenarios;
+namespace tables = middleware::content::packages::tables;
+
+/** Room for the widest value line this overlay builds. */
+constexpr std::size_t kValueCapacity = 96;
+/** Widest label, which sets the value column for every row. */
+constexpr char kWidestLabel[] = "Closest spawn";
+/** Shown while no destination is loaded. */
+constexpr char kOutOfWorld[] = "not in world";
+/** Shown for a value the published State does not name. */
+constexpr char kUnknown[] = "unknown";
+/** Shown while the player's position has not been read yet. */
+constexpr char kNoPosition[] = "waiting for a position";
+/** Shown when every line of this overlay is switched off. */
+constexpr char kNoLines[] = "every status line is off";
+/** How long a closest-spawn result is kept before the bank is searched again. */
+constexpr std::uint64_t kSpawnSearchIntervalMs = 250;
+
+/** One value line, built once per frame. */
+using Value = std::array<char, kValueCapacity>;
+
+/** Everything the overlay draws, read in one pass so the lines cannot disagree. */
+struct Status {
+    Value activity{};
+    Value bubble{};
+    Value sliceSet{};
+    Value spawn{};
+    bool inWorld{};
+};
+
+/** The last point search. It is kept between frames because the bank is large. */
+struct SpawnResult {
+    Value text{};
+    std::uint64_t searchedTick{};
+    bool valid{};
+};
+
+SpawnResult g_spawn{};
+
+/** @param value Text to store. @param output Receives it with a null. */
+void assign(std::string_view value, Value& output) noexcept {
+    output = {};
+    (void)std::snprintf(
+        output.data(), output.size(), "%.*s", static_cast<int>(value.size()), value.data());
+}
+
+/** Names one hash. @param storage Gets the found row. @return The name, or an empty view. */
+[[nodiscard]] std::string_view resolve_name(std::uint32_t hash,
+                                            state::build_data::hash_names::Name& storage) noexcept {
+    if (!state::build_data::find_hash_name(hash, storage)) {
+        return {};
+    }
+    return {storage.name.data(), storage.nameLength};
+}
+
+/** Names one spawn set. The packages name few, so the two the client defines are named here. */
+[[nodiscard]] std::string_view
+spawn_set_name(std::uint32_t hash, state::build_data::hash_names::Name& storage) noexcept {
+    if (hash == tables::kDefaultSpawnNameHash) {
+        return "default";
+    }
+    if (hash == tables::kUnnamedSpawnNameHash) {
+        return "unnamed";
+    }
+    return resolve_name(hash, storage);
+}
+
+/** @param selection Committed destination. @return Its package name as a bounded view. */
+[[nodiscard]] std::string_view
+name_of(const activity::destination::DestinationSelection& selection) noexcept {
+    return {reinterpret_cast<const char*>(selection.packageName.data()),
+            selection.packageNameLength};
+}
+
+/** Fills the bubble line from the destination layout the region belongs to. */
+void build_bubble(const layouts::Definition& layout, std::int32_t region, Value& output) noexcept {
+    const auto bubble = static_cast<std::size_t>(region) / tables::kSliceSetIndexFactor;
+    if (region < 0 || bubble >= layout.bubbleCount) {
+        assign(kUnknown, output);
+        return;
+    }
+    state::build_data::hash_names::Name storage{};
+    const std::string_view named = resolve_name(layout.bubbleHashes[bubble], storage);
+    (void)std::snprintf(output.data(),
+                        output.size(),
+                        "%zu  0x%08X%s%.*s",
+                        bubble,
+                        layout.bubbleHashes[bubble],
+                        named.empty() ? "" : "  ",
+                        static_cast<int>(named.size()),
+                        named.data());
+}
+
+/** Fills the slice-set line, which is the region index and the state inside its bubble. */
+void build_slice_set(std::int32_t region, Value& output) noexcept {
+    if (region < 0) {
+        assign(kUnknown, output);
+        return;
+    }
+    const auto index = static_cast<std::uint32_t>(region);
+    (void)std::snprintf(
+        output.data(), output.size(), "%u  state %u", index, index % tables::kSliceSetIndexFactor);
+}
+
+/**
+ * Finds the spawn nearest the player, at most once per interval.
+ * The bank holds every installed point, so a search costs more than one frame should.
+ * @param stem Map stem of the loaded destination. @param output Receives the kept line.
+ */
+void build_spawn(std::string_view stem, Value& output) noexcept {
+    const client::player::position::Snapshot player = client::player::position::snapshot();
+    const std::uint64_t now = GetTickCount64();
+    if (!player.present || stem.empty()) {
+        assign(kNoPosition, output);
+        return;
+    }
+    if (g_spawn.valid && now - g_spawn.searchedTick < kSpawnSearchIntervalMs) {
+        output = g_spawn.text;
+        return;
+    }
+    state::build_data::spawn_sets::Point point{};
+    float distance = 0.0F;
+    g_spawn = {};
+    g_spawn.searchedTick = now;
+    if (!state::build_data::find_nearest_spawn_point(stem, player.position, point, distance)) {
+        assign(kUnknown, output);
+        return;
+    }
+    state::build_data::hash_names::Name storage{};
+    const std::string_view named = spawn_set_name(point.nameHash, storage);
+    (void)std::snprintf(g_spawn.text.data(),
+                        g_spawn.text.size(),
+                        "0x%08X%s%.*s  %.1f units",
+                        point.nameHash,
+                        named.empty() ? "" : "  ",
+                        static_cast<int>(named.size()),
+                        named.data(),
+                        static_cast<double>(distance));
+    g_spawn.valid = true;
+    output = g_spawn.text;
+}
+
+/** @return Every line's text, read from published State in one pass. */
+[[nodiscard]] Status read_status() noexcept {
+    Status status{};
+    const std::uint64_t sessionId =
+        activity::membership::live_region_session(activity::kAbsentSessionId);
+    // The client's own step, published every frame. The world phase only moves on the spawn gate,
+    // which stops being polled once the player is in, so it stays `arrived` in orbit.
+    status.inWorld = client::hooks::bootflow::in_world() && sessionId != activity::kAbsentSessionId;
+    if (!status.inWorld) {
+        g_spawn = {};
+        // The next destination has its own map, so a position from this one must not carry over.
+        client::player::position::reset();
+        return status;
+    }
+    activity::destination::DestinationSelection selection{};
+    (void)activity::destination::snapshot(sessionId, selection);
+    const std::string_view name = name_of(selection);
+    assign(name.empty() ? std::string_view(kUnknown) : name, status.activity);
+
+    const std::int32_t region = activity::membership::reported_region(sessionId);
+    layouts::Definition layout{};
+    if (!state::build_data::find_scenario_layout(name, layout)) {
+        assign(kUnknown, status.bubble);
+        assign(kUnknown, status.spawn);
+        build_slice_set(region, status.sliceSet);
+        return status;
+    }
+    build_bubble(layout, region, status.bubble);
+    build_slice_set(region, status.sliceSet);
+    build_spawn({layout.spawnStem.data(), layout.spawnStemLength}, status.spawn);
+    return status;
+}
+
+/** Draws one line as a label and a value. @param valueColumn Left edge of the value column. */
+void draw_line(StatusLine line, const Value& value, float valueColumn) noexcept {
+    if (!enabled(line)) {
+        return;
+    }
+    ImGui::TextDisabled("%s", display_name(line));
+    ImGui::SameLine(valueColumn);
+    ImGui::TextUnformatted(value.data());
+}
+
+} // namespace
+
+/** Draws the current-status lines inside the overlay window the stack has already started. */
+void draw() noexcept {
+    if (!enabled(StatusLine::activity) && !enabled(StatusLine::bubble)
+        && !enabled(StatusLine::sliceSet) && !enabled(StatusLine::closestSpawn)) {
+        // With every line off the overlay would draw an empty box and say nothing.
+        ImGui::TextDisabled("%s", kNoLines);
+        return;
+    }
+    const Status status = read_status();
+    if (!status.inWorld) {
+        // One line rather than the same stand-in on every row.
+        ImGui::TextDisabled("%s", kOutOfWorld);
+        return;
+    }
+    const float valueColumn =
+        ImGui::CalcTextSize(kWidestLabel).x + (ImGui::GetStyle().ItemSpacing.x * 2.0F);
+    draw_line(StatusLine::activity, status.activity, valueColumn);
+    draw_line(StatusLine::bubble, status.bubble, valueColumn);
+    draw_line(StatusLine::sliceSet, status.sliceSet, valueColumn);
+    draw_line(StatusLine::closestSpawn, status.spawn, valueColumn);
+}
+
+} // namespace sunrise::core::ui::hud::overlays::status

+ 8 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::core::ui::hud::overlays::status {
+
+/** Draws the current-status lines inside the overlay window the stack has already started. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::hud::overlays::status

+ 198 - 0
Sunrise/src/core/ui/hud/store/hud_settings_store.cpp

@@ -0,0 +1,198 @@
+/**
+ * The HUD switch store. It is separate from Core settings because the interface changes these
+ * values while the game runs and saves each one at once. Core settings are read once.
+ */
+
+#include "hud_settings_store.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../filesystem/path.h"
+#include "../../../logging/log.h"
+
+namespace sunrise::core::ui::hud::store {
+namespace {
+
+/** The module-owned switch file, beside the generated settings and logs. */
+constexpr std::wstring_view kFileSuffix = L"\\hud.json";
+/** 2048 bytes hold the two brace lines and about fifty switch rows, well past the table size. */
+constexpr std::size_t kFileCapacity = 2048;
+/** 48 bytes fit one quoted overlay key, the same ceiling the module stable IDs use. */
+constexpr std::size_t kQuotedKeyCapacity = 48;
+/** The two values a switch row can carry. Anything else keeps the caller's default. */
+constexpr char kTrueText[] = "true";
+constexpr char kFalseText[] = "false";
+
+path::Buffer g_path{};
+bool g_pathResolved{};
+
+/** @param reason Key naming the step that failed. */
+void report_fail(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written =
+        std::snprintf(line.data(), line.size(), "ev=hud stage=store result=fail reason=%s", reason);
+    if (written > 0) {
+        log::write(
+            log::Channel::core, log::Level::warn, {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Finds one key and reads the switch value after it.
+ * @param text Whole document.
+ * @param key Bare overlay key, quoted here so a shorter key cannot match inside a longer one.
+ * @param output Receives the value, untouched when the key or the value is absent.
+ */
+void switch_for(std::string_view text, const char* key, bool& output) noexcept {
+    std::array<char, kQuotedKeyCapacity> quoted{};
+    const int length = std::snprintf(quoted.data(), quoted.size(), "\"%s\"", key);
+    if (length <= 0) {
+        return;
+    }
+    const std::size_t at =
+        text.find(std::string_view(quoted.data(), static_cast<std::size_t>(length)));
+    if (at == std::string_view::npos) {
+        return;
+    }
+    const std::size_t colon = text.find(':', at);
+    if (colon == std::string_view::npos) {
+        return;
+    }
+    std::size_t begin = colon + 1;
+    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
+        ++begin;
+    }
+    const std::string_view value = text.substr(begin);
+    if (value.starts_with(kTrueText)) {
+        output = true;
+    } else if (value.starts_with(kFalseText)) {
+        output = false;
+    }
+}
+
+/**
+ * Appends one formatted row and keeps the write inside the document buffer.
+ * @param document Whole document buffer.
+ * @param offset Bytes already written, advanced by the appended row.
+ * @param entry Switch the row reports.
+ * @param last True for the row that takes no trailing comma.
+ * @return False when the row does not fit, which leaves the document unusable.
+ */
+[[nodiscard]] bool append_row(std::array<char, kFileCapacity>& document,
+                              int& offset,
+                              const Switch& entry,
+                              bool last) noexcept {
+    const auto used = static_cast<std::size_t>(offset);
+    const int written = std::snprintf(document.data() + offset,
+                                      document.size() - used,
+                                      "  \"%s\": %s%s\n",
+                                      entry.key,
+                                      entry.on ? kTrueText : kFalseText,
+                                      last ? "" : ",");
+    if (written <= 0 || static_cast<std::size_t>(written) >= document.size() - used) {
+        return false;
+    }
+    offset += written;
+    return true;
+}
+
+} // namespace
+
+/** Resolves the switch file. It reads nothing; load does that. */
+void initialize(void* module) noexcept {
+    g_path = path::Buffer{};
+    g_pathResolved = path::artifact_directory(module, g_path) && path::append(g_path, kFileSuffix);
+    if (!g_pathResolved) {
+        report_fail("path");
+    }
+}
+
+/** Drops the resolved file path. */
+void shutdown() noexcept {
+    g_path = path::Buffer{};
+    g_pathResolved = false;
+}
+
+/** Applies the saved state over the caller's defaults. */
+void load(std::span<Switch> switches) noexcept {
+    if (!g_pathResolved) {
+        return;
+    }
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    std::array<char, kFileCapacity> buffer{};
+    DWORD read = 0;
+    const bool readOk =
+        ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size() - 1), &read, nullptr)
+        != FALSE;
+    (void)CloseHandle(file);
+    if (!readOk || read == 0) {
+        return;
+    }
+    // A missing or malformed key keeps its default, so a hand-edited file cannot stop the boot.
+    const std::string_view text(buffer.data(), read);
+    for (Switch& entry : switches) {
+        switch_for(text, entry.key, entry.on);
+    }
+}
+
+/** Writes the whole file. */
+bool save(std::span<const Switch> switches) noexcept {
+    if (!g_pathResolved) {
+        return false;
+    }
+    std::array<char, kFileCapacity> document{};
+    int offset = std::snprintf(document.data(), document.size(), "{\n");
+    if (offset <= 0) {
+        return false;
+    }
+    for (std::size_t index = 0; index < switches.size(); ++index) {
+        if (!append_row(document, offset, switches[index], index + 1 == switches.size())) {
+            report_fail("capacity");
+            return false;
+        }
+    }
+    const auto used = static_cast<std::size_t>(offset);
+    const int closed = std::snprintf(document.data() + offset, document.size() - used, "}\n");
+    if (closed <= 0 || static_cast<std::size_t>(closed) >= document.size() - used) {
+        report_fail("capacity");
+        return false;
+    }
+    offset += closed;
+
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        report_fail("open");
+        return false;
+    }
+    DWORD written = 0;
+    const auto size = static_cast<DWORD>(offset);
+    bool complete =
+        WriteFile(file, document.data(), size, &written, nullptr) != FALSE && written == size;
+    complete = CloseHandle(file) != FALSE && complete;
+    if (!complete) {
+        report_fail("write");
+    }
+    return complete;
+}
+
+} // namespace sunrise::core::ui::hud::store

+ 35 - 0
Sunrise/src/core/ui/hud/store/hud_settings_store.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <span>
+
+namespace sunrise::core::ui::hud::store {
+
+/** One overlay's file key and its switch state. */
+struct Switch {
+    const char* key{};
+    bool on{};
+};
+
+/**
+ * Resolves the switch file. It reads nothing; load does that.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ */
+void initialize(void* module) noexcept;
+
+/** Drops the resolved file path. */
+void shutdown() noexcept;
+
+/**
+ * Applies the saved state over the caller's defaults.
+ * @param switches Keys to look for. A state is replaced only by a key the file carries.
+ */
+void load(std::span<Switch> switches) noexcept;
+
+/**
+ * Writes the whole file. It is small enough that a complete rewrite is the simplest save.
+ * @param switches Every key and its state, in table order.
+ * @return True when every byte reached the file.
+ */
+bool save(std::span<const Switch> switches) noexcept;
+
+} // namespace sunrise::core::ui::hud::store

+ 219 - 0
Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp

@@ -0,0 +1,219 @@
+/**
+ * The HUD overlay stack. Overlays are fixed windows in the top-left corner: each one draws its
+ * own content, and the stack places it under the one before it.
+ */
+
+#include <array>
+#include <cstddef>
+#include <imgui.h>
+
+#include "../scaling/dpi/ui_dpi_scaling.h"
+#include "overlay.h"
+#include "overlays/ui_hud_logo_overlay.h"
+#include "overlays/ui_hud_session_overlay.h"
+#include "overlays/ui_hud_status_overlay.h"
+#include "store/hud_settings_store.h"
+
+namespace sunrise::core::ui::hud {
+namespace {
+
+/** One overlay's identity, frame entry and starting switch state. */
+struct Entry {
+    const char* displayName;
+    /** Key the switch file stores this overlay under. It must outlive every table change. */
+    const char* storageKey;
+    const char* windowId;
+    void (*draw)() noexcept;
+    bool startsOn;
+};
+
+/** 24 pixels keep the stack clear of the viewport corner, as the other overlays do. */
+constexpr float kViewportMargin = 24.0F;
+/** 8 pixels separate two stacked overlays. */
+constexpr float kOverlayGap = 8.0F;
+/** Overlays carry no decoration, take no input, and are never saved. */
+constexpr ImGuiWindowFlags kOverlayFlags =
+    ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav
+    | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize
+    | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoMove;
+/** Overlay count, taken from the enum so the table and the switches cannot disagree. */
+constexpr std::size_t kOverlayCount = static_cast<std::size_t>(Overlay::count);
+/** Status-line count, taken from the enum for the same reason. */
+constexpr std::size_t kStatusLineCount = static_cast<std::size_t>(StatusLine::count);
+/** Every switch the file carries: one per overlay, then one per status line. */
+constexpr std::size_t kSwitchCount = kOverlayCount + kStatusLineCount;
+
+/** Every overlay, in Overlay order. The menu lists them and the corner stacks them in it. */
+constexpr std::array<Entry, kOverlayCount> kOverlays{
+    Entry{"Sunrise Card", "sunrise_card", "##sunrise_hud_card", &overlays::logo::draw, true},
+    // Both start off: they report what the server is doing, which no ordinary run needs on screen.
+    Entry{
+        "Current Status", "current_status", "##sunrise_hud_status", &overlays::status::draw, false},
+    Entry{"Session", "session", "##sunrise_hud_session", &overlays::session::draw, false},
+};
+
+/** One status line's identity and starting switch state. */
+struct LineEntry {
+    const char* displayName;
+    /** Key the switch file stores this line under. It must outlive every table change. */
+    const char* storageKey;
+    bool startsOn;
+};
+
+/** Every status line, in StatusLine order. The overlay draws them in it. */
+constexpr std::array<LineEntry, kStatusLineCount> kStatusLines{
+    LineEntry{"Activity", "status_activity", true},
+    LineEntry{"Bubble", "status_bubble", true},
+    LineEntry{"Slice set", "status_slice_set", true},
+    LineEntry{"Closest spawn", "status_closest_spawn", true},
+};
+
+/** @return The switch state every overlay starts with. */
+[[nodiscard]] constexpr std::array<bool, kOverlayCount> starting_state() noexcept {
+    std::array<bool, kOverlayCount> state{};
+    for (std::size_t index = 0; index < state.size(); ++index) {
+        state[index] = kOverlays[index].startsOn;
+    }
+    return state;
+}
+
+/** @return The switch state every status line starts with. */
+[[nodiscard]] constexpr std::array<bool, kStatusLineCount> starting_line_state() noexcept {
+    std::array<bool, kStatusLineCount> state{};
+    for (std::size_t index = 0; index < state.size(); ++index) {
+        state[index] = kStatusLines[index].startsOn;
+    }
+    return state;
+}
+
+/** Switch state. Only the presentation thread reads or writes it. */
+std::array<bool, kOverlayCount> g_enabled{starting_state()};
+/** Status-line switch state, on the same thread. */
+std::array<bool, kStatusLineCount> g_lineEnabled{starting_line_state()};
+
+/** @param overlay Overlay to check. @return True when it names one of the entries. */
+[[nodiscard]] bool in_range(Overlay overlay) noexcept {
+    return static_cast<std::size_t>(overlay) < kOverlayCount;
+}
+
+/** @param line Status line to check. @return True when it names one of the entries. */
+[[nodiscard]] bool in_range(StatusLine line) noexcept {
+    return static_cast<std::size_t>(line) < kStatusLineCount;
+}
+
+/** @return Every file key paired with the switch state it holds now. */
+[[nodiscard]] std::array<store::Switch, kSwitchCount> switch_state() noexcept {
+    std::array<store::Switch, kSwitchCount> switches{};
+    for (std::size_t index = 0; index < kOverlayCount; ++index) {
+        switches[index] = {kOverlays[index].storageKey, g_enabled[index]};
+    }
+    for (std::size_t index = 0; index < kStatusLineCount; ++index) {
+        switches[kOverlayCount + index] = {kStatusLines[index].storageKey, g_lineEnabled[index]};
+    }
+    return switches;
+}
+
+/** Writes every switch. One file holds them all, so a partial save would drop the rest. */
+void save_switches() noexcept {
+    const std::array<store::Switch, kSwitchCount> switches = switch_state();
+    // A failed write is reported by the store and never blocks the switch itself.
+    (void)store::save(switches);
+}
+
+/**
+ * Draws one overlay at a fixed position.
+ * @param entry Overlay to draw.
+ * @param position Top-left corner, in final framebuffer pixels.
+ * @return Height the window took, which is known only after its content is submitted.
+ */
+[[nodiscard]] float draw_overlay(const Entry& entry, const ImVec2& position) noexcept {
+    ImGui::SetNextWindowPos(position, ImGuiCond_Always);
+    const bool submitContents = ImGui::Begin(entry.windowId, nullptr, kOverlayFlags);
+    if (submitContents) {
+        entry.draw();
+    }
+    // Read inside the window, because the size belongs to it and not to the caller's window.
+    const float height = ImGui::GetWindowSize().y;
+    ImGui::End();
+    return height;
+}
+
+} // namespace
+
+/** Resolves the switch file and applies the saved state. */
+void initialize(void* module) noexcept {
+    store::initialize(module);
+    std::array<store::Switch, kSwitchCount> switches = switch_state();
+    store::load(switches);
+    for (std::size_t index = 0; index < kOverlayCount; ++index) {
+        g_enabled[index] = switches[index].on;
+    }
+    for (std::size_t index = 0; index < kStatusLineCount; ++index) {
+        g_lineEnabled[index] = switches[kOverlayCount + index].on;
+    }
+}
+
+/** Drops the switch file path. The switches keep their values. */
+void shutdown() noexcept {
+    store::shutdown();
+}
+
+/** @return The overlay's menu label. */
+const char* display_name(Overlay overlay) noexcept {
+    return in_range(overlay) ? kOverlays[static_cast<std::size_t>(overlay)].displayName : "";
+}
+
+/** @return True while the overlay draws. */
+bool enabled(Overlay overlay) noexcept {
+    return in_range(overlay) && g_enabled[static_cast<std::size_t>(overlay)];
+}
+
+/** Switches one overlay on or off and saves every switch at once. */
+void set_enabled(Overlay overlay, bool on) noexcept {
+    if (!in_range(overlay) || g_enabled[static_cast<std::size_t>(overlay)] == on) {
+        return;
+    }
+    g_enabled[static_cast<std::size_t>(overlay)] = on;
+    save_switches();
+}
+
+/** @return The status line's menu label. */
+const char* display_name(StatusLine line) noexcept {
+    return in_range(line) ? kStatusLines[static_cast<std::size_t>(line)].displayName : "";
+}
+
+/** @return True while the status line draws. */
+bool enabled(StatusLine line) noexcept {
+    return in_range(line) && g_lineEnabled[static_cast<std::size_t>(line)];
+}
+
+/** Switches one status line on or off and saves every switch at once. */
+void set_enabled(StatusLine line, bool on) noexcept {
+    if (!in_range(line) || g_lineEnabled[static_cast<std::size_t>(line)] == on) {
+        return;
+    }
+    g_lineEnabled[static_cast<std::size_t>(line)] = on;
+    save_switches();
+}
+
+/** Draws every enabled overlay, stacked down the top-left corner. */
+bool draw(bool interfaceEnabled) noexcept {
+    const ImGuiViewport* viewport = ImGui::GetMainViewport();
+    if (!interfaceEnabled || viewport == nullptr) {
+        return false;
+    }
+    const float margin = scaling::dpi::pixels(kViewportMargin);
+    const float gap = scaling::dpi::pixels(kOverlayGap);
+    ImVec2 position{viewport->WorkPos.x + margin, viewport->WorkPos.y + margin};
+    bool drawn = false;
+    for (std::size_t index = 0; index < kOverlayCount; ++index) {
+        if (!g_enabled[index]) {
+            continue;
+        }
+        position.y += draw_overlay(kOverlays[index], position) + gap;
+        drawn = true;
+    }
+    return drawn;
+}
+
+} // namespace sunrise::core::ui::hud

+ 37 - 3
Sunrise/src/core/ui/layout/ui_layout_render.cpp

@@ -6,6 +6,7 @@
 #include "../../../../resources/resource.h"
 #include "../animation/transition/ui_transition_animation.h"
 #include "../components/card/ui_card_component.h"
+#include "../components/logo/ui_logo_component.h"
 #include "../components/section/ui_section_component.h"
 #include "../scaling/dpi/ui_dpi_scaling.h"
 #include "navigation/ui_layout_navigation.h"
@@ -48,6 +49,14 @@ constexpr float kClosedProgress = 0.0F;
 constexpr float kOpeningScale = 0.96F;
 /** Full size, reached when the surface is fully open. */
 constexpr float kOpenScale = 1.0F;
+/** 34 authored pixels give the title logo presence without crowding the title row. */
+constexpr float kTitleLogoExtent = 34.0F;
+/** The title is drawn at this multiple of the body text, so it holds the logo's row. */
+constexpr float kTitleTextRatio = 1.5F;
+/** Half a difference centers one item against a taller one. */
+constexpr float kHalfExtent = 2.0F;
+/** The surface names the tool with the same wordmark the HUD card carries. */
+constexpr char kTitle[] = "SUNRISE";
 
 /**
  * Copies one display name into null-terminated component storage.
@@ -98,6 +107,33 @@ void draw_content(const navigation::Selection& selected) noexcept {
     selected.descriptor.frame_callback()();
 }
 
+/** Draws the animated logo, then the name and version, on one title row. */
+void draw_title() noexcept {
+    const float extent = scaling::dpi::pixels(kTitleLogoExtent);
+    const bool logoDrawn = components::logo::draw(extent);
+    if (logoDrawn) {
+        ImGui::SameLine();
+    }
+
+    // The size is the authored one, because the style carries the display scale separately.
+    ImGui::PushFont(nullptr, ImGui::GetStyle().FontSizeBase * kTitleTextRatio);
+    const float titleHeight = ImGui::GetTextLineHeight();
+    const float rowY = ImGui::GetCursorPosY();
+    // The title is shorter than the logo, so it sits lower to stay level with it.
+    const float titleY =
+        logoDrawn ? rowY + ((std::max)(extent - titleHeight, 0.0F) / kHalfExtent) : rowY;
+    ImGui::SetCursorPosY(titleY);
+    ImGui::TextUnformatted(kTitle);
+    ImGui::PopFont();
+
+    ImGui::SameLine();
+    // SameLine returns to the row the logo opened, so the version is placed against the title
+    // again, centered on it because it stays at body size.
+    ImGui::SetCursorPosY(
+        titleY + ((std::max)(titleHeight - ImGui::GetTextLineHeight(), 0.0F) / kHalfExtent));
+    ImGui::TextDisabled(SUNRISE_VER_STRING);
+}
+
 } // namespace
 
 /** Draws the centered Sunrise surface inside the caller's active Dear ImGui frame. */
@@ -130,9 +166,7 @@ bool render(bool visible) noexcept {
     ImGui::PushStyleVar(ImGuiStyleVar_Alpha, progress);
     const bool submitContents = ImGui::Begin("Sunrise", nullptr, kMainWindowFlags);
     if (submitContents) {
-        ImGui::TextUnformatted("SUNRISE");
-        ImGui::SameLine();
-        ImGui::TextDisabled(SUNRISE_VER_STRING);
+        draw_title();
         ImGui::Separator();
 
         const StateSnapshot state = snapshot();

+ 15 - 0
Sunrise/src/core/ui/modules/hud/hud.h

@@ -0,0 +1,15 @@
+#pragma once
+
+namespace sunrise::core::ui::modules::hud {
+
+/**
+ * Loads the saved overlay switches, then registers the page.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ * @return True when the Core HUD page owns its registry slot.
+ */
+[[nodiscard]] bool initialize(void* module) noexcept;
+
+/** Removes the Core HUD page and drops the switch file path. */
+void shutdown() noexcept;
+
+} // namespace sunrise::core::ui::modules::hud

+ 34 - 0
Sunrise/src/core/ui/modules/hud/hud_module_runtime.cpp

@@ -0,0 +1,34 @@
+#include <string_view>
+
+#include "../../hud/overlay.h"
+#include "../registry/ui_module_registry.h"
+#include "../ui_module_descriptor.h"
+#include "hud.h"
+#include "internal.h"
+
+namespace sunrise::core::ui::modules::hud {
+namespace {
+
+/** Namespaced stable ID keeps this page distinct from feature modules. */
+constexpr std::string_view kStableId = "core.hud";
+/** Menu label for the on-screen overlay switches. */
+constexpr std::string_view kDisplayName = "HUD";
+
+registry::PageRegistration g_page;
+
+} // namespace
+
+/** Loads the saved overlay switches, then registers the page. */
+bool initialize(void* module) noexcept {
+    // Loaded before the page registers, so the first frame draws the saved switches.
+    ui::hud::initialize(module);
+    return g_page.acquire(Owner::core, kStableId, kDisplayName, &internal::draw);
+}
+
+/** Removes the Core HUD page and drops the switch file path. */
+void shutdown() noexcept {
+    g_page.release();
+    ui::hud::shutdown();
+}
+
+} // namespace sunrise::core::ui::modules::hud

+ 43 - 0
Sunrise/src/core/ui/modules/hud/hud_panel_render.cpp

@@ -0,0 +1,43 @@
+/** The HUD page. One switch per overlay, in the order the overlays stack on screen. */
+
+#include <cstddef>
+#include <imgui.h>
+
+#include "../../components/section/ui_section_component.h"
+#include "../../components/toggle/ui_toggle_component.h"
+#include "../../hud/overlay.h"
+#include "internal.h"
+
+namespace sunrise::core::ui::modules::hud::internal {
+
+/** Draws the HUD page inside the active Core UI frame. */
+void draw() noexcept {
+    ImGui::TextWrapped("Overlays draw in the top-left corner while the game runs, with or "
+                       "without this menu open.");
+    ImGui::Spacing();
+
+    for (std::size_t index = 0; index < static_cast<std::size_t>(ui::hud::Overlay::count);
+         ++index) {
+        const auto overlay = static_cast<ui::hud::Overlay>(index);
+        bool on = ui::hud::enabled(overlay);
+        // The label is unique per overlay, so it carries the control's identity on its own.
+        if (components::toggle::control(ui::hud::display_name(overlay), on)) {
+            ui::hud::set_enabled(overlay, on);
+        }
+    }
+
+    ImGui::Spacing();
+    components::section::header("Current status lines",
+                                "Each line of the current status overlay, on its own.");
+    ImGui::Spacing();
+    for (std::size_t index = 0; index < static_cast<std::size_t>(ui::hud::StatusLine::count);
+         ++index) {
+        const auto line = static_cast<ui::hud::StatusLine>(index);
+        bool on = ui::hud::enabled(line);
+        if (components::toggle::control(ui::hud::display_name(line), on)) {
+            ui::hud::set_enabled(line, on);
+        }
+    }
+}
+
+} // namespace sunrise::core::ui::modules::hud::internal

+ 8 - 0
Sunrise/src/core/ui/modules/hud/internal.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::core::ui::modules::hud::internal {
+
+/** Draws the HUD page inside the active Core UI frame. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::modules::hud::internal

+ 36 - 0
Sunrise/src/core/ui/textures/ui_texture_slots.cpp

@@ -0,0 +1,36 @@
+#include "ui_texture_slots.h"
+
+#include <array>
+#include <cstddef>
+
+namespace sunrise::core::ui::textures {
+namespace {
+
+/** Published identifiers. Only the presentation thread reads or writes them. */
+std::array<ImTextureID, static_cast<std::size_t>(Slot::count)> g_textures{};
+
+/** @param slot Slot to check. @return True when it names one of the images. */
+[[nodiscard]] bool in_range(Slot slot) noexcept {
+    return static_cast<std::size_t>(slot) < g_textures.size();
+}
+
+} // namespace
+
+/** Publishes the renderer identifier of one uploaded image. */
+void publish(Slot slot, ImTextureID texture) noexcept {
+    if (in_range(slot)) {
+        g_textures[static_cast<std::size_t>(slot)] = texture;
+    }
+}
+
+/** @return The slot's identifier, or ImTextureID_Invalid while it is unloaded. */
+ImTextureID get(Slot slot) noexcept {
+    return in_range(slot) ? g_textures[static_cast<std::size_t>(slot)] : ImTextureID_Invalid;
+}
+
+/** Empties every slot. The renderer owns the textures and frees them itself. */
+void clear() noexcept {
+    g_textures = {};
+}
+
+} // namespace sunrise::core::ui::textures

+ 29 - 0
Sunrise/src/core/ui/textures/ui_texture_slots.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstdint>
+#include <imgui.h>
+
+namespace sunrise::core::ui::textures {
+
+/** One slot per bundled image the interface can draw. */
+enum class Slot : std::uint8_t {
+    /** Grayscale sprite sheet of the animated Sunrise logo. */
+    logoSheet,
+    count,
+};
+
+/**
+ * Publishes the renderer identifier of one uploaded image.
+ * The presentation thread owns every slot: it uploads, publishes and frees the textures.
+ * @param slot Image the identifier belongs to.
+ * @param texture Renderer identifier, or ImTextureID_Invalid to empty the slot.
+ */
+void publish(Slot slot, ImTextureID texture) noexcept;
+
+/** @param slot Image to read. @return Its identifier, or ImTextureID_Invalid while unloaded. */
+[[nodiscard]] ImTextureID get(Slot slot) noexcept;
+
+/** Empties every slot. The renderer owns the textures and frees them itself. */
+void clear() noexcept;
+
+} // namespace sunrise::core::ui::textures

+ 3 - 2
Sunrise/src/server/bap/encrypted/internal.h

@@ -249,8 +249,9 @@ append_account_resync_notification(Scratch& scratch,
                                               queuez::SessionState& after) noexcept;
 
 /**
- * Appends the family-zero move that follows an opcode-504 pick.
- * The Client holds the objIdx-1 buffer for one character at a time, so the pair moves with it.
+ * Appends the family-zero pair that follows an opcode-504 pick.
+ * The Client holds the objIdx-1 buffer for one character at a time, so the pair moves with it. A
+ * pick naming the character it already holds republishes the pair in place.
  * @param before Queuez state after the family-four move.
  * @param selectedCharacter Character the pick named.
  * @param key Active AES-GCM session key.

+ 34 - 15
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -21,8 +21,8 @@ constexpr std::size_t kSkipLineCapacity = 96;
 
 /**
  * Reports the banner move declining to build a frame.
- * The move has 4 separate refusals and all leave the emblem where it is, so the key is the only
- * way to tell a pick owing nothing from a ladder that was never seeded.
+ * Both refusals leave the emblem where it is, so the key is the only way to tell a ladder that was
+ * never seeded from one with no root.
  * @param reason Key naming the refusal.
  */
 void report_skip(const char* reason) noexcept {
@@ -36,6 +36,22 @@ void report_skip(const char* reason) noexcept {
     }
 }
 
+/**
+ * Reports the banner move or its republish failing to build a frame.
+ * @param stage Stage name the caller resolved, so a republish does not report as a move.
+ * @param reason Key naming the failure.
+ */
+void report_fail(const char* stage, const char* reason) noexcept {
+    std::array<char, kSkipLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=queuez stage=%s result=fail reason=%s", stage, reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Validates and appends one same-character Family-0 appearance refresh. */
 [[nodiscard]] bool append_appearance_frame(Scratch& scratch,
                                            const queuez::CharacterAppearanceRefresh& refresh,
@@ -200,9 +216,10 @@ bool append_banner_notification(Scratch& scratch,
 }
 
 /**
- * Appends the family-zero move that follows an opcode-504 pick.
+ * Appends the family-zero pair that follows an opcode-504 pick.
  * The Client holds the objIdx-1 buffer for one character at a time, allocated from the character
- * the anchor names, so the pair moves with the pick or the banner keeps the old emblem.
+ * the anchor names, so the pair moves with the pick or the banner keeps the old emblem. A pick
+ * naming the character the pair already holds republishes it in place.
  * @param scratch Lock-owned transform buffers.
  * @param before Queuez state after the family-four move.
  * @param selectedCharacter Character the pick named.
@@ -224,14 +241,11 @@ bool append_banner_move_notification(Scratch& scratch,
     bool publish = false;
     bool incremental = false;
     after = before;
-    // A pick that names the character the pair already holds owes nothing, and so does a family
-    // zero that has not had its first delivery yet.
+    // A family zero with no first delivery yet has no ladder to move, and no root to name it with.
     const char* reason = nullptr;
     if (!queuez::stage_family0_subscription(
             before, selectedCharacter, publish, incremental, after)) {
         reason = "stage";
-    } else if (!publish) {
-        reason = "unchanged";
     } else if (before.family4RootSoid == 0) {
         reason = "no_root";
     }
@@ -240,6 +254,15 @@ bool append_banner_move_notification(Scratch& scratch,
         after = before;
         return false;
     }
+    // A pick naming the character the pair already holds still republishes it: the only body the
+    // Client would otherwise hold is the boot burst's, built before any pick. Nothing is released,
+    // because deleting the key the same frame re-adds tears the family down.
+    const bool republish = !publish;
+    if (republish) {
+        incremental = false;
+        after.family0Version = before.family0Version + 1;
+    }
+    const char* const stage = republish ? "banner_republish" : "banner_move";
     snapshot::Prepared prepared{};
     // A first delivery releases nothing: the Client holds no record for this family yet, so the
     // pair goes out as its own full snapshot instead of as a move off a previous character.
@@ -248,9 +271,7 @@ bool append_banner_move_notification(Scratch& scratch,
                                   after.family0Version,
                                   incremental ? before.family0Character : 0,
                                   prepared)) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=banner_move result=fail reason=prepare");
+        report_fail(stage, "prepare");
         after = before;
         return false;
     }
@@ -264,14 +285,12 @@ bool append_banner_move_notification(Scratch& scratch,
                               nonce,
                               response,
                               written)) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=banner_move result=fail reason=frame");
+        report_fail(stage, "frame");
         after = before;
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push("banner_move",
+    queuez_report::push(stage,
                         prepared.family.type,
                         objectCount,
                         written - beforeBytes,

+ 2 - 2
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -29,8 +29,8 @@ namespace sunrise::server::bap::encrypted::queuez {
 
 /**
  * Decides whether one family-zero subscription publishes, and as which kind of frame.
- * Retail sets the full-snapshot flag once per family and adds one to every later push, so a
- * repeat that changes nothing is not sent at all.
+ * A repeat naming the character the pair already holds reports no publish and no version bump.
+ * Both callers send anyway, so the answer is which frame to build, not whether to answer.
  * @param before Current queuez state owned by the peer.
  * @param selectedCharacter Character the family-zero pair names now.
  * @param publish Gets whether a frame is needed.

+ 2 - 1
Sunrise/src/server/gameplay/gameplay_advertisement.cpp

@@ -164,7 +164,8 @@ void report_outcome(Skip skip,
     // `public_activity_host_mismatch` when they disagree, so both carry the same allocated id.
     // The key is the machine id, because that is what the peer echoes in every later message.
     bool claimedSlot = false;
-    const std::uint64_t hostSession = group::activity_host_session(join.machineId, claimedSlot);
+    const std::uint64_t hostSession =
+        group::activity_host_session(join.machineId, regionIndex, claimedSlot);
     if (hostSession == state::activity::kAbsentSessionId) {
         // A claimed slot is filled by the next service slice. An unclaimed one never will be, so
         // the two must not read the same to a caller deciding whether to wait.

+ 19 - 1
Sunrise/src/server/gameplay/group/group_host.cpp

@@ -341,7 +341,7 @@ void answer_parameters(std::uint64_t sessionId, std::uint64_t requested) noexcep
     std::uint64_t carried = requested & wire::kEncodableParameters;
     // Claims the region's slot rather than only reading it, so a request arriving before the
     // advertisement still makes the service slice allocate one.
-    if (activity_host_session(sessionId) == state::activity::kAbsentSessionId) {
+    if (activity_host_session(sessionId, kUnknownRegion) == state::activity::kAbsentSessionId) {
         // See publish_activity_host: a zero host id is worse than no answer for this one.
         carried &= ~(std::uint64_t{1} << static_cast<std::uint8_t>(wire::Parameter::activityHost));
     }
@@ -703,6 +703,24 @@ bool view_accepted(std::uint64_t sessionId) noexcept {
     return peer::view_bound(sessionId);
 }
 
+/** Copies every admitted group-session record. */
+void snapshot_admitted(std::span<AdmittedRow> output, std::size_t& count) noexcept {
+    count = 0;
+    AcquireSRWLockShared(&g_admittedLock);
+    for (const Admitted& entry : g_admitted) {
+        if (!entry.occupied || count >= output.size()) {
+            continue;
+        }
+        output[count] = {entry.sessionId,
+                         entry.endpoint,
+                         entry.joinComplete,
+                         entry.activityHostPublished,
+                         entry.playerPublished};
+        ++count;
+    }
+    ReleaseSRWLockShared(&g_admittedLock);
+}
+
 /** Clears every group-session record. */
 void reset() noexcept {
     g_membershipRevision.store(0);

+ 20 - 1
Sunrise/src/server/gameplay/group/group_host.h

@@ -46,21 +46,40 @@ namespace sunrise::server::gameplay::group {
  * change while the region keeps its slot, or the peer errors `public_activity_host_mismatch`.
  * @param groupSessionId Session the peer names for this region, which is the descriptor's machine
  *        id and not its session id.
+ * @param regionIndex Region being advertised, or kUnknownRegion.
  * @return Allocated session id, or zero while State cannot allocate one yet.
  */
-[[nodiscard]] std::uint64_t activity_host_session(std::uint64_t groupSessionId) noexcept;
+[[nodiscard]] std::uint64_t activity_host_session(std::uint64_t groupSessionId,
+                                                  std::int32_t regionIndex) noexcept;
 
 /**
  * Reports one region's activity session and whether it holds a table slot at all.
  * A caller that waits for the session needs the difference. Without a slot nothing will ever
  * allocate one, so waiting never ends.
  * @param groupSessionId Session the peer names for this region.
+ * @param regionIndex Region being advertised, or kUnknownRegion.
  * @param claimedSlot Set when a slot names this region, with or without a session in it yet.
  * @return Allocated session id, or zero while the slot has none.
  */
 [[nodiscard]] std::uint64_t activity_host_session(std::uint64_t groupSessionId,
+                                                  std::int32_t regionIndex,
                                                   bool& claimedSlot) noexcept;
 
+/** One admitted peer's group session and how far its join has got. */
+struct AdmittedRow {
+    std::uint64_t sessionId{};
+    state::gameplay::Endpoint endpoint{};
+    /** Set once the peer reported its join finished. */
+    bool joinComplete{};
+    /** Set once the `activity-host` parameter is on the peer's reliable channel. */
+    bool activityHostPublished{};
+    /** Set once a snapshot naming the peer's player is on that channel. */
+    bool playerPublished{};
+};
+
+/** Copies every admitted group-session record. @param count Receives the copied row count. */
+void snapshot_admitted(std::span<AdmittedRow> output, std::size_t& count) noexcept;
+
 /**
  * Retries any publish the reliable queue refused, on a timer.
  * The queue is fullest while the join promotion and the `activity-host` parameter are owed, so

+ 33 - 7
Sunrise/src/server/gameplay/group/group_host_sessions.cpp

@@ -17,6 +17,8 @@ struct HostSession {
     std::uint64_t groupSessionId{};
     std::uint64_t hostSessionId{};
     std::uint64_t lastUse{};
+    /** Region the advertisement named. The interface reads it; no lookup uses it. */
+    std::int32_t regionIndex{};
     bool occupied{};
 };
 
@@ -38,21 +40,28 @@ std::size_t g_evictedCount = 0;
 /**
  * Names one region in the table, taking a slot when it holds none. The caller holds the lock.
  * @param groupSessionId Group session the region advertises.
+ * @param regionIndex Region the caller is advertising, kept on the row.
  * @param held Receives the session the region already holds, or the absent id.
  * @return True when a slot names the region afterwards.
  */
-[[nodiscard]] bool claim_locked(std::uint64_t groupSessionId, std::uint64_t& held) noexcept {
+[[nodiscard]] bool
+claim_locked(std::uint64_t groupSessionId, std::int32_t regionIndex, std::uint64_t& held) noexcept {
     held = state::activity::kAbsentSessionId;
     for (HostSession& entry : g_hostSessions) {
         if (entry.occupied && entry.groupSessionId == groupSessionId) {
             entry.lastUse = ++g_useStamp;
+            // A caller with no region keeps the one the advertisement recorded.
+            if (regionIndex != kUnknownRegion) {
+                entry.regionIndex = regionIndex;
+            }
             held = entry.hostSessionId;
             return true;
         }
     }
     for (HostSession& entry : g_hostSessions) {
         if (!entry.occupied) {
-            entry = {groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, true};
+            entry = {
+                groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, regionIndex, true};
             return true;
         }
     }
@@ -72,7 +81,7 @@ std::size_t g_evictedCount = 0;
         ++g_evictedCount;
     }
     g_hostSessions[oldest] = {
-        groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, true};
+        groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, regionIndex, true};
     return true;
 }
 
@@ -111,14 +120,31 @@ std::uint64_t held_host_session(std::uint64_t groupSessionId) noexcept {
     return held;
 }
 
+/** Copies every occupied host-session row. */
+void snapshot_host_sessions(std::span<HostSessionRow> output, std::size_t& count) noexcept {
+    count = 0;
+    AcquireSRWLockShared(&g_hostSessionLock);
+    for (const HostSession& entry : g_hostSessions) {
+        if (!entry.occupied || count >= output.size()) {
+            continue;
+        }
+        output[count] = {entry.groupSessionId, entry.hostSessionId, entry.regionIndex};
+        ++count;
+    }
+    ReleaseSRWLockShared(&g_hostSessionLock);
+}
+
 /** Reports one region's activity session, asking the gameplay slice to allocate a missing one. */
-std::uint64_t activity_host_session(std::uint64_t groupSessionId) noexcept {
+std::uint64_t activity_host_session(std::uint64_t groupSessionId,
+                                    std::int32_t regionIndex) noexcept {
     bool claimed = false;
-    return activity_host_session(groupSessionId, claimed);
+    return activity_host_session(groupSessionId, regionIndex, claimed);
 }
 
 /** Reports one region's activity session and whether it holds a slot at all. */
-std::uint64_t activity_host_session(std::uint64_t groupSessionId, bool& claimedSlot) noexcept {
+std::uint64_t activity_host_session(std::uint64_t groupSessionId,
+                                    std::int32_t regionIndex,
+                                    bool& claimedSlot) noexcept {
     claimedSlot = false;
     if (groupSessionId == 0) {
         return state::activity::kAbsentSessionId;
@@ -127,7 +153,7 @@ std::uint64_t activity_host_session(std::uint64_t groupSessionId, bool& claimedS
     // of the push that called in. The slot is claimed now and `service` fills it for the next one.
     std::uint64_t held = state::activity::kAbsentSessionId;
     AcquireSRWLockExclusive(&g_hostSessionLock);
-    const bool claimed = claim_locked(groupSessionId, held);
+    const bool claimed = claim_locked(groupSessionId, regionIndex, held);
     ReleaseSRWLockExclusive(&g_hostSessionLock);
     claimedSlot = claimed;
     if (!claimed) {

+ 15 - 0
Sunrise/src/server/gameplay/group/group_host_sessions.h

@@ -1,9 +1,21 @@
 #pragma once
 
+#include <cstddef>
 #include <cstdint>
+#include <span>
 
 namespace sunrise::server::gameplay::group {
 
+/** Region of a caller that knows none. Such a call keeps the region already on the row. */
+inline constexpr std::int32_t kUnknownRegion = -1;
+
+/** One region's advertised group session and the activity host session it holds. */
+struct HostSessionRow {
+    std::uint64_t groupSessionId{};
+    std::uint64_t hostSessionId{};
+    std::int32_t regionIndex{};
+};
+
 /**
  * Reports the activity host session already held for one region, without claiming a slot.
  * @param groupSessionId Group session the region advertises.
@@ -11,6 +23,9 @@ namespace sunrise::server::gameplay::group {
  */
 [[nodiscard]] std::uint64_t held_host_session(std::uint64_t groupSessionId) noexcept;
 
+/** Copies every occupied host-session row. @param count Receives the copied row count. */
+void snapshot_host_sessions(std::span<HostSessionRow> output, std::size_t& count) noexcept;
+
 /**
  * Fills every claimed host-session slot that has no session yet, and frees every evicted session.
  * The allocation advances the state revision, so it must never run inside a staged push. That

+ 13 - 0
Sunrise/src/server/gameplay/peer/peer_transport.cpp

@@ -790,6 +790,19 @@ bool view_bound(std::uint64_t sessionId) noexcept {
     return bound;
 }
 
+/** Reports how far the link carrying one group session has got. */
+bool link_stage(std::uint64_t sessionId, state::gameplay::PeerStage& stage) noexcept {
+    stage = state::gameplay::PeerStage::absent;
+    AcquireSRWLockShared(&g_lock);
+    const state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    const bool present = peer != nullptr;
+    if (present) {
+        stage = peer->stage;
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return present;
+}
+
 /** Sends any owed acknowledgement. */
 void service(std::uint64_t now) noexcept {
     std::array<state::gameplay::PeerLink, state::gameplay::kAssociationCapacity> owed{};

+ 7 - 0
Sunrise/src/server/gameplay/peer/peer_transport.h

@@ -98,6 +98,13 @@ void bind_view(std::uint64_t sessionId, const state::gameplay::ViewSignature& si
 /** @return True once a view signature is bound for that session's link. */
 [[nodiscard]] bool view_bound(std::uint64_t sessionId) noexcept;
 
+/**
+ * Reports how far the link carrying one group session has got.
+ * @param stage Receives the stage, or the absent one when no link carries the session.
+ * @return True when a link carries it.
+ */
+[[nodiscard]] bool link_stage(std::uint64_t sessionId, state::gameplay::PeerStage& stage) noexcept;
+
 /**
  * Sends any owed acknowledgement.
  * Without it the peer keeps retransmitting every reliable message it has sent.

+ 3 - 0
Sunrise/src/server/ui/activity_override/activity_override_panel.cpp

@@ -12,6 +12,7 @@
 #include <span>
 #include <string_view>
 
+#include "../../../core/ui/components/label/ui_label_component.h"
 #include "../../../core/ui/components/picker/ui_picker_component.h"
 #include "../../../core/ui/components/section/ui_section_component.h"
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
@@ -22,6 +23,7 @@ namespace sunrise::server::ui::activity_override {
 namespace {
 
 namespace forced = state::activity::forced;
+namespace label = core::ui::components::label;
 namespace picker = core::ui::components::picker;
 
 /** Preview shown by a picker with nothing chosen. */
@@ -61,6 +63,7 @@ std::size_t g_spawnRow{kNoRow};
                               std::size_t count,
                               const char* preview,
                               std::size_t& row) noexcept {
+    label::align();
     ImGui::TextUnformatted(caption);
     std::array<picker::Item, kItemCapacity> items{};
     const std::size_t shown = (std::min)(count, items.size());

+ 2 - 1
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -113,7 +113,8 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         || !scenarios::replace(domains.scenarios, domains.rosterGroups)
         // An empty catalog is complete, so the spawn-set replace is skipped rather than failed.
         || (!domains.spawnStems.empty()
-            && !spawn_sets::replace(domains.spawnStems, domains.spawnNameHashes))
+            && !spawn_sets::replace(
+                domains.spawnStems, domains.spawnNameHashes, domains.spawnPoints))
         // An empty catalog is complete, so the vendor replace is skipped rather than failed.
         || (!domains.vendorIndex.empty()
             && !vendors::replace(domains.vendorIndex,

+ 2 - 0
Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp

@@ -36,6 +36,7 @@ namespace {
            && counts.rosterGroups <= output.rosterGroups.size()
            && counts.spawnStems <= output.spawnStems.size()
            && counts.spawnNameHashes <= output.spawnNameHashes.size()
+           && counts.spawnPoints <= output.spawnPoints.size()
            && counts.hashNames <= output.hashNames.size()
            && counts.vendorIndex <= output.vendorIndex.size()
            && counts.vendorDefinitions <= output.vendorDefinitions.size()
@@ -63,6 +64,7 @@ namespace {
         header.rosterGroupCount,
         header.spawnStemCount,
         header.spawnNameHashCount,
+        header.spawnPointCount,
         header.hashNameCount,
         header.vendorIndexCount,
         header.vendorDefinitionCount,

+ 6 - 0
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -89,6 +89,7 @@ void clear(records::MutableDomains output) noexcept {
     std::fill(output.rosterGroups.begin(), output.rosterGroups.end(), scenarios::RosterGroup{});
     std::fill(output.spawnStems.begin(), output.spawnStems.end(), spawn_sets::Stem{});
     std::fill(output.spawnNameHashes.begin(), output.spawnNameHashes.end(), spawn_sets::NameHash{});
+    std::fill(output.spawnPoints.begin(), output.spawnPoints.end(), spawn_sets::Point{});
     std::fill(output.hashNames.begin(), output.hashNames.end(), hash_names::Name{});
     std::fill(output.vendorIndex.begin(), output.vendorIndex.end(), vendors::IndexEntry{});
     std::fill(
@@ -120,6 +121,7 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
            && add_records(counts.rosterGroups, sizeof(records::RosterGroupRecord), size)
            && add_records(counts.spawnStems, sizeof(records::SpawnStemRecord), size)
            && add_records(counts.spawnNameHashes, sizeof(records::SpawnNameHashRecord), size)
+           && add_records(counts.spawnPoints, sizeof(records::SpawnPointRecord), size)
            && add_records(counts.hashNames, sizeof(records::HashNameRecord), size)
            && add_records(counts.vendorIndex, sizeof(records::VendorIndexRecord), size)
            && add_records(counts.vendorDefinitions, sizeof(records::VendorDefinitionRecord), size)
@@ -185,6 +187,9 @@ bool read_payload(HANDLE file,
     valid = valid
             && read_domain<records::SpawnNameHashRecord>(
                 file, output.spawnNameHashes.first(counts.spawnNameHashes), checksum);
+    valid = valid
+            && read_domain<records::SpawnPointRecord>(
+                file, output.spawnPoints.first(counts.spawnPoints), checksum);
     valid = valid
             && read_domain<records::HashNameRecord>(
                 file, output.hashNames.first(counts.hashNames), checksum);
@@ -222,6 +227,7 @@ bool read_payload(HANDLE file,
         output.rosterGroups.first(counts.rosterGroups),
         output.spawnStems.first(counts.spawnStems),
         output.spawnNameHashes.first(counts.spawnNameHashes),
+        output.spawnPoints.first(counts.spawnPoints),
         output.hashNames.first(counts.hashNames),
         output.vendorIndex.first(counts.vendorIndex),
         output.vendorDefinitions.first(counts.vendorDefinitions),

+ 3 - 1
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -149,6 +149,7 @@ template <typename Value, typename Less>
            && counts.rosterGroups <= domains.rosterGroups.size()
            && counts.spawnStems <= domains.spawnStems.size()
            && counts.spawnNameHashes <= domains.spawnNameHashes.size()
+           && counts.spawnPoints <= domains.spawnPoints.size()
            && counts.hashNames <= domains.hashNames.size()
            && counts.vendorIndex <= domains.vendorIndex.size()
            && counts.vendorDefinitions <= domains.vendorDefinitions.size()
@@ -220,8 +221,9 @@ bool valid_domains(Domains domains) noexcept {
         // An empty catalog is complete. It is what a build with no installed spawn set means.
         // Both arrays must be empty together, because a stem names its hashes by range.
         || (domains.spawnStems.empty()
-                ? !domains.spawnNameHashes.empty()
+                ? !(domains.spawnNameHashes.empty() && domains.spawnPoints.empty())
                 : !spawn_sets::valid(domains.spawnStems, domains.spawnNameHashes))
+        || !spawn_sets::valid_points(domains.spawnPoints, domains.spawnStems)
         // An empty catalog is complete. With no index there is no vendor domain, so the
         // definitions and both row banks must be empty too.
         || (domains.vendorIndex.empty()

+ 19 - 0
Sunrise/src/state/build_data/cache/records/cache_spawn_set_records.cpp

@@ -75,4 +75,23 @@ bool decode(const SpawnNameHashRecord& record, spawn_sets::NameHash& value) noex
     return true;
 }
 
+/** Encodes one spawn point. */
+bool encode(const spawn_sets::Point& value, SpawnPointRecord& record) noexcept {
+    record = {};
+    record.position = value.position;
+    record.nameHash = value.nameHash;
+    record.stemIndex = value.stemIndex;
+    return true;
+}
+
+/** Decodes one spawn point. */
+bool decode(const SpawnPointRecord& record, spawn_sets::Point& value) noexcept {
+    value = {};
+    if (record.reserved != decltype(record.reserved){}) {
+        return false;
+    }
+    value = {record.position, record.nameHash, record.stemIndex};
+    return true;
+}
+
 } // namespace sunrise::state::build_data::cache::records

+ 6 - 0
Sunrise/src/state/build_data/cache/records/codec.h

@@ -285,6 +285,12 @@ namespace sunrise::state::build_data::cache::records {
  */
 [[nodiscard]] bool decode(const SpawnNameHashRecord& record, spawn_sets::NameHash& value) noexcept;
 
+/** @param record Receives the packed disk row. @return Always true. */
+[[nodiscard]] bool encode(const spawn_sets::Point& value, SpawnPointRecord& record) noexcept;
+
+/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
+[[nodiscard]] bool decode(const SpawnPointRecord& record, spawn_sets::Point& value) noexcept;
+
 /** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const vendors::IndexEntry& value, VendorIndexRecord& record) noexcept;
 

+ 3 - 0
Sunrise/src/state/build_data/cache/records/domains.h

@@ -40,6 +40,7 @@ struct DomainCounts {
     std::size_t rosterGroups{};
     std::size_t spawnStems{};
     std::size_t spawnNameHashes{};
+    std::size_t spawnPoints{};
     std::size_t hashNames{};
     std::size_t vendorIndex{};
     std::size_t vendorDefinitions{};
@@ -68,6 +69,7 @@ struct MutableDomains {
     std::span<scenarios::RosterGroup> rosterGroups;
     std::span<spawn_sets::Stem> spawnStems;
     std::span<spawn_sets::NameHash> spawnNameHashes;
+    std::span<spawn_sets::Point> spawnPoints;
     std::span<hash_names::Name> hashNames;
     std::span<vendors::IndexEntry> vendorIndex;
     std::span<vendors::Definition> vendorDefinitions;
@@ -95,6 +97,7 @@ struct Domains {
     std::span<const scenarios::RosterGroup> rosterGroups;
     std::span<const spawn_sets::Stem> spawnStems;
     std::span<const spawn_sets::NameHash> spawnNameHashes;
+    std::span<const spawn_sets::Point> spawnPoints;
     std::span<const hash_names::Name> hashNames;
     std::span<const vendors::IndexEntry> vendorIndex;
     std::span<const vendors::Definition> vendorDefinitions;

+ 15 - 2
Sunrise/src/state/build_data/cache/records/format.h

@@ -28,7 +28,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Current build-data cache format. An older cache is rebuilt rather than read, so a bump needs
  * no other edit. Bump it whenever a domain's stored shape changes.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 34;
+inline constexpr std::uint32_t kCacheFormatVersion = 35;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -81,6 +81,7 @@ struct Header {
     std::uint32_t rosterGroupCount{};
     std::uint32_t spawnStemCount{};
     std::uint32_t spawnNameHashCount{};
+    std::uint32_t spawnPointCount{};
     std::uint32_t hashNameCount{};
     std::uint32_t vendorIndexCount{};
     std::uint32_t vendorDefinitionCount{};
@@ -322,6 +323,15 @@ struct SpawnNameHashRecord {
     std::array<std::uint16_t, spawn_sets::kPackageCapacity> activityPackages{};
 };
 
+/** Disk form of one spawn point and the set it belongs to. */
+struct SpawnPointRecord {
+    std::array<float, spawn_sets::kPositionComponents> position{};
+    std::uint32_t nameHash{};
+    std::uint16_t stemIndex{};
+    /** Must be zero, so the packed point row always matches. */
+    std::array<std::uint8_t, 2> reserved{};
+};
+
 /** Disk form of one vendor index row. */
 struct VendorIndexRecord {
     std::uint32_t definitionHash{};
@@ -395,8 +405,11 @@ static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t));
 static_assert(sizeof(InvestmentConstants)
               == constants::kCharacterStatRowCount + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(Header)
-              == kCacheMagic.size() + 25 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
+              == kCacheMagic.size() + 26 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
                      + sizeof(InvestmentConstants));
+static_assert(sizeof(SpawnPointRecord)
+              == spawn_sets::kPositionComponents * sizeof(float) + sizeof(std::uint32_t)
+                     + sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(VendorDefinitionRecord)
               == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));

+ 2 - 0
Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp

@@ -81,6 +81,7 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep
            && checksum_domain<records::RosterGroupRecord>(domains.rosterGroups, checksum)
            && checksum_domain<records::SpawnStemRecord>(domains.spawnStems, checksum)
            && checksum_domain<records::SpawnNameHashRecord>(domains.spawnNameHashes, checksum)
+           && checksum_domain<records::SpawnPointRecord>(domains.spawnPoints, checksum)
            && checksum_domain<records::HashNameRecord>(domains.hashNames, checksum)
            && checksum_domain<records::VendorIndexRecord>(domains.vendorIndex, checksum)
            && checksum_domain<records::VendorDefinitionRecord>(domains.vendorDefinitions, checksum)
@@ -109,6 +110,7 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept {
            && write_domain<records::RosterGroupRecord>(file, domains.rosterGroups)
            && write_domain<records::SpawnStemRecord>(file, domains.spawnStems)
            && write_domain<records::SpawnNameHashRecord>(file, domains.spawnNameHashes)
+           && write_domain<records::SpawnPointRecord>(file, domains.spawnPoints)
            && write_domain<records::HashNameRecord>(file, domains.hashNames)
            && write_domain<records::VendorIndexRecord>(file, domains.vendorIndex)
            && write_domain<records::VendorDefinitionRecord>(file, domains.vendorDefinitions)

+ 1 - 0
Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp

@@ -123,6 +123,7 @@ enum class WriteStatus {
         static_cast<std::uint32_t>(domains.rosterGroups.size()),
         static_cast<std::uint32_t>(domains.spawnStems.size()),
         static_cast<std::uint32_t>(domains.spawnNameHashes.size()),
+        static_cast<std::uint32_t>(domains.spawnPoints.size()),
         static_cast<std::uint32_t>(domains.hashNames.size()),
         static_cast<std::uint32_t>(domains.vendorIndex.size()),
         static_cast<std::uint32_t>(domains.vendorDefinitions.size()),

+ 18 - 1
Sunrise/src/state/build_data/runtime.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
 #include <span>
@@ -375,10 +376,26 @@ publish_scenario_layouts(std::span<const scenarios::Definition> definitions,
  * Publishes the spawn-set catalog extracted from the installed packages, in one step.
  * @param stems Complete stem rows in ascending name order, or an empty complete catalog.
  * @param nameHashes Complete flat bank in stem then hash order.
+ * @param points Complete point bank in extraction order, which may be empty.
  * @return True when the rows pass the checks and any needed cache write succeeds.
  */
 [[nodiscard]] bool publish_spawn_sets(std::span<const spawn_sets::Stem> stems,
-                                      std::span<const spawn_sets::NameHash> nameHashes) noexcept;
+                                      std::span<const spawn_sets::NameHash> nameHashes,
+                                      std::span<const spawn_sets::Point> points) noexcept;
+
+/**
+ * Finds the spawn point of one map-package stem nearest a world position.
+ * @param stem Normalized stem a destination row carries.
+ * @param position World position to measure from.
+ * @param point Receives the nearest point and the set it belongs to.
+ * @param distance Receives the distance to it, in world units.
+ * @return True when the catalog is ready, holds the stem, and the stem owns a point.
+ */
+[[nodiscard]] bool
+find_nearest_spawn_point(std::string_view stem,
+                         const std::array<float, spawn_sets::kPositionComponents>& position,
+                         spawn_sets::Point& point,
+                         float& distance) noexcept;
 
 /**
  * Copies the whole flat spawn-name hash bank.

+ 14 - 3
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -177,14 +177,15 @@ bool spawn_sets_ready() noexcept {
 
 /** Publishes the spawn-set catalog extracted from the installed packages, in one step. */
 bool publish_spawn_sets(std::span<const spawn_sets::Stem> stems,
-                        std::span<const spawn_sets::NameHash> nameHashes) noexcept {
+                        std::span<const spawn_sets::NameHash> nameHashes,
+                        std::span<const spawn_sets::Point> points) noexcept {
     runtime::persistence::Transaction transaction;
     if (!transaction.active()) {
         return false;
     }
     // An empty catalog is complete. It is what a build with no installed spawn set means.
-    const bool replaced =
-        stems.empty() ? nameHashes.empty() : spawn_sets::replace(stems, nameHashes);
+    const bool replaced = stems.empty() ? nameHashes.empty() && points.empty()
+                                        : spawn_sets::replace(stems, nameHashes, points);
     if (!replaced) {
         return transaction.finish(false, rollback_spawn_catalog_publication);
     }
@@ -209,6 +210,16 @@ bool find_spawn_sets(std::string_view stem,
            && spawn_sets::stem_hashes(row, output, count);
 }
 
+/** Finds the spawn point of one map-package stem nearest a world position. */
+bool find_nearest_spawn_point(std::string_view stem,
+                              const std::array<float, spawn_sets::kPositionComponents>& position,
+                              spawn_sets::Point& point,
+                              float& distance) noexcept {
+    point = {};
+    distance = 0.0F;
+    return spawn_sets_ready() && spawn_sets::nearest_point(stem, position, point, distance);
+}
+
 /** Finds one destination's bubble layout by package name. */
 bool find_scenario_layout(std::string_view name, scenarios::Definition& definition) noexcept {
     definition = {};

+ 6 - 0
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -85,6 +85,7 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && scenarios::snapshot_groups(scratch.rosterGroups, counts.rosterGroups)
            && spawn_sets::snapshot(scratch.spawnStems, counts.spawnStems)
            && spawn_sets::snapshot_hashes(scratch.spawnNameHashes, counts.spawnNameHashes)
+           && spawn_sets::snapshot_points(scratch.spawnPoints, counts.spawnPoints)
            && hash_names::snapshot(scratch.hashNames, counts.hashNames)
            && vendors::snapshot_index(scratch.vendorIndex, counts.vendorIndex)
            && vendors::snapshot_definitions(scratch.vendorDefinitions, counts.vendorDefinitions)
@@ -162,6 +163,8 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto spawnNameHashes =
         ensure_scratch<spawn_sets::NameHash, spawn_sets::kNameHashCapacity>(
             state.spawnNameHashScratch);
+    const auto spawnPoints =
+        ensure_scratch<spawn_sets::Point, spawn_sets::kPointCapacity>(state.spawnPointScratch);
     const auto hashNames =
         ensure_scratch<hash_names::Name, hash_names::kNameCapacity>(state.hashNameScratch);
     const auto vendorIndex =
@@ -193,6 +196,7 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         rosterGroups,
         spawnStems,
         spawnNameHashes,
+        spawnPoints,
         hashNames,
         vendorIndex,
         vendorDefinitions,
@@ -229,6 +233,7 @@ void release_scratch_locked(Context& state) noexcept {
     release_bank(state.rosterGroupScratch);
     release_bank(state.spawnStemScratch);
     release_bank(state.spawnNameHashScratch);
+    release_bank(state.spawnPointScratch);
     release_bank(state.hashNameScratch);
     release_bank(state.vendorIndexScratch);
     release_bank(state.vendorDefinitionScratch);
@@ -287,6 +292,7 @@ cache::records::Domains occupied_domains(Context& state,
         std::span<const spawn_sets::Stem>{state.spawnStemScratch.data(), counts.spawnStems},
         std::span<const spawn_sets::NameHash>{state.spawnNameHashScratch.data(),
                                               counts.spawnNameHashes},
+        std::span<const spawn_sets::Point>{state.spawnPointScratch.data(), counts.spawnPoints},
         std::span<const hash_names::Name>{state.hashNameScratch.data(), counts.hashNames},
         std::span<const vendors::IndexEntry>{state.vendorIndexScratch.data(), counts.vendorIndex},
         std::span<const vendors::Definition>{state.vendorDefinitionScratch.data(),

+ 1 - 0
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h

@@ -46,6 +46,7 @@ struct Context {
     std::vector<scenarios::RosterGroup> rosterGroupScratch{};
     std::vector<spawn_sets::Stem> spawnStemScratch{};
     std::vector<spawn_sets::NameHash> spawnNameHashScratch{};
+    std::vector<spawn_sets::Point> spawnPointScratch{};
     std::vector<hash_names::Name> hashNameScratch{};
     std::vector<vendors::IndexEntry> vendorIndexScratch{};
     std::vector<vendors::Definition> vendorDefinitionScratch{};

+ 12 - 0
Sunrise/src/state/build_data/spawn_sets/definition.h

@@ -6,6 +6,11 @@
 
 namespace sunrise::state::build_data::spawn_sets {
 
+/** Three floats make one world position, in the basis the game stores a body position in. */
+inline constexpr std::size_t kPositionComponents = 3;
+/** Spawn points kept across every stem. A pass that reaches this drops the rest and says so. */
+inline constexpr std::size_t kPointCapacity = 65'536;
+
 /**
  * Map-package stems the class sweep finds. The live count is 77.
  * A stem is the key a destination joins its spawn sets by.
@@ -68,4 +73,11 @@ struct NameHash {
     std::array<std::uint16_t, kPackageCapacity> activityPackages{};
 };
 
+/** One spawn point. The set is named by hash, because the flat bank is built after the points. */
+struct Point {
+    std::array<float, kPositionComponents> position{};
+    std::uint32_t nameHash{};
+    std::uint16_t stemIndex{};
+};
+
 } // namespace sunrise::state::build_data::spawn_sets

+ 105 - 8
Sunrise/src/state/build_data/spawn_sets/spawn_set_catalog.cpp

@@ -1,6 +1,7 @@
 #include "spawn_set_catalog.h"
 
 #include <algorithm>
+#include <cmath>
 #include <limits>
 #include <string_view>
 
@@ -9,11 +10,15 @@
 namespace sunrise::state::build_data::spawn_sets {
 namespace {
 
-// One lock covers both tables: a stem names a range inside the hash bank, so the pair must move
-// together or a stem could point past the end of the bank a reader sees.
+/** Widest world coordinate a point may carry. Maps are far smaller, so this rejects junk only. */
+constexpr float kPositionBound = 1.0e9F;
+
+// One lock covers all three tables. A stem names a hash range and a point names a stem row, so
+// replacing one alone would leave a reader resolving past the end.
 Lock g_lock;
 Table<Stem, kStemCapacity> g_stems;
 Table<NameHash, kNameHashCapacity> g_nameHashes;
+Table<Point, kPointCapacity> g_points;
 
 /** @param stem Stem row. @return Its bounded logical name. */
 [[nodiscard]] std::string_view name_of(const Stem& stem) noexcept {
@@ -63,13 +68,40 @@ Table<NameHash, kNameHashCapacity> g_nameHashes;
     return points == stem.pointCount && points <= (std::numeric_limits<std::uint32_t>::max)();
 }
 
+/** Finds one stem row by name, under the caller's lock. @param index Receives its row index. */
+[[nodiscard]] bool stem_index_locked(std::string_view name, std::size_t& index) noexcept {
+    index = 0;
+    const std::span<const Stem> rows = g_stems.rows();
+    const auto found =
+        std::lower_bound(rows.begin(), rows.end(), name, [](const Stem& row, auto key) {
+            return name_of(row) < key;
+        });
+    if (found == rows.end() || name_of(*found) != name) {
+        return false;
+    }
+    index = static_cast<std::size_t>(found - rows.begin());
+    return true;
+}
+
+/** @return The squared distance between two world positions. */
+[[nodiscard]] float distance_squared(const std::array<float, kPositionComponents>& from,
+                                     const std::array<float, kPositionComponents>& to) noexcept {
+    float total = 0.0F;
+    for (std::size_t lane = 0; lane < kPositionComponents; ++lane) {
+        const float delta = to[lane] - from[lane];
+        total += delta * delta;
+    }
+    return total;
+}
+
 } // namespace
 
-/** Clears every extracted stem and name-hash row under the catalog lock. */
+/** Clears every extracted stem, name-hash and point row under the catalog lock. */
 void clear() noexcept {
     const Lock::Exclusive guard(g_lock);
     g_stems.clear();
     g_nameHashes.clear();
+    g_points.clear();
 }
 
 /** Checks one complete spawn-set catalog in canonical stem and hash order. */
@@ -88,17 +120,82 @@ bool valid(std::span<const Stem> stems, std::span<const NameHash> nameHashes) no
     return expectedOffset == nameHashes.size();
 }
 
+/** Checks the point bank against the stems it names. */
+bool valid_points(std::span<const Point> points, std::span<const Stem> stems) noexcept {
+    if (points.size() > kPointCapacity) {
+        return false;
+    }
+    for (const Point& point : points) {
+        if (point.stemIndex >= stems.size()) {
+            return false;
+        }
+        for (const float lane : point.position) {
+            // A not-a-number lane would win every distance comparison it entered.
+            if (!(lane == lane) || lane > kPositionBound || lane < -kPositionBound) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
 /** Replaces the complete spawn-set catalog in one step. */
-bool replace(std::span<const Stem> stems, std::span<const NameHash> nameHashes) noexcept {
-    if (!valid(stems, nameHashes)) {
+bool replace(std::span<const Stem> stems,
+             std::span<const NameHash> nameHashes,
+             std::span<const Point> points) noexcept {
+    if (!valid(stems, nameHashes) || !valid_points(points, stems)) {
         return false;
     }
     const Lock::Exclusive guard(g_lock);
-    // Both run, with no short-circuit, so the pair cannot be left half replaced. valid() already
-    // checked each against its size, which is the only reason either can refuse.
+    // All three run with no short-circuit, so the set is never left half replaced.
     const bool storedStems = g_stems.replace(stems);
     const bool storedHashes = g_nameHashes.replace(nameHashes);
-    return storedStems && storedHashes;
+    const bool storedPoints = g_points.replace(points);
+    return storedStems && storedHashes && storedPoints;
+}
+
+/** Finds the spawn point of one stem nearest a world position. */
+bool nearest_point(std::string_view stem,
+                   const std::array<float, kPositionComponents>& position,
+                   Point& point,
+                   float& distance) noexcept {
+    point = {};
+    distance = 0.0F;
+    const Lock::Shared guard(g_lock);
+    std::size_t stemIndex = 0;
+    if (!stem_index_locked(stem, stemIndex)) {
+        return false;
+    }
+    const Point* found = nullptr;
+    float best = 0.0F;
+    for (const Point& row : g_points.rows()) {
+        if (row.stemIndex != stemIndex) {
+            continue;
+        }
+        const float candidate = distance_squared(position, row.position);
+        if (found == nullptr || candidate < best) {
+            found = &row;
+            best = candidate;
+        }
+    }
+    if (found == nullptr) {
+        return false;
+    }
+    point = *found;
+    distance = std::sqrt(best);
+    return true;
+}
+
+/** Copies the whole point bank. */
+bool snapshot_points(std::span<Point> output, std::size_t& count) noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_points.snapshot(output, count);
+}
+
+/** @return The point row count, read under the lock. */
+std::size_t point_count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_points.count();
 }
 
 /** Finds one stem by its normalized name. */

+ 37 - 1
Sunrise/src/state/build_data/spawn_sets/spawn_set_catalog.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
 #include <span>
@@ -23,14 +24,49 @@ void clear() noexcept;
 [[nodiscard]] bool valid(std::span<const Stem> stems,
                          std::span<const NameHash> nameHashes) noexcept;
 
+/**
+ * Checks the point bank against the stems it names.
+ * @param points Candidate point bank in extraction order.
+ * @param stems Stem rows the points index into.
+ * @return True when every point names a stem in range and carries a finite position.
+ */
+[[nodiscard]] bool valid_points(std::span<const Point> points,
+                                std::span<const Stem> stems) noexcept;
+
 /**
  * Replaces the complete spawn-set catalog in one step.
  * @param stems Complete stem rows in ascending name order.
  * @param nameHashes Complete flat bank in stem then hash order.
+ * @param points Complete point bank in extraction order, which may be empty.
  * @return True when the catalog passes validation and fits fixed State storage.
  */
 [[nodiscard]] bool replace(std::span<const Stem> stems,
-                           std::span<const NameHash> nameHashes) noexcept;
+                           std::span<const NameHash> nameHashes,
+                           std::span<const Point> points) noexcept;
+
+/**
+ * Finds the spawn point of one stem nearest a world position.
+ * @param stem Normalized map-package stem.
+ * @param position World position to measure from.
+ * @param point Receives the nearest point.
+ * @param distance Receives the distance to it, in world units.
+ * @return True when the stem is present and owns at least one point.
+ */
+[[nodiscard]] bool nearest_point(std::string_view stem,
+                                 const std::array<float, kPositionComponents>& position,
+                                 Point& point,
+                                 float& distance) noexcept;
+
+/**
+ * Copies the whole point bank.
+ * @param output Caller-owned fixed row storage.
+ * @param count Receives the copied row count, or zero when output is too small.
+ * @return True when output can hold every row.
+ */
+[[nodiscard]] bool snapshot_points(std::span<Point> output, std::size_t& count) noexcept;
+
+/** @return The point row count, read under the lock. */
+[[nodiscard]] std::size_t point_count() noexcept;
 
 /**
  * Finds one stem by its normalized name.