瀏覽代碼

Merge pull request #90 from SkyzerFlyzer/perf/rarest-byte-anchor

 Reduce boot time in the signature sweep and hook attach
stan 4 天之前
父節點
當前提交
22c405cb02
共有 49 個文件被更改,包括 3240 次插入160 次删除
  1. 4 0
      Sunrise/Sunrise.vcxproj
  2. 2 1
      Sunrise/resources/default_settings.json
  3. 30 0
      Sunrise/src/client/content/scenarios/internal.h
  4. 2 1
      Sunrise/src/client/content/scenarios/scenario_roster_build.cpp
  5. 152 0
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp
  6. 69 0
      Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp
  7. 1119 0
      Sunrise/src/client/diagnostics/entity_create_probe.cpp
  8. 34 0
      Sunrise/src/client/diagnostics/entity_create_probe.h
  9. 278 0
      Sunrise/src/client/diagnostics/image_dump.cpp
  10. 26 0
      Sunrise/src/client/diagnostics/image_dump.h
  11. 169 30
      Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp
  12. 86 12
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp
  13. 15 9
      Sunrise/src/client/hooks/bootflow/character_select_hold.cpp
  14. 15 9
      Sunrise/src/client/hooks/bootflow/composition_check.cpp
  15. 75 24
      Sunrise/src/client/hooks/bootflow/internal.h
  16. 15 9
      Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp
  17. 13 8
      Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp
  18. 13 8
      Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp
  19. 15 9
      Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp
  20. 21 13
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  21. 13 8
      Sunrise/src/client/hooks/bootflow/spawn_hold.cpp
  22. 124 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  23. 53 0
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  24. 114 6
      Sunrise/src/client/patterns/registry.cpp
  25. 15 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  26. 2 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  27. 2 0
      Sunrise/src/client/runtime/internal.h
  28. 18 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  29. 22 0
      Sunrise/src/core/settings/client/definition.h
  30. 34 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  31. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  32. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  33. 18 0
      Sunrise/src/core/settings/state/activity_default_destination_parser.cpp
  34. 195 3
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
  35. 17 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  36. 69 0
      Sunrise/src/middleware/content/packages/tables/bubble_state_reader.cpp
  37. 9 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp
  38. 21 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.h
  39. 117 5
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  40. 11 2
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  41. 2 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  42. 5 1
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  43. 10 0
      Sunrise/src/state/activity/bubble_authority/definition.h
  44. 10 0
      Sunrise/src/state/activity/bubble_authority/runtime.h
  45. 27 2
      Sunrise/src/state/activity/bubble_authority/transactions/activity_bubble_authority_grant.cpp
  46. 16 0
      Sunrise/src/state/activity/defaults/definition.h
  47. 68 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp
  48. 15 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h
  49. 50 0
      Sunrise/src/state/build_data/cache/records/cache_spawn_set_records.cpp

+ 4 - 0
Sunrise/Sunrise.vcxproj

@@ -1057,6 +1057,8 @@
     <ClCompile Include="src\client\content\items\packages\package_build_report.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_root_tables.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
+    <ClCompile Include="src\client\diagnostics\entity_create_probe.cpp" />
+    <ClCompile Include="src\client\diagnostics\image_dump.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
     <ClCompile Include="src\core\settings\address_text.cpp" />
@@ -1866,6 +1868,8 @@
     <ClInclude Include="src\server\transport\internal.h" />
     <ClInclude Include="src\middleware\content\packages\reader\locator_cache.h" />
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
+    <ClInclude Include="src\client\diagnostics\entity_create_probe.h" />
+    <ClInclude Include="src\client\diagnostics\image_dump.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
     <ClInclude Include="src\core\settings\address_text.h" />

+ 2 - 1
Sunrise/resources/default_settings.json

@@ -50,7 +50,8 @@
       "transport_address": "127.0.0.1",
       "port": 30976,
       "server_reserve_count": 256,
-      "client_join_grant_count": 8192
+      "client_join_grant_count": 8192,
+      "client_lease_high_water": 0
     },
     "activation": {
       "default_client_activation": true,

+ 30 - 0
Sunrise/src/client/content/scenarios/internal.h

@@ -58,6 +58,35 @@ struct RosterStorage {
     std::size_t cursor{};
     /** Tag reads spent in the current call, which is what bounds how long it blocks. */
     std::size_t reads{};
+    /**
+     * Destination whose scenario is being walked, for diagnostics only.
+     * Several scenarios share one map and walk the same bubbles, so a per-object trace without
+     * this cannot say which destination reached an object and is easy to misread.
+     */
+    std::uint32_t destinationTag{};
+    /**
+     * Why the descriptor walk of the object being resolved fell short, counted per exit.
+     * A group is refused when its found slots miss its declared ones, and the summary says only
+     * how many were refused. These say which step lost them, which is what picks the fix.
+     * Cleared with the slot list, so every count belongs to one object.
+     */
+    struct WalkExits {
+        /** Handles enumerated across the object's per-bubble sub-blocks. */
+        std::size_t handles{};
+        /** Descriptor blobs reached, which is where a slot can still be recorded. */
+        std::size_t blobs{};
+        /** A bubble entry did not decode, which abandons every bubble after it. */
+        std::size_t bubbleAborts{};
+        /** A placed handle did not decode, which abandons the rest of the walk. */
+        std::size_t handleAborts{};
+        /** One handle's chain reached a tag that would not read. */
+        std::size_t readFailures{};
+        /** One handle's chain reached a class with no next tag. */
+        std::size_t chainEnds{};
+        /** One handle's chain was still unresolved at the depth limit. */
+        std::size_t depthExhausted{};
+    };
+    WalkExits exits{};
 };
 
 /** Tag-read budget bounds one process-freeze interval and keeps worker shutdown responsive. */
@@ -246,6 +275,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept;
                                   reader::Scratch& scratch,
                                   RosterStorage& storage,
                                   std::uint32_t objectTag,
+                                  std::uint32_t sliceSetIndex,
                                   std::uint16_t& group) noexcept;
 
 /**

+ 2 - 1
Sunrise/src/client/content/scenarios/scenario_roster_build.cpp

@@ -107,7 +107,7 @@ void note_candidate(Walk& walk,
                 return false;
             }
             std::uint16_t group = kNotARosterGroup;
-            if (!resolve_object(source, scratch, storage, objectTag, group)) {
+            if (!resolve_object(source, scratch, storage, objectTag, sliceSetIndex, group)) {
                 return false;
             }
             if (group == kNotARosterGroup) {
@@ -197,6 +197,7 @@ bool build_rosters(const reader::Source& source,
         }
         layouts::Definition& row = rows[storage.cursor];
         ++storage.cursor;
+        storage.destinationTag = row.tag;
         row.rosterGroupCount = 0;
         row.rosterGroups = {};
         row.bubbleGroupCount = 0;

+ 152 - 0
Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

@@ -1,3 +1,9 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <span>
+
+#include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
@@ -34,6 +40,7 @@ struct ChainReadContext {
     auto& chain = *static_cast<ChainReadContext*>(context);
     ++chain.storage->reads;
     if (!reader::read_tag(*chain.source, *chain.scratch, tag, chain.storage->chain, classId)) {
+        ++chain.storage->exits.readFailures;
         blob = {};
         return false;
     }
@@ -83,13 +90,16 @@ struct ChainReadContext {
     for (std::uint64_t index = 0; index < bubbles.count; ++index) {
         tables::ObjectBubble bubble{};
         if (!tables::object_bubble_at(objectBlob, bubbles, index, bubble)) {
+            ++storage.exits.bubbleAborts;
             return false;
         }
         for (std::uint64_t slot = 0; slot < bubble.handleCount; ++slot) {
             std::uint32_t handle = 0;
             if (!tables::object_placed_handle_at(objectBlob, bubble, slot, handle)) {
+                ++storage.exits.handleAborts;
                 return false;
             }
+            ++storage.exits.handles;
             if (!follow_handle(source, scratch, storage, handle, registryKey)) {
                 return false;
             }
@@ -98,6 +108,136 @@ struct ChainReadContext {
     return true;
 }
 
+/**
+ * Group objects reported per run. The measured drop count is 59, so this shows every one and
+ * still bounds a content tree that drops far more.
+ */
+constexpr std::size_t kMaxUnresolvedReports = 128;
+/** Size of one line, set by its tag, key and the per-exit counts that follow them. */
+constexpr std::size_t kUnresolvedLineCapacity = 256;
+
+/** Lines already spent, so a long walk cannot flood the sink. */
+std::atomic_size_t g_unresolvedReports{0};
+
+/**
+ * Names one group object the descriptor walk could not fill.
+ * The domain summary counts these but names none, so a walk that drops most of what it finds
+ * reads the same as one that found little. The gap between declared and found is what says
+ * whether the chain stopped early or the classification refused what it reached.
+ * @param objectTag Tag of the object being resolved.
+ * @param registryKey Registry key the object declares.
+ * @param declaredSlotCount Slots the object's own slot array declares.
+ * @param storage Working storage holding what the walk recovered.
+ */
+void report_unresolved(std::uint32_t objectTag,
+                       std::uint32_t registryKey,
+                       std::uint64_t declaredSlotCount,
+                       const RosterStorage& storage) noexcept {
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_unresolvedReports.fetch_add(1, std::memory_order_relaxed) >= kMaxUnresolvedReports) {
+        return;
+    }
+    std::array<char, kUnresolvedLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=roster result=unresolved tag=0x%08X "
+                                      "key=0x%08X declared=%llu found=%zu overflow=%u "
+                                      "handles=%zu blobs=%zu bubble_abort=%zu handle_abort=%zu "
+                                      "read_fail=%zu chain_end=%zu depth=%zu",
+                                      objectTag,
+                                      registryKey,
+                                      static_cast<unsigned long long>(declaredSlotCount),
+                                      storage.slotCount,
+                                      storage.slotsOverflowed ? 1U : 0U,
+                                      storage.exits.handles,
+                                      storage.exits.blobs,
+                                      storage.exits.bubbleAborts,
+                                      storage.exits.handleAborts,
+                                      storage.exits.readFailures,
+                                      storage.exits.chainEnds,
+                                      storage.exits.depthExhausted);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Objects named per run by the placement trace.
+ * The installed tree holds 5,991 placed objects and each is traced once, so this shows every one.
+ */
+constexpr std::size_t kMaxPlacementReports = 8192;
+/** Slot types listed per line. No installed object declares more than this many. */
+constexpr std::size_t kTracedSlotTypes = 24;
+/** Size of one line: the fixed fields plus up to `kTracedSlotTypes` short decimal numbers. */
+constexpr std::size_t kPlacementLineCapacity = 256;
+
+/** Lines already spent, so a full content walk cannot flood the sink. */
+std::atomic_size_t g_placementReports{0};
+
+/**
+ * Names one placed object and every slot type it declares, before any filter has judged it.
+ * `carries_roster_slot` admits an object only when it declares one of `kRosterSlotTypes`, and an
+ * object it refuses leaves no trace anywhere: it is not counted, not published, and not reported.
+ * So a bubble whose objects all declare some other type is indistinguishable from a bubble with no
+ * objects at all, which is exactly the ambiguity that has to be settled before that list is
+ * widened. Widening it blind is not safe — `kRosterKeyCapacity` overflow costs a destination every
+ * group it publishes today, so this reports what the filter sees rather than changing it.
+ * @param sliceSetIndex Slice set whose registry named this object.
+ * @param objectTag Tag of the placed object.
+ * @param object Whole placed-object bytes.
+ * @param admitted Whether `carries_roster_slot` accepted it.
+ */
+void report_placement(std::uint32_t destinationTag,
+                      std::uint32_t sliceSetIndex,
+                      std::uint32_t objectTag,
+                      std::span<const std::byte> object,
+                      bool admitted) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_placementReports.fetch_add(1, std::memory_order_relaxed) >= kMaxPlacementReports) {
+        return;
+    }
+    std::uint32_t key = 0;
+    (void)tables::object_key(object, key);
+    tables::Array slots{};
+    const bool hasSlots = tables::object_slots(object, slots);
+    std::array<char, kPlacementLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=placement dest=0x%08X slice=%u bubble=%u "
+                                "tag=0x%08X key=0x%08X admitted=%u slots=%llu types=",
+                                destinationTag,
+                                sliceSetIndex,
+                                sliceSetIndex / tables::kSliceSetIndexFactor,
+                                objectTag,
+                                key,
+                                admitted ? 1U : 0U,
+                                hasSlots ? static_cast<unsigned long long>(slots.count) : 0ULL);
+    if (written <= 0) {
+        return;
+    }
+    auto used = static_cast<std::size_t>(written);
+    const std::uint64_t listed =
+        hasSlots && slots.count < kTracedSlotTypes ? slots.count : kTracedSlotTypes;
+    for (std::uint64_t index = 0; hasSlots && index < listed && used < line.size(); ++index) {
+        tables::Slot slot{};
+        if (!tables::object_slot_at(object, slots, index, slot)) {
+            break;
+        }
+        written = std::snprintf(
+            line.data() + used, line.size() - used, index == 0 ? "%u" : ",%u", slot.type);
+        if (written <= 0) {
+            break;
+        }
+        used += static_cast<std::size_t>(written);
+    }
+    core::log::write(core::log::Channel::state, core::log::Level::debug, {line.data(), used});
+}
+
 /** @param storage Working storage. @param tag Object tag. @return Its memo slot, or capacity. */
 [[nodiscard]] std::size_t memo_slot(const RosterStorage& storage, std::uint32_t tag) noexcept {
     std::size_t probe = tag % kObjectMemoCapacity;
@@ -125,6 +265,7 @@ bool resolve_object(const reader::Source& source,
                     reader::Scratch& scratch,
                     RosterStorage& storage,
                     std::uint32_t objectTag,
+                    std::uint32_t sliceSetIndex,
                     std::uint16_t& group) noexcept {
     group = kNotARosterGroup;
     const std::size_t slot = memo_slot(storage, objectTag);
@@ -132,6 +273,9 @@ bool resolve_object(const reader::Source& source,
         return false;
     }
     if (storage.memo[slot].tag == objectTag) {
+        // The memo spans the whole pass, so an object first seen under another destination is
+        // answered from here and never re-traced. A destination's own trace is therefore its
+        // first sighting of each object, not every registry that names it.
         group = storage.memo[slot].group;
         return true;
     }
@@ -142,6 +286,12 @@ bool resolve_object(const reader::Source& source,
         return true;
     }
 
+    report_placement(storage.destinationTag,
+                     sliceSetIndex,
+                     objectTag,
+                     storage.object,
+                     tables::carries_roster_slot(storage.object));
+
     layouts::RosterGroup candidate{};
     tables::Array declared{};
     if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
@@ -152,8 +302,10 @@ bool resolve_object(const reader::Source& source,
     }
     storage.slotCount = 0;
     storage.slotsOverflowed = false;
+    storage.exits = {};
     if (!collect_descriptors(source, scratch, storage, storage.object, candidate.registryKey)
         || !fill_slots(storage, declared.count, candidate)) {
+        report_unresolved(objectTag, candidate.registryKey, declared.count, storage);
         // A completed walk may prove that some declared slots have no descriptor. A failed walk
         // cannot distinguish that absence from unread content, so it refuses the whole group.
         ++storage.unresolvedGroups;

+ 69 - 0
Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp

@@ -1,5 +1,9 @@
 #include <algorithm>
+#include <array>
 #include <cstddef>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
 
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "internal.h"
@@ -9,6 +13,9 @@ namespace {
 
 namespace tables = middleware::content::packages::tables;
 
+/** Size of one publish line: the fixed fields plus the hex values that follow them. */
+constexpr std::size_t kPublishLineCapacity = 192;
+
 /**
  * Orders the safe groups the way the destination publishes them.
  * A group that binds the player or reports the lifetime comes first, then one reached through the
@@ -99,6 +106,67 @@ void publish_per_bubble(Walk& walk, layouts::Definition& row) noexcept {
 
 } // namespace
 
+/**
+ * Names every candidate and every intersection key one destination reached, and what became of it.
+ * A candidate that is admitted by the slot filter and then lost in the split leaves no trace: the
+ * row simply publishes fewer groups, which reads the same as a destination that never had them.
+ * raid_beanstalk admits objects in bubbles 8 through 13 and 15 but publishes per-bubble groups for
+ * only two of them, and nothing today says which step drops the rest.
+ * @param walk Accumulator for one destination, before the split consumes it.
+ * @param row Destination row being published into.
+ */
+void report_publish(const Walk& walk, const layouts::Definition& row) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    const tables::RosterIntersection& seen = walk.intersection;
+    std::array<char, kPublishLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish tag=0x%08X keys=%zu candidates=%zu "
+                                "overflow=%u unresolved_set=%u observed=0x%llX top=%u bubble=%u",
+                                row.tag,
+                                seen.keyCount,
+                                walk.candidateCount,
+                                seen.overflowed ? 1U : 0U,
+                                seen.unresolvedSet ? 1U : 0U,
+                                static_cast<unsigned long long>(seen.observedSets),
+                                static_cast<unsigned>(row.rosterGroupCount),
+                                static_cast<unsigned>(row.bubbleGroupCount));
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // One line per key, because the split is decided per key: a mask equal to `observed` is
+    // top-level, a partial mask is per-bubble, and zero is dropped.
+    for (std::size_t index = 0; index < seen.keyCount; ++index) {
+        const std::uint64_t mask = seen.masks[index];
+        const char* fate = mask == 0                 ? "none"
+                           : mask == seen.observedSets ? "all"
+                                                       : "partial";
+        // A key with no candidate cannot publish: the split matches candidates against keys.
+        bool paired = false;
+        for (std::size_t candidate = 0; candidate < walk.candidateCount; ++candidate) {
+            paired = paired || walk.candidates[candidate].key == seen.keys[index];
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish_key tag=0x%08X key=0x%08X "
+                                "mask=0x%llX fate=%s paired=%u",
+                                row.tag,
+                                seen.keys[index],
+                                static_cast<unsigned long long>(mask),
+                                fate,
+                                paired ? 1U : 0U);
+        if (written > 0) {
+            core::log::write(core::log::Channel::state,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
 /** Splits the candidates between the destination row's two lists. */
 void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     row.rosterGroupCount = 0;
@@ -110,6 +178,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     // The per-bubble half is independent of the top-level one: its keys register through the
     // delta's own field 1, and a destination may reach one half and not the other.
     publish_per_bubble(walk, row);
+    report_publish(walk, row);
 }
 
 } // namespace sunrise::client::content::scenarios

+ 1119 - 0
Sunrise/src/client/diagnostics/entity_create_probe.cpp

@@ -0,0 +1,1119 @@
+#include "entity_create_probe.h"
+
+#include <Windows.h>
+#include <intrin.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+#include <string_view>
+
+#include "../../core/logging/log.h"
+#include "../hooking/detour.h"
+#include "../patterns/image_scan.h"
+#include "../patterns/signature_text.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+namespace patterns = client::patterns;
+namespace detour = client::hooking::detour;
+
+/**
+ * The index allocator the entity creator calls first.
+ * Recovered from the mapped-image dump. Its body is unmistakable: it stores -1 into the caller's
+ * out-parameter, then asks a pool at `+0xC118` sized `0x2000` for a free index. The frame size is
+ * wildcarded so the match carries no position-dependent byte.
+ */
+constexpr std::string_view kIndexAllocatorText =
+    "48 89 5C 24 08 48 89 74 24 10 57 48 83 EC ? 48 8B DA C7 02 FF FF FF FF 48 8B F9 "
+    "BA 00 20 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kIndexAllocator =
+    patterns::signature<patterns::signature_length(kIndexAllocatorText)>(kIndexAllocatorText);
+
+/** The allocator answers this in its out-parameter when it has no index to give. */
+constexpr std::int32_t kNoIndex = -1;
+/**
+ * Byte offset of the free-slot bitmap inside the manager the allocator is handed.
+ * Read out of the allocator's body: it calls the bitmap search with `rcx = manager + 0xC118` and
+ * a width of `0x2000`, then clears the bit it was given. A set bit is therefore a FREE slot, and
+ * the search answers -1 only when every word is zero.
+ */
+constexpr std::size_t kFreeBitmapOffset = 0xC118;
+/** Slots the bitmap covers, from the width the allocator passes. */
+constexpr std::size_t kFreeBitmapBits = 0x2000;
+/** Words in that bitmap. */
+constexpr std::size_t kFreeBitmapWords = kFreeBitmapBits / 32;
+
+/**
+ * Counts the free slots the manager currently holds.
+ * The exhaustion line alone cannot separate "the host never gave the client any slots" from
+ * "the client used everything it was given", and those need opposite fixes.
+ * @param pool Manager the allocator was handed.
+ * @return Set bits in its free bitmap, or -1 when the bitmap cannot be read.
+ */
+[[nodiscard]] std::int64_t free_slot_count(const void* pool) noexcept {
+    if (pool == nullptr) {
+        return -1;
+    }
+    std::int64_t free = 0;
+    __try {
+        const auto* words = reinterpret_cast<const std::uint32_t*>(
+            static_cast<const std::byte*>(pool) + kFreeBitmapOffset);
+        for (std::size_t word = 0; word < kFreeBitmapWords; ++word) {
+            free += static_cast<std::int64_t>(__popcnt(words[word]));
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return -1;
+    }
+    return free;
+}
+/** Outcomes reported per run, so a per-frame failure cannot fill the log. */
+constexpr LONG kMaxReports = 200;
+/**
+ * Stack frames captured above this probe on each allocation.
+ * The allocator itself is generic — one function serves every entity in the game — so its own
+ * address says nothing about what is being built. The callers above it are what differ, and six
+ * frames is enough to separate "the world is placing an object" from "a weapon spawned a
+ * projectile" without unwinding the whole fiber stack.
+ */
+constexpr ULONG kTraceFrames = 6;
+/**
+ * Allocation traces per run.
+ * A raid load builds a few hundred entities, so this holds several bubble loads while still
+ * bounding what a long firefight can write.
+ */
+constexpr LONG kMaxTraces = 4096;
+/** Traces already spent. */
+volatile LONG g_traces{};
+/**
+ * Image offset of the pointer to the game's entity record table.
+ * Recovered from the creation path itself, which indexes it as `base + (handle & 0x1FFF) * stride`
+ * at `0x4D71F7`: `imul ebx, [rip -> 0x1F93430]` then `add rbx, [rip -> 0x1F93428]`. The mask is the
+ * same 13 bits the allocator's bitmap covers, so a record addresses exactly one allocated index.
+ */
+constexpr std::uintptr_t kEntityTableBaseRva = 0x1F93428;
+/** Image offset of the record stride that pairs with the table above. */
+constexpr std::uintptr_t kEntityTableStrideRva = 0x1F93430;
+/** Stride the dump reports. Checked at runtime, because a wrong one would read foreign memory. */
+constexpr std::uint32_t kExpectedRecordStride = 224;
+/** Bytes of each record dumped. The whole record, so the type field can be found by comparison. */
+constexpr std::size_t kRecordDumpBytes = kExpectedRecordStride;
+/** Records dumped per run, bounded so a long session cannot fill the sink. */
+constexpr LONG kMaxRecords = 512;
+/** Records already dumped. */
+volatile LONG g_records{};
+/**
+ * Record class every live entity carries at `+0x64`.
+ * Constant across all 57 records of a run, so it marks a slot the game has actually built rather
+ * than one holding whatever the last entity left behind.
+ */
+constexpr std::uint32_t kRecordClass = 0x80809783;
+/** Offset of the record class within a record. */
+constexpr std::size_t kRecordClassOffset = 0x64;
+/** Offset of the object's definition hash. Varies per object kind; `0xFFFFFFFF` where absent. */
+constexpr std::size_t kRecordDefinitionOffset = 0x88;
+/** Offset of the instance ordinal that counts copies of one definition. */
+constexpr std::size_t kRecordOrdinalOffset = 0x8C;
+/** Offset of the transform block, which is still unset when a record is first dumped. */
+constexpr std::size_t kRecordTransformOffset = 0xA0;
+/** Dwords of the transform block reported, covering the orientation and position quads. */
+constexpr std::size_t kRecordTransformDwords = 8;
+/** Seconds between censuses. Short enough to catch a bubble soon after it settles. */
+constexpr DWORD kCensusIntervalMs = 15'000;
+/**
+ * Most recent manager the allocator was handed.
+ * The census needs the free bitmap to tell a live record from one an entity left behind, and the
+ * allocator is the only place the manager pointer is known.
+ */
+void* volatile g_lastPool{};
+/** Entries one census reports, so a fully populated table cannot fill the sink. */
+constexpr LONG kCensusEntryBudget = 2'048;
+/**
+ * Distinct record classes counted per census.
+ * The census filtered on one class, `kRecordClass`, and so never reported an index above ~1019.
+ * An interaction incident then named entity **3539** as its target while the player stood on the
+ * Wall of Wishes activation plate -- an object that works -- and the twenty panels that do not
+ * work sit at 749..768. Whatever separates them is not visible while the walk only ever admits
+ * one class, so every class is counted and sampled now.
+ */
+constexpr std::size_t kClassCapacity = 24;
+/**
+ * Records dumped per distinct class, so a large class cannot crowd out a small one.
+ * Set at 48 this hid the very thing it was built to find: one class holds every real record, so
+ * only indices 0..47 were ever dumped and the Wall of Wishes panels at 749..768 fell outside the
+ * log entirely. That absence then read as "the player never reached the wall", which was wrong.
+ * The share only needs to stop one class starving another, so it sits at the whole budget.
+ */
+constexpr LONG kPerClassDump = 2'048;
+/** Cleared to stop the census thread. */
+volatile LONG g_censusRunning{};
+/** Census thread handle. */
+HANDLE g_censusThread{};
+
+/**
+ * Index whose record has not been dumped yet.
+ * The record is empty when the allocator hands the index out — the creator fills it afterwards — so
+ * each index is read one allocation late, when whatever built it has finished.
+ */
+volatile LONG g_pendingIndex{-1};
+
+/**
+ * Dumps one entity record so the entity can be named rather than counted.
+ * Counting proved the pool works and says nothing about what is in it. The record is the only place
+ * the client keeps an entity's identity, and every entity in the run shares one creation path, so
+ * the bytes here are what separate a wall panel from a projectile.
+ * @param index Index whose record to read.
+ */
+void report_record(std::int32_t index) noexcept {
+    if (index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_records) > kMaxRecords) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = 0;
+    __try {
+        const auto table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        const auto stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+        // A stride that has moved means this offset no longer names the table, and reading through
+        // it would dump unrelated memory as if it were an entity.
+        if (table == nullptr || stride != kExpectedRecordStride) {
+            return;
+        }
+        const auto* const record = table + static_cast<std::size_t>(index) * stride;
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=entity_create stage=record idx=%d hex=",
+                                static_cast<int>(index));
+        for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                     && static_cast<std::size_t>(written) + 3 < line.size();
+             ++offset) {
+            const int more = std::snprintf(line.data() + written,
+                                           line.size() - static_cast<std::size_t>(written),
+                                           "%02X",
+                                           std::to_integer<unsigned char>(record[offset]));
+            if (more <= 0) {
+                break;
+            }
+            written += more;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (written <= 0) {
+        return;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+/** Resolved `RtlCaptureStackBackTrace`, or null when ntdll would not give it up. */
+USHORT(NTAPI* g_captureBacktrace)(ULONG, ULONG, PVOID*, PULONG){};
+/** Allocations between pool samples. Frequent enough to shape the drain, rare enough to be free. */
+constexpr LONG kSampleInterval = 16;
+/** Bytes in the bitmap, from the width the client's own stocking path passes to its fill. */
+constexpr std::size_t kFreeBitmapBytes = kFreeBitmapBits / 8;
+/**
+ * High slots the host keeps for its own entities and never leases to the client.
+ * The join grant is `kSlotCount - kDefaultServerReserve` = 7936, so the top 256 indices are the
+ * host's. The client's own initialiser frees the whole bitmap because in its intended world it
+ * owns every slot; here it does not, and handing it the reserve would let it allocate an index
+ * the host also considers its own.
+ */
+constexpr std::size_t kServerReserveSlots = 256;
+/** Bytes of the bitmap that stay clear, covering the reserve at the top of the index space. */
+constexpr std::size_t kReserveBytes = kServerReserveSlots / 8;
+/** Bytes of the bitmap that are freed to the client. */
+constexpr std::size_t kClientBytes = kFreeBitmapBytes - kReserveBytes;
+/** Words of the bitmap covering the client's half. The split lands on a word boundary. */
+constexpr std::size_t kClientWords = kClientBytes / sizeof(std::uint32_t);
+static_assert(kClientBytes % sizeof(std::uint32_t) == 0,
+              "the client half must end on a word so a refill never touches the reserve");
+/** Bits per bitmap word. */
+constexpr std::size_t kBitsPerWord = 32;
+/**
+ * Address span treated as belonging to the game's image.
+ * The dump reports an image size of 0x8A5EA00, so this clears it with room for a larger build while
+ * still rejecting a frame that landed in Sunrise's own module or on a foreign allocation.
+ */
+constexpr std::uintptr_t kImageSpan = 0x10000000;
+
+/**
+ * The allocator's real shape, read from its body rather than guessed.
+ * It uses exactly two arguments: `rcx` is the manager whose free-slot bitmap sits at `+0xC118`,
+ * and `rdx` is the out-parameter it fills with the allocated index. It returns `rdx` unchanged.
+ */
+using IndexAllocator = void*(__fastcall*)(void*, std::int32_t*) noexcept;
+
+detour::Handle g_allocator{};
+volatile LONG g_reports{};
+/** Successful allocations seen, used only to space the samples. */
+volatile LONG g_allocations{};
+/**
+ * One manager's record of the indices this probe has watched the allocator hand out.
+ *
+ * A blanket `memset(bitmap, 0xFF, ...)` is what made the very first stocking work and what made
+ * every later one lethal. It frees index 0 upward, and by the time a pool has drained, index 0
+ * belongs to a live entity. The allocator picks the lowest set bit, so the next creation lands on
+ * top of a live entity and the world stops being a consistent list of them. That is the crash on
+ * respawn, the crash on Worldline Zero's ability, and the mainloop stall that ends a Shuro Chi run
+ * a few seconds after the room loads.
+ *
+ * Keeping the set of indices already handed out turns the refill from "free everything" into
+ * "free what was never taken", which is the only form of it that is safe to run on a live pool.
+ */
+struct PoolRecord {
+    /** Manager this record belongs to, or null while the slot is unused. */
+    void* pool;
+    /** Set bit per index the allocator gave out and the client has not since handed back. */
+    std::array<volatile LONG, kFreeBitmapWords> live;
+    /** Whether this pool has been refilled at least once. */
+    volatile LONG stocked;
+};
+
+/** Managers tracked at once. A world change builds a new one, so several are live per run. */
+constexpr std::size_t kTrackedPoolCapacity = 16;
+/** Per-manager occupancy records, claimed on first sight. */
+std::array<PoolRecord, kTrackedPoolCapacity> g_pools{};
+
+/**
+ * Finds the record for one manager, claiming a free slot on first sight.
+ * @param pool Manager the allocator was handed.
+ * @return Its record, or null when the table is full.
+ */
+[[nodiscard]] PoolRecord* find_pool(void* pool) noexcept {
+    for (auto& record : g_pools) {
+        if (record.pool == pool) {
+            return &record;
+        }
+    }
+    for (auto& record : g_pools) {
+        auto* const slot = reinterpret_cast<void* volatile*>(&record.pool);
+        if (InterlockedCompareExchangePointer(slot, pool, nullptr) == nullptr
+            || record.pool == pool) {
+            return &record;
+        }
+    }
+    // Past capacity nothing is tracked, so nothing is refilled either. A missed refill costs this
+    // world's entities; an untracked one corrupts a live pool.
+    return nullptr;
+}
+
+/**
+ * Records that one index is now owned by an entity.
+ * @param record Manager record, or null when the manager is untracked.
+ * @param index Index the allocator produced.
+ */
+void mark_live(PoolRecord* record, std::int32_t index) noexcept {
+    if (record == nullptr || index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits) {
+        return;
+    }
+    const auto slot = static_cast<std::size_t>(index);
+    (void)InterlockedOr(&record->live[slot / kBitsPerWord],
+                        static_cast<LONG>(1u << (slot % kBitsPerWord)));
+}
+/** Off leaves the probe reporting only, which is what it did before it could write. */
+bool g_stockUnstockedPool{};
+/** Refill a drained pool as well as an unstocked one. Safe now that the refill spares live slots. */
+bool g_restockAlways{};
+
+/**
+ * Reports one probe outcome, up to the per-run budget.
+ * @param stage Which half answered.
+ * @param outcome What it answered.
+ * @param detail Free slots left in the pool, or -1 when the bitmap could not be read.
+ */
+void report_pair(const char* stage,
+                 const char* outcome,
+                 std::int64_t detail,
+                 std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld allocs=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail),
+                                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports one refill, naming how many slots it actually handed back.
+ * @param outcome Whether the pool answered after the refill.
+ * @param free Free slots the bitmap holds now.
+ * @param freed Slots this refill put back.
+ * @param allocations Successful allocations seen so far.
+ */
+void report_stock(const char* outcome,
+                  std::int64_t free,
+                  std::int64_t freed,
+                  std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=entity_create stage=allocate result=%s free=%lld freed=%lld allocs=%lld",
+                      outcome,
+                      static_cast<long long>(free),
+                      static_cast<long long>(freed),
+                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Names one allocation and the call sites that asked for it.
+ *
+ * Counting allocations proved the pool works; it cannot say what is being built, and that is the
+ * question a missing Wall of Wishes actually poses. Its panels are one repeated object, so a burst
+ * of identical traces landing on consecutive indices as a bubble loads is the wall being created,
+ * and the absence of such a burst is the wall never being asked for. Those two need opposite fixes.
+ *
+ * Addresses are image-relative because the game is rebased every run; an RVA maps straight into the
+ * mapped-image dump, where file offset equals RVA.
+ * @param pool Manager the index came from, so per-type managers would show as distinct pointers.
+ * @param index Index the allocator produced.
+ * @param sequence Allocation ordinal within the run.
+ */
+void report_allocation(const void* pool, std::int32_t index, LONG sequence) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_traces) > kMaxTraces) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                               line.size(),
+                               "ev=entity_create stage=alloc n=%ld idx=%d pool=0x%llX sites=",
+                               static_cast<long>(sequence),
+                               static_cast<int>(index),
+                               static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(pool)));
+    if (written <= 0) {
+        return;
+    }
+    std::array<void*, kTraceFrames> frames{};
+    // Frame 0 is this probe, which is never interesting, so the capture starts one above it.
+    const USHORT captured = g_captureBacktrace == nullptr
+                                ? 0
+                                : g_captureBacktrace(1, kTraceFrames, frames.data(), nullptr);
+    for (USHORT frame = 0; frame < captured && written > 0
+                           && static_cast<std::size_t>(written) < line.size();
+         ++frame) {
+        const auto site = reinterpret_cast<std::uintptr_t>(frames[frame]);
+        // A frame inside Sunrise's own module is noise here; only the game's code is addressable
+        // in the dump, so anything outside it is printed as a gap rather than a misleading offset.
+        const bool inImage = base != 0 && site >= base && (site - base) < kImageSpan;
+        const int more =
+            std::snprintf(line.data() + written,
+                          line.size() - static_cast<std::size_t>(written),
+                          inImage ? "%s0x%llX" : "%s-",
+                          frame == 0 ? "" : ",",
+                          static_cast<unsigned long long>(inImage ? site - base : 0));
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+
+void report(const char* stage, const char* outcome, std::int64_t detail) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Mirrors the index allocator and reports whether it produced an index.
+ * The out-parameter is the answer: the original writes -1 into it before doing anything, and
+ * overwrites it only on success.
+ */
+/**
+ * Reads the game's entity record table, or reports that it cannot be addressed.
+ * @param table Receives the table base.
+ * @param stride Receives the record stride.
+ * @return True when both were read and the stride still matches this build.
+ */
+[[nodiscard]] bool entity_table(const std::byte*& table, std::uint32_t& stride) noexcept {
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return false;
+    }
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return false;
+    }
+    return table != nullptr && stride == kExpectedRecordStride;
+}
+
+/**
+ * Reports which of one word's 32 indices already hold an entity.
+ *
+ * The probe's own record of handed-out indices covers only what came through the hooked allocator,
+ * and a census measured that as 58 of 830 — the world's placed objects reach the table by some
+ * other path entirely. Trusting that record alone therefore freed 7936 slots while 42 entities
+ * were sitting in them, and the client then allocated straight over the top. The game's own record
+ * table is the authority on which slots are taken, so occupancy is read from there instead.
+ * @param table Entity record table base.
+ * @param stride Record stride.
+ * @param word Word of the free bitmap being refilled.
+ * @return Set bit per index in that word whose record is live.
+ */
+[[nodiscard]] LONG occupied_mask(const std::byte* table, std::uint32_t stride, std::size_t word) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t bit = 0; bit < kBitsPerWord; ++bit) {
+        const std::size_t index = word * kBitsPerWord + bit;
+        __try {
+            if (*reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                        + kRecordClassOffset)
+                == kRecordClass) {
+                mask |= 1u << bit;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            // An unreadable record is treated as taken, which costs a slot rather than an entity.
+            mask |= 1u << bit;
+        }
+    }
+    return static_cast<LONG>(mask);
+}
+
+/**
+ * Frees every client slot that no entity holds, leaving the ones that do alone.
+ *
+ * The client's own initialiser at `0x7FF71DDADB20` fills this bitmap with `0xFF` — every slot free
+ * — but only when a role global reads zero; here it reads 3, so the fill never runs and the bitmap
+ * is all-zero from the first frame. Every entity creation then fails, which is why no enemy, plate,
+ * door or banner ever appeared and why an encounter bubble kicked to orbit. Writing those bytes
+ * ourselves is right exactly once, on a pool that is still empty. On a pool that has drained it is
+ * catastrophic, because the slots the client is using read as clear too and become free again.
+ *
+ * So the refill is driven by `record->live` instead of by a constant. A slot is freed only when the
+ * bitmap says it is taken AND this probe never watched the allocator hand it out. Two passes,
+ * because another thread may claim a slot while the first one runs: the second re-clears anything
+ * that became live in between, so no index is ever offered twice.
+ * @param pool Manager the allocator was handed.
+ * @param record Occupancy record for that manager.
+ * @param failure Receives a Windows error, or 1 for a null pool and 2 for a faulting write.
+ * @return Slots freed, or -1 when the bitmap could not be written.
+ */
+[[nodiscard]] std::int64_t stock_pool(void* pool,
+                                      PoolRecord* record,
+                                      std::uint32_t& failure) noexcept {
+    failure = 0;
+    if (pool == nullptr || record == nullptr) {
+        failure = 1;
+        return -1;
+    }
+    auto* const bitmap = static_cast<std::byte*>(pool) + kFreeBitmapOffset;
+    // The bitmap sits in the game's own allocation, so it carries whatever protection that
+    // allocation was given. Reading it worked, which does not prove it is writable.
+    DWORD previous = 0;
+    if (VirtualProtect(bitmap, kFreeBitmapBytes, PAGE_READWRITE, &previous) == FALSE) {
+        failure = GetLastError();
+        return -1;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    const bool hasTable = entity_table(table, stride);
+    std::int64_t freed = 0;
+    __try {
+        auto* const words = reinterpret_cast<volatile LONG*>(bitmap);
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG available = words[word];
+            // A slot the client has put back is no longer live, so it returns to the pool with the
+            // rest. Without this the record would only ever grow and the refill would fade to a
+            // no-op over a long session.
+            const LONG live = InterlockedAnd(&record->live[word], ~available) & ~available;
+            // The record table is the authority; the probe's own list is kept as a second opinion
+            // for anything created in the window before its record is filled in.
+            const LONG occupied = hasTable ? occupied_mask(table, stride, word) : 0;
+            const LONG missing =
+                static_cast<LONG>(~static_cast<std::uint32_t>(live | available | occupied));
+            if (missing != 0) {
+                (void)InterlockedOr(&words[word], missing);
+                freed += __popcnt(static_cast<unsigned int>(missing));
+            }
+        }
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG live = record->live[word];
+            if (live != 0) {
+                (void)InterlockedAnd(&words[word], ~live);
+            }
+        }
+        // The host's reserve at the top of the space stays clear so the client cannot allocate an
+        // index the host also considers its own.
+        for (std::size_t word = kClientWords; word < kFreeBitmapWords; ++word) {
+            (void)InterlockedAnd(&words[word], 0);
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        failure = 2;
+        freed = -1;
+    }
+    DWORD restored = 0;
+    (void)VirtualProtect(bitmap, kFreeBitmapBytes, previous, &restored);
+    if (freed >= 0) {
+        (void)InterlockedExchange(&record->stocked, 1);
+        if (!hasTable) {
+            // Worth saying out loud: without the table the refill is back to trusting a list that
+            // has been measured as 7% complete, which is how live entities got overwritten.
+            report("allocate", "stock_without_table", freed);
+        }
+    }
+    return freed;
+}
+
+void* __fastcall allocator_body(void* pool, std::int32_t* index) noexcept {
+    const auto call = reinterpret_cast<IndexAllocator>(g_allocator.original);
+    if (call == nullptr) {
+        return nullptr;
+    }
+    void* result = call(pool, index);
+    InterlockedExchangePointer(&g_lastPool, pool);
+    PoolRecord* const record = find_pool(pool);
+    if (index == nullptr || *index != kNoIndex) {
+        // Every index the client takes is recorded before anything else can act on it, because a
+        // refill that does not know about it would offer the same index to a second entity.
+        if (index != nullptr) {
+            mark_live(record, *index);
+        }
+        // Sample the pool as it is spent. A steadily falling count means indices are allocated and
+        // never returned; a count that rises again means the client's own free path does work and
+        // the drain is simply the world being large. Those need opposite fixes, and the exhaustion
+        // line alone cannot tell them apart because it only ever fires at zero.
+        const LONG seen = InterlockedIncrement(&g_allocations);
+        report_allocation(pool, index == nullptr ? kNoIndex : *index, seen);
+        // One allocation behind, so the creator has had time to fill the record being read.
+        report_record(InterlockedExchange(&g_pendingIndex, index == nullptr ? -1 : *index));
+        if ((seen % kSampleInterval) == 0) {
+            // The count is reported beside the free total: if the pool empties while this barely
+            // moves, the bitmap is being cleared by something other than allocation.
+            report_pair("allocate", "sample", free_slot_count(pool), seen);
+        }
+        return result;
+    }
+    const std::int64_t free = free_slot_count(pool);
+    // A pool is refilled the first time it is seen empty, and again on every later drain when the
+    // knob is on. Both are safe now: the refill spares the indices already handed out, so it can
+    // no longer hand one index to two entities the way the old blanket fill did.
+    const bool allowed = g_stockUnstockedPool && record != nullptr
+                         && (g_restockAlways || record->stocked == 0);
+    if (free != 0 || !allowed) {
+        report_pair("allocate", "exhausted", free, g_allocations);
+        return result;
+    }
+    std::uint32_t failure = 0;
+    const std::int64_t freed = stock_pool(pool, record, failure);
+    if (freed < 0) {
+        // Naming the reason matters: a refused write and a faulting page need different fixes.
+        report("allocate", "stock_failed", static_cast<std::int64_t>(failure));
+        return result;
+    }
+    result = call(pool, index);
+    // `freed` is the number that matters. It should fall well short of the whole client half: the
+    // gap is the live entities the old fill used to trample.
+    report_stock(*index == kNoIndex ? "stocked_still_empty" : "stocked",
+                 free_slot_count(pool),
+                 freed,
+                 g_allocations);
+    if (index != nullptr) {
+        mark_live(record, *index);
+    }
+    return result;
+}
+
+/**
+ * Image offset of the pointer that reaches the game's entity pool descriptors.
+ * From the creation path at `0x4D71B5`: `mov rcx, [rip -> 0x2439C70]` then `add rdx, [rcx]` with
+ * the pool ordinal already shifted left by six, so descriptors are 64 bytes apart and their array
+ * base is one further dereference in. Within a descriptor, `+0x08` is the pool base and `+0x30`
+ * its element size -- `imul eax, [rdx + 0x30]` then `add rcx, [rdx + 8]`.
+ */
+constexpr std::uintptr_t kPoolDirectoryRva = 0x2439C70;
+/** Bytes between pool descriptors. */
+constexpr std::size_t kPoolDescriptorStride = 64;
+/** Descriptors probed. The ordinal comes from a handle's high bits, which are six wide. */
+constexpr std::size_t kPoolDescriptorCount = 64;
+/** Offset of a pool's base pointer within its descriptor. */
+constexpr std::size_t kPoolBaseOffset = 0x08;
+/** Offset of a pool's element size within its descriptor. */
+constexpr std::size_t kPoolElementSizeOffset = 0x30;
+/** An element size outside this is not a record, so the descriptor is not one either. */
+constexpr std::uint32_t kMaximumElementSize = 4096;
+/**
+ * Pools whose elements match the entity record stride, walked by the census.
+ * The directory holds TWO 224-byte pools, ordinals 33 and 35, at stable and distinct bases. The
+ * census has only ever read whichever one `kEntityTableBaseRva` points at, so half the records of
+ * this shape were never looked at -- and the activation plate that works, entity 3539, is not in
+ * the half that was.
+ */
+constexpr std::size_t kRecordPoolCapacity = 4;
+/** Bases of the record-shaped pools found in the directory. */
+std::array<const std::byte*, kRecordPoolCapacity> g_recordPools{};
+/** Ordinals of those pools, in the same order. */
+std::array<std::size_t, kRecordPoolCapacity> g_recordPoolOrdinals{};
+/** Record-shaped pools found. */
+std::size_t g_recordPoolCount{};
+
+/**
+ * Reports every entity pool the game keeps, not just the one the census walks.
+ *
+ * The class tally proved the 224-byte table holds exactly one class and 830 records, and that
+ * everything read above them is out-of-bounds noise. So the Wall of Wishes activation plate, which
+ * an interaction incident named as entity 3539 and which visibly works, cannot be in that table at
+ * all -- while the twenty panels that do not work are. Handles carry a pool ordinal in their high
+ * bits, which is why one table was never the whole picture.
+ */
+void report_pools() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (image == 0) {
+        return;
+    }
+    for (std::size_t ordinal = 0; ordinal < kPoolDescriptorCount; ++ordinal) {
+        const std::byte* poolBase = nullptr;
+        std::uint32_t elementSize = 0;
+        __try {
+            const auto* const directory =
+                *reinterpret_cast<const std::byte* const*>(image + kPoolDirectoryRva);
+            if (directory == nullptr) {
+                return;
+            }
+            const auto* const descriptors = *reinterpret_cast<const std::byte* const*>(directory);
+            if (descriptors == nullptr) {
+                return;
+            }
+            const auto* const descriptor = descriptors + ordinal * kPoolDescriptorStride;
+            poolBase = *reinterpret_cast<const std::byte* const*>(descriptor + kPoolBaseOffset);
+            elementSize =
+                *reinterpret_cast<const std::uint32_t*>(descriptor + kPoolElementSizeOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (poolBase == nullptr || elementSize == 0 || elementSize > kMaximumElementSize) {
+            continue;
+        }
+        if (elementSize == kExpectedRecordStride && g_recordPoolCount < kRecordPoolCapacity) {
+            g_recordPoolOrdinals[g_recordPoolCount] = ordinal;
+            g_recordPools[g_recordPoolCount++] = poolBase;
+        }
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=entity_census stage=pool ordinal=%zu base=0x%llX element=%u",
+                          ordinal,
+                          static_cast<unsigned long long>(
+                              reinterpret_cast<std::uintptr_t>(poolBase)),
+                          elementSize);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports the built records of one record-shaped pool other than the cached one.
+ *
+ * The cached pointer at `kEntityTableBaseRva` names a single pool, and the directory shows two of
+ * this shape. An object that works and an object that does not may simply live in different pools,
+ * and that is not visible while only one is read.
+ * @param poolBase Base of the pool to walk.
+ * @param stride Record stride, the same for every pool of this shape.
+ * @param ordinal Directory ordinal, reported so the two can be told apart.
+ */
+void walk_pool(const std::byte* poolBase, std::uint32_t stride, std::size_t ordinal) noexcept {
+    LONG reported = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits && reported < kCensusEntryBudget; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = poolBase + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass != kRecordClass) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry pool=%zu idx=%zu cls=0x%08X def=0x%08X ord=%u rec=",
+                ordinal,
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset));
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++reported;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=pool_end ordinal=%zu records=%ld",
+                                      ordinal,
+                                      static_cast<long>(reported));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Walks the whole entity table and reports every slot the game has built.
+ *
+ * The per-allocation dump reads a record one allocation after it is handed out, which is early
+ * enough that the transform is still its default — every instance of one definition reported the
+ * same placement, which cannot be true. A census taken well after a bubble has settled reads the
+ * finished records instead, and placement is the field that matters here: a grid of identical
+ * co-planar objects is a wall of shootable panels and nothing else is, so this can identify the
+ * Wall of Wishes without knowing the game's own name for it.
+ */
+void run_census() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (table == nullptr || stride != kExpectedRecordStride) {
+        return;
+    }
+    // A record keeps its class marker after the entity is gone, so the marker alone cannot
+    // distinguish a live entity from a slot one left behind. The free bitmap can: a slot the
+    // allocator would hand out is not holding anything, whatever its record still says.
+    const auto* freeWords = static_cast<const std::uint32_t*>(nullptr);
+    if (void* const pool = g_lastPool; pool != nullptr) {
+        freeWords = reinterpret_cast<const std::uint32_t*>(static_cast<std::byte*>(pool)
+                                                           + kFreeBitmapOffset);
+    }
+    // First pass counts every class present. A record whose class word is zero or all ones has
+    // never been built, so those are the only two values treated as empty.
+    std::array<std::uint32_t, kClassCapacity> classes{};
+    std::array<LONG, kClassCapacity> classCounts{};
+    std::array<LONG, kClassCapacity> classDumped{};
+    std::size_t classCount = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::uint32_t value = 0;
+        __try {
+            value = *reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                            + kRecordClassOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (value == 0 || value == 0xFFFFFFFFU) {
+            continue;
+        }
+        std::size_t slot = 0;
+        while (slot < classCount && classes[slot] != value) {
+            ++slot;
+        }
+        if (slot == classCount) {
+            if (classCount == kClassCapacity) {
+                continue;
+            }
+            classes[classCount++] = value;
+        }
+        ++classCounts[slot];
+    }
+    for (std::size_t slot = 0; slot < classCount; ++slot) {
+        std::array<char, core::log::kLineCapacity> head{};
+        const int headWritten = std::snprintf(head.data(),
+                                              head.size(),
+                                              "ev=entity_census stage=class value=0x%08X count=%ld",
+                                              classes[slot],
+                                              static_cast<long>(classCounts[slot]));
+        if (headWritten > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {head.data(), static_cast<std::size_t>(headWritten)});
+        }
+    }
+    g_recordPoolCount = 0;
+    report_pools();
+    // Name the pool the census has been reading all along, so its ordinal can be matched against
+    // the directory rather than assumed.
+    {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=entity_census stage=table base=0x%llX stride=%u pools=%zu",
+            static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(table)),
+            stride,
+            g_recordPoolCount);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+    // Every record-shaped pool, not just the cached one. A pool the cached pointer already names
+    // is not walked twice.
+    for (std::size_t slot = 0; slot < g_recordPoolCount; ++slot) {
+        if (g_recordPools[slot] == table) {
+            continue;
+        }
+        walk_pool(g_recordPools[slot], stride, g_recordPoolOrdinals[slot]);
+    }
+    LONG live = 0;
+    LONG stale = 0;
+    // Per pass, not per run: a shared budget truncated the one census that mattered.
+    LONG entries = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = table + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass == 0 || recordClass == 0xFFFFFFFFU) {
+                continue;
+            }
+            std::size_t slot = 0;
+            while (slot < classCount && classes[slot] != recordClass) {
+                ++slot;
+            }
+            const bool spent = slot == classCount || classDumped[slot] >= kPerClassDump;
+            if (!spent) {
+                ++classDumped[slot];
+            }
+            unsigned slotFree = 0;
+            if (freeWords != nullptr
+                && (freeWords[index / kBitsPerWord] & (1u << (index % kBitsPerWord))) != 0) {
+                slotFree = 1;
+                ++stale;
+            } else {
+                ++live;
+            }
+            // The tally above counts every record; only the dump is rationed.
+            if (spent || entries >= kCensusEntryBudget) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry idx=%zu cls=0x%08X def=0x%08X ord=%u free=%u rec=",
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset),
+                slotFree);
+            // The whole record, not just the transform block. The block at `+0xA0` decodes as a
+            // clean quaternion but the four dwords after it are not the position — as floats they
+            // are denormals and values in the trillions. Somewhere in these 224 bytes there are
+            // three coordinates, and the way to find them is to scan every aligned offset across a
+            // group for one that varies plausibly. A 5x5 grid of co-planar panels is the Wall of
+            // Wishes and nothing else in the room is shaped like that, so placement identifies it
+            // where counting has not.
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++entries;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=end live=%ld stale=%ld allocs=%ld",
+                                      static_cast<long>(live),
+                                      static_cast<long>(stale),
+                                      static_cast<long>(g_allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Runs a census on its own thread so it does not sit inside the game's allocation path.
+ * @param unused Thread parameter, unused.
+ * @return Always zero.
+ */
+DWORD WINAPI census_thread(LPVOID unused) noexcept {
+    (void)unused;
+    while (g_censusRunning != 0) {
+        Sleep(kCensusIntervalMs);
+        if (g_censusRunning == 0) {
+            break;
+        }
+        run_census();
+    }
+    return 0;
+}
+
+/**
+ * Attaches one probe, reporting its own outcome.
+ * @param signature Pattern to find.
+ * @param name Reported name.
+ * @param replacement Probe body.
+ * @param handle Receives the trampoline.
+ * @return True when the target was found and the detour attached.
+ */
+[[nodiscard]] bool attach(std::span<const patterns::PatternByte> signature,
+                          const char* name,
+                          void* replacement,
+                          detour::Handle& handle) noexcept {
+    std::byte* const target = patterns::scan_main_image_unique(signature, name);
+    std::array<char, core::log::kLineCapacity> line{};
+    if (target == nullptr) {
+        const int written = std::snprintf(line.data(),
+                                          line.size(),
+                                          "ev=entity_create stage=attach name=%s result=fail",
+                                          name);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+        return false;
+    }
+    const detour::Spec spec{target, replacement};
+    const bool attached = detour::install(spec, handle);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=attach name=%s result=%s",
+                                      name,
+                                      attached ? "ok" : "fail");
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         attached ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return attached;
+}
+
+} // namespace
+
+/** Reports which half of the client's entity creation refuses. */
+bool install_entity_create_probe(bool stockUnstockedPool, bool restockAlways) noexcept {
+    g_stockUnstockedPool = stockUnstockedPool;
+    g_restockAlways = restockAlways;
+    // Resolved rather than linked: the trace is a diagnostic, and a missing export should cost the
+    // call sites in the log, not the probe that stocks the pool.
+    if (HMODULE const ntdll = GetModuleHandleW(L"ntdll.dll"); ntdll != nullptr) {
+        g_captureBacktrace = reinterpret_cast<decltype(g_captureBacktrace)>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "RtlCaptureStackBackTrace")));
+    }
+    const bool allocator = attach(kIndexAllocator,
+                                  "entity_index_allocator",
+                                  reinterpret_cast<void*>(&allocator_body),
+                                  g_allocator);
+    if (allocator) {
+        InterlockedExchange(&g_censusRunning, 1);
+        g_censusThread = CreateThread(nullptr, 0, &census_thread, nullptr, 0, nullptr);
+    }
+    // The initialiser is deliberately NOT hooked. Its fifth argument is passed on the stack
+    // (`mov dword [var_20h], eax` before the call), and a four-argument replacement got that
+    // wrong and black-screened the load. It does not need hooking anyway: the allocator alone
+    // answers the question, because the initialiser only runs when the allocator succeeded.
+    return allocator;
+}
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept {
+    InterlockedExchange(&g_censusRunning, 0);
+    if (g_censusThread != nullptr) {
+        // The census only reads, so a shutdown that beats it costs a census, never the process.
+        (void)CloseHandle(g_censusThread);
+        g_censusThread = nullptr;
+    }
+    if (g_allocator.attached) {
+        (void)detour::uninstall(g_allocator);
+    }
+}
+
+} // namespace sunrise::client::diagnostics

+ 34 - 0
Sunrise/src/client/diagnostics/entity_create_probe.h

@@ -0,0 +1,34 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Reports which half of the client's entity creation refuses.
+ * The client logs `failed to create '<type>' entity` and nothing else, and Sunrise's own note at
+ * `server/bap/encrypted/transactions/service_outcome_commit.cpp` reads that as "it has no free
+ * index". That reading is an assumption, and acting on it once already cost a build-and-run cycle:
+ * a lease top-up landed and changed nothing.
+ *
+ * The creator calls two things in order — an index allocator that answers -1 when it has nothing
+ * to give, then an initialiser that answers false when it refuses the entity it was handed. Both
+ * end at the same log line, so the line cannot tell them apart. These two detours can: each
+ * reports its own outcome, so one run says which half is failing and the guessing stops.
+ *
+ * Diagnostic only. Neither replacement changes an argument or a result, and both are found with
+ * an independent scan rather than through the shared target registry, so a signature that no
+ * longer matches this build costs the probe and nothing else.
+ * @param stockUnstockedPool Refill a bitmap that is entirely unstocked, which is what the
+ *        client's own initialiser would have done had its role global read zero.
+ * @param restockAlways Also refill a pool that has drained, not only one never stocked. Needed to
+ *        get past an encounter bubble the drained pool would otherwise refuse. Safe: the refill
+ *        spares every index the probe watched the allocator hand out, so it cannot re-free one that
+ *        is still owned the way the earlier blanket fill did.
+ * @return True when the probe attached.
+ */
+[[nodiscard]] bool install_entity_create_probe(bool stockUnstockedPool,
+                                               bool restockAlways) noexcept;
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 278 - 0
Sunrise/src/client/diagnostics/image_dump.cpp

@@ -0,0 +1,278 @@
+#include "image_dump.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+/** Dumps are isolated below the shared generated-artifact directory, beside the logs. */
+constexpr std::wstring_view kDumpDirectorySuffix = L"\\dumps";
+/** One stable name, so a second diagnostic run replaces the first rather than filling the disk. */
+constexpr std::wstring_view kImageFileSuffix = L"\\game_image.bin";
+/** The manifest carries the load base, without which the dump's addresses mean nothing. */
+constexpr std::wstring_view kManifestFileSuffix = L"\\game_image.txt";
+/**
+ * Bytes moved per read.
+ * Reads are page-granular in effect, so this only bounds the staging buffer and the cost of one
+ * failed read. 64 KiB keeps the buffer off the stack-sized path while staying one allocation.
+ */
+constexpr std::size_t kChunkBytes = 64 * 1024;
+/** A mapped image larger than this is not one this build can be looking at. */
+constexpr std::size_t kMaximumImageBytes = 1024ULL * 1024ULL * 1024ULL;
+
+/**
+ * Creates one directory, tolerating an existing one.
+ * @param path Full directory path.
+ * @return True when the directory exists afterwards.
+ */
+[[nodiscard]] bool ensure_directory(const core::path::Buffer& path) noexcept {
+    if (CreateDirectoryW(path.chars.data(), nullptr) != FALSE) {
+        return true;
+    }
+    if (GetLastError() != ERROR_ALREADY_EXISTS) {
+        return false;
+    }
+    // ERROR_ALREADY_EXISTS also covers files, so verify the existing object is a directory.
+    const DWORD attributes = GetFileAttributesW(path.chars.data());
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+}
+
+/**
+ * Opens one file for writing, replacing anything already there.
+ * @param path Full file path.
+ * @return Open handle, or INVALID_HANDLE_VALUE.
+ */
+[[nodiscard]] HANDLE create_file(const core::path::Buffer& path) noexcept {
+    return CreateFileW(path.chars.data(),
+                       GENERIC_WRITE,
+                       0,
+                       nullptr,
+                       CREATE_ALWAYS,
+                       FILE_ATTRIBUTE_NORMAL,
+                       nullptr);
+}
+
+/**
+ * Writes one whole buffer.
+ * @param file Open file handle.
+ * @param data First byte.
+ * @param size Byte count.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool write_all(HANDLE file, const void* data, std::size_t size) noexcept {
+    const auto* cursor = static_cast<const std::byte*>(data);
+    std::size_t remaining = size;
+    while (remaining != 0) {
+        const DWORD wanted =
+            static_cast<DWORD>(remaining < kChunkBytes ? remaining : kChunkBytes);
+        DWORD written = 0;
+        if (WriteFile(file, cursor, wanted, &written, nullptr) == FALSE || written == 0) {
+            return false;
+        }
+        cursor += written;
+        remaining -= written;
+    }
+    return true;
+}
+
+/** Header fields the dump is described by, read once from the mapped image. */
+struct ImageHeader {
+    std::byte* base{};
+    std::size_t imageSize{};
+    std::uint16_t sectionCount{};
+    std::size_t sectionOffset{};
+};
+
+/**
+ * Reads the mapped PE headers of the main module.
+ * @param output Receives the load base and image span.
+ * @return True when the headers are a usable 64-bit PE.
+ */
+[[nodiscard]] bool read_header(ImageHeader& output) noexcept {
+    output = {};
+    auto* base = reinterpret_cast<std::byte*>(GetModuleHandleW(nullptr));
+    if (base == nullptr) {
+        return false;
+    }
+    const auto& dos = *reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
+    if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0) {
+        return false;
+    }
+    const auto& nt = *reinterpret_cast<const IMAGE_NT_HEADERS64*>(base + dos.e_lfanew);
+    if (nt.Signature != IMAGE_NT_SIGNATURE
+        || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
+        return false;
+    }
+    const std::size_t imageSize = nt.OptionalHeader.SizeOfImage;
+    if (imageSize == 0 || imageSize > kMaximumImageBytes) {
+        return false;
+    }
+    output.base = base;
+    output.imageSize = imageSize;
+    output.sectionCount = nt.FileHeader.NumberOfSections;
+    output.sectionOffset = static_cast<std::size_t>(dos.e_lfanew) + sizeof(DWORD)
+                           + sizeof(IMAGE_FILE_HEADER) + nt.FileHeader.SizeOfOptionalHeader;
+    return true;
+}
+
+/**
+ * Writes the flat image span, substituting zeroes for pages the process will not read.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Receives the byte count that had to be zero-filled.
+ * @return True when the whole span was written.
+ */
+[[nodiscard]] bool
+write_image(HANDLE file, const ImageHeader& header, std::size_t& unreadable) noexcept {
+    unreadable = 0;
+    static std::array<std::byte, kChunkBytes> chunk{};
+    for (std::size_t offset = 0; offset < header.imageSize; offset += kChunkBytes) {
+        const std::size_t remaining = header.imageSize - offset;
+        const std::size_t wanted = remaining < kChunkBytes ? remaining : kChunkBytes;
+        SIZE_T copied = 0;
+        // ReadProcessMemory rather than memcpy: a guard or no-access page inside the image is
+        // normal for a packed binary and must not fault the game we are dumping from.
+        if (ReadProcessMemory(
+                GetCurrentProcess(), header.base + offset, chunk.data(), wanted, &copied)
+                == FALSE
+            || copied != wanted) {
+            chunk.fill(std::byte{});
+            unreadable += wanted;
+        }
+        if (!write_all(file, chunk.data(), wanted)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Writes the manifest naming the load base and every section.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Bytes the image pass had to zero-fill.
+ * @return True when the manifest was written.
+ */
+[[nodiscard]] bool
+write_manifest(HANDLE file, const ImageHeader& header, std::size_t unreadable) noexcept {
+    std::array<char, 512> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "# Sunrise mapped-image dump of the running game.\r\n"
+                                "# Load with the base below, e.g.  r2 -B 0x%llX game_image.bin\r\n"
+                                "base=0x%llX\r\n"
+                                "image_size=0x%zX\r\n"
+                                "unreadable_bytes=%zu\r\n"
+                                "sections=%u\r\n",
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                header.imageSize,
+                                unreadable,
+                                static_cast<unsigned>(header.sectionCount));
+    if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+        return false;
+    }
+    for (std::uint16_t index = 0; index < header.sectionCount; ++index) {
+        const auto& section = *reinterpret_cast<const IMAGE_SECTION_HEADER*>(
+            header.base + header.sectionOffset + index * sizeof(IMAGE_SECTION_HEADER));
+        // The name field is not guaranteed to be null-terminated at 8 characters.
+        std::array<char, IMAGE_SIZEOF_SHORT_NAME + 1> name{};
+        for (std::size_t character = 0; character < IMAGE_SIZEOF_SHORT_NAME; ++character) {
+            name[character] = static_cast<char>(section.Name[character]);
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "section name=%-8s va=0x%08lX size=0x%08lX flags=0x%08lX\r\n",
+                                name.data(),
+                                static_cast<unsigned long>(section.VirtualAddress),
+                                static_cast<unsigned long>(section.Misc.VirtualSize),
+                                static_cast<unsigned long>(section.Characteristics));
+        if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Reports the outcome of one dump attempt.
+ * @param stage Step that decided the outcome.
+ * @param succeeded Whether the dump completed.
+ * @param bytes Image bytes written, or zero.
+ * @param unreadable Bytes zero-filled because the page would not read.
+ */
+void report(const char* stage,
+            bool succeeded,
+            std::size_t bytes,
+            std::size_t unreadable) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=image_dump stage=%s result=%s bytes=%zu unreadable=%zu",
+                                      stage,
+                                      succeeded ? "ok" : "fail",
+                                      bytes,
+                                      unreadable);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         succeeded ? core::log::Level::info : core::log::Level::error,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Writes the game's mapped image to disk so it can be disassembled offline. */
+bool dump_game_image(void* module) noexcept {
+    ImageHeader header{};
+    if (!read_header(header)) {
+        report("header", false, 0, 0);
+        return false;
+    }
+    core::path::Buffer directory{};
+    if (!core::path::artifact_directory(module, directory)
+        || !core::path::append(directory, kDumpDirectorySuffix) || !ensure_directory(directory)) {
+        report("path", false, 0, 0);
+        return false;
+    }
+
+    core::path::Buffer imagePath = directory;
+    HANDLE file = core::path::append(imagePath, kImageFileSuffix) ? create_file(imagePath)
+                                                                  : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("create", false, 0, 0);
+        return false;
+    }
+    std::size_t unreadable = 0;
+    const bool wrote = write_image(file, header, unreadable);
+    CloseHandle(file);
+    if (!wrote) {
+        report("write", false, 0, unreadable);
+        return false;
+    }
+
+    core::path::Buffer manifestPath = directory;
+    file = core::path::append(manifestPath, kManifestFileSuffix) ? create_file(manifestPath)
+                                                                 : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("manifest", false, header.imageSize, unreadable);
+        return false;
+    }
+    const bool described = write_manifest(file, header, unreadable);
+    CloseHandle(file);
+    report(described ? "complete" : "manifest", described, header.imageSize, unreadable);
+    return described;
+}
+
+} // namespace sunrise::client::diagnostics

+ 26 - 0
Sunrise/src/client/diagnostics/image_dump.h

@@ -0,0 +1,26 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Writes the game's mapped image to disk so it can be disassembled offline.
+ * `destiny2.exe` is VMProtect-packed: on disk its `.text` is fully encrypted, the retail log
+ * strings are absent, and the byte signatures in `patterns/game_signatures.cpp` match nothing.
+ * They match at runtime because every scan runs against the mapped image the packer has already
+ * decrypted, so that mapped image is the only readable copy of the code and the only thing a
+ * disassembler can be pointed at.
+ *
+ * The dump is one flat file covering the whole `SizeOfImage` span, so a file offset is the image
+ * offset and a virtual address is the load base plus that offset. A page the process will not let
+ * us read is written as zeroes rather than abandoning the dump, because an unreadable page is
+ * normal in a packed image and losing the rest of the file to it helps nobody.
+ *
+ * Off unless `client.dump_game_image` is set. The file is large — the whole image, about 140 MB —
+ * and writing it costs a second or two of boot, so it is a deliberate diagnostic run rather than
+ * something every start pays for.
+ * @param module Sunrise's own loaded module, used to resolve the artifact directory.
+ * @return True when the whole image was written and the manifest beside it was too.
+ */
+[[nodiscard]] bool dump_game_image(void* module) noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 169 - 30
Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp

@@ -13,6 +13,46 @@ namespace {
 /** 4 protected functions per hook bound the fixed range storage, so no heap is used. */
 constexpr std::size_t kProtectedCodeLimit = 64;
 
+/** Access an enlisted thread is opened with. Detours reads and rewrites its context. */
+constexpr DWORD kEnlistAccess =
+    THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT;
+/** Access the walk needs of a thread it only names. Asking for less refuses fewer threads. */
+constexpr DWORD kWalkAccess = THREAD_QUERY_LIMITED_INFORMATION;
+/** The walk is over. NtGetNextThread reports it as a failure status, so it is checked by value. */
+constexpr LONG kStatusNoMoreEntries = static_cast<LONG>(0x8000001AL);
+
+/**
+ * Hands back the next thread of one process, in an order fixed for the length of the walk.
+ * Passing a null cursor starts it. The returned handle carries the requested access.
+ */
+using NextThread = LONG(NTAPI*)(HANDLE process,
+                                HANDLE cursor,
+                                ACCESS_MASK access,
+                                ULONG attributes,
+                                ULONG flags,
+                                HANDLE* next) noexcept;
+
+/**
+ * Finds ntdll's own thread walk, once.
+ * The documented walk is a Toolhelp snapshot, which enumerates every thread on the system to
+ * reach this process's fifty: it costs about 25 ms a pass, twice a transaction, and a boot holds
+ * one transaction per hook. This walk stays inside the process and costs about 0.08 ms. It is
+ * not a documented export, so a build that does not have it keeps the snapshot instead.
+ * @return The entry point, or null when ntdll does not export it.
+ */
+[[nodiscard]] NextThread next_thread_entry() noexcept {
+    static const NextThread entry = [] {
+        const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
+        if (ntdll == nullptr) {
+            return static_cast<NextThread>(nullptr);
+        }
+        // The cast is through a void function pointer because GetProcAddress returns FARPROC.
+        return reinterpret_cast<NextThread>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "NtGetNextThread")));
+    }();
+    return entry;
+}
+
 /** Exact executable range described by one x64 unwind record. */
 struct CodeRange {
     DWORD64 begin{};
@@ -45,6 +85,109 @@ void close_threads(Threads& threads) noexcept {
     return false;
 }
 
+/** How far one enlistment pass got. */
+enum class PassResult {
+    /** Every thread of the process was seen and taken. */
+    complete,
+    /** The walk stopped early without handing Detours anything, so another pass may still run. */
+    enumerationFailed,
+    /** Detours refused a thread. Nothing can continue this transaction. */
+    transactionFailed,
+};
+
+/**
+ * Enlists one process thread by id, unless this transaction already holds it.
+ * The handle comes from OpenThread rather than from whatever named the id. A thread Windows will
+ * not open here is one the transaction must leave alone: handing Detours a thread it cannot
+ * suspend sets a transaction-wide pending error that fails every later attach and that nothing
+ * can clear. The set of enlisted threads therefore stays exactly what a snapshot pass would take,
+ * whichever walk found them.
+ * @param threads Receives the handle, which stays suspended until the transaction ends.
+ * @param threadId Candidate process thread id.
+ * @param currentThreadId The calling thread, which the transaction enlists separately.
+ * @param foundUnseen Set when the thread was new to this transaction.
+ * @return False when Detours refused the thread and the transaction is spent.
+ */
+[[nodiscard]] bool enlist_thread_id(Threads& threads,
+                                    DWORD threadId,
+                                    DWORD currentThreadId,
+                                    bool& foundUnseen) noexcept {
+    if (threadId == 0 || threadId == currentThreadId || contains(threads, threadId)) {
+        return true;
+    }
+    foundUnseen = true;
+    if (threads.count == threads.handles.size()) {
+        return false;
+    }
+    const HANDLE thread = OpenThread(kEnlistAccess, FALSE, threadId);
+    if (thread == nullptr) {
+        // A disappearing thread is absent from the next stable pass.
+        return GetLastError() == ERROR_INVALID_PARAMETER;
+    }
+    if (DetourUpdateThread(thread) != NO_ERROR) {
+        CloseHandle(thread);
+        return false;
+    }
+    threads.handles[threads.count] = thread;
+    threads.ids[threads.count] = threadId;
+    ++threads.count;
+    return true;
+}
+
+/**
+ * Says whether a thread is still running.
+ * The walk reaches threads that have already exited: their objects outlive them for as long as
+ * something holds a handle, and the kernel thread list still carries them. A snapshot never
+ * reports one. Detours suspends a thread the moment it is handed over, suspending an exited
+ * thread fails, and that failure is a transaction-wide error that nothing can clear, so an exited
+ * thread has to be dropped before it is offered.
+ * @param thread Handle opened with at least THREAD_QUERY_LIMITED_INFORMATION.
+ * @return True only when the thread is confirmed running.
+ */
+[[nodiscard]] bool thread_is_running(HANDLE thread) noexcept {
+    DWORD exitCode = 0;
+    return GetExitCodeThread(thread, &exitCode) != FALSE && exitCode == STILL_ACTIVE;
+}
+
+/**
+ * Enlists every unseen live thread of this process using ntdll's own walk.
+ * The walk names and vets each thread; enlisting it then runs on the shared path.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return How far the pass got.
+ */
+[[nodiscard]] PassResult enlist_process_walk(Threads& threads, bool& foundUnseen) noexcept {
+    const NextThread nextThread = next_thread_entry();
+    if (nextThread == nullptr) {
+        return PassResult::enumerationFailed;
+    }
+    const DWORD currentThreadId = GetCurrentThreadId();
+    HANDLE cursor = nullptr;
+    for (;;) {
+        HANDLE next = nullptr;
+        const LONG status = nextThread(GetCurrentProcess(), cursor, kWalkAccess, 0, 0, &next);
+        // The cursor is only a position in the walk; the transaction never holds it.
+        if (cursor != nullptr) {
+            CloseHandle(cursor);
+        }
+        cursor = nullptr;
+        if (status == kStatusNoMoreEntries) {
+            return PassResult::complete;
+        }
+        if (status < 0 || next == nullptr) {
+            return PassResult::enumerationFailed;
+        }
+        // The walk's own handle answers both questions, so the enlist handle is only opened for
+        // a thread that is going to be offered.
+        const DWORD threadId = thread_is_running(next) ? GetThreadId(next) : 0;
+        if (!enlist_thread_id(threads, threadId, currentThreadId, foundUnseen)) {
+            CloseHandle(next);
+            return PassResult::transactionFailed;
+        }
+        cursor = next;
+    }
+}
+
 /**
  * Enlists every unseen thread present in one process-wide snapshot.
  * @param threads Receives handles that stay suspended until the transaction ends.
@@ -52,7 +195,6 @@ void close_threads(Threads& threads) noexcept {
  * @return True when the whole snapshot was inspected without a hard failure.
  */
 [[nodiscard]] bool enlist_snapshot(Threads& threads, bool& foundUnseen) noexcept {
-    foundUnseen = false;
     const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
     if (snapshot == INVALID_HANDLE_VALUE) {
         return false;
@@ -65,33 +207,8 @@ void close_threads(Threads& threads) noexcept {
     const DWORD currentThreadId = GetCurrentThreadId();
     bool succeeded = true;
     while (available != FALSE && succeeded) {
-        const bool belongsToProcess = entry.th32OwnerProcessID == processId;
-        const bool needsEnlistment =
-            entry.th32ThreadID != currentThreadId && !contains(threads, entry.th32ThreadID);
-        if (belongsToProcess && needsEnlistment) {
-            foundUnseen = true;
-            if (threads.count == threads.handles.size()) {
-                succeeded = false;
-                break;
-            }
-
-            const HANDLE thread =
-                OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
-                           FALSE,
-                           entry.th32ThreadID);
-            if (thread == nullptr) {
-                // A disappearing thread is absent from the next stable snapshot.
-                if (GetLastError() != ERROR_INVALID_PARAMETER) {
-                    succeeded = false;
-                }
-            } else if (DetourUpdateThread(thread) != NO_ERROR) {
-                CloseHandle(thread);
-                succeeded = false;
-            } else {
-                threads.handles[threads.count] = thread;
-                threads.ids[threads.count] = entry.th32ThreadID;
-                ++threads.count;
-            }
+        if (entry.th32OwnerProcessID == processId) {
+            succeeded = enlist_thread_id(threads, entry.th32ThreadID, currentThreadId, foundUnseen);
         }
         available = Thread32Next(snapshot, &entry);
     }
@@ -104,14 +221,36 @@ void close_threads(Threads& threads) noexcept {
 }
 
 /**
- * Enlists new process threads until a full snapshot finds no unseen thread id.
+ * Enlists every unseen process thread in one pass, by whichever walk this build has.
+ * A partly finished process walk leaves its handles enlisted and the snapshot completes the pass:
+ * both dedupe on the thread id, so the fallback cannot enlist a thread twice.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return True when the pass completed without a hard failure.
+ */
+[[nodiscard]] bool enlist_pass(Threads& threads, bool& foundUnseen) noexcept {
+    foundUnseen = false;
+    const PassResult walked = enlist_process_walk(threads, foundUnseen);
+    if (walked == PassResult::complete) {
+        return true;
+    }
+    // A refused thread has already spent the transaction, so no second walk can rescue it. Only
+    // a walk that stopped before Detours was told anything falls through to the snapshot.
+    if (walked == PassResult::transactionFailed) {
+        return false;
+    }
+    return enlist_snapshot(threads, foundUnseen);
+}
+
+/**
+ * Enlists new process threads until a full pass finds no unseen thread id.
  * @param threads Receives every handle the transaction holds.
  * @return True when a full pass found no new thread.
  */
 [[nodiscard]] bool enlist_until_stable(Threads& threads) noexcept {
     bool foundUnseen{};
     do {
-        if (!enlist_snapshot(threads, foundUnseen)) {
+        if (!enlist_pass(threads, foundUnseen)) {
             return false;
         }
         // Earlier handles stay suspended while a later pass finds newly created threads.

+ 86 - 12
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -1,6 +1,9 @@
 #include "bootflow_hook_lifecycle.h"
 
+#include <array>
 #include <atomic>
+#include <cstddef>
+#include <span>
 
 #include "internal.h"
 
@@ -9,29 +12,100 @@ namespace {
 
 std::atomic_bool g_installed{false};
 
+/** One boot-step fix that attaches a detour, in the order the group installs them. */
+struct Fix {
+    StageResult (*stage)(hooking::detour::Spec&) noexcept;
+    void (*publish)(const hooking::detour::Handle&) noexcept;
+};
+
+/**
+ * Every fix that attaches a detour. `world_step` and `fade_release` are absent: they only find
+ * addresses to call, so they open no transaction and cost the group nothing.
+ */
+constexpr std::array kFixes{
+    Fix{&stage_character_select_hold, &publish_character_select_hold},
+    Fix{&stage_orbit_slice_set, &publish_orbit_slice_set},
+    Fix{&stage_profile_setup_skip, &publish_profile_setup_skip},
+    Fix{&stage_composition_check, &publish_composition_check},
+    Fix{&stage_orbit_handoff, &publish_orbit_handoff},
+    Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
+    Fix{&stage_region_private, &publish_region_private},
+    Fix{&stage_spawn_hold, &publish_spawn_hold},
+};
+
+/** Marks a fix that staged nothing, so no handle is ever published to it. */
+constexpr std::size_t kNotStaged = kFixes.size();
+
+/** One fix's place in the batch, and what it already was before staging. */
+struct Placement {
+    std::size_t slot{kNotStaged};
+    StageResult result{StageResult::unavailable};
+};
+
 } // namespace
 
 /**
  * Attaches the boot-step fixes that carry sign-in through to orbit.
  * Each fix stands alone at one site, so a miss on one is reported and the others still attach.
+ *
+ * Every resolved fix attaches in one transaction rather than one each. A transaction enlists the
+ * threads it must suspend by walking every thread on the system, which is far more work than the
+ * attach itself, so nine transactions cost nine of those walks and one costs one. A fix whose
+ * target is missing simply is not in the batch, which is what keeps one miss off the others. If
+ * the batch itself fails the fixes are retried one at a time, so a single target Detours refuses
+ * cannot take the rest of the group down with it.
  * @return True when every fix attached.
  */
 bool install() noexcept {
-    const bool hold = install_character_select_hold();
-    const bool sliceSet = install_orbit_slice_set();
-    const bool skip = install_profile_setup_skip();
-    const bool composition = install_composition_check();
-    const bool handoff = install_orbit_handoff();
-    const bool ownerSlot = install_owner_activity_slot();
-    const bool regionPrivate = install_region_private();
+    std::array<hooking::detour::Spec, kFixes.size()> specs{};
+    std::array<hooking::detour::Handle, kFixes.size()> handles{};
+    std::array<Placement, kFixes.size()> placement{};
+    std::size_t staged = 0;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        hooking::detour::Spec spec{};
+        const StageResult result = kFixes[index].stage(spec);
+        placement[index].result = result;
+        if (result != StageResult::staged) {
+            continue;
+        }
+        specs[staged] = spec;
+        placement[index].slot = staged;
+        ++staged;
+    }
+
+    if (staged != 0
+        && !hooking::detour::install(std::span(specs).first(staged),
+                                     std::span(handles).first(staged))) {
+        // One refused target must not cost the others their fix, so the slow path stands them up
+        // separately. It runs only when the whole batch failed, which no supported build does.
+        for (std::size_t slot = 0; slot < staged; ++slot) {
+            handles[slot] = {};
+            (void)hooking::detour::install(specs[slot], handles[slot]);
+        }
+    }
+
+    bool anyFix = false;
+    bool everyFix = true;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        const Placement& place = placement[index];
+        if (place.slot == kNotStaged) {
+            // An already-attached fix stays attached; only a missing target is a failure.
+            anyFix = anyFix || place.result == StageResult::attached;
+            everyFix = everyFix && place.result == StageResult::attached;
+            continue;
+        }
+        const hooking::detour::Handle& handle = handles[place.slot];
+        kFixes[index].publish(handle);
+        anyFix = anyFix || handle.attached;
+        everyFix = everyFix && handle.attached;
+    }
+
+    // Neither of these attaches anything, so they stay outside the transaction.
     const bool worldStep = install_world_step();
-    const bool spawn = install_spawn_hold();
     const bool fade = install_fade_release();
-    const bool anyFix = hold || sliceSet || skip || composition || handoff || ownerSlot
-                        || regionPrivate || worldStep || spawn || fade;
+    anyFix = anyFix || worldStep || fade;
     g_installed.store(anyFix, std::memory_order_release);
-    return hold && sliceSet && skip && composition && handoff && ownerSlot && regionPrivate
-           && worldStep && spawn && fade;
+    return everyFix && worldStep && fade;
 }
 
 /** Detaches every boot-step fix, in the reverse order of install. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/character_select_hold.cpp

@@ -65,32 +65,38 @@ __declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept {
 } // namespace
 
 /**
- * Attaches the character-select hold.
- * @return True when the target is found and the detour attaches.
+ * Stages the character-select hold.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
-bool install_character_select_hold() noexcept {
+StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&enter_handler)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&enter_handler)};
+    return StageResult::staged;
+}
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<EnterHandler>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=character_select result=ok");
-    return true;
 }
 
 /** Detaches the character-select hold. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/composition_check.cpp

@@ -104,32 +104,38 @@ __declspec(noinline) std::int64_t __fastcall check(void* config, std::byte* prop
 } // namespace
 
 /**
- * Attaches the solo composition fix.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
-bool install_composition_check() noexcept {
+StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "matchmaking_composition");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Check>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=composition result=ok");
-    return true;
 }
 
 /** Detaches the solo composition fix. */

+ 75 - 24
Sunrise/src/client/hooks/bootflow/internal.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "../../hooking/detour.h"
 #include "../../patterns/image_scan.h"
 
 namespace sunrise::client::hooks::bootflow {
@@ -10,66 +11,112 @@ using patterns::signature;
 using patterns::signature_length;
 
 /**
- * Attaches the character-select hold, which stops the sign-in step auto-selecting.
- * @return True when the target is found and the detour attaches.
+ * One boot-step fix resolves its target, then the group attaches every resolved fix together.
+ * Splitting the two halves is what lets the group hold one detour transaction instead of one per
+ * fix. A transaction enlists every thread on the system to find this process's own, which costs
+ * far more than the attach it guards, so the count of transactions is what the boot pays for.
+ *
+ * A publish call is made only for a fix that staged, and takes a detached handle when the group's
+ * attach did not happen.
  */
-[[nodiscard]] bool install_character_select_hold() noexcept;
+enum class StageResult : unsigned char {
+    /** The target is missing. The fix reported that itself and staged nothing. */
+    unavailable,
+    /** An earlier install already attached this fix, so there is nothing to stage. */
+    attached,
+    /** The spec is filled and the fix wants attaching. */
+    staged,
+};
+
+/**
+ * Stages the character-select hold, which stops the sign-in step auto-selecting.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
+ */
+[[nodiscard]] StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the character-select hold. */
 void uninstall_character_select_hold() noexcept;
 
 /**
- * Attaches the profile-setup skip, which skips the startup setup screens.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip, which skips the startup setup screens.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_profile_setup_skip() noexcept;
+[[nodiscard]] StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the profile-setup skip. */
 void uninstall_profile_setup_skip() noexcept;
 
 /**
- * Attaches the orbit slice-set picker, so the sign-in step's map load finds its target.
- * @return True when the picker is found and the detour attaches.
+ * Stages the orbit slice-set picker, so the sign-in step's map load finds its target.
+ * @param spec Receives the target and replacement.
+ * @return staged when the picker was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_orbit_slice_set() noexcept;
+[[nodiscard]] StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit slice-set picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the orbit slice-set picker. */
 void uninstall_orbit_slice_set() noexcept;
 
 /**
- * Attaches the solo composition fix, which clears the count the matchmaking check rejects.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix, which clears the count the matchmaking check rejects.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_composition_check() noexcept;
+[[nodiscard]] StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the solo composition fix. */
 void uninstall_composition_check() noexcept;
 
 /**
- * Attaches the orbit handoff release, which stops the destination step parking.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release, which stops the destination step parking.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_orbit_handoff() noexcept;
+[[nodiscard]] StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the orbit handoff release. */
 void uninstall_orbit_handoff() noexcept;
 
 /**
- * Attaches the owner activity slot force. It pins the participation record to the replicated
+ * Stages the owner activity slot force. It pins the participation record to the replicated
  * snapshot at `comp + 496` instead of the local one at `comp + 1256`.
- * @return True when the target is found and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_owner_activity_slot() noexcept;
+[[nodiscard]] StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the owner activity slot force. */
 void uninstall_owner_activity_slot() noexcept;
 
 /**
- * Attaches the private-region force, so a public region takes the path a private one takes.
+ * Stages the private-region force, so a public region takes the path a private one takes.
  * A public region otherwise holds its slice-set switch until a public activity host connects.
- * @return True when both targets are found, the call site is unique and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when both targets and the call site were found, unavailable on a miss.
  */
-[[nodiscard]] bool install_region_private() noexcept;
+[[nodiscard]] StageResult stage_region_private(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the private-region force. */
 void uninstall_region_private() noexcept;
@@ -91,10 +138,14 @@ void uninstall_world_step() noexcept;
 void observe_world_step() noexcept;
 
 /**
- * Attaches the spawn hold, which puts the player spawn after the world-transition fade is armed.
- * @return True when the target is found and the detour attaches.
+ * Stages the spawn hold, which puts the player spawn after the world-transition fade is armed.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_spawn_hold() noexcept;
+[[nodiscard]] StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the spawn hold. */
 void uninstall_spawn_hold() noexcept;

+ 15 - 9
Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp

@@ -65,31 +65,37 @@ __declspec(noinline) bool __fastcall destination_hold(void* stepCtx) noexcept {
 } // namespace
 
 /**
- * Attaches the orbit handoff release.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
-bool install_orbit_handoff() noexcept {
+StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kHoldSignature, "orbit_destination_hold");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&destination_hold)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&destination_hold)};
+    return StageResult::staged;
+}
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=orbit_handoff result=ok");
-    return true;
 }
 
 /** Detaches the orbit handoff release. */

+ 13 - 8
Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp

@@ -68,29 +68,34 @@ std::uint32_t* __fastcall pick_target(LoaderContext* context, std::uint32_t* sel
 
 } // namespace
 
-/** Attaches the picker so the orbit target is found. */
-bool install_orbit_slice_set() noexcept {
+/** Stages the picker so the orbit target is found. */
+StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const picker = scan_main_image_unique(kPickerSignature, "slice_set_target_picker");
     if (picker == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{picker, reinterpret_cast<void*>(&pick_target)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{picker, reinterpret_cast<void*>(&pick_target)};
+    return StageResult::staged;
+}
+
+/** Takes the picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=slice_set result=ok");
-    return true;
 }
 
 /** Detaches the picker. */

+ 13 - 8
Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp

@@ -95,30 +95,35 @@ __declspec(noinline) std::uint8_t __fastcall check(void* container,
 
 } // namespace
 
-/** Attaches the owner activity slot force. */
-bool install_owner_activity_slot() noexcept {
+/** Stages the owner activity slot force. */
+StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "check_activity_bubbles");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<CheckBubbles>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=owner_slot result=ok");
-    return true;
 }
 
 /** Detaches the owner activity slot force. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp

@@ -90,32 +90,38 @@ __declspec(noinline) char __fastcall update(std::byte* step) noexcept {
 } // namespace
 
 /**
- * Attaches the profile-setup skip.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
-bool install_profile_setup_skip() noexcept {
+StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kUpdateSignature, "profile_setup_update");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&update)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&update)};
+    return StageResult::staged;
+}
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Update>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=profile_setup result=ok");
-    return true;
 }
 
 /** Detaches the profile-setup skip. */

+ 21 - 13
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -135,8 +135,8 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     return !forced;
 }
 
-/** @param reason Key naming the step that failed. @return False, for a direct return. */
-[[nodiscard]] bool fail(const char* reason) noexcept {
+/** @param reason Key naming the step that failed. */
+void report_failure(const char* reason) noexcept {
     std::array<char, kLineCapacity> line{};
     const int written = std::snprintf(
         line.data(), line.size(), "ev=bootflow stage=region result=fail reason=%s", reason);
@@ -145,39 +145,47 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
                          core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
-    return false;
 }
 
 } // namespace
 
-/** Attaches the private-region force. */
-bool install_region_private() noexcept {
+/** Stages the private-region force. */
+StageResult stage_region_private(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kReaderSignature, "slice_set_is_public");
     if (target == nullptr) {
-        return fail("reader");
+        report_failure("reader");
+        return StageResult::unavailable;
     }
     const std::byte* const starter =
         scan_main_image_unique(kStarterSignature, "region_start_transition");
     if (starter == nullptr) {
-        return fail("starter");
+        report_failure("starter");
+        return StageResult::unavailable;
     }
     const std::byte* const returnSite = find_return_site(starter, target);
     if (returnSite == nullptr) {
-        return fail("call_site");
+        report_failure("call_site");
+        return StageResult::unavailable;
     }
     // Published before the detour attaches, so the first call already has its filter.
     g_returnSite.store(returnSite, std::memory_order_release);
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&reader)};
-    if (!hooking::detour::install(spec, g_handle)) {
-        return fail("attach");
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&reader)};
+    return StageResult::staged;
+}
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
+        report_failure("attach");
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Reader>(g_handle.original), std::memory_order_release);
     core::log::write(
         core::log::Channel::client, core::log::Level::info, "ev=bootflow stage=region result=ok");
-    return true;
 }
 
 /** Detaches the private-region force. */

+ 13 - 8
Sunrise/src/client/hooks/bootflow/spawn_hold.cpp

@@ -59,36 +59,41 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
 
 } // namespace
 
-/** Attaches the spawn hold. */
-bool install_spawn_hold() noexcept {
+/** Stages the spawn hold. */
+StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kSpawnGateSignature, "player_spawn_gate");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     if (!spawn::resolve(target)) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=current_slice result=fail reason=targets");
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&spawn_gate)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&spawn_gate)};
+    return StageResult::staged;
+}
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         spawn::forget();
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<SpawnGate>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=spawn_hold result=ok");
-    return true;
 }
 
 /** Detaches the spawn hold. */

+ 124 - 0
Sunrise/src/client/hooks/membership_probe/membership_probe.cpp

@@ -182,6 +182,129 @@ void report_bind_inputs(const std::byte* client) noexcept {
     }
 }
 
+/** Bytes per hex log line. Two characters a byte keeps a line well inside its capacity. */
+constexpr std::size_t kHexBytesPerLine = 64;
+/** Bytes at the membership header to dump, which is where the decoded member table starts. */
+constexpr std::size_t kMemberDumpBytes = 512;
+/** Most of one region record to dump. A record carrying a 128-byte descriptor still fits. */
+constexpr std::size_t kRegionDumpBytes = 320;
+/** The wire numbers regions 0, 8, 16 ... 504, so consecutive terms differ by this. */
+constexpr std::int32_t kRegionIndexStride = 8;
+/** Terms matched before a candidate is accepted as the region table. */
+constexpr std::size_t kRegionMatchTerms = 4;
+/** Largest in-memory stride between two region records that is still worth testing. */
+constexpr std::size_t kMaximumRegionStride = 8'192;
+/** End of the searchable span. The entity-slot mask sits above it and holds no region table. */
+constexpr std::size_t kScanEndOffset = kPendingMaskOffset;
+/** Clients whose region table is dumped. Later messages repeat a table that has already been read.
+ */
+constexpr std::uint32_t kRegionDumpBudget = 3;
+
+std::atomic<std::uint32_t> g_regionDumps{0};
+
+/**
+ * Emits one labelled hex run over as many lines as it needs.
+ * @param stage Log stage name.
+ * @param base Offset the run starts at, so a line names where its bytes came from.
+ * @param data First byte of the run.
+ * @param size Bytes to emit.
+ */
+void report_hex(const char* stage,
+                std::size_t base,
+                const std::byte* data,
+                std::size_t size) noexcept {
+    for (std::size_t offset = 0; offset < size; offset += kHexBytesPerLine) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const std::size_t run =
+            (size - offset) < kHexBytesPerLine ? size - offset : kHexBytesPerLine;
+        int written = std::snprintf(
+            line.data(), line.size(), "ev=probe stage=%s at=%zu raw=", stage, base + offset);
+        for (std::size_t index = 0; written > 0 && index < run; ++index) {
+            const int part =
+                std::snprintf(line.data() + written,
+                              line.size() - static_cast<std::size_t>(written),
+                              "%02X",
+                              std::to_integer<unsigned>(data[offset + index]));
+            if (part <= 0) {
+                break;
+            }
+            written += part;
+        }
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports one found region table and the two records that differ by the advertisement.
+ * @param client ActivityClient.
+ * @param offset Offset of the record holding region 8.
+ * @param stride Bytes between two consecutive records.
+ */
+void report_region_table(const std::byte* client, std::size_t offset, std::size_t stride) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=probe stage=regions result=found client=0x%llX "
+                                      "at=%zu stride=%zu",
+                                      address_of(client),
+                                      offset,
+                                      stride);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // Region 8 is the one the advertisement rides in and region 16 never carries one, so the
+    // bytes that differ between them are exactly what the client kept of the descriptor.
+    const std::size_t dump = stride < kRegionDumpBytes ? stride : kRegionDumpBytes;
+    report_hex("members", kMembershipHeaderOffset, client + kMembershipHeaderOffset, kMemberDumpBytes);
+    report_hex("region8", offset, client + offset, dump);
+    if (offset + stride + dump <= kScanEndOffset) {
+        report_hex("region16", offset + stride, client + offset + stride, dump);
+    }
+}
+
+/**
+ * Finds the client's decoded region table with no signature and dumps two of its records.
+ * Four consecutive terms of the 8-step region sequence at one fixed stride name the table, and
+ * nothing else in the object is expected to hold that run.
+ * @param client ActivityClient the handler has just committed a body into.
+ */
+void report_regions(const std::byte* client) noexcept {
+    if (g_regionDumps.fetch_add(1, std::memory_order_relaxed) >= kRegionDumpBudget) {
+        return;
+    }
+    for (std::size_t offset = kMembershipHeaderOffset; offset + sizeof(std::int32_t) <= kScanEndOffset;
+         offset += sizeof(std::int32_t)) {
+        if (field<std::int32_t>(client, offset) != kRegionIndexStride) {
+            continue;
+        }
+        for (std::size_t stride = sizeof(std::int32_t); stride <= kMaximumRegionStride;
+             stride += sizeof(std::int32_t)) {
+            if (offset + kRegionMatchTerms * stride > kScanEndOffset) {
+                break;
+            }
+            bool matched = true;
+            for (std::size_t term = 1; matched && term < kRegionMatchTerms; ++term) {
+                matched = field<std::int32_t>(client, offset + term * stride)
+                          == kRegionIndexStride * static_cast<std::int32_t>(term + 1);
+            }
+            if (!matched) {
+                continue;
+            }
+            report_region_table(client, offset, stride);
+            return;
+        }
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=probe stage=regions result=absent");
+}
+
 /** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
 [[nodiscard]] std::size_t pending_slots(const std::byte* client) noexcept {
     const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
@@ -226,6 +349,7 @@ char __fastcall receive(const std::byte* client, std::int64_t body, int size) no
     const auto after = field<std::uint16_t>(client, kStatusWordOffset);
     report(client, before, after);
     report_bind_inputs(client);
+    report_regions(client);
     track(client, GetTickCount64());
     return result;
 }

+ 53 - 0
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -1,5 +1,7 @@
 #include "retail_log_enqueue_observer.h"
 
+#include <intrin.h>
+
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -83,6 +85,53 @@ void capture_line(std::int32_t siteId, const char* text) noexcept {
     core::log::write(core::log::Channel::client, core::log::Level::info, {line.data(), length});
 }
 
+/** Text whose emitting call site is worth locating in the image. */
+constexpr std::string_view kTracedText = "failed to create";
+/** Call sites named per run, so a repeating line cannot flood the sink. */
+constexpr std::size_t kMaxCallSiteReports = 64;
+
+/** Reports already spent. */
+volatile LONG g_callSiteReports{};
+
+/**
+ * Names the image offset of the code that emitted one line.
+ * The packed executable cannot be disassembled on disk, so a dump of the mapped image is the only
+ * readable copy, and an offset from the load base is what addresses it. The retail text itself
+ * carries no address, and the site id is assigned by the game's own registration rather than by
+ * position, so nothing else here says which function produced a line. `_ReturnAddress` inside the
+ * funnel is the emitting call site, which is exactly the function to disassemble.
+ * @param returnAddress Return address captured in the funnel.
+ * @param text Already-formatted native line.
+ */
+void report_call_site(const void* returnAddress, const char* text) noexcept {
+    if (returnAddress == nullptr
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    const auto site = reinterpret_cast<std::uintptr_t>(returnAddress);
+    if (base == 0 || site < base) {
+        return;
+    }
+    if (InterlockedIncrement(&g_callSiteReports) > static_cast<LONG>(kMaxCallSiteReports)) {
+        return;
+    }
+    std::array<char, kEventCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=retail_site stage=caller rva=0x%llX va=0x%llX text=%s",
+                                      static_cast<unsigned long long>(site - base),
+                                      static_cast<unsigned long long>(site),
+                                      text);
+    if (written > 0) {
+        const auto length = static_cast<std::size_t>(written) < line.size()
+                                ? static_cast<std::size_t>(written)
+                                : line.size() - 1;
+        core::log::write(core::log::Channel::client, core::log::Level::debug,
+                         {line.data(), length});
+    }
+}
+
 /**
  * Mirrors the single funnel every retail log line passes through.
  * @param siteId Registered site id.
@@ -99,6 +148,10 @@ __declspec(noinline) void __fastcall enqueue_body(std::int32_t siteId, const cha
     if (outer) {
         if (siteId != kUnregisteredSite && text != nullptr) {
             capture_line(siteId, text);
+            // Cheap guard first: the search only runs on the handful of lines that match.
+            if (std::string_view(text).find(kTracedText) != std::string_view::npos) {
+                report_call_site(_ReturnAddress(), text);
+            }
         }
         assert_verbosity();
         g_inObserver = false;

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

@@ -1,5 +1,9 @@
 #include "registry.h"
 
+#include <Windows.h>
+
+#include <array>
+#include <cstdint>
 #include <cstring>
 
 namespace sunrise::client::patterns {
@@ -7,6 +11,91 @@ namespace {
 
 /** Returned by next_candidate when a range holds no further anchor byte. */
 constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
+/** One count per distinct byte value. */
+constexpr std::size_t kByteValueCount = 256;
+/** Most ranges one fingerprint describes. No PE image carries more sections than this. */
+constexpr std::size_t kFingerprintCapacity = 96;
+
+/** How often each byte value occurs across one set of scanned ranges. */
+struct ByteCounts {
+    std::array<std::uint64_t, kByteValueCount> values{};
+};
+
+/** Identity of the range set one histogram was built from. */
+struct Fingerprint {
+    std::array<const std::byte*, kFingerprintCapacity> data{};
+    std::array<std::size_t, kFingerprintCapacity> size{};
+    std::size_t count{};
+    /** False for a range set too large to describe, which must never match a stored print. */
+    bool valid{};
+};
+
+/**
+ * The byte histogram and the ranges it came from.
+ * Building it costs one traversal of the image. Without this cache every pattern would pay that
+ * traversal, which is the very cost the anchor choice exists to avoid.
+ */
+struct FrequencyCache {
+    SRWLOCK lock{SRWLOCK_INIT};
+    Fingerprint fingerprint{};
+    ByteCounts counts{};
+};
+
+FrequencyCache g_frequency;
+
+/** @return Fingerprint of one range set, invalid when it holds more ranges than one can describe. */
+[[nodiscard]] Fingerprint fingerprint_of(std::span<const ImageRange> image) noexcept {
+    Fingerprint print{};
+    if (image.size() > kFingerprintCapacity) {
+        return print;
+    }
+    for (std::size_t index = 0; index < image.size(); ++index) {
+        print.data[index] = image[index].bytes.data();
+        print.size[index] = image[index].bytes.size();
+    }
+    print.count = image.size();
+    print.valid = true;
+    return print;
+}
+
+/** @return True when both fingerprints name the same ranges in the same order. */
+[[nodiscard]] bool same_ranges(const Fingerprint& left, const Fingerprint& right) noexcept {
+    if (!left.valid || !right.valid || left.count != right.count) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.count; ++index) {
+        if (left.data[index] != right.data[index] || left.size[index] != right.size[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Counts every byte value across one range set. */
+void count_bytes(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    counts = {};
+    for (const ImageRange range : image) {
+        for (const std::byte value : range.bytes) {
+            ++counts.values[std::to_integer<unsigned char>(value)];
+        }
+    }
+}
+
+/**
+ * Reads the byte histogram for one range set, building it on the first request.
+ * @param image Ranges about to be scanned.
+ * @param counts Receives a copy, so no caller holds the cache lock while it scans.
+ */
+void byte_counts(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    const Fingerprint wanted = fingerprint_of(image);
+    AcquireSRWLockExclusive(&g_frequency.lock);
+    if (!same_ranges(g_frequency.fingerprint, wanted)) {
+        count_bytes(image, g_frequency.counts);
+        g_frequency.fingerprint = wanted;
+    }
+    counts = g_frequency.counts;
+    ReleaseSRWLockExclusive(&g_frequency.lock);
+}
 
 /**
  * The one exact byte a pattern's candidate search keys on.
@@ -23,19 +112,34 @@ struct Anchor {
 
 /**
  * Picks the anchor byte for one pattern.
+ * The candidate search keys on this byte, so the rarest exact byte is the one that lets memchr
+ * skip the most. Taking the first exact byte instead lands on a REX prefix for most function
+ * prologues, and those are among the most common bytes there are in compiled x64: the sweep then
+ * stops to verify millions of times per pattern.
  * @param pattern Pattern name, bytes, and exact-byte mask.
+ * @param counts How often each byte value occurs in the ranges about to be scanned.
  * @return A valid anchor when the pattern has a name, bytes, and at least one exact byte.
  */
-[[nodiscard]] Anchor anchor_of(const Pattern& pattern) noexcept {
+[[nodiscard]] Anchor anchor_of(const Pattern& pattern, const ByteCounts& counts) noexcept {
     if (pattern.name.empty() || pattern.bytes.empty()) {
         return {};
     }
+    Anchor best{};
+    std::uint64_t bestCount = 0;
     for (std::size_t index = 0; index < pattern.bytes.size(); ++index) {
-        if (pattern.bytes[index].exact) {
-            return Anchor{index, std::to_integer<unsigned char>(pattern.bytes[index].value), true};
+        if (!pattern.bytes[index].exact) {
+            continue;
+        }
+        const auto value = std::to_integer<unsigned char>(pattern.bytes[index].value);
+        const std::uint64_t occurrences = counts.values[value];
+        // The earliest byte wins a tie, so one image always picks the same anchor.
+        if (best.valid && occurrences >= bestCount) {
+            continue;
         }
+        best = Anchor{index, value, true};
+        bestCount = occurrences;
     }
-    return {};
+    return best;
 }
 
 /**
@@ -109,8 +213,10 @@ bool resolve_all(std::span<const ImageRange> image,
         return false;
     }
 
+    ByteCounts counts;
+    byte_counts(image, counts);
     for (std::size_t index = 0; index < patterns.size(); ++index) {
-        const Anchor anchor = anchor_of(patterns[index]);
+        const Anchor anchor = anchor_of(patterns[index], counts);
         matches[index] = anchor.valid ? Match{MatchStatus::missing, nullptr} : Match{};
         if (!anchor.valid) {
             continue;
@@ -146,7 +252,9 @@ bool resolve_all(std::span<const ImageRange> image,
 std::size_t collect_matches(std::span<const ImageRange> image,
                             const Pattern& pattern,
                             std::span<std::byte*> output) noexcept {
-    const Anchor anchor = anchor_of(pattern);
+    ByteCounts counts;
+    byte_counts(image, counts);
+    const Anchor anchor = anchor_of(pattern, counts);
     if (!anchor.valid || output.empty()) {
         return 0;
     }

+ 15 - 0
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -7,11 +7,14 @@
 #include <string_view>
 
 #include "../../core/logging/log.h"
+#include "../../core/settings/settings.h"
 #include "../../core/ui/busy/busy.h"
 #include "../../core/ui/notice/ui_notice_overlay.h"
 #include "../content/activity/scriptable_catalog_worker.h"
 #include "../content/bootstrap/bootstrap_token_publish.h"
 #include "../content/investment/worker.h"
+#include "../diagnostics/entity_create_probe.h"
+#include "../diagnostics/image_dump.h"
 #include "../executable/image.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
 #include "../hooks/async_io/async_io_lifetime_guard.h"
@@ -49,6 +52,7 @@ StageState g_mainStage{StageState::pending};
 StageState g_graphicsStage{StageState::pending};
 StageState g_platformStage{StageState::pending};
 HMODULE g_platformModule{};
+void* g_sunriseModule{};
 
 namespace {
 
@@ -134,6 +138,11 @@ void clear_game_targets() noexcept {
         clear_game_targets();
         return false;
     }
+    // The inspection above proves the packer has finished: these spans are the decrypted code the
+    // signatures match. That makes this the first point at which a dump is worth taking.
+    if (core::settings::get().client.dumpGameImage) {
+        (void)diagnostics::dump_game_image(g_sunriseModule);
+    }
     const std::span<patterns::ImageRange> imageRanges = ranges(gameImage);
     if (!targets::game::resolution::resolve(imageRanges)) {
         report_resolve_failure();
@@ -172,6 +181,12 @@ void clear_game_targets() noexcept {
                      packageKeys ? "ev=activate stage=package_keys result=ok"
                                  : "ev=activate stage=package_keys result=fail");
     // Diagnostic capture reports its own outcome and never demotes this stage.
+    // The probe hooks only the index allocator, whose two-argument shape was read out of its own
+    // body. The initialiser beside it is left alone: its fifth argument is passed on the stack,
+    // and a four-argument replacement black-screened the load on 2026-08-25.
+    (void)diagnostics::install_entity_create_probe(
+        core::settings::get().client.stockEntityPool,
+        core::settings::get().client.restockDrainedEntityPool);
     (void)hooks::retail_log::install();
     (void)hooks::assert_handler::install();
     // Read-only. At a hitch it dumps every in-flight job record from the watchdog snapshot,

+ 2 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -38,6 +38,8 @@ bool initialize(void* module) noexcept {
         core::settings::get().activitySdkGeneration;
     content::activity::sdk_generation::initialize(module,
                                                   {generation.enabled, generation.luaDeclarations});
+    // Kept for activation, which resolves the artifact directory from Sunrise's own module.
+    runtime::g_sunriseModule = module;
     // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     player::initialize(module);

+ 2 - 0
Sunrise/src/client/runtime/internal.h

@@ -18,5 +18,7 @@ extern StageState g_mainStage;
 extern StageState g_graphicsStage;
 extern StageState g_platformStage;
 extern HMODULE g_platformModule;
+/** Sunrise's own module, kept so activation can resolve the artifact directory. */
+extern void* g_sunriseModule;
 
 } // namespace sunrise::client::runtime

+ 18 - 0
Sunrise/src/core/settings/client/client_settings_parser.cpp

@@ -17,6 +17,9 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     bool hasPinReplicatedRecord = false;
     bool hasHoldSpawn = false;
     bool hasSpawnHoldMs = false;
+    bool hasDumpGameImage = false;
+    bool hasStockEntityPool = false;
+    bool hasRestockDrained = false;
     if (consume('}')) {
         return true;
     }
@@ -73,6 +76,21 @@ bool Parser::client_settings(client::Settings& output) noexcept {
             }
             candidate.spawnHoldMs = value;
             hasSpawnHoldMs = true;
+        } else if (key == "dump_game_image") {
+            if (hasDumpGameImage || !boolean(candidate.dumpGameImage)) {
+                return false;
+            }
+            hasDumpGameImage = true;
+        } else if (key == "stock_entity_pool") {
+            if (hasStockEntityPool || !boolean(candidate.stockEntityPool)) {
+                return false;
+            }
+            hasStockEntityPool = true;
+        } else if (key == "restock_drained_entity_pool") {
+            if (hasRestockDrained || !boolean(candidate.restockDrainedEntityPool)) {
+                return false;
+            }
+            hasRestockDrained = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 22 - 0
Sunrise/src/core/settings/client/definition.h

@@ -56,6 +56,28 @@ struct Settings {
     bool holdSpawn{true};
     /** How long the spawn waits for a load. `hold_spawn` decides whether it waits at all. */
     std::uint64_t spawnHoldMs{kDefaultSpawnHoldMs};
+    /**
+     * Writes the game's decrypted mapped image to `Sunrise\dumps` during activation.
+     * The packed executable on disk cannot be disassembled, so this is the only way to read the
+     * code that decodes the activity wire format. Off by default: the file is the whole image and
+     * writing it costs a second or two of every boot that enables it.
+     */
+    bool dumpGameImage{false};
+    /**
+     * Stock the client's entity free-slot bitmap when it is left entirely unstocked.
+     * The client fills that bitmap itself only when a role global reads zero; here it reads 3, so
+     * the fill never runs and every entity creation fails from the first frame. On, Sunrise writes
+     * the same bytes the client would have. Off restores the previous behaviour with no rebuild.
+     */
+    bool stockEntityPool{true};
+    /**
+     * Also refill the entity pool once it has drained, not only when it was never stocked.
+     * The pool empties from 7935 free to zero inside a minute, and a drained pool makes an
+     * encounter bubble kick to orbit again. Refilling anyway gets past that, but it re-frees
+     * indices that are still owned, so one index can reach two entities — that crashed a respawn.
+     * Off by default: the kick is recoverable, the corruption is not.
+     */
+    bool restockDrainedEntityPool{false};
 };
 
 } // namespace sunrise::core::settings::client

+ 34 - 0
Sunrise/src/core/settings/server/gameplay/definition.h

@@ -42,6 +42,19 @@ inline constexpr std::uint16_t kClientLeaseMinimum = 4096;
 inline constexpr std::uint16_t kDefaultClientJoinGrant = 8'192;
 /** Below this a join cannot cover the client's own low water mark of 400. */
 inline constexpr std::uint16_t kMinimumClientJoinGrant = 400;
+/**
+ * Lease the client is topped up to whenever it asks for more slots. Zero disables the top-up.
+ * Disabled by default because a run on 2026-08-25 measured it as useless: the top-up landed
+ * (`held=2048`) and the client returned the surplus 32 ms later (`kind=release picked=1840`,
+ * back to `held=208`), then failed to create the same three `sobject` entities it always fails
+ * on. The client manages its own lease tightly and will not hold slots it has not asked for.
+ * Capacity was never the constraint either — it failed with 208 slots held while needing 3
+ * entities, and the bubble-14 switch succeeded holding only 151. Kept as a knob because it is
+ * the cheapest way to re-run that experiment, not because a value above zero is expected to help.
+ */
+inline constexpr std::uint16_t kDefaultClientLeaseHighWater = 0;
+/** Below the client's own 400 low water mark a top-up would not change what it can create. */
+inline constexpr std::uint16_t kMinimumClientLeaseHighWater = 400;
 
 /**
  * Gameplay endpoint topology and the entity-slot split it implies.
@@ -69,6 +82,19 @@ struct Settings {
      * refuses; a family-4 completion at arrival frees it. Off by default.
      */
     bool holdLaunchCinematic{false};
+    /** Lease one grant tops the client up to, so the next slice set is covered before it asks. */
+    std::uint16_t clientLeaseHighWater{kDefaultClientLeaseHighWater};
+    /**
+     * Ignore the slot mask the client sends on message 21 instead of shrinking its lease by it.
+     * Measured 2026-08-25: the client "releases" 7785 of the 7936 slots its join was granted, and
+     * 1840 of every later top-up, always within 32 ms and always leaving exactly what it had asked
+     * for. A client handing back 98% of a lease it never used is not plausible; a mask that names
+     * the slots it is KEEPING, read as the ones it is giving up, produces precisely this. The
+     * consequence is real: the client reconciles its own entity bitmap to the host's mask, so the
+     * shrunken lease starves entity creation and an encounter bubble kicks to orbit.
+     * On, the release is still framed and reported, only the lease is left alone.
+     */
+    bool ignoreClientSlotRelease{false};
 };
 
 /**
@@ -95,4 +121,12 @@ struct Settings {
  */
 [[nodiscard]] std::size_t join_grant(const Settings& settings) noexcept;
 
+/**
+ * Reports the lease one grant tops the client up to.
+ * A disabled channel reserves nothing, so the high water is bounded by the whole slot space.
+ * @param settings Active gameplay settings.
+ * @return Configured high water, capped at what the reserve leaves free.
+ */
+[[nodiscard]] std::size_t lease_high_water(const Settings& settings) noexcept;
+
 } // namespace sunrise::core::settings::server::gameplay

+ 15 - 0
Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp

@@ -52,6 +52,8 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
     bool hasReserve = false;
     bool hasJoinGrant = false;
     bool hasHoldLaunchCinematic = false;
+    bool hasLeaseHighWater = false;
+    bool hasIgnoreRelease = false;
     for (;;) {
         std::string_view key;
         if (!string(key) || !consume(':')) {
@@ -112,6 +114,19 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
                 return false;
             }
             hasHoldLaunchCinematic = true;
+        } else if (key == "client_lease_high_water") {
+            std::uint64_t value = 0;
+            if (hasLeaseHighWater || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            candidate.clientLeaseHighWater = static_cast<std::uint16_t>(value);
+            hasLeaseHighWater = true;
+        } else if (key == "ignore_client_slot_release") {
+            if (hasIgnoreRelease || !boolean(candidate.ignoreClientSlotRelease)) {
+                return false;
+            }
+            hasIgnoreRelease = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 15 - 0
Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp

@@ -46,6 +46,12 @@ bool valid(const Settings& settings) noexcept {
     // leaves. Requiring it to fit beside the reserve here would refuse the default, which asks
     // for the whole space on purpose.
     constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    // Zero disables the top-up. Any other value must still clear the client's own low water.
+    if (settings.clientLeaseHighWater != 0
+        && (settings.clientLeaseHighWater < kMinimumClientLeaseHighWater
+            || static_cast<std::size_t>(settings.clientLeaseHighWater) > kSlotCount)) {
+        return false;
+    }
     return settings.clientJoinGrantCount >= kMinimumClientJoinGrant
            && static_cast<std::size_t>(settings.clientJoinGrantCount) <= kSlotCount;
 }
@@ -64,4 +70,13 @@ std::size_t join_grant(const Settings& settings) noexcept {
     return wanted < free ? wanted : free;
 }
 
+/** Reports the lease one grant tops the client up to. */
+std::size_t lease_high_water(const Settings& settings) noexcept {
+    // One activity session owns exactly this many entity-slot lease bits.
+    constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    const std::size_t free = kSlotCount - static_cast<std::size_t>(effective_reserve(settings));
+    const std::size_t wanted = settings.clientLeaseHighWater;
+    return wanted < free ? wanted : free;
+}
+
 } // namespace sunrise::core::settings::server::gameplay

+ 18 - 0
Sunrise/src/core/settings/state/activity_default_destination_parser.cpp

@@ -62,6 +62,9 @@ bool Parser::activity_settings(state::activity::defaults::ActivityDefaults& outp
     bool hasDefaultDestination = false;
     bool hasArrivalOverrides = false;
     bool hasRosterKeyFromIdentity = false;
+    bool hasRosterKeyOnAllSlots = false;
+    bool hasAuthorDirectorBodies = false;
+    bool hasAuthorWideRecordBodies = false;
     if (consume('}')) {
         return true;
     }
@@ -85,6 +88,21 @@ bool Parser::activity_settings(state::activity::defaults::ActivityDefaults& outp
                 return false;
             }
             hasRosterKeyFromIdentity = true;
+        } else if (key == "roster_key_on_all_slots") {
+            if (hasRosterKeyOnAllSlots || !boolean(output.rosterKeyOnAllSlots)) {
+                return false;
+            }
+            hasRosterKeyOnAllSlots = true;
+        } else if (key == "author_director_bodies") {
+            if (hasAuthorDirectorBodies || !boolean(output.authorDirectorBodies)) {
+                return false;
+            }
+            hasAuthorDirectorBodies = true;
+        } else if (key == "author_wide_record_bodies") {
+            if (hasAuthorWideRecordBodies || !boolean(output.authorWideRecordBodies)) {
+                return false;
+            }
+            hasAuthorWideRecordBodies = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 195 - 3
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp

@@ -1,5 +1,11 @@
 #include "sensor_auth_update.h"
 
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
+
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 namespace {
 
@@ -12,6 +18,25 @@ constexpr std::uint8_t kSlotTypeConfiguration = 8;
 constexpr std::uint8_t kSlotTypePackage = 16;
 constexpr std::uint8_t kSlotTypeQueues = 41;
 constexpr std::uint8_t kSlotTypeSpawnKeys = 67;
+/**
+ * The one published slot this host still announces without a body.
+ * A run measured every published width: 13 carries 224 bits, 16 carries 7, 17 carries 520, 18
+ * carries 386, 35 carries 359, and 37 carries **zero**. Its 1750-bit width was recovered from the
+ * client's own field tables alongside the type-35 and type-18 ones, and never written. The block
+ * ships on every region -- the roster body is byte-identical at region 8, 112 and 144 -- so it is
+ * in the stream while the player stands at the Wall of Wishes.
+ */
+constexpr std::uint8_t kSlotTypeWideRecord = 37;
+/**
+ * The slot the bubble-14 roster group brought in, and the second one found shipping bodyless.
+ * Publishing the Wall of Wishes room's object added type 30 to the stream and a measured run
+ * reported it at `bits=0`.
+ */
+constexpr std::uint8_t kSlotTypeRegionRecord = 30;
+/** The mission director. Its body is what an encounter bubble's script objects come from. */
+constexpr std::uint8_t kSlotTypeDirector = 35;
+/** The activity script runtime, which ships beside the director in the same group. */
+constexpr std::uint8_t kSlotTypeScriptRuntime = 18;
 
 /** Body widths, each checked against the writer after the body is written. */
 constexpr std::size_t kParticipationBits = 192;
@@ -21,6 +46,48 @@ constexpr std::size_t kConfigurationBits = 35;
 constexpr std::size_t kPackageBits = 7;
 constexpr std::size_t kQueueBits = 12;
 constexpr std::size_t kSpawnKeyBits = 32 * 32 + 1 + 32;
+/**
+ * Width of the empty type-37 body, derived from the client's schema tree rather than recalled.
+ *
+ * Slot 37 is schema `0x80805007` -> `0x80805008`, which holds two `0x8080500B` records and one
+ * `0x80805009`. `0x8080500B` is 32 + 8 + `0x8080500F` (four groups of i8,i8,u32,bool = 196) + 7 + 1
+ * + 32 + 32 + five biased i32 + `0x8080500D`; `0x80805009` is 32 + 8 + 8. `0x8080500D` is a 7-bit
+ * COUNT followed by that many 16-bit elements, so **this body is variable width** -- a fixed number
+ * cannot be right for it in general, and a zero count is the well-formed empty form.
+ *
+ * 2 x 475 + 48 = 998. An earlier note recorded 1750, which no whole element count produces
+ * (23 gives 1734, 24 gives 1766); it was never verified on the wire the way the type-35 and
+ * type-18 widths were, and it is not used.
+ */
+constexpr std::size_t kWideRecordBits = 998;
+/**
+ * Width of the type-30 body, from the client's field tables.
+ * Slot 30 is schema `0x80809532`: a nested `0x80809C42` of {u32, 7-bit biased +1, 16-bit biased
+ * +0x8000} followed by a 32-bit field biased +2^31. Fixed width, no presence bit and no array, so
+ * there is exactly one legal length and the width check below is exact -- the same shape as the
+ * type-35 and type-18 bodies, which this same decode reproduces at 359 and 386 exactly.
+ */
+constexpr std::size_t kRegionRecordBits = 32 + 7 + 16 + 32;
+/**
+ * The record shared by the director and the script runtime, class `0x808099C4`.
+ * One bool, five raw 64-bit words and a raw 32-bit word. Every field is unbiased, so a zero body
+ * decodes to zeroes rather than to a sentinel.
+ */
+constexpr std::size_t kSharedDirectorRecordBits = 1 + 5 * 64 + 32;
+/** Words in that shared record. */
+constexpr std::size_t kSharedDirectorWords = 5;
+/** Director body: two bools, two bias-1 selectors, then the shared record. */
+constexpr std::size_t kDirectorBits = 1 + 1 + 2 + 2 + kSharedDirectorRecordBits;
+/** Script-runtime body: the shared record, a bool, then one biased signed word. */
+constexpr std::size_t kScriptRuntimeBits = kSharedDirectorRecordBits + 1 + 32;
+/** Width of the director's two selectors, each stored as a signed byte biased by one. */
+constexpr std::uint8_t kDirectorSelectorWidth = 2;
+/** Wire value those selectors need for zero. Zero would decode to -1, the none sentinel. */
+constexpr std::uint32_t kDirectorSelectorZero = 1;
+/** Type-30's 7-bit field carries a bias of one, so this wire value decodes to a literal zero. */
+constexpr std::uint32_t kRegionSelectorZero = 1;
+/** Type-30's 16-bit field carries a bias of 0x8000, so this wire value decodes to zero. */
+constexpr std::uint32_t kUnsignedShortZero = 0x8000;
 
 /** Signed fields in these bodies carry a -2^31 bias, so this wire value stores zero. */
 constexpr std::uint32_t kSignedZero = 0x80000000;
@@ -103,6 +170,56 @@ constexpr std::size_t kSpawnKeyCount = 32;
            && writer.write(0, 3);
 }
 
+/**
+ * Writes the record shared by the director and the script-runtime bodies, class `0x808099C4`.
+ * Recovered from the client's own static field table, whose walker reads a 1-bit bool, five
+ * unbiased 64-bit words and one unbiased raw 32-bit word. Unbiased means a zero wire value stores
+ * a literal zero, so this is the neutral, fully-constructed form of the record rather than one
+ * that decodes to a sentinel.
+ * @param writer Body writer.
+ * @return True when the record fits.
+ */
+[[nodiscard]] bool write_shared_director_record(bits::Writer& writer) noexcept {
+    bool encoded = writer.write(0, kPresenceWidth);
+    for (std::size_t word = 0; encoded && word < kSharedDirectorWords; ++word) {
+        encoded = writer.write(0, 64);
+    }
+    return encoded && writer.write(0, 32);
+}
+
+/**
+ * Writes the mission-director body, class `0x808099BF`.
+ * The director is the slot an encounter bubble's script objects are authored from, and until this
+ * existed `auth_body_bits` returned zero for it, so the block went out with a header and no body.
+ * The body is fixed width: the client's field table declares no presence bit, no array and no
+ * variant field, so there is exactly one legal length and the width check below is exact.
+ *
+ * The two selectors are the only fields that are not zero-safe. Their descriptors carry a bias of
+ * one, so a zero wire value decodes to -1 — the engine's none sentinel, the same shape as the
+ * lifetime's `+1` and the bias-one spawn-override index whose zero disables the override.
+ * @param writer Body writer.
+ * @return True when the body fits.
+ */
+[[nodiscard]] bool write_director(bits::Writer& writer) noexcept {
+    return writer.write(0, kPresenceWidth) && writer.write(0, kPresenceWidth)
+           && writer.write(kDirectorSelectorZero, kDirectorSelectorWidth)
+           && writer.write(kDirectorSelectorZero, kDirectorSelectorWidth)
+           && write_shared_director_record(writer);
+}
+
+/**
+ * Writes the activity-script-runtime body, class `0x80809919`.
+ * The shared record comes first here, then this slot's own bool and signed word. The trailing word
+ * rides the same `+2^31` bias as every other signed field in these bodies, so it carries the bias
+ * rather than a plain zero.
+ * @param writer Body writer.
+ * @return True when the body fits.
+ */
+[[nodiscard]] bool write_script_runtime(bits::Writer& writer) noexcept {
+    return write_shared_director_record(writer) && writer.write(0, kPresenceWidth)
+           && writer.write(kSignedZero, 32);
+}
+
 /**
  * Writes the spawn-key body, which maps the 32 ordinals to themselves.
  * @param writer Body writer.
@@ -118,9 +235,10 @@ constexpr std::size_t kSpawnKeyCount = 32;
 
 } // namespace
 
-/** Reports how many bits of auth body one slot carries. */
-std::size_t
-auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlayerKey) noexcept {
+/** Reports how many bits of auth body one slot carries, without reporting it. */
+std::size_t auth_body_bits_of(const Snapshot& snapshot,
+                              std::uint8_t slotType,
+                              bool carriesPlayerKey) noexcept {
     if (slotType == kSlotTypeParticipation) {
         return carriesPlayerKey
                    ? kParticipationBits + (snapshot.hasRegion ? kParticipationRegionBits : 0)
@@ -141,9 +259,68 @@ auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlay
     if (slotType == kSlotTypeSpawnKeys) {
         return kSpawnKeyBits;
     }
+    // Both are settings-gated: a body of the wrong width does not fail this host's own width
+    // check, it desynchronises the client's parse of every block after it in the same phase-2
+    // stream, which would cost the player their spawn. Off, they go out bodyless as before.
+    if (slotType == kSlotTypeDirector) {
+        return snapshot.authorDirectorBodies ? kDirectorBits : 0;
+    }
+    if (slotType == kSlotTypeScriptRuntime) {
+        return snapshot.authorDirectorBodies ? kScriptRuntimeBits : 0;
+    }
+    if (slotType == kSlotTypeWideRecord) {
+        return snapshot.authorWideRecordBodies ? kWideRecordBits : 0;
+    }
+    if (slotType == kSlotTypeRegionRecord) {
+        return snapshot.authorWideRecordBodies ? kRegionRecordBits : 0;
+    }
     return 0;
 }
 
+/**
+ * Names each published slot type and the body width it goes out with, once per distinct pair.
+ *
+ * A slot whose width is zero is announced to the client and then described with nothing -- the
+ * exact shape of the gap that types 35 and 18 had before their bodies were written. Types 21 and
+ * 37 are admitted by `kRosterSlotTypes` and still fall through to `return 0` here, and type 37's
+ * body was measured at 1750 bits and never implemented. Printing the pairs says which published
+ * slots are actually bodyless on this destination instead of inferring it from the filter.
+ * @param slotType Slot type being sized.
+ * @param bits Body width it will carry.
+ */
+void report_slot_width(std::uint8_t slotType, std::size_t bits) noexcept {
+    static std::atomic<std::uint64_t> reported{};
+    if (slotType >= 64 || !core::log::accepts(core::log::Channel::middleware,
+                                              core::log::Level::debug)) {
+        return;
+    }
+    const std::uint64_t bit = 1ULL << slotType;
+    if ((reported.fetch_or(bit, std::memory_order_relaxed) & bit) != 0) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=slot_width type=%u bits=%zu%s",
+                                      static_cast<unsigned>(slotType),
+                                      bits,
+                                      bits == 0 ? " result=bodyless" : "");
+    if (written > 0) {
+        core::log::write(core::log::Channel::middleware,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports how many bits of auth body one slot carries. */
+std::size_t
+auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlayerKey) noexcept {
+    const std::size_t width = auth_body_bits_of(snapshot, slotType, carriesPlayerKey);
+    report_slot_width(slotType, width);
+    return width;
+}
+
+
 /** Writes one slot's auth body. */
 bool write_auth_body(bits::Writer& writer,
                      const Snapshot& snapshot,
@@ -167,6 +344,21 @@ bool write_auth_body(bits::Writer& writer,
         encoded = writer.write(0, 7) && writer.write(0, 5);
     } else if (slotType == kSlotTypeSpawnKeys) {
         encoded = write_spawn_keys(writer);
+    } else if (slotType == kSlotTypeDirector && snapshot.authorDirectorBodies) {
+        encoded = write_director(writer);
+    } else if (slotType == kSlotTypeScriptRuntime && snapshot.authorDirectorBodies) {
+        encoded = write_script_runtime(writer);
+    } else if (slotType == kSlotTypeRegionRecord && snapshot.authorWideRecordBodies) {
+        // Zero is NOT the constructed state here: three of the four fields carry a bias, so a
+        // neutral body writes each bias rather than a zero. Writing zeros would decode to -1 in
+        // the 7-bit field and to large negatives in the other two.
+        encoded = writer.write(0, 32) && writer.write(kRegionSelectorZero, 7)
+                  && writer.write(kUnsignedShortZero, 16) && writer.write(kSignedZero, 32);
+    } else if (slotType == kSlotTypeWideRecord && snapshot.authorWideRecordBodies) {
+        // Zeroes are the empty form here rather than merely a neutral one: the two element
+        // counts inside `0x8080500D` read zero, so the body declares two empty arrays and every
+        // other field at its unbiased zero.
+        encoded = pad_bits(writer, kWideRecordBits);
     }
     return encoded && writer.bit_count() == start + expected;
 }

+ 17 - 0
Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h

@@ -171,6 +171,23 @@ struct Snapshot final {
     bool awaitClientSync{};
     /** Register the groups and seed no object. Separates no components from no auth state. */
     bool phaseOneOnly{};
+    /**
+     * Fill the type-35 mission-director and type-18 script-runtime bodies.
+     * These two are what an encounter bubble's script objects are authored from, and they shipped
+     * bodyless until their layouts were recovered from the client's own field tables. Gated
+     * because a wrong width does not fail this host's width check: it shifts every block after it
+     * in the same phase-2 stream, which costs the player their spawn rather than just the
+     * encounter. Off restores the previous bodyless behaviour with no rebuild.
+     */
+    bool authorDirectorBodies{};
+    /**
+     * Fill the type-37 body, the last published slot that still ships bodyless.
+     * A measured run showed every other published slot carrying a body (13:224, 16:7, 17:520,
+     * 18:386, 35:359) and 37 carrying zero. Same gate rationale as the director bodies: a wrong
+     * width shifts every block after it in the same phase-2 stream, so off restores the bodyless
+     * behaviour with no rebuild.
+     */
+    bool authorWideRecordBodies{};
     /**
      * Fill the participation body on every type-13 slot, not only the group's first.
      * The gate reads the record of the object the player datum names. Only one type-13 slot

+ 69 - 0
Sunrise/src/middleware/content/packages/tables/bubble_state_reader.cpp

@@ -1,8 +1,13 @@
 #include "bubble_state_reader.h"
 
 #include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstdio>
 
+#include "../../../../core/logging/log.h"
 #include "component_container_reader.h"
+#include "internal.h"
 
 namespace sunrise::middleware::content::packages::tables {
 namespace {
@@ -24,6 +29,60 @@ void add_package(BubbleStates& output, std::uint16_t packageId) noexcept {
     output.packages[output.packageCount++] = packageId;
 }
 
+/**
+ * Slice-state rows reported per run, so a full package sweep cannot fill the sink.
+ * 359 destinations are walked and most declare tens of bubbles, so this is a sample, not a census.
+ */
+constexpr std::size_t kMaxStateReports = 4096;
+/** Rows already spent. */
+std::atomic<std::size_t> g_stateReports{};
+
+/**
+ * Dumps one slice-set state whole, so its map-global bubble index can be located rather than
+ * assumed.
+ *
+ * `kStateMapBubbleIndexOffset` is an inference, and the values it produces do not behave like a
+ * map-global index: within one destination most bubbles resolve to zero, and the field is what a
+ * container's bubble mask is keyed by, so a wrong offset collapses every bubble's spawn sets and
+ * components onto whichever bubble reads zero. The bubble name hash is the same for one bubble
+ * whichever destination walks it, so grouping these rows by hash decides the offset outright: the
+ * right one agrees across every destination that names the bubble and differs between bubbles.
+ * @param ordinal Bubble ordinal within its scenario.
+ * @param nameHash The bubble's own name hash, stable across destinations.
+ * @param state Raw inline bytes of slice-set state zero.
+ */
+void report_state(std::uint64_t ordinal,
+                  std::uint32_t nameHash,
+                  std::span<const std::byte> state) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)
+        || g_stateReports.fetch_add(1, std::memory_order_relaxed) >= kMaxStateReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=slice_state bubble=%llu hash=0x%08X raw=",
+                                static_cast<unsigned long long>(ordinal),
+                                nameHash);
+    for (std::size_t offset = 0; offset < state.size() && written > 0
+                                 && static_cast<std::size_t>(written) + 3 < line.size();
+         ++offset) {
+        const int more = std::snprintf(line.data() + written,
+                                       line.size() - static_cast<std::size_t>(written),
+                                       "%02X",
+                                       std::to_integer<unsigned char>(state[offset]));
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+    }
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 } // namespace
 
 /** Builds one scenario's per-bubble state array. */
@@ -46,6 +105,16 @@ bool bubble_states(std::span<const std::byte> scenario, BubbleStates& output) no
         std::uint8_t value = kBubbleDisabledByte;
         SliceState state{};
         std::uint16_t mapIndex = kAbsentMapBubbleIndex;
+        std::size_t stateOffset = 0;
+        if (bubble.stateCount != 0
+            && element_offset(
+                bubble.stateDataOffset, bubble.stateCount, kSliceStateStride, 0, stateOffset)
+            && stateOffset + kSliceStateStride <= scenario.size()) {
+            report_state(index,
+                         bubble.nameHash,
+                         scenario.subspan(static_cast<std::size_t>(stateOffset),
+                                          kSliceStateStride));
+        }
         if (bubble.stateCount != 0 && slice_state_at(scenario, bubble, 0, state)) {
             value = state.enabled ? kBubbleEnabledByte : kBubbleDisabledByte;
             // An index no container mask can name is absent, because nothing could match it.

+ 9 - 0
Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp

@@ -33,6 +33,15 @@ bool carries_roster_slot(std::span<const std::byte> object) noexcept {
     if (!object_slots(object, slots)) {
         return false;
     }
+    // A named key is admitted on its own account, before the type test it would fail.
+    std::uint32_t key = 0;
+    if (object_key(object, key)) {
+        for (const std::uint32_t forced : kForcedRosterKeys) {
+            if (key == forced) {
+                return true;
+            }
+        }
+    }
     for (std::uint64_t index = 0; index < slots.count; ++index) {
         Slot slot{};
         if (!object_slot_at(object, slots, index, slot)) {

+ 21 - 0
Sunrise/src/middleware/content/packages/tables/roster_intersection.h

@@ -70,6 +70,27 @@ void observe_unresolved_slice_set(RosterIntersection& state) noexcept;
  * Only 56 installed objects declare any of them, and the key limit above holds only for that
  * filtered set. Feeding every placed object instead overflows most destinations.
  */
+/**
+ * Object keys admitted whatever slot types they declare.
+ *
+ * The slot-type filter below is what decides which placed objects become roster groups, and a
+ * placement trace shows it admitting **68 of 5986** objects overall and **1 of 32** across the
+ * whole raid. Bubble 14 -- the Wall of Wishes room -- has exactly two objects, `0x101DECCF`
+ * (785 slots) and `0x432A36E6` (21 slots), and neither declares an admitted type, so the host
+ * sends no per-object data for that bubble at all while the client builds its twenty panels
+ * locally and never shows them.
+ *
+ * Widening the type list is not the way to test that: only 56 installed objects declare any of the
+ * nine types, the key limit holds only for that filtered set, and admitting common types overflows
+ * `kRosterKeyCapacity` on most destinations, which makes a destination publish ZERO groups. Naming
+ * one key instead adds a single group to one destination -- 3 keys become 4 of 16, and 21 slots sit
+ * well inside `kRosterSlotCapacity` -- so the experiment is bounded and reversible.
+ *
+ * The 785-slot container is deliberately NOT listed: its slots would each carry a header and the
+ * roster body is already 976 bytes, so it risks the message size rather than testing the idea.
+ */
+inline constexpr std::array<std::uint32_t, 1> kForcedRosterKeys = {0x432A36E6U};
+
 inline constexpr std::array<std::uint16_t, 9> kRosterSlotTypes = {
     8, 13, 16, 17, 21, 35, 37, 41, 67};
 

+ 117 - 5
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -29,6 +29,8 @@
 #include "../../../../middleware/crypto/hmac.h"
 #include "../../../../middleware/crypto/random_bytes.h"
 #include "../../../../middleware/encoding/byte_order.h"
+#include "../../../../state/activity/bubble_authority/runtime.h"
+#include "../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../state/activity/receipts/activity_receipts.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/activity_sdk/runtime.h"
@@ -45,6 +47,7 @@ namespace sunrise::server::bap::encrypted::activity_message {
 namespace {
 
 namespace service = middleware::bap::activity_message;
+namespace authority = middleware::bap::activity_message::entity_authority;
 namespace store = state::activity::receipts;
 namespace wire_schema = middleware::bap::activity_message::wire_schema;
 namespace communication = wire_schema::communication;
@@ -435,6 +438,12 @@ void report_message(std::uint32_t messageType,
 
 /**
  * Prepares only currently free slots for one positive client request.
+ * The ask is a floor, not the amount. The client requests the slots one slice set needs only once
+ * it has begun creating that slice set's entities, so a grant sized to the ask arrives after the
+ * creates it was meant to cover have already failed. Topping the lease up to a standing high
+ * water instead leaves the slots held before the next switch starts. `prepare_grant` picks from
+ * the free complement and documents that an ask above the slot count degrades to every remaining
+ * free slot, so an over-large top-up cannot fail a request that would otherwise have succeeded.
  * @param request Validated owned svc8 envelope.
  * @param plan Cleared, then receives the chosen lease mask.
  * @return True for a valid positive request, including an exhausted zero-mask grant.
@@ -442,11 +451,40 @@ void report_message(std::uint32_t messageType,
 [[nodiscard]] bool prepare_grant(const service::Request& request, ActivityPlan& plan) noexcept {
     std::int32_t requested = 0;
     if (!service::entity_slot_request::parse_entity_slot_request(request.payload, requested)
-        || requested <= 0
-        || !state::activity::entity_slots::prepare_grant(
-            request.sessionId, static_cast<std::size_t>(requested), plan.entitySlotMutation)) {
+        || requested <= 0) {
         return false;
     }
+    std::size_t wanted = static_cast<std::size_t>(requested);
+    const std::size_t highWater = core::settings::server::gameplay::lease_high_water(
+        core::settings::get().server.gameplay);
+    std::size_t held = 0;
+    std::size_t reserved = 0;
+    // A session with no readable lease keeps the client's own ask, which is today's behaviour.
+    if (state::activity::entity_slots::lease_counts(request.sessionId, held, reserved)
+        && held < highWater) {
+        wanted = (std::max)(wanted, highWater - held);
+    }
+    if (!state::activity::entity_slots::prepare_grant(
+            request.sessionId, wanted, plan.entitySlotMutation)) {
+        return false;
+    }
+    // The lease line downstream reports the topped-up count, so without this the size of the
+    // client's own ask — the thing that says which slice set it is about to build — is lost.
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=lease_topup soid=0x%llX asked=%d "
+                                      "wanted=%zu held=%zu high_water=%zu",
+                                      static_cast<unsigned long long>(request.sessionId),
+                                      requested,
+                                      wanted,
+                                      held,
+                                      highWater);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
     plan.sessionId = request.sessionId;
     plan.entitySlotsRequested.requestedCount = requested;
     plan.delivery = Delivery::entitySlotNotification;
@@ -465,6 +503,15 @@ void report_message(std::uint32_t messageType,
     if (!service::entity_slots::decode_entity_slots(request.payload, decoded)) {
         return false;
     }
+    if (core::settings::get().server.gameplay.ignoreClientSlotRelease) {
+        // Framed and receipted as before; only the lease is left standing. The mask's meaning is
+        // unproven, and reading it the wrong way round shrinks the lease the client's own entity
+        // bitmap mirrors, which starves entity creation.
+        plan.sessionId = request.sessionId;
+        plan.delivery = Delivery::none;
+        plan.mutationDomain = MutationDomain::none;
+        return true;
+    }
     state::activity::entity_slots::LeaseMask returned{};
     std::copy(decoded.begin(), decoded.end(), returned.begin());
     if (!state::activity::entity_slots::prepare_release(
@@ -483,17 +530,81 @@ struct FramingRoute {
     receipts::Framed (*frame)(const service::Request&) noexcept;
 };
 
+/**
+ * Drops the recorded grant for the bubble one release names, but only once the client has left it.
+ * The receipts module reports without touching State by design, so the State change a hand-back
+ * implies is made here. Without it the bubble stays recorded as granted for the rest of the
+ * session and re-entering it — which is what every wipe, retry and backtrack does — runs with no
+ * authority, because `select_grant` only ever grants a bubble whose token is zero.
+ *
+ * The occupancy test is what makes this safe on the common path. Msg 26 is documented as the
+ * bubble exit, but msg 33 gives up *a set of slots* and the client sends it without leaving.
+ * Clearing the token while the player is still inside would let the next roster push — one every
+ * second during the load burst — re-grant the occupied bubble under a new token, on every
+ * destination rather than only this raid. Comparing the selector against the region the client
+ * last reported keeps the release to a real exit; a session that has reported no region yet
+ * cannot be judged, so it is left alone.
+ *
+ * The test is deliberately fail-safe rather than exact. `reported_region` lags a boundary
+ * crossing, so a release sent the instant the client leaves can still name the region it is
+ * leaving and be skipped. That loses a re-arm, which is the behaviour before this change; it
+ * never clears a bubble the player occupies, which would be worse than that behaviour.
+ * The selector is the raw bubble index, not a biased field: the captured releases carry 1, 14
+ * and 12, matching dream_shore, raid_larceny_staging and raid_larceny_alarm — the three bubbles
+ * that run actually visited.
+ * @param request Validated owned activity envelope carrying the release.
+ * @param expectReason True for abandon, which trails a reason after the mask.
+ */
+void release_named_bubble(const service::Request& request, bool expectReason) noexcept {
+    authority::Release decoded{};
+    const bool parsed = expectReason ? authority::parse_abandon(request.payload, decoded)
+                                     : authority::parse_abdicate(request.payload, decoded);
+    if (!parsed || decoded.selector >= state::activity::bubble_authority::kFallbackBubble) {
+        return;
+    }
+    const std::int32_t region =
+        state::activity::membership::reported_region(request.sessionId);
+    if (region < 0) {
+        return;
+    }
+    const auto occupied = static_cast<std::uint8_t>(
+        region >> state::activity::bubble_authority::kSliceSetToBubbleShift);
+    if (decoded.selector == occupied) {
+        return;
+    }
+    state::activity::bubble_authority::release_grant(request.sessionId, decoded.selector);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=authority result=released type=%u "
+                                      "selector=%u occupied=%u",
+                                      request.messageType,
+                                      static_cast<unsigned>(decoded.selector),
+                                      static_cast<unsigned>(occupied));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Frames one abandon, which trails a reason after the mask. */
 [[nodiscard]] receipts::Framed frame_abandon(const service::Request& request) noexcept {
+    release_named_bubble(request, true);
     return receipts::frame_authority_release(request, true);
 }
 
 /** Frames one abdicate, which carries no reason. */
 [[nodiscard]] receipts::Framed frame_abdicate(const service::Request& request) noexcept {
+    release_named_bubble(request, false);
     return receipts::frame_authority_release(request, false);
 }
 
-/** Every adapter this route frames and records without changing State. */
+/**
+ * Every adapter this route frames and records.
+ * All of these are read-only except the two authority releases, which drop the grant token for a
+ * bubble the client has left so it can be granted again on re-entry.
+ */
 constexpr std::array<FramingRoute, 19> kFramingRoutes{{
     {IngressAdapter::routeMisuseReceipt, receipts::frame_route_misuse},
     {IngressAdapter::reservationRequest, receipts::frame_reservation_request},
@@ -517,7 +628,8 @@ constexpr std::array<FramingRoute, 19> kFramingRoutes{{
 }};
 
 /**
- * Frames one message that changes no State and records its receipt.
+ * Frames one message and records its receipt.
+ * Read-only except for the two authority releases, which clear a departed bubble's grant token.
  * @param request Validated envelope.
  * @return Always true: a framing-only message can never fail the transport frame.
  */

+ 11 - 2
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -8,6 +8,7 @@
 #include "activity_message_receipts.h"
 
 #include <array>
+#include <bit>
 #include <cstdarg>
 #include <cstddef>
 #include <cstdint>
@@ -505,11 +506,19 @@ Framed frame_authority_release(const message::Request& request, bool expectReaso
     if (!parsed) {
         return {report_malformed("authority", request), 0};
     }
+    // The mask says how much lease the client believes it is handing back with the bubble. A
+    // release Sunrise records as authority-only while the client counts it as slots returned is
+    // how the two ledgers drift apart, and nothing else on this path reports the size.
+    std::size_t returning = 0;
+    for (const std::byte byte : decoded.mask) {
+        returning += static_cast<std::size_t>(std::popcount(std::to_integer<unsigned char>(byte)));
+    }
     report(core::log::Level::debug,
-           "ev=activity stage=authority result=noted type=%u selector=%u reason=%d",
+           "ev=activity stage=authority result=noted type=%u selector=%u reason=%d slots=%zu",
            request.messageType,
            static_cast<unsigned>(decoded.selector),
-           decoded.hasReason ? decoded.reason : 0);
+           decoded.hasReason ? decoded.reason : 0,
+           returning);
     return {Verdict::framed, payload_bits(request)};
 }
 

+ 2 - 0
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -1371,6 +1371,8 @@ build_roster_snapshot(Session& session,
     // them. So every participation record carries the same player key. Selecting the first slot
     // leaves the authored cinematic participant unbound whenever it names another record.
     snapshot.keyOnEveryParticipationSlot = true;
+    snapshot.authorDirectorBodies = defaults.authorDirectorBodies;
+    snapshot.authorWideRecordBodies = defaults.authorWideRecordBodies;
     // The participation record's `+0` latches only when the region index is known.
     snapshot.region = static_cast<std::uint32_t>(region.index);
     snapshot.hasRegion = true;

+ 5 - 1
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -97,7 +97,7 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
         std::snprintf(line.data(),
                       line.size(),
                       "ev=activity stage=entity_slots result=%s kind=%s soid=0x%llX "
-                      "requested=%zu picked=%zu held=%zu reserved=%zu known=%u",
+                      "requested=%zu picked=%zu held=%zu reserved=%zu returned=%zu known=%u",
                       committed ? "ok" : "fail",
                       kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
                       static_cast<unsigned long long>(mutation.sessionId),
@@ -105,6 +105,10 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
                       slots::slot_count(mutation.mask),
                       held,
                       reserved,
+                      // Only a release carries one, so every other kind reports zero. A release
+                      // whose returned set and picked set disagree means the two ledgers have
+                      // diverged, which nothing else on this path would show.
+                      slots::slot_count(mutation.returnedMask),
                       known ? 1U : 0U);
     if (written > 0) {
         core::log::write(core::log::Channel::server,

+ 10 - 0
Sunrise/src/state/activity/bubble_authority/definition.h

@@ -16,6 +16,8 @@ inline constexpr std::int32_t kMaximumGrantSliceSetIndex = 511;
 inline constexpr std::uint8_t kSliceSetToBubbleShift = 3;
 /** The client's cleared mirror changes when the first nonzero token arrives. */
 inline constexpr std::uint16_t kInitialGrantToken = 1;
+/** The token rides a 16-bit field, so it saturates here rather than wrapping onto a live value. */
+inline constexpr std::uint16_t kMaximumGrantToken = 0xFFFF;
 /** The cleared grant slot uses a value outside the 65-entry authority table. */
 inline constexpr std::uint8_t kInvalidBubble = 0xFF;
 
@@ -27,7 +29,15 @@ struct Grant final {
 
 /** Persistent grant-token mirrors owned by one activity session. */
 struct AuthorityState final {
+    /** Token in force per bubble. Zero means the bubble is owed a grant. */
     std::array<std::uint16_t, kAuthoritySlotCount> grantTokens{};
+    /**
+     * Highest token ever issued per bubble, which a release does not clear.
+     * The client compares an arriving token against its own mirror and ignores a repeat, so a
+     * re-grant after a hand-back has to carry a token it has not already seen. Keeping the issued
+     * value separately from the in-force one is what lets the next grant differ.
+     */
+    std::array<std::uint16_t, kAuthoritySlotCount> issuedTokens{};
 };
 
 } // namespace sunrise::state::activity::bubble_authority

+ 10 - 0
Sunrise/src/state/activity/bubble_authority/runtime.h

@@ -33,4 +33,14 @@ void record_grant(std::uint64_t sessionId, const Grant& grant) noexcept;
  */
 void clear_grants(std::uint64_t sessionId) noexcept;
 
+/**
+ * Drops one bubble's recorded grant, so re-entering it is granted again.
+ * The client abdicates a bubble on leaving it and re-enters the same bubble on any wipe, retry or
+ * backtrack. While the record survives that hand-back, `select_grant` sees the bubble as already
+ * owed-and-paid and never grants it a second time, so the re-entry runs unauthorised.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble the client handed back.
+ */
+void release_grant(std::uint64_t sessionId, std::uint8_t bubble) noexcept;
+
 } // namespace sunrise::state::activity::bubble_authority

+ 27 - 2
Sunrise/src/state/activity/bubble_authority/transactions/activity_bubble_authority_grant.cpp

@@ -20,8 +20,13 @@ bool select_grant(std::uint64_t sessionId, std::int32_t sliceSetIndex, Grant& gr
     const std::size_t target = activity::transactions::find_session(state, sessionId);
     if (target != kInvalidSessionSlot && bubble < kFallbackBubble
         && state.sessions[target].bubbleAuthority.grantTokens[bubble] == 0) {
+        // A bubble handed back and re-entered must be granted a token the client's mirror has not
+        // already seen, so the next one follows the highest ever issued rather than restarting.
+        const std::uint16_t issued = state.sessions[target].bubbleAuthority.issuedTokens[bubble];
         grant.bubble = bubble;
-        grant.token = kInitialGrantToken;
+        grant.token = issued < kMaximumGrantToken
+                          ? static_cast<std::uint16_t>(issued + 1)
+                          : kMaximumGrantToken;
         owed = true;
     }
     ReleaseSRWLockShared(&runtime::storage::g_stateLock);
@@ -38,6 +43,23 @@ void record_grant(std::uint64_t sessionId, const Grant& grant) noexcept {
     const std::size_t target = activity::transactions::find_session(state, sessionId);
     if (target != kInvalidSessionSlot) {
         state.sessions[target].bubbleAuthority.grantTokens[grant.bubble] = grant.token;
+        state.sessions[target].bubbleAuthority.issuedTokens[grant.bubble] = grant.token;
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+}
+
+/** Drops one bubble's recorded grant, so re-entering it is granted again. */
+void release_grant(std::uint64_t sessionId, std::uint8_t bubble) noexcept {
+    if (sessionId == kAbsentSessionId || bubble >= kAuthoritySlotCount) {
+        return;
+    }
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    ActivityState& state = runtime::storage::g_state.activity;
+    const std::size_t target = activity::transactions::find_session(state, sessionId);
+    if (target != kInvalidSessionSlot) {
+        // Only the in-force token clears. `issuedTokens` stays so the next grant advances past
+        // what the client already mirrors.
+        state.sessions[target].bubbleAuthority.grantTokens[bubble] = 0;
     }
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 }
@@ -51,7 +73,10 @@ void clear_grants(std::uint64_t sessionId) noexcept {
     ActivityState& state = runtime::storage::g_state.activity;
     const std::size_t target = activity::transactions::find_session(state, sessionId);
     if (target != kInvalidSessionSlot) {
-        state.sessions[target].bubbleAuthority = {};
+        // Only the in-force tokens clear. `issuedTokens` is what stops a re-grant re-sending a
+        // token the client's mirror already holds, and a join that resets the roster container
+        // does not reset that mirror, so wiping it here would reintroduce the invisible re-grant.
+        state.sessions[target].bubbleAuthority.grantTokens = {};
     }
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 }

+ 16 - 0
Sunrise/src/state/activity/defaults/definition.h

@@ -73,6 +73,22 @@ struct ActivityDefaults final {
      * That field is the member record's `+16`, which is the value this key must equal.
      */
     bool rosterKeyFromIdentity{};
+    /**
+     * Fills message 5's participation body on every type-13 slot of the key group.
+     * The old encoder fills only the group's first, and the gate reads whichever object the player
+     * datum names, which need not be that one.
+     */
+    bool rosterKeyOnAllSlots{};
+    /**
+     * Author the type-35 mission-director and type-18 script-runtime auth bodies.
+     * On by default: they are what an encounter bubble's script objects come from, and shipping
+     * them bodyless is why Last Wish's encounter bubbles create no objects. Turn off to restore
+     * the previous behaviour without a rebuild if a body ever desynchronises the phase-2 stream,
+     * whose symptom is the player failing to spawn at all rather than only the encounter failing.
+     */
+    bool authorDirectorBodies{true};
+    /** Fill the type-37 auth body. See `authorDirectorBodies` for the width-gate rationale. */
+    bool authorWideRecordBodies{true};
 };
 
 } // namespace sunrise::state::activity::defaults

+ 68 - 0
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp

@@ -8,6 +8,7 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/tables/region_reader.h"
 #include "../../build_data/runtime.h"
 
 namespace sunrise::state::activity::destination {
@@ -68,8 +69,75 @@ void report_dropped(std::string_view name, std::uint32_t hash) noexcept {
     }
 }
 
+/**
+ * Tests whether one bubble of a destination is named by a spawn row's bubble mask.
+ * The mask is keyed by map-global bubble index, so the destination's own ordinal has to be
+ * translated through its map-index table first.
+ * @param layout Destination row carrying the map-index table.
+ * @param row Spawn-set row carrying the mask.
+ * @param bubble Destination bubble ordinal.
+ * @return True when the row declares that bubble.
+ */
+[[nodiscard]] bool bubble_declares_set(const build_data::scenarios::Definition& layout,
+                                       const build_data::spawn_sets::NameHash& row,
+                                       std::size_t bubble) noexcept {
+    if (bubble >= layout.bubbleCount || bubble >= layout.bubbleMapIndices.size()) {
+        return false;
+    }
+    const std::size_t mapIndex = layout.bubbleMapIndices[bubble];
+    const std::size_t byteIndex = mapIndex / 8;
+    if (byteIndex >= row.bubbleMask.size()) {
+        return false;
+    }
+    return (row.bubbleMask[byteIndex] >> (mapIndex % 8) & 1U) != 0;
+}
+
 } // namespace
 
+/** Finds the slice set whose bubble actually declares one spawn set. */
+std::uint16_t spawn_set_slice_set(const DestinationSelection& selection,
+                                  std::uint32_t spawnSetHash,
+                                  std::uint16_t arrivalSliceSet) noexcept {
+    namespace tables = middleware::content::packages::tables;
+    if (spawnSetHash == 0 || spawnSetHash == kAbsentSpawnSetHash) {
+        return arrivalSliceSet;
+    }
+    const std::string_view name = name_of(selection);
+    build_data::scenarios::Definition layout{};
+    if (name.empty() || !build_data::find_scenario_layout(name, layout)) {
+        return arrivalSliceSet;
+    }
+    const std::string_view stem(layout.spawnStem.data(), layout.spawnStemLength);
+    static std::array<build_data::spawn_sets::NameHash, kSpawnRowCapacity> rows{};
+    std::size_t count = 0;
+    if (stem.empty() || !build_data::find_spawn_sets(stem, rows, count)) {
+        return arrivalSliceSet;
+    }
+    for (std::size_t index = 0; index < count; ++index) {
+        if (rows[index].value != spawnSetHash) {
+            continue;
+        }
+        // The arrival wins whenever it is valid, so every configuration that already places a
+        // player keeps the exact slice set it publishes today.
+        const std::size_t arrivalBubble = arrivalSliceSet / tables::kSliceSetIndexFactor;
+        if (bubble_declares_set(layout, rows[index], arrivalBubble)) {
+            return arrivalSliceSet;
+        }
+        const std::size_t declared = layout.bubbleCount < layout.bubbleMapIndices.size()
+                                         ? layout.bubbleCount
+                                         : layout.bubbleMapIndices.size();
+        for (std::size_t bubble = 0; bubble < declared; ++bubble) {
+            if (bubble_declares_set(layout, rows[index], bubble)) {
+                return static_cast<std::uint16_t>(
+                    tables::region_index(static_cast<std::uint32_t>(bubble)));
+            }
+        }
+        return arrivalSliceSet;
+    }
+    // A hash no row carries is not proof of a miss: the row set can be capped.
+    return arrivalSliceSet;
+}
+
 /** Drops a spawn set the destination cannot load. Only a proved miss is dropped. */
 std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
                                         std::uint32_t fallback) noexcept {

+ 15 - 0
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h

@@ -17,4 +17,19 @@ namespace sunrise::state::activity::destination {
 [[nodiscard]] std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
                                                       std::uint32_t fallback) noexcept;
 
+/**
+ * Finds the slice set the type-17 spawn override must name for one spawn-set hash.
+ * The override is a pair, and the Client searches for the hash inside the slice set the pair
+ * names. Naming the arrival works only while the arrival bubble is one the set is declared in;
+ * anywhere else the search finds nothing and the player never receives a spawn point. A set the
+ * catalog cannot place leaves the arrival standing, which is the previous behaviour.
+ * @param selection Committed destination.
+ * @param spawnSetHash Hash the override will carry.
+ * @param arrivalSliceSet Slice set the destination arrives in.
+ * @return The arrival when it already declares the set, otherwise the set's own slice set.
+ */
+[[nodiscard]] std::uint16_t spawn_set_slice_set(const DestinationSelection& selection,
+                                                std::uint32_t spawnSetHash,
+                                                std::uint16_t arrivalSliceSet) noexcept;
+
 } // namespace sunrise::state::activity::destination

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

@@ -1,5 +1,8 @@
 #include <algorithm>
+#include <array>
+#include <cstdio>
 
+#include "../../../../core/logging/log.h"
 #include "codec.h"
 
 namespace sunrise::state::build_data::cache::records {
@@ -36,12 +39,59 @@ bool decode(const SpawnStemRecord& record, spawn_sets::Stem& value) noexcept {
     return true;
 }
 
+/**
+ * Names the map-global bubbles one spawn set is offered by.
+ *
+ * `bubble` in an `arrival_overrides` row is read against this mask through
+ * `bubbleMapIndices[bubble]`, so the row that works names a bubble whose map index has a bit here
+ * -- which is not the bubble the player lands in, and is the part that reads as a bad mapping when
+ * an override has to be found by trial. Printing the bits turns that from trial into a lookup.
+ * @param value Finished spawn-set row.
+ */
+void report_offered_bubbles(const spawn_sets::NameHash& value) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=spawn_set hash=0x%08X stem=%u points=%u "
+                                "unbound=%u offered_map_bubbles=",
+                                value.value,
+                                static_cast<unsigned>(value.stemIndex),
+                                static_cast<unsigned>(value.pointCount),
+                                static_cast<unsigned>(value.unbound));
+    bool first = true;
+    for (std::size_t bit = 0; bit < value.bubbleMask.size() * 8 && written > 0
+                              && static_cast<std::size_t>(written) + 6 < line.size();
+         ++bit) {
+        if ((value.bubbleMask[bit / 8] >> (bit % 8) & 1U) == 0) {
+            continue;
+        }
+        const int more = std::snprintf(line.data() + written,
+                                       line.size() - static_cast<std::size_t>(written),
+                                       first ? "%zu" : ",%zu",
+                                       bit);
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+        first = false;
+    }
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Encodes one distinct spawn-name hash and its point count. */
 bool encode(const spawn_sets::NameHash& value, SpawnNameHashRecord& record) noexcept {
     record = {};
     if (value.pointCount == 0) {
         return false;
     }
+    report_offered_bubbles(value);
     record.value = value.value;
     record.pointCount = value.pointCount;
     record.stemIndex = value.stemIndex;