瀏覽代碼

WIP: Last Wish raid research -- entity pool, ambassador slot, auth bodies

A snapshot of in-progress work, kept as one commit because it was built
and tested as one DLL. Not meant to be reviewed as a unit; the entity
pool fix is split out cleanly on fix/client-entity-pool-stocking.

Client
  - Entity pool stocking and the allocator hook. The client never stocks
    its own free-slot bitmap on this host, so every entity creation
    fails. See the split-out branch for the full rationale.
  - Entity census and per-record dump: walks the 224-byte record table
    every 15s and logs whole records. This is what established that the
    Wall of Wishes already exists as 20 live entities (0x80F18F7E,
    ordinals 0-19, contiguous indices 749-768) while being invisible in
    game, so it is not a spawning problem.
  - Mapped-image dump (client.dump_game_image). destiny2.exe is packed,
    so signatures and structures can only be read out of the decrypted
    image at runtime.
  - Membership probe: decodes the client's own copy of the region table
    (ActivityClient +378152, stride 232) to confirm what it did with an
    advertisement rather than inferring it from the wire.

Server and wire
  - Ambassador slot now names the local member instead of slot 1. The
    client keeps a member it cannot resolve to a peer and then releases
    it by machine id; naming itself makes it take PAH ambassadorship of
    every region entered, which had never been observed before.
  - Activity auth bodies authored at their recovered widths: type 35
    (359 bits), type 18 (386), type 37 (998).
  - Bubble authority re-arm, so re-entering a bubble runs authorised.
  - Destination spawn binding, roster and build_data diagnostics.

Known state at this commit: the Wall of Wishes is still invisible and the
client still opens no socket to the gameplay endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Joe McNally 1 周之前
父節點
當前提交
adaf61ac8b
共有 37 個文件被更改,包括 2675 次插入15 次删除
  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. 124 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  12. 53 0
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  13. 15 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  14. 2 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  15. 2 0
      Sunrise/src/client/runtime/internal.h
  16. 18 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  17. 22 0
      Sunrise/src/core/settings/client/definition.h
  18. 34 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  19. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  20. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  21. 18 0
      Sunrise/src/core/settings/state/activity_default_destination_parser.cpp
  22. 195 3
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
  23. 17 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  24. 69 0
      Sunrise/src/middleware/content/packages/tables/bubble_state_reader.cpp
  25. 9 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp
  26. 21 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.h
  27. 116 5
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  28. 11 2
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  29. 2 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  30. 5 1
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  31. 10 0
      Sunrise/src/state/activity/bubble_authority/definition.h
  32. 10 0
      Sunrise/src/state/activity/bubble_authority/runtime.h
  33. 27 2
      Sunrise/src/state/activity/bubble_authority/transactions/activity_bubble_authority_grant.cpp
  34. 16 0
      Sunrise/src/state/activity/defaults/definition.h
  35. 68 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp
  36. 15 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h
  37. 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

+ 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;

+ 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};
 

+ 116 - 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"
@@ -435,6 +437,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 +450,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 +502,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 +529,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 +627,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;