Просмотр исходного кода

entity roster and reuse/retire paths

stan 3 дней назад
Родитель
Сommit
10aa578acd
100 измененных файлов с 7258 добавлено и 561 удалено
  1. 25 1
      Sunrise/Sunrise.vcxproj
  2. 59 0
      Sunrise/src/client/content/activity/entity_object_type_build.cpp
  3. 9 0
      Sunrise/src/client/content/activity/entity_object_type_build.h
  4. 192 0
      Sunrise/src/client/content/activity/entity_position_profile_build.cpp
  5. 9 0
      Sunrise/src/client/content/activity/entity_position_profile_build.h
  6. 3 1
      Sunrise/src/client/content/investment/investment_refresh.cpp
  7. 5 2
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  8. 45 0
      Sunrise/src/client/hooks/world_objects/world_object_rebind_trace.h
  9. 815 14
      Sunrise/src/client/hooks/world_objects/world_object_registry.cpp
  10. 6 16
      Sunrise/src/middleware/bap/activity_message/activity_entity_authority_parser.cpp
  11. 24 13
      Sunrise/src/middleware/bap/activity_message/activity_entity_slots_decoder.cpp
  12. 21 12
      Sunrise/src/middleware/bap/activity_message/activity_entity_slots_encoder.cpp
  13. 6 6
      Sunrise/src/middleware/bap/activity_message/activity_host_control.h
  14. 11 35
      Sunrise/src/middleware/bap/activity_message/activity_host_control_codec.cpp
  15. 2 1
      Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
  16. 20 4
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp
  17. 34 0
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
  18. 1 1
      Sunrise/src/middleware/bap/activity_message/entity_authority.h
  19. 15 4
      Sunrise/src/middleware/bap/activity_message/entity_slots.h
  20. 13 7
      Sunrise/src/middleware/bap/activity_message/replicate_membership.h
  21. 14 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  22. 249 0
      Sunrise/src/middleware/bap/activity_message/squad_sense_state.cpp
  23. 53 0
      Sunrise/src/middleware/bap/activity_message/squad_sense_state.h
  24. 30 0
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec.h
  25. 203 8
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h
  26. 160 6
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h
  27. 238 0
      Sunrise/src/middleware/content/packages/tables/entity_position_profile_extractor.cpp
  28. 30 0
      Sunrise/src/middleware/content/packages/tables/entity_position_profile_extractor.h
  29. 652 123
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp
  30. 151 1
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.h
  31. 201 0
      Sunrise/src/middleware/gameplay/external/entity_identity_metadata.cpp
  32. 36 0
      Sunrise/src/middleware/gameplay/external/entity_identity_metadata.h
  33. 275 65
      Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp
  34. 56 3
      Sunrise/src/middleware/gameplay/external/external_entity_codec.h
  35. 58 5
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  36. 6 3
      Sunrise/src/server/activity/activity_sdk_squad_runtime.h
  37. 73 0
      Sunrise/src/server/activity/host_runtime.cpp
  38. 21 11
      Sunrise/src/server/activity/host_runtime.h
  39. 9 0
      Sunrise/src/server/activity/host_runtime_internal.h
  40. 28 11
      Sunrise/src/server/activity/host_runtime_scriptable.cpp
  41. 3 1
      Sunrise/src/server/activity/mission/mission_script_lua_squad_api.cpp
  42. 7 2
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  43. 8 4
      Sunrise/src/server/bap/bap_route.cpp
  44. 58 0
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  45. 23 0
      Sunrise/src/server/bap/encrypted/activity_message/definition.h
  46. 12 3
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  47. 71 11
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  48. 2 1
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.h
  49. 81 2
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  50. 5 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  51. 98 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp
  52. 10 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.h
  53. 31 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  54. 8 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  55. 8 0
      Sunrise/src/server/bap/internal.h
  56. 2 1
      Sunrise/src/server/bap/runtime.h
  57. 34 28
      Sunrise/src/server/gameplay/actor_command_policy_lane.cpp
  58. 164 13
      Sunrise/src/server/gameplay/actor_command_policy_transport.cpp
  59. 253 0
      Sunrise/src/server/gameplay/entity_identities.cpp
  60. 71 0
      Sunrise/src/server/gameplay/entity_identities.h
  61. 40 0
      Sunrise/src/server/gameplay/entity_position_profile_provider.cpp
  62. 14 0
      Sunrise/src/server/gameplay/entity_position_profile_provider.h
  63. 4 0
      Sunrise/src/server/gameplay/gameplay_runtime.cpp
  64. 313 90
      Sunrise/src/server/gameplay/peer/peer_established.cpp
  65. 4 0
      Sunrise/src/server/gameplay/peer/peer_out_of_band.cpp
  66. 138 8
      Sunrise/src/server/gameplay/peer/peer_transport.cpp
  67. 41 4
      Sunrise/src/server/gameplay/peer/peer_transport.h
  68. 15 3
      Sunrise/src/server/gameplay/peer/peer_transport_internal.h
  69. 331 0
      Sunrise/src/server/gameplay/squad_entity_retirement.cpp
  70. 42 0
      Sunrise/src/server/gameplay/squad_entity_retirement.h
  71. 7 0
      Sunrise/src/state/activity/bubble_authority/definition.h
  72. 44 5
      Sunrise/src/state/activity/bubble_authority/runtime.h
  73. 118 6
      Sunrise/src/state/activity/bubble_authority/transactions/activity_bubble_authority_grant.cpp
  74. 1 0
      Sunrise/src/state/activity/mission/activity_mission_state.cpp
  75. 1 0
      Sunrise/src/state/activity/mission/definition.h
  76. 12 1
      Sunrise/src/state/build_data/build_data_runtime.cpp
  77. 16 8
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  78. 21 2
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  79. 1 0
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.h
  80. 14 2
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  81. 60 0
      Sunrise/src/state/build_data/cache/records/cache_entity_metadata_records.cpp
  82. 7 0
      Sunrise/src/state/build_data/cache/records/codec.h
  83. 10 0
      Sunrise/src/state/build_data/cache/records/domains.h
  84. 25 3
      Sunrise/src/state/build_data/cache/records/format.h
  85. 3 1
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  86. 9 3
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  87. 3 0
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  88. 2 0
      Sunrise/src/state/build_data/runtime.h
  89. 32 1
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  90. 3 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  91. 2 0
      Sunrise/src/state/gameplay/definition.h
  92. 542 0
      Sunrise/src/state/gameplay/external/entity_identity.cpp
  93. 151 0
      Sunrise/src/state/gameplay/external/entity_identity.h
  94. 126 0
      Sunrise/src/state/gameplay/external/entity_object_types.cpp
  95. 30 0
      Sunrise/src/state/gameplay/external/entity_object_types.h
  96. 146 0
      Sunrise/src/state/gameplay/external/entity_position_profiles.cpp
  97. 49 0
      Sunrise/src/state/gameplay/external/entity_position_profiles.h
  98. 53 0
      Sunrise/src/state/gameplay/external/replication_common_reconciler.cpp
  99. 15 0
      Sunrise/src/state/gameplay/external/replication_common_reconciler.h
  100. 6 0
      Sunrise/src/state/gameplay/external/replication_view_receptor.cpp

+ 25 - 1
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -99,6 +99,7 @@
     <None Include="vendor\lua\UPSTREAM_README" />
   </ItemGroup>
   <ItemGroup>
+    <ClCompile Include="src\state\gameplay\external\entity_object_types.cpp" />
     <ClCompile Include="src\dllmain.cpp" />
     <ClCompile Include="src\core\logging\log.cpp" />
     <ClCompile Include="src\core\logging\snapshot\log_snapshot_ring.cpp" />
@@ -653,6 +654,7 @@
     <ClCompile Include="src\state\build_data\vendors\vendor_expression.cpp" />
     <ClCompile Include="src\client\content\vendors\package_vendor_build.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_entity_metadata_records.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_detail_links.cpp" />
     <ClCompile Include="src\state\build_data\scenarios\scenario_catalog.cpp" />
     <ClCompile Include="src\state\build_data\spawn_sets\spawn_set_catalog.cpp" />
@@ -1260,6 +1262,7 @@
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
     <ClInclude Include="src\client\hooks\world_objects\world_object_registry.h" />
+    <ClInclude Include="src\client\hooks\world_objects\world_object_rebind_trace.h" />
     <ClInclude Include="src\client\ui\movement\movement_panel.h" />
     <ClInclude Include="src\client\runtime\internal.h" />
     <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
@@ -1958,5 +1961,26 @@
     <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
     <ClInclude Include="src\core\runtime\server_clock.h" />
   </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\middleware\bap\activity_message\squad_sense_state.cpp" />
+    <ClInclude Include="src\middleware\bap\activity_message\squad_sense_state.h" />
+  </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\state\gameplay\external\entity_identity.cpp" />
+    <ClCompile Include="src\server\gameplay\entity_identities.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\entity_identity_metadata.cpp" />
+  </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\state\gameplay\external\entity_position_profiles.cpp" />
+    <ClCompile Include="src\state\gameplay\external\rsat_decode_plans.cpp" />
+    <ClCompile Include="src\state\gameplay\external\squad_entity_retirement_store.cpp" />
+    <ClCompile Include="src\server\gameplay\squad_entity_retirement.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\entity_position_profile_extractor.cpp" />
+    <ClCompile Include="src\client\content\activity\entity_position_profile_build.cpp" />
+    <ClCompile Include="src\client\content\activity\entity_object_type_build.cpp" />
+    <ClCompile Include="src\server\gameplay\entity_position_profile_provider.cpp" />
+    <ClInclude Include="src\state\gameplay\external\entity_position_profiles.h" />
+    <ClInclude Include="src\server\gameplay\entity_position_profile_provider.h" />
+  </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 59 - 0
Sunrise/src/client/content/activity/entity_object_type_build.cpp

@@ -0,0 +1,59 @@
+#include "entity_object_type_build.h"
+
+#include <algorithm>
+#include <cstring>
+namespace sunrise::client::content::activity::entity_object_types {
+namespace {
+namespace reader = middleware::content::packages::reader;
+namespace types = state::gameplay::entity_object_types;
+/** Package classes and offsets define the reciprocal RSAT/class join. */
+constexpr std::uint32_t kRsatClass = 0x80809BB6U, kDefinitionClass = 0x80809C0FU;
+constexpr std::size_t kReverseOffset = 8, kForwardOffset = 136, kObjectTypeOffset = 150;
+struct Context {
+    const reader::Source& source;
+    reader::Scratch& scratch;
+    types::Rows rows;
+};
+/** The native class definition stores its RSAT at 136 and its object type at 150. */
+bool collect(void* opaque, std::uint32_t rsat) noexcept {
+    auto& context = *static_cast<Context*>(opaque);
+    try {
+        if (context.rows.size() >= types::kMaximumRows) return false;
+        std::vector<std::byte> resource, definition;
+        std::uint32_t cls{}, backlink{}, forward{};
+        if (!reader::read_tag(context.source, context.scratch, rsat, resource, cls)
+            || cls != kRsatClass || resource.size() < kReverseOffset + sizeof(std::uint32_t))
+            return false;
+        std::memcpy(&backlink, resource.data() + kReverseOffset, sizeof backlink);
+        if (!backlink || backlink == 0xFFFFFFFFU
+            || !reader::read_tag(context.source, context.scratch, backlink, definition, cls)
+            || cls != kDefinitionClass || definition.size() <= kObjectTypeOffset)
+            return false;
+        std::memcpy(&forward, definition.data() + kForwardOffset, sizeof forward);
+        const auto objectType = std::to_integer<std::uint8_t>(definition[kObjectTypeOffset]);
+        if (forward != rsat || objectType > types::kMaximumObjectType) return false;
+        context.rows.push_back({rsat, backlink, objectType});
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+} // namespace
+/** No partial or ambiguous package-class catalogue is published. */
+bool build(const reader::Source& source,
+           reader::Scratch& scratch,
+           const types::Fingerprint& fingerprint) noexcept {
+    try {
+        Context context{source, scratch, {}};
+        reader::ScanResult result{};
+        if (!reader::scan_class(source.directory, kRsatClass, &collect, &context, result))
+            return false;
+        std::sort(context.rows.begin(), context.rows.end(), [](const auto& a, const auto& b) {
+            return a.rsatTag < b.rsatTag;
+        });
+        return types::publish(std::move(context.rows), fingerprint);
+    } catch (...) {
+        return false;
+    }
+}
+} // namespace sunrise::client::content::activity::entity_object_types

+ 9 - 0
Sunrise/src/client/content/activity/entity_object_type_build.h

@@ -0,0 +1,9 @@
+#pragma once
+#include "../../../middleware/content/packages/reader/reader.h"
+#include "../../../state/gameplay/external/entity_object_types.h"
+namespace sunrise::client::content::activity::entity_object_types {
+/** Scans installed RSATs and validates every reverse and forward class link. */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source&,
+                         middleware::content::packages::reader::Scratch&,
+                         const state::gameplay::entity_object_types::Fingerprint&) noexcept;
+} // namespace sunrise::client::content::activity::entity_object_types

+ 192 - 0
Sunrise/src/client/content/activity/entity_position_profile_build.cpp

@@ -0,0 +1,192 @@
+#include "entity_position_profile_build.h"
+
+#include <algorithm>
+#include <cstdio>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <map>
+#include <set>
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/named_tags.h"
+#include "../../../middleware/content/packages/tables/entity_position_profile_extractor.h"
+#include "../../../state/build_data/runtime.h"
+#include "../../../state/content_manifest/content_manifest_state_runtime.h"
+#include "../../../state/gameplay/external/entity_position_profiles.h"
+#include "entity_object_type_build.h"
+
+namespace sunrise::client::content::activity::entity_position_profiles {
+namespace {
+namespace profiles = state::gameplay::entity_position_profiles;
+namespace extractor = middleware::content::packages::position_profiles;
+namespace named = middleware::content::packages::named_tags;
+namespace reader = middleware::content::packages::reader;
+using Blob = std::vector<std::byte>;
+struct Name final {
+    extractor::NamedTag value;
+    std::uint32_t patch{};
+    bool conflict{};
+};
+struct Names final {
+    std::map<std::string, Name> rows;
+    bool base{};
+    std::uint32_t patch{};
+};
+struct Context final {
+    const reader::Source& source;
+    reader::Scratch& scratch;
+};
+/** The manifest identity includes the installed package builds. */
+bool fingerprint(void* opaque, const state::content_manifest::View& view) noexcept {
+    std::copy(view.buildFingerprint.begin(),
+              view.buildFingerprint.end(),
+              static_cast<profiles::Fingerprint*>(opaque)->begin());
+    return true;
+}
+/** Same-patch name conflicts cannot select an arbitrary package. */
+bool collect_name(void* opaque, const named::Entry& entry) noexcept {
+    try {
+        auto& names = *static_cast<Names*>(opaque);
+        if (entry.classId != 0x808091DE && entry.classId != 0x80809994) return true;
+        const std::string name(entry.name.data(), entry.nameLength);
+        auto found = names.rows.find(name);
+        if (found == names.rows.end() || found->second.patch < names.patch)
+            names.rows[name] = {{name, entry.tag, entry.classId, names.base}, names.patch, false};
+        else if (found->second.patch == names.patch
+                 && (found->second.value.tag != entry.tag
+                     || found->second.value.classId != entry.classId))
+            found->second.conflict = true;
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+template <class T> T value(const Blob& bytes, std::size_t offset) {
+    if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) throw 0;
+    T result{};
+    std::memcpy(&result, bytes.data() + offset, sizeof result);
+    return result;
+}
+/** Metadata references merge patches within a family before cross-family conflict checks. */
+bool inventory(std::wstring_view directory,
+               std::vector<extractor::NamedTag>& names,
+               std::vector<extractor::KeyTag>& keys) {
+    struct File final {
+        std::filesystem::path path;
+        std::wstring family;
+        std::uint32_t patch{};
+    };
+    std::vector<File> files;
+    for (const auto& file : std::filesystem::directory_iterator(directory)) {
+        if (!file.is_regular_file() || file.path().extension() != L".pkg") continue;
+        const auto stem = file.path().stem().wstring();
+        const auto separator = stem.rfind(L'_');
+        if (separator == std::wstring::npos) return false;
+        std::size_t consumed{};
+        const auto patch = std::stoul(stem.substr(separator + 1), &consumed);
+        if (consumed != stem.size() - separator - 1 || patch > UINT32_MAX) return false;
+        files.push_back(
+            {file.path(), stem.substr(0, separator), static_cast<std::uint32_t>(patch)});
+    }
+    if (files.empty()) return false;
+    std::sort(files.begin(), files.end(), [](const File& a, const File& b) {
+        return a.family < b.family || (a.family == b.family && a.patch < b.patch);
+    });
+    Names collected;
+    std::map<std::wstring, std::map<std::uint64_t, std::pair<std::uint32_t, std::uint32_t>>>
+        families;
+    for (const auto& file : files) {
+        collected.base = file.family.find(L"_activities_") == std::wstring::npos;
+        collected.patch = file.patch;
+        named::Result result{};
+        if (!named::extract_file(file.path.c_str(), &collect_name, &collected, result))
+            return false;
+        std::ifstream stream(file.path, std::ios::binary | std::ios::ate);
+        const auto length = stream.tellg();
+        Blob header(0x180);
+        stream.seekg(0);
+        if (!stream.read(reinterpret_cast<char*>(header.data()), header.size())
+            || value<std::uint16_t>(header, 0) != 38)
+            return false;
+        /** Beta metadata has no hash64 reference directory at offset 48. */
+        if (value<std::uint8_t>(header, 0x1A) == 0) continue;
+        if (value<std::uint8_t>(header, 0x1A) != 1) return false;
+        const auto offset = value<std::uint32_t>(header, 0xF0),
+                   size = value<std::uint32_t>(header, 0xF4);
+        if (size == 0) continue;
+        if (size > 64 * 1024 * 1024
+            || static_cast<std::uint64_t>(offset) + size > static_cast<std::uint64_t>(length))
+            return false;
+        Blob metadata(size);
+        stream.seekg(offset);
+        if (!stream.read(reinterpret_cast<char*>(metadata.data()), size)) return false;
+        if (metadata.size() < 64) continue;
+        std::vector<std::size_t> offsets;
+        if (!extractor::array(metadata, 48, 16, 0x80809D02, offsets)) return false;
+        auto& rows = families[file.family];
+        for (auto member : offsets)
+            rows[value<std::uint64_t>(metadata, member)] = {
+                value<std::uint32_t>(metadata, member + 8),
+                value<std::uint32_t>(metadata, member + 12)};
+    }
+    std::map<std::uint64_t, std::set<std::pair<std::uint32_t, std::uint32_t>>> merged;
+    for (const auto& family : families)
+        for (const auto& row : family.second)
+            merged[row.first].insert(row.second);
+    for (const auto& row : merged)
+        if (row.second.size() == 1)
+            keys.push_back({row.first, row.second.begin()->first, row.second.begin()->second});
+    for (const auto& row : collected.rows)
+        if (!row.second.conflict) names.push_back(row.second.value);
+    return true;
+}
+/** Class checks apply to every live tag reached by the extraction. */
+bool read(void* opaque, std::uint32_t tag, std::uint32_t expected, Blob& bytes) noexcept {
+    auto& context = *static_cast<Context*>(opaque);
+    std::uint32_t actual{};
+    return reader::read_tag(context.source, context.scratch, tag, bytes, actual)
+           && actual == expected;
+}
+} // namespace
+bool ready() noexcept {
+    profiles::Fingerprint identity{};
+    if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) return false;
+    const bool positions = profiles::confirm(identity);
+    const bool objects = state::gameplay::entity_object_types::confirm(identity);
+    return positions && objects;
+}
+/** The package pass confirms shared-cache rows or publishes a complete extraction. */
+bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
+    try {
+        profiles::Fingerprint identity{};
+        if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) return false;
+        const bool positions = profiles::confirm(identity);
+        const bool objects = state::gameplay::entity_object_types::confirm(identity);
+        if (positions && objects) return true;
+        if (!objects && !entity_object_types::build(source, scratch, identity)) return false;
+        if (positions) {
+            state::build_data::invalidate_cache();
+            return true;
+        }
+        profiles::reset();
+        std::vector<extractor::NamedTag> names;
+        std::vector<extractor::KeyTag> keys;
+        profiles::Rows rows;
+        Context context{source, scratch};
+        if (!inventory(source.directory, names, keys)
+            || !extractor::extract(names, keys, &read, &context, rows))
+            return false;
+        const auto count = rows.size();
+        const bool published = profiles::publish(std::move(rows), identity);
+        if (published) state::build_data::invalidate_cache();
+        char line[160]{};
+        (void)std::snprintf(
+            line, sizeof line, "entity_position_profiles source=packages rows=%zu", count);
+        core::log::write(core::log::Channel::client, core::log::Level::info, line);
+        return published;
+    } catch (...) {
+        return false;
+    }
+}
+} // namespace sunrise::client::content::activity::entity_position_profiles

+ 9 - 0
Sunrise/src/client/content/activity/entity_position_profile_build.h

@@ -0,0 +1,9 @@
+#pragma once
+#include "../../../middleware/content/packages/reader/reader.h"
+namespace sunrise::client::content::activity::entity_position_profiles {
+/** Checks the content fingerprint before the package pass skips extraction. */
+[[nodiscard]] bool ready() noexcept;
+/** Confirms restored shared-cache rows or extracts profiles through the package reader. */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
+                         middleware::content::packages::reader::Scratch& scratch) noexcept;
+} // namespace sunrise::client::content::activity::entity_position_profiles

+ 3 - 1
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -4,6 +4,7 @@
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/runtime/runtime.h"
+#include "../activity/entity_position_profile_build.h"
 #include "../items/packages/build.h"
 #include "internal.h"
 #include "runtime.h"
@@ -20,7 +21,8 @@ SRWLOCK g_refreshLock{SRWLOCK_INIT};
  * extracted once the others finish, and the cache can then never be written.
  */
 [[nodiscard]] bool ready() noexcept {
-    return state::build_data::named_catalog_ready() && state::build_data::item_definitions_ready()
+    return activity::entity_position_profiles::ready() && state::build_data::named_catalog_ready()
+           && state::build_data::item_definitions_ready()
            && state::build_data::collectible_definitions_ready()
            && state::build_data::material_requirement_sets_ready()
            && state::build_data::configured_item_details_ready()

+ 5 - 2
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -18,6 +18,7 @@
 #include "../../../../state/runtime/runtime.h"
 #include "../../../memory/current_process_memory.h"
 #include "../../../targets/game.h"
+#include "../../activity/entity_position_profile_build.h"
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
@@ -29,7 +30,8 @@ namespace {
 
 /** @return True when every domain owned by the package pass is published. */
 [[nodiscard]] bool package_domains_ready() noexcept {
-    return state::build_data::item_definitions_ready()
+    return content::activity::entity_position_profiles::ready()
+           && state::build_data::item_definitions_ready()
            && state::build_data::collectible_definitions_ready()
            && state::build_data::material_requirement_sets_ready()
            && state::build_data::configured_item_details_ready()
@@ -84,13 +86,14 @@ bool build() noexcept {
     // storage. Both are independent of the item table, so a failure here leaves it alone.
     {
         const reader::Source packageSource{directory.chars.data(), &keys};
+        (void)content::activity::entity_position_profiles::build(packageSource, storage.scratch);
         (void)content::scenarios::build(packageSource, storage.scratch);
         (void)content::spawn_sets::build(packageSource, storage.scratch);
         (void)content::hash_names::build(packageSource, storage.scratch);
     }
     if (root_domains_ready()) {
         SecureZeroMemory(&keys, sizeof keys);
-        return true;
+        return package_domains_ready();
     }
     reason = "tag";
     std::array<std::uint32_t, kContainerCandidates> candidates{};

+ 45 - 0
Sunrise/src/client/hooks/world_objects/world_object_rebind_trace.h

@@ -0,0 +1,45 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::hooks::world_objects::trace {
+
+/** Associates observations only with one native rebind iterator and its direct calls. */
+struct Association final {
+    std::uintptr_t iterator{};
+    std::uint32_t actor{0xFFFFFFFFU}, owner{0xFFFFFFFFU};
+    bool active{};
+
+    /** Accepts only the direct iterator call made by the native rebind pass. */
+    bool visit(std::uintptr_t caller, std::uintptr_t expected, std::uintptr_t value) noexcept {
+        if (!active || caller != expected || (iterator != 0 && iterator != value)) return false;
+        iterator = value;
+        actor = owner = 0xFFFFFFFFU;
+        return true;
+    }
+
+    /** Accepts only calls for the current actor's owner on the direct rebind stack. */
+    bool
+    matches(std::uintptr_t caller, std::uintptr_t expected, std::uint32_t value) const noexcept {
+        return active && actor != 0xFFFFFFFFU && caller == expected && owner == value;
+    }
+};
+
+/** Restores the enclosing thread-local trace when a nested native call returns. */
+template <typename Value> class Scope final {
+public:
+    Scope(Value*& slot, Value& current) noexcept : slot_(slot), prior_(slot) {
+        slot_ = &current;
+    }
+    ~Scope() {
+        slot_ = prior_;
+    }
+    Scope(const Scope&) = delete;
+    Scope& operator=(const Scope&) = delete;
+
+private:
+    Value*& slot_;
+    Value* prior_;
+};
+
+} // namespace sunrise::client::hooks::world_objects::trace

+ 815 - 14
Sunrise/src/client/hooks/world_objects/world_object_registry.cpp

@@ -2,6 +2,7 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <atomic>
 #include <cstddef>
@@ -19,6 +20,7 @@
 #include "../../patterns/image_scan.h"
 #include "../../patterns/registry.h"
 #include "../../patterns/signature_text.h"
+#include "world_object_rebind_trace.h"
 
 namespace sunrise::client::hooks::world_objects {
 namespace {
@@ -92,6 +94,62 @@ using Instantiate = std::uint32_t*(__fastcall*)(std::uint32_t*,
 using Destroy = std::uintptr_t(__fastcall*)(std::uint32_t) noexcept;
 using Allocate = Instantiate;
 using LogicalDestroy = std::uintptr_t(__fastcall*)(std::uint32_t) noexcept;
+using CreateEntity = bool(__fastcall*)(void*, const void*, std::uint32_t, std::uint32_t);
+using EntityPolicy = std::uint32_t(__fastcall*)(void*, std::uint32_t);
+using PurgeEntities = void(__fastcall*)(void*,
+                                        std::int32_t,
+                                        const std::uint32_t*,
+                                        std::uint32_t*,
+                                        std::uint32_t*,
+                                        std::uint32_t*,
+                                        std::uint8_t);
+
+/** Unique native entity-create, purge, and glue-token mapping entries. */
+constexpr std::string_view kCreateEntityText =
+    "48 89 5C 24 08 48 89 74 24 18 55 57 41 54 41 56 41 57 48 8D AC 24 ? ? ? ? 48 81 EC ? ? ? ? 48 "
+    "8B 05 ? ? ? ?";
+constexpr std::string_view kPurgeEntitiesText =
+    "40 55 56 57 41 54 41 55 41 56 41 57 48 8D 6C 24 ? 48 81 EC ? ? ? ? 48 8B 05 ? ? ? ? 48 33 C4 "
+    "48 89 45 ? 48 8B 75 ? 44 8B FA 4C 8B 75 ? 48 8B F9 BA 00 20 00 00";
+constexpr std::string_view kGlueMappingText =
+    "81 E1 FF 1F 00 00 0F AF 0D ? ? ? ? 8B C1 48 03 05 ? ? ? ? 89 10 C3";
+constexpr auto kCreateEntityPattern =
+    signature<signature_length(kCreateEntityText)>(kCreateEntityText);
+constexpr auto kPurgeEntitiesPattern =
+    signature<signature_length(kPurgeEntitiesText)>(kPurgeEntitiesText);
+constexpr auto kGlueMappingPattern =
+    signature<signature_length(kGlueMappingText)>(kGlueMappingText);
+/** Unique record-pool reference and native policy entry. */
+constexpr std::string_view kEntityPoolText =
+    "48 8D 04 5B 48 0F BF 84 46 14 01 00 00 48 6B D8 70 48 8D 05 ? ? ? ? 48 03 D8 83 7B 48 FF";
+constexpr std::string_view kEntityPolicyText =
+    "40 55 57 48 8D AC 24 ? ? ? ? 48 81 EC ? ? ? ? 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 ? ? ? ? 33 "
+    "FF 83 FA FF";
+constexpr auto kEntityPoolPattern = signature<signature_length(kEntityPoolText)>(kEntityPoolText);
+constexpr auto kEntityPolicyPattern =
+    signature<signature_length(kEntityPolicyText)>(kEntityPolicyText);
+/** Entity indices occupy the low thirteen bits; the native mask contains 256 words. */
+constexpr std::uint32_t kEntityIndexMask = 0x1FFFU;
+constexpr std::size_t kEntityMaskWords = 256;
+/** The simulation view stores its shared replication epoch at this byte. */
+constexpr std::size_t kViewEpochOffset = 53284;
+/** Each view index maps to a signed record number in a six-byte row. */
+constexpr std::size_t kViewMapOffset = 276, kViewMapStride = 6;
+/** Per-record diagnostics are bounded; mask-word logging retains the complete selection. */
+constexpr std::size_t kPurgeTraceCapacity = 64;
+/** Native global records have a fixed stride, pool bound, and view occupancy mask. */
+constexpr std::size_t kEntityRecordStride = 112, kEntityRecordCapacity = 1024;
+constexpr std::size_t kViewOccupiedOffset = 50464, kEntityFlagsOffset = 80;
+
+struct EntityRecordPrefix final {
+    std::uint8_t type{}, lifecycle{};
+    std::uint16_t cell{};
+    std::uint32_t glue{}, token{}, parent{};
+};
+struct PolicyTrace final {
+    std::uint32_t glue{}, policy{};
+    bool reported{};
+};
 
 struct HandlePair final {
     std::uint32_t generation{kNone};
@@ -150,11 +208,20 @@ std::array<std::uint32_t, kDynamicHandleCapacity> g_dynamicHandles{};
 std::array<std::uint64_t, kDynamicHandleCapacity> g_dynamicOrdinals{};
 std::atomic_uint32_t g_activeCalls{};
 std::atomic_bool g_accepting{};
-std::array<hooking::detour::Handle, 4> g_handles{};
+std::array<hooking::detour::Handle, 15> g_handles{};
 std::atomic<Instantiate> g_instantiateOriginal{nullptr};
 std::atomic<Destroy> g_destroyOriginal{nullptr};
 std::atomic<Allocate> g_allocateOriginal{nullptr};
 std::atomic<LogicalDestroy> g_logicalDestroyOriginal{nullptr};
+std::atomic<CreateEntity> g_createEntityOriginal{nullptr};
+std::atomic<PurgeEntities> g_purgeEntitiesOriginal{nullptr};
+std::atomic<EntityPolicy> g_entityPolicyOriginal{nullptr};
+std::uintptr_t g_entityRecordBase{};
+std::array<PolicyTrace, kPurgeTraceCapacity> g_policyTrace{};
+const std::uintptr_t* g_glueBaseStorage{};
+const std::uint32_t* g_glueStrideStorage{};
+thread_local std::uint32_t t_entityGlue{kNone};
+thread_local std::uint32_t t_entityNetwork{kNone};
 std::size_t g_logicalDestroyReportBudget{kIdentityReportBudget};
 
 /** Set while this thread is inside the instantiate detour, whose allocation is already reported. */
@@ -168,6 +235,63 @@ const std::uintptr_t* g_datumBaseStorage{};
 std::uintptr_t g_moduleBase{};
 const std::uint32_t* g_datumStrideStorage{};
 
+/** Native diagnostic entry points retain their original Windows x64 return registers. */
+using Observer = std::uintptr_t(__fastcall*)(void*, void*, const std::uint8_t*);
+using Rebind = std::uintptr_t(__fastcall*)();
+using IteratorValue = std::uintptr_t(__fastcall*)(void*, std::uint32_t*);
+using SourceRef = std::uint8_t(__fastcall*)(std::uint32_t, void*);
+using ResolveSource = std::uint8_t(__fastcall*)(const void*, void*);
+using Predicate = std::uintptr_t(__fastcall*)(std::uint32_t);
+using BindActor = void(__fastcall*)(void*, std::uint32_t);
+using Teardown = std::uintptr_t(__fastcall*)(void*);
+using ActorOwner = std::uint32_t*(__fastcall*)(std::uint32_t*, std::uint32_t);
+using SliceManager = void*(__fastcall*)();
+using CurrentBubble = std::uint32_t*(__fastcall*)(void*, std::uint32_t*);
+std::atomic<Observer> g_observerOriginal{};
+std::atomic<Rebind> g_rebindOriginal{};
+std::atomic<IteratorValue> g_iteratorOriginal{};
+std::atomic<SourceRef> g_sourceOriginal{};
+std::atomic<ResolveSource> g_resolveSourceOriginal{};
+std::atomic<Predicate> g_predicateOriginal{};
+std::atomic<BindActor> g_bindOriginal{};
+std::atomic<Teardown> g_teardownOriginal{};
+ActorOwner g_actorOwner{};
+const std::uintptr_t* g_actorBaseStorage{};
+const std::uint32_t* g_actorStrideStorage{};
+SliceManager g_sliceManager{};
+CurrentBubble g_currentBubble{};
+/** Rebind callers are checked against their resolved function-relative return offsets. */
+std::uintptr_t g_rebindAddress{}, g_observerAddress{};
+/** Each diagnostic run admits 128 passes and 4096 actor/member rows. */
+constexpr std::uint32_t kRebindPassBudget = 128, kRebindRowBudget = 4096;
+std::atomic_uint32_t g_rebindPasses{}, g_rebindRows{}, g_observerReports{};
+/** Native squad collections contain at most 80 generation-checked eight-byte actor references. */
+constexpr std::size_t kSquadMembers = 80, kSquadCountOffset = 0x314, kSquadRefsOffset = 0x318;
+struct ActorSource final {
+    std::uint32_t key{kNone};
+    std::uint8_t type{0xFF}, padding{};
+    std::uint16_t index{0xFFFF};
+};
+static_assert(sizeof(ActorSource) == 8);
+struct RebindTrace final {
+    trace::Association association{};
+    ActorSource source{};
+    void* activity{};
+    std::uint32_t bubble{kNone}, pass{}, visited{}, bound{};
+    int sourceResult{-1}, resolveResult{-1}, predicate{-1};
+    std::int32_t countBefore{-1}, countAfter{-1};
+    std::array<std::uint64_t, 2> bindingBefore{}, bindingAfter{};
+    bool bindCalled{}, bindingBeforeKnown{}, bindingAfterKnown{};
+    std::uint32_t flags{}, flagsAfter{};
+    bool flagsKnown{}, flagsAfterKnown{}, sourceKnown{};
+};
+thread_local RebindTrace* t_rebindTrace{};
+struct ObserverTrace final {
+    void* activity{};
+    std::uint32_t bubble{kNone};
+};
+thread_local ObserverTrace* t_observerTrace{};
+
 /** Counts one in-flight detour call, so teardown can wait for the hooks to drain. */
 class ActiveCall final {
 public:
@@ -574,6 +698,7 @@ __declspec(noinline) std::uint32_t* __fastcall instantiate(std::uint32_t* output
     return flags;
 }
 
+/** Associates an allocation with its native creator and any active entity identity. */
 void report_allocation(std::uint32_t handle, std::uintptr_t caller) noexcept {
     // The allocation site alone does not say which subsystem asked. Unwind names the frames.
     std::array<void*, kFrameCount> frames{};
@@ -588,6 +713,21 @@ void report_allocation(std::uint32_t handle, std::uintptr_t caller) noexcept {
     if (!report) {
         return;
     }
+    if (t_entityGlue != kNone) {
+        std::array<char, 192> identity{};
+        const int length = std::snprintf(
+            identity.data(),
+            identity.size(),
+            "ev=world_object stage=entity_create handle=0x%08X glue=0x%08X network=0x%08X slot=%u",
+            handle,
+            t_entityGlue,
+            t_entityNetwork,
+            t_entityNetwork & kEntityIndexMask);
+        if (length > 0)
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {identity.data(), static_cast<std::size_t>(length)});
+    }
     std::array<char, 192> line{};
     int written = std::snprintf(
         line.data(),
@@ -616,6 +756,226 @@ void report_allocation(std::uint32_t handle, std::uintptr_t caller) noexcept {
     }
 }
 
+/** Carries native entity identity into the allocator trace without changing creation. */
+__declspec(noinline) bool __fastcall create_entity(void* definition,
+                                                   const void* data,
+                                                   std::uint32_t glue,
+                                                   std::uint32_t parent) {
+    ActiveCall active;
+    const auto original = g_createEntityOriginal.load(std::memory_order_acquire);
+    const auto savedGlue = t_entityGlue;
+    const auto savedNetwork = t_entityNetwork;
+    t_entityGlue = glue;
+    t_entityNetwork = kNone;
+    if (glue != kNone && g_glueBaseStorage != nullptr && g_glueStrideStorage != nullptr) {
+        const auto row =
+            *g_glueBaseStorage
+            + static_cast<std::uintptr_t>(*g_glueStrideStorage) * (glue & kEntityIndexMask);
+        static_cast<void>(memory::read_current_process(
+            nullptr,
+            row,
+            std::span(reinterpret_cast<std::byte*>(&t_entityNetwork), sizeof(t_entityNetwork))));
+    }
+    const bool result = original != nullptr && original(definition, data, glue, parent);
+    t_entityGlue = savedGlue;
+    t_entityNetwork = savedNetwork;
+    return result;
+}
+
+/** Reports native policy changes for the bounded glue slots under investigation. */
+__declspec(noinline) std::uint32_t __fastcall entity_policy(void* definition, std::uint32_t glue) {
+    ActiveCall active;
+    const auto original = g_entityPolicyOriginal.load(std::memory_order_acquire);
+    const std::uint32_t policy = original != nullptr ? original(definition, glue) : 0;
+    if (!g_accepting.load(std::memory_order_acquire) || glue == kNone
+        || (glue & kEntityIndexMask) >= kPurgeTraceCapacity || g_glueBaseStorage == nullptr
+        || g_glueStrideStorage == nullptr)
+        return policy;
+    std::uint32_t token = kNone;
+    const auto row =
+        *g_glueBaseStorage
+        + static_cast<std::uintptr_t>(*g_glueStrideStorage) * (glue & kEntityIndexMask);
+    if (!memory::read_current_process(
+            nullptr, row, std::span(reinterpret_cast<std::byte*>(&token), sizeof(token)))
+        || token == kNone || (token & kEntityIndexMask) >= kPurgeTraceCapacity)
+        return policy;
+    AcquireSRWLockExclusive(&g_lock);
+    PolicyTrace& previous = g_policyTrace[token & kEntityIndexMask];
+    const bool changed = !previous.reported || previous.glue != glue || previous.policy != policy;
+    previous = {glue, policy, true};
+    ReleaseSRWLockExclusive(&g_lock);
+    if (changed) {
+        std::array<char, 160> line{};
+        const int length = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=world_object stage=entity_policy slot=%u token=0x%08X glue=0x%08X value=%u",
+            token & kEntityIndexMask,
+            token,
+            glue,
+            policy);
+        if (length > 0)
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(length)});
+    }
+    return policy;
+}
+
+/** Logs unselected records without invoking native policy or mutating their state. */
+void report_unselected_records(void* view,
+                               const std::array<std::uint32_t, kEntityMaskWords>& mask,
+                               std::uint8_t epoch) noexcept {
+    for (std::uint32_t slot = 0; g_entityRecordBase != 0 && slot < kPurgeTraceCapacity; ++slot) {
+        if ((mask[slot / 32U] & (1U << (slot % 32U))) != 0) continue;
+        std::uint32_t occupied = 0;
+        if (!memory::read_current_process(
+                nullptr,
+                reinterpret_cast<std::uintptr_t>(view) + kViewOccupiedOffset
+                    + sizeof(occupied) * (slot / 32U),
+                std::span(reinterpret_cast<std::byte*>(&occupied), sizeof(occupied)))
+            || (occupied & (1U << (slot % 32U))) == 0)
+            continue;
+        std::int16_t ordinal = -1;
+        if (!memory::read_current_process(
+                nullptr,
+                reinterpret_cast<std::uintptr_t>(view) + kViewMapOffset + kViewMapStride * slot,
+                std::span(reinterpret_cast<std::byte*>(&ordinal), sizeof(ordinal)))
+            || ordinal < 0 || static_cast<std::size_t>(ordinal) >= kEntityRecordCapacity)
+            continue;
+        EntityRecordPrefix record{};
+        std::uint16_t flags = 0;
+        const auto address =
+            g_entityRecordBase + kEntityRecordStride * static_cast<std::size_t>(ordinal);
+        if (!memory::read_current_process(
+                nullptr, address, std::span(reinterpret_cast<std::byte*>(&record), sizeof(record)))
+            || !memory::read_current_process(
+                nullptr,
+                address + kEntityFlagsOffset,
+                std::span(reinterpret_cast<std::byte*>(&flags), sizeof(flags))))
+            continue;
+        std::array<char, 240> line{};
+        const int length =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=world_object stage=entity_unselected epoch=%u slot=%u type=%u "
+                          "lifecycle=0x%02X cell=%u flags=0x%04X parent=0x%08X glue=0x%08X",
+                          static_cast<unsigned>(epoch),
+                          slot,
+                          static_cast<unsigned>(record.type),
+                          static_cast<unsigned>(record.lifecycle),
+                          static_cast<unsigned>(record.cell),
+                          static_cast<unsigned>(flags),
+                          record.parent,
+                          record.glue);
+        if (length > 0)
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(length)});
+    }
+}
+
+/** Logs the mask the native purge actually consumes and preserves all seven arguments. */
+__declspec(noinline) void __fastcall purge_entities(void* view,
+                                                    std::int32_t reason,
+                                                    const std::uint32_t* mask,
+                                                    std::uint32_t* work0,
+                                                    std::uint32_t* work1,
+                                                    std::uint32_t* work2,
+                                                    std::uint8_t epoch) {
+    ActiveCall active;
+    const auto original = g_purgeEntitiesOriginal.load(std::memory_order_acquire);
+    std::array<std::uint32_t, kEntityMaskWords> words{};
+    const bool readable = memory::read_current_process(
+        nullptr, reinterpret_cast<std::uintptr_t>(mask), std::as_writable_bytes(std::span(words)));
+    if (readable && g_accepting.load(std::memory_order_acquire))
+        report_unselected_records(view, words, epoch);
+    std::array<std::uint32_t, kPurgeTraceCapacity> selected{};
+    std::array<std::int16_t, kPurgeTraceCapacity> mappedBefore{};
+    std::size_t selectedCount = 0;
+    for (std::uint32_t slot = 0;
+         readable && slot <= kEntityIndexMask && selectedCount < selected.size();
+         ++slot) {
+        if ((words[slot / 32U] & (1U << (slot % 32U))) == 0) continue;
+        selected[selectedCount] = slot;
+        mappedBefore[selectedCount] = -1;
+        static_cast<void>(memory::read_current_process(
+            nullptr,
+            reinterpret_cast<std::uintptr_t>(view) + kViewMapOffset + kViewMapStride * slot,
+            std::span(reinterpret_cast<std::byte*>(&mappedBefore[selectedCount]),
+                      sizeof(std::int16_t))));
+        ++selectedCount;
+    }
+    std::uint8_t before = 0;
+    static_cast<void>(memory::read_current_process(
+        nullptr,
+        reinterpret_cast<std::uintptr_t>(view) + kViewEpochOffset,
+        std::span(reinterpret_cast<std::byte*>(&before), sizeof(before))));
+    if (readable && g_accepting.load(std::memory_order_acquire)) {
+        for (std::size_t word = 0; word < words.size(); ++word) {
+            if (words[word] == 0) continue;
+            std::array<char, 192> line{};
+            const int length = std::snprintf(line.data(),
+                                             line.size(),
+                                             "ev=world_object stage=entity_purge epoch=%u prior=%u "
+                                             "reason=%d word=%zu bits=0x%08X",
+                                             static_cast<unsigned>(epoch),
+                                             static_cast<unsigned>(before),
+                                             reason,
+                                             word,
+                                             words[word]);
+            if (length > 0)
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::debug,
+                                 {line.data(), static_cast<std::size_t>(length)});
+        }
+    }
+    if (original != nullptr) original(view, reason, mask, work0, work1, work2, epoch);
+    for (std::size_t index = 0;
+         index < selectedCount && g_accepting.load(std::memory_order_acquire);
+         ++index) {
+        std::int16_t mappedAfter = -1;
+        static_cast<void>(memory::read_current_process(
+            nullptr,
+            reinterpret_cast<std::uintptr_t>(view) + kViewMapOffset
+                + kViewMapStride * selected[index],
+            std::span(reinterpret_cast<std::byte*>(&mappedAfter), sizeof(mappedAfter))));
+        std::array<char, 192> line{};
+        const int length = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=world_object stage=entity_purge_slot epoch=%u slot=%u before=%d after=%d view=%p",
+            static_cast<unsigned>(epoch),
+            selected[index],
+            static_cast<int>(mappedBefore[index]),
+            static_cast<int>(mappedAfter),
+            view);
+        if (length > 0)
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(length)});
+    }
+    std::uint8_t after = before;
+    static_cast<void>(memory::read_current_process(
+        nullptr,
+        reinterpret_cast<std::uintptr_t>(view) + kViewEpochOffset,
+        std::span(reinterpret_cast<std::byte*>(&after), sizeof(after))));
+    if (g_accepting.load(std::memory_order_acquire)) {
+        std::array<char, 160> line{};
+        const int length = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=world_object stage=entity_purge result=returned epoch=%u current=%u readable=%u",
+            static_cast<unsigned>(epoch),
+            static_cast<unsigned>(after),
+            static_cast<unsigned>(readable));
+        if (length > 0)
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(length)});
+    }
+}
+
 /** Calls native allocation first, then reports it when the instantiate hook did not ask. */
 __declspec(noinline) std::uint32_t* __fastcall allocate(std::uint32_t* output,
                                                         const void* entry,
@@ -709,6 +1069,322 @@ __declspec(noinline) std::uintptr_t __fastcall destroy(std::uint32_t handle) noe
     return g_activeCalls.load(std::memory_order_acquire) == 0;
 }
 
+/** Reads flags only when the datum still names the observed full handle. */
+bool trace_flags(std::uint32_t handle, std::uint32_t& flags) noexcept {
+    std::uintptr_t base{};
+    std::uint32_t stride{}, self{};
+    if (handle == kNone || !read_value(g_datumBaseStorage, base)
+        || !read_value(g_datumStrideStorage, stride) || !base || stride < 16)
+        return false;
+    const auto datum = base + static_cast<std::uintptr_t>(stride) * (handle & kEntityIndexMask);
+    return read_value(reinterpret_cast<const std::uint32_t*>(datum + 12), self) && self == handle
+           && read_value(reinterpret_cast<const std::uint32_t*>(datum + 4), flags);
+}
+
+/** Logs a bounded line without trusting the formatter's required buffer length. */
+template <typename... Args> void trace_log(const char* format, Args... args) noexcept {
+    std::array<char, 640> line{};
+    const int length = std::snprintf(line.data(), line.size(), format, args...);
+    if (length > 0)
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::debug,
+            {line.data(), std::min(static_cast<std::size_t>(length), line.size() - 1)});
+}
+
+/** Flushes the results of actual native calls for one actor visited by the rebind pass. */
+void flush_rebind_actor(RebindTrace& value) noexcept {
+    if (value.association.actor == kNone) return;
+    if (g_rebindRows.fetch_add(1) < kRebindRowBudget) {
+        value.flagsAfterKnown = trace_flags(value.association.owner, value.flagsAfter);
+        trace_log("ev=world_object stage=squad_rebind_actor pass=%u activity=%p bubble=%u "
+                  "actor=0x%08X owner=0x%08X flags=0x%08X flags_known=%u after=0x%08X "
+                  "after_known=%u source_result=%d source_known=%u key=0x%08X type=%u index=%u "
+                  "resolved=%d predicate=%d bind_called=%u count_before=%d count_after=%d "
+                  "binding_before_known=%u binding_after_known=%u before0=0x%016llX "
+                  "before1=0x%016llX after0=0x%016llX after1=0x%016llX",
+                  value.pass,
+                  value.activity,
+                  value.bubble,
+                  value.association.actor,
+                  value.association.owner,
+                  value.flags,
+                  value.flagsKnown,
+                  value.flagsAfter,
+                  value.flagsAfterKnown,
+                  value.sourceResult,
+                  value.sourceKnown,
+                  value.source.key,
+                  value.source.type,
+                  value.source.index,
+                  value.resolveResult,
+                  value.predicate,
+                  value.bindCalled,
+                  value.countBefore,
+                  value.countAfter,
+                  value.bindingBeforeKnown,
+                  value.bindingAfterKnown,
+                  value.bindingBefore[0],
+                  value.bindingBefore[1],
+                  value.bindingAfter[0],
+                  value.bindingAfter[1]);
+    }
+    value.association.actor = kNone;
+}
+
+/** Preserves the observer's original call and records the bubble used by its native gate. */
+__declspec(noinline) std::uintptr_t __fastcall trace_observer(void* observer,
+                                                              void* activity,
+                                                              const std::uint8_t* bubble) {
+    ActiveCall active;
+    const auto original = g_observerOriginal.load(std::memory_order_acquire);
+    ObserverTrace observerTrace{};
+    trace::Scope<ObserverTrace> scope(t_observerTrace, observerTrace);
+    std::uint8_t requested{0xFF};
+    std::uint32_t current{kNone};
+    const bool report = g_accepting.load() && g_observerReports.fetch_add(1) < kRebindPassBudget;
+    if (report && read_value(bubble, requested)) {
+        observerTrace.activity = activity;
+        observerTrace.bubble = requested;
+        if (g_sliceManager && g_currentBubble) g_currentBubble(g_sliceManager(), &current);
+        trace_log("ev=world_object stage=squad_observer activity=%p bubble=%u current=%u",
+                  activity,
+                  requested,
+                  current);
+    }
+    const auto result = original ? original(observer, activity, bubble) : 0;
+    return result;
+}
+
+/** Nested calls restore their caller's diagnostic scope without changing native execution. */
+__declspec(noinline) std::uintptr_t __fastcall trace_rebind() {
+    ActiveCall active;
+    const auto original = g_rebindOriginal.load(std::memory_order_acquire);
+    RebindTrace value{};
+    trace::Scope<RebindTrace> scope(t_rebindTrace, value);
+    const auto pass = g_rebindPasses.fetch_add(1);
+    value.association.active = g_accepting.load() && pass < kRebindPassBudget;
+    value.activity = t_observerTrace ? t_observerTrace->activity : nullptr;
+    value.bubble = t_observerTrace ? t_observerTrace->bubble : kNone;
+    value.pass = pass;
+    const auto result = original ? original() : 0;
+    flush_rebind_actor(value);
+    if (value.association.active)
+        trace_log("ev=world_object stage=squad_rebind pass=%u activity=%p bubble=%u visited=%u "
+                  "bind_calls=%u",
+                  pass,
+                  value.activity,
+                  value.bubble,
+                  value.visited,
+                  value.bound);
+    return result;
+}
+
+/** Captures only the rebind function's direct actor iterator call. */
+__declspec(noinline) std::uintptr_t __fastcall trace_iterator(void* iterator,
+                                                              std::uint32_t* output) {
+    ActiveCall active;
+    const auto original = g_iteratorOriginal.load(std::memory_order_acquire);
+    const auto result = original ? original(iterator, output) : 0;
+    auto* value = t_rebindTrace;
+    const auto caller = reinterpret_cast<std::uintptr_t>(_ReturnAddress());
+    if (value && value->association.active && caller == g_rebindAddress + 0x5F
+        && (value->association.iterator == 0
+            || value->association.iterator == reinterpret_cast<std::uintptr_t>(iterator))) {
+        flush_rebind_actor(*value);
+        if (value->association.visit(
+                caller, g_rebindAddress + 0x5F, reinterpret_cast<std::uintptr_t>(iterator))) {
+            value->source = {};
+            value->sourceResult = value->resolveResult = value->predicate = -1;
+            value->countBefore = value->countAfter = -1;
+            value->bindCalled = value->bindingBeforeKnown = value->bindingAfterKnown = false;
+            value->bindingBefore = value->bindingAfter = {};
+            value->flagsKnown = value->flagsAfterKnown = value->sourceKnown = false;
+            value->flags = value->flagsAfter = 0;
+            if (read_value(output, value->association.actor) && value->association.actor != kNone) {
+                ++value->visited;
+                if (g_actorOwner) g_actorOwner(&value->association.owner, value->association.actor);
+                value->flagsKnown = trace_flags(value->association.owner, value->flags);
+            }
+        }
+    }
+    return result;
+}
+
+/** Associates the actual source lookup with the current iterator output. */
+__declspec(noinline) std::uint8_t __fastcall trace_source(std::uint32_t owner, void* output) {
+    ActiveCall active;
+    const auto original = g_sourceOriginal.load(std::memory_order_acquire);
+    const std::uint8_t result = original ? original(owner, output) : std::uint8_t{};
+    auto* value = t_rebindTrace;
+    if (value && value->association.active && value->association.actor != kNone
+        && reinterpret_cast<std::uintptr_t>(_ReturnAddress()) == g_rebindAddress + 0x83) {
+        value->association.owner = owner;
+        value->sourceResult = result;
+        value->flagsKnown = trace_flags(owner, value->flags);
+        if (result)
+            value->sourceKnown = read_value(static_cast<const ActorSource*>(output), value->source);
+    }
+    return result;
+}
+
+/** Records only the source resolver called directly by the current rebind pass. */
+__declspec(noinline) std::uint8_t __fastcall trace_resolve(const void* source, void* output) {
+    ActiveCall active;
+    const auto original = g_resolveSourceOriginal.load(std::memory_order_acquire);
+    const std::uint8_t result = original ? original(source, output) : std::uint8_t{};
+    auto* value = t_rebindTrace;
+    ActorSource actual{};
+    if (value && value->association.active && value->sourceKnown
+        && reinterpret_cast<std::uintptr_t>(_ReturnAddress()) == g_rebindAddress + 0xA5
+        && read_value(static_cast<const ActorSource*>(source), actual)
+        && actual.key == value->source.key && actual.type == value->source.type
+        && actual.index == value->source.index)
+        value->resolveResult = result;
+    return result;
+}
+
+/** Records the native exclusion predicate without invoking it a second time. */
+__declspec(noinline) std::uintptr_t __fastcall trace_predicate(std::uint32_t owner) {
+    ActiveCall active;
+    const auto original = g_predicateOriginal.load(std::memory_order_acquire);
+    const auto result = original ? original(owner) : 0;
+    auto* value = t_rebindTrace;
+    if (value
+        && value->association.matches(
+            reinterpret_cast<std::uintptr_t>(_ReturnAddress()), g_rebindAddress + 0x103, owner))
+        value->predicate = static_cast<std::uint8_t>(result);
+    return result;
+}
+
+/** Reads a binding only for the actual generation-valid actor selected by native iteration. */
+bool trace_actor_binding(std::uint32_t actor, std::array<std::uint64_t, 2>& output) noexcept {
+    std::uintptr_t base{};
+    std::uint32_t stride{};
+    if (actor == kNone || !read_value(g_actorBaseStorage, base)
+        || !read_value(g_actorStrideStorage, stride) || !base || stride < 0x48)
+        return false;
+    return read_value(
+        reinterpret_cast<const std::array<std::uint64_t, 2>*>(
+            base + static_cast<std::uintptr_t>(stride) * (actor & kEntityIndexMask) + 0x38),
+        output);
+}
+
+/** Records collection counts and the actor's binding around the actual native insertion call. */
+__declspec(noinline) void __fastcall trace_bind(void* squad, std::uint32_t actor) {
+    ActiveCall active;
+    const auto original = g_bindOriginal.load(std::memory_order_acquire);
+    auto* value = t_rebindTrace;
+    const bool selected =
+        value && value->association.active && value->association.actor == actor
+        && reinterpret_cast<std::uintptr_t>(_ReturnAddress()) == g_rebindAddress + 0x135;
+    const auto count = reinterpret_cast<const std::int32_t*>(reinterpret_cast<std::uintptr_t>(squad)
+                                                             + kSquadCountOffset);
+    if (selected) {
+        value->bindCalled = true;
+        ++value->bound;
+        static_cast<void>(read_value(count, value->countBefore));
+        value->bindingBeforeKnown = trace_actor_binding(actor, value->bindingBefore);
+    }
+    if (original) original(squad, actor);
+    if (selected) {
+        static_cast<void>(read_value(count, value->countAfter));
+        value->bindingAfterKnown = trace_actor_binding(actor, value->bindingAfter);
+    }
+}
+
+/** Captures generation-valid member owners before native teardown and checks them afterward. */
+__declspec(noinline) std::uintptr_t __fastcall trace_teardown(void* squad) {
+    ActiveCall active;
+    const auto original = g_teardownOriginal.load(std::memory_order_acquire);
+    std::array<std::uint32_t, kSquadMembers> owners{}, flags{};
+    std::array<bool, kSquadMembers> known{};
+    std::int32_t count{-1};
+    const auto base = reinterpret_cast<std::uintptr_t>(squad);
+    const bool report =
+        g_accepting.load() && g_rebindRows.load() < kRebindRowBudget
+        && read_value(reinterpret_cast<const std::int32_t*>(base + kSquadCountOffset), count)
+        && count >= 0 && count <= static_cast<std::int32_t>(kSquadMembers);
+    if (report)
+        for (std::int32_t index = 0; index < count; ++index) {
+            HandlePair pair{};
+            std::int32_t actor{-1};
+            owners[index] = kNone;
+            if (read_value(reinterpret_cast<const HandlePair*>(base + kSquadRefsOffset + 8 * index),
+                           pair)
+                && g_validatePair && g_actorOwner) {
+                g_validatePair(&pair, &actor);
+                if (actor != -1) g_actorOwner(&owners[index], static_cast<std::uint32_t>(actor));
+                known[index] = trace_flags(owners[index], flags[index]);
+            }
+        }
+    const auto result = original ? original(squad) : 0;
+    if (report && g_rebindRows.fetch_add(1) < kRebindRowBudget) {
+        std::int32_t after{-1};
+        static_cast<void>(
+            read_value(reinterpret_cast<const std::int32_t*>(base + kSquadCountOffset), after));
+        trace_log("ev=world_object stage=squad_teardown squad=%p before=%d after=%d",
+                  squad,
+                  count,
+                  after);
+        for (std::int32_t index = 0; index < count; ++index) {
+            if (g_rebindRows.fetch_add(1) >= kRebindRowBudget) break;
+            std::uint32_t afterFlags{};
+            const bool afterKnown = trace_flags(owners[index], afterFlags);
+            trace_log("ev=world_object stage=squad_teardown_member squad=%p index=%d owner=0x%08X "
+                      "before=0x%08X before_known=%u after=0x%08X after_known=%u",
+                      squad,
+                      index,
+                      owners[index],
+                      flags[index],
+                      known[index],
+                      afterFlags,
+                      afterKnown);
+        }
+    }
+    return result;
+}
+
+/** Private signatures select the verified native diagnostic ABI. */
+constexpr std::string_view kObserverTraceText =
+    "48 89 5C 24 08 57 48 83 EC 20 49 8B F8 48 8B DA E8 ? ? ? ? 48 8B C8 48 8D 54 24 38 E8 ? ? ? ? "
+    "E8 ? ? ? ? 48 3B D8 75 13";
+constexpr auto kObserverTracePattern =
+    signature<signature_length(kObserverTraceText)>(kObserverTraceText);
+constexpr std::string_view kRebindTraceText =
+    "48 89 5C 24 10 48 89 7C 24 18 55 48 8D AC 24 30 FF FF FF 48 81 EC D0 01 00 00 48 8B 05 ? ? ? "
+    "? 48 33 C4 48 89 85 C0 00 00 00 33 FF 48 8D 4C 24 40 33 D2 89 7C 24 70 E8 E3 47 59 00";
+constexpr auto kRebindTracePattern =
+    signature<signature_length(kRebindTraceText)>(kRebindTraceText);
+constexpr std::string_view kIteratorTraceText =
+    "40 53 48 83 EC 20 48 8B DA E8 82 7D FB FF 48 8B C3 48 83 C4 20 5B C3";
+constexpr auto kIteratorTracePattern =
+    signature<signature_length(kIteratorTraceText)>(kIteratorTraceText);
+constexpr std::string_view kSourceTraceText =
+    "48 89 5C 24 10 56 48 83 EC 20 48 8B F2 8B D9 83 F9 FF 0F 84 8C 00 00 00 48 89 7C 24 30";
+constexpr auto kSourceTracePattern =
+    signature<signature_length(kSourceTraceText)>(kSourceTraceText);
+constexpr std::string_view kResolveSourceTraceText =
+    "40 53 48 83 EC 20 48 0F BE 41 04 48 8B DA 83 F8 3C 77 17 48 BA 00 00 00 00 00 B0 01 18";
+constexpr auto kResolveSourceTracePattern =
+    signature<signature_length(kResolveSourceTraceText)>(kResolveSourceTraceText);
+constexpr std::string_view kPredicateTraceText =
+    "48 8B 05 ? ? ? ? 8B D1 48 8B C8 4C 8B 00 49 FF A0 08 01 00 00";
+constexpr auto kPredicateTracePattern =
+    signature<signature_length(kPredicateTraceText)>(kPredicateTraceText);
+constexpr std::string_view kBindTraceText = "48 89 5C 24 08 57 48 83 EC 30 48 8B F9 8B DA 48 81 C1 "
+                                            "F0 02 00 00 E8 ? ? ? ? 84 C0 0F 84 97 00 00 00";
+constexpr auto kBindTracePattern = signature<signature_length(kBindTraceText)>(kBindTraceText);
+constexpr std::string_view kTeardownTraceText =
+    "40 56 48 83 EC 40 83 B9 FC 05 00 00 FF 48 8B F1 0F 84 27 01 00 00";
+constexpr auto kTeardownTracePattern =
+    signature<signature_length(kTeardownTraceText)>(kTeardownTraceText);
+constexpr std::string_view kActorOwnerTraceText =
+    "C7 01 FF FF FF FF 83 FA FF 74 1B 81 E2 FF 1F 00 00 0F AF 15 ? ? ? ? 8B C2 48 03 05 ? ? ? ? 8B "
+    "50 4C 89 11 48 8B C1 C3";
+constexpr auto kActorOwnerTracePattern =
+    signature<signature_length(kActorOwnerTraceText)>(kActorOwnerTraceText);
+
 struct Targets final {
     std::byte* instantiate{};
     std::byte* destroy{};
@@ -717,6 +1393,20 @@ struct Targets final {
     std::byte* resolvePair{};
     std::byte* validatePair{};
     std::byte* datumLayout{};
+    std::byte* createEntity{};
+    std::byte* purgeEntities{};
+    std::byte* glueMapping{};
+    std::byte* entityPool{};
+    std::byte* entityPolicy{};
+    std::byte* observer{};
+    std::byte* rebind{};
+    std::byte* iterator{};
+    std::byte* source{};
+    std::byte* resolveSource{};
+    std::byte* predicate{};
+    std::byte* bind{};
+    std::byte* teardown{};
+    std::byte* actorOwner{};
 };
 
 /** @return The main module's base, or zero when it cannot be read. */
@@ -743,6 +1433,21 @@ struct Targets final {
         patterns::Pattern{"object_handle_pair", kResolvePairSignature},
         patterns::Pattern{"object_handle_validate", kValidatePairSignature},
         patterns::Pattern{"object_datum_layout", kDatumLayoutSignature},
+        patterns::Pattern{"simulation_sobject_create", kCreateEntityPattern},
+        patterns::Pattern{"simulation_entity_purge", kPurgeEntitiesPattern},
+        patterns::Pattern{"simulation_glue_mapping", kGlueMappingPattern},
+        patterns::Pattern{"simulation_entity_pool", kEntityPoolPattern},
+        patterns::Pattern{"simulation_entity_policy", kEntityPolicyPattern},
+        patterns::Pattern{"squad_trace_observer", kObserverTracePattern},
+        patterns::Pattern{"squad_trace_rebind", kRebindTracePattern},
+        patterns::Pattern{"squad_trace_iterator", kIteratorTracePattern},
+        patterns::Pattern{"squad_trace_source", kSourceTracePattern},
+        patterns::Pattern{"squad_trace_resolveSource", kResolveSourceTracePattern},
+        patterns::Pattern{"squad_trace_predicate", kPredicateTracePattern},
+        patterns::Pattern{"squad_trace_bind", kBindTracePattern},
+        patterns::Pattern{"squad_trace_teardown", kTeardownTracePattern},
+        patterns::Pattern{"squad_trace_actorOwner", kActorOwnerTracePattern},
+
     };
     std::array<patterns::Match, definitions.size()> matches{};
     if (!patterns::resolve_all(std::span(ranges.data(), main.count), definitions, matches)) {
@@ -753,13 +1458,12 @@ struct Targets final {
             return false;
         }
     }
-    output = {matches[0].address,
-              matches[1].address,
-              matches[2].address,
-              matches[3].address,
-              matches[4].address,
-              matches[5].address,
-              matches[6].address};
+    output = {matches[0].address,  matches[1].address,  matches[2].address,  matches[3].address,
+              matches[4].address,  matches[5].address,  matches[6].address,  matches[7].address,
+              matches[8].address,  matches[9].address,  matches[10].address, matches[11].address,
+              matches[12].address, matches[13].address, matches[14].address, matches[15].address,
+              matches[16].address, matches[17].address, matches[18].address, matches[19].address,
+              matches[20].address};
     return true;
 }
 
@@ -781,13 +1485,17 @@ struct Targets final {
 bool install() noexcept {
     AcquireSRWLockExclusive(&g_lock);
     if (g_handles[0].attached && g_handles[1].attached && g_handles[2].attached
-        && g_handles[3].attached) {
+        && g_handles[3].attached && g_handles[4].attached && g_handles[5].attached
+        && std::all_of(
+            g_handles.begin() + 6, g_handles.end(), [](const auto& h) { return h.attached; })) {
         const bool accepting = g_accepting.load(std::memory_order_acquire);
         ReleaseSRWLockExclusive(&g_lock);
         return accepting;
     }
     if (g_handles[0].attached || g_handles[1].attached || g_handles[2].attached
-        || g_handles[3].attached) {
+        || g_handles[3].attached || g_handles[4].attached || g_handles[5].attached
+        || std::any_of(
+            g_handles.begin() + 6, g_handles.end(), [](const auto& h) { return h.attached; })) {
         ReleaseSRWLockExclusive(&g_lock);
         return false;
     }
@@ -801,12 +1509,30 @@ bool install() noexcept {
         return false;
     }
     g_resolvePair = reinterpret_cast<ResolvePair>(targets.resolvePair);
+    g_glueStrideStorage = reinterpret_cast<const std::uint32_t*>(
+        patterns::resolve_relative(targets.glueMapping + 9, targets.glueMapping + 13));
+    g_glueBaseStorage = reinterpret_cast<const std::uintptr_t*>(
+        patterns::resolve_relative(targets.glueMapping + 18, targets.glueMapping + 22));
+    g_entityRecordBase = reinterpret_cast<std::uintptr_t>(
+        patterns::resolve_relative(targets.entityPool + 20, targets.entityPool + 24));
     g_validatePair = reinterpret_cast<ValidatePair>(targets.validatePair);
     const std::array specs{
         hooking::detour::Spec{targets.instantiate, reinterpret_cast<void*>(&instantiate)},
         hooking::detour::Spec{targets.destroy, reinterpret_cast<void*>(&destroy)},
         hooking::detour::Spec{targets.allocate, reinterpret_cast<void*>(&allocate)},
         hooking::detour::Spec{targets.logicalDestroy, reinterpret_cast<void*>(&logical_destroy)},
+        hooking::detour::Spec{targets.createEntity, reinterpret_cast<void*>(&create_entity)},
+        hooking::detour::Spec{targets.purgeEntities, reinterpret_cast<void*>(&purge_entities)},
+        hooking::detour::Spec{targets.entityPolicy, reinterpret_cast<void*>(&entity_policy)},
+        hooking::detour::Spec{targets.observer, reinterpret_cast<void*>(&trace_observer)},
+        hooking::detour::Spec{targets.rebind, reinterpret_cast<void*>(&trace_rebind)},
+        hooking::detour::Spec{targets.iterator, reinterpret_cast<void*>(&trace_iterator)},
+        hooking::detour::Spec{targets.source, reinterpret_cast<void*>(&trace_source)},
+        hooking::detour::Spec{targets.resolveSource, reinterpret_cast<void*>(&trace_resolve)},
+        hooking::detour::Spec{targets.predicate, reinterpret_cast<void*>(&trace_predicate)},
+        hooking::detour::Spec{targets.bind, reinterpret_cast<void*>(&trace_bind)},
+        hooking::detour::Spec{targets.teardown, reinterpret_cast<void*>(&trace_teardown)},
+
     };
     if (!hooking::detour::install(specs, g_handles)) {
         g_resolvePair = nullptr;
@@ -827,7 +1553,43 @@ bool install() noexcept {
                              std::memory_order_release);
     g_logicalDestroyOriginal.store(reinterpret_cast<LogicalDestroy>(g_handles[3].original),
                                    std::memory_order_release);
+    g_createEntityOriginal.store(reinterpret_cast<CreateEntity>(g_handles[4].original),
+                                 std::memory_order_release);
+    g_purgeEntitiesOriginal.store(reinterpret_cast<PurgeEntities>(g_handles[5].original),
+                                  std::memory_order_release);
+    g_entityPolicyOriginal.store(reinterpret_cast<EntityPolicy>(g_handles[6].original),
+                                 std::memory_order_release);
 
+    g_observerOriginal.store(reinterpret_cast<Observer>(g_handles[7].original),
+                             std::memory_order_release);
+    g_rebindOriginal.store(reinterpret_cast<Rebind>(g_handles[8].original),
+                           std::memory_order_release);
+    g_iteratorOriginal.store(reinterpret_cast<IteratorValue>(g_handles[9].original),
+                             std::memory_order_release);
+    g_sourceOriginal.store(reinterpret_cast<SourceRef>(g_handles[10].original),
+                           std::memory_order_release);
+    g_resolveSourceOriginal.store(reinterpret_cast<ResolveSource>(g_handles[11].original),
+                                  std::memory_order_release);
+    g_predicateOriginal.store(reinterpret_cast<Predicate>(g_handles[12].original),
+                              std::memory_order_release);
+    g_bindOriginal.store(reinterpret_cast<BindActor>(g_handles[13].original),
+                         std::memory_order_release);
+    g_teardownOriginal.store(reinterpret_cast<Teardown>(g_handles[14].original),
+                             std::memory_order_release);
+    g_actorOwner = reinterpret_cast<ActorOwner>(targets.actorOwner);
+    g_actorStrideStorage = reinterpret_cast<const std::uint32_t*>(
+        patterns::resolve_relative(targets.actorOwner + 20, targets.actorOwner + 24));
+    g_actorBaseStorage = reinterpret_cast<const std::uintptr_t*>(
+        patterns::resolve_relative(targets.actorOwner + 29, targets.actorOwner + 33));
+    g_rebindAddress = reinterpret_cast<std::uintptr_t>(targets.rebind);
+    g_observerAddress = reinterpret_cast<std::uintptr_t>(targets.observer);
+    g_sliceManager = reinterpret_cast<SliceManager>(
+        patterns::resolve_relative(targets.observer + 17, targets.observer + 21));
+    g_currentBubble = reinterpret_cast<CurrentBubble>(
+        patterns::resolve_relative(targets.observer + 30, targets.observer + 34));
+    g_rebindPasses.store(0);
+    g_rebindRows.store(0);
+    g_observerReports.store(0);
     g_accepting.store(true, std::memory_order_release);
     ReleaseSRWLockExclusive(&g_lock);
     core::log::write(core::log::Channel::client,
@@ -840,17 +1602,31 @@ bool install() noexcept {
 bool uninstall() noexcept {
     AcquireSRWLockExclusive(&g_lock);
     if (!g_handles[0].attached && !g_handles[1].attached && !g_handles[2].attached
-        && !g_handles[3].attached) {
+        && !g_handles[3].attached && !g_handles[4].attached && !g_handles[5].attached
+        && std::none_of(
+            g_handles.begin() + 6, g_handles.end(), [](const auto& h) { return h.attached; })) {
         clear_registry();
         ReleaseSRWLockExclusive(&g_lock);
         return true;
     }
     g_accepting.store(false, std::memory_order_release);
-    const std::array<hooking::detour::ProtectedCodeEntry, 4> protectedEntries{
+    const std::array<hooking::detour::ProtectedCodeEntry, 15> protectedEntries{
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&instantiate)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&destroy)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&allocate)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&logical_destroy)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&create_entity)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&purge_entities)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&entity_policy)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_observer)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_rebind)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_iterator)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_source)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_resolve)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_predicate)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_bind)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&trace_teardown)},
+
     };
     const hooking::detour::UninstallResult result =
         hooking::detour::uninstall(g_handles, protectedEntries, &calls_idle);
@@ -862,6 +1638,27 @@ bool uninstall() noexcept {
     g_destroyOriginal.store(nullptr, std::memory_order_release);
     g_allocateOriginal.store(nullptr, std::memory_order_release);
     g_logicalDestroyOriginal.store(nullptr, std::memory_order_release);
+    g_createEntityOriginal.store(nullptr, std::memory_order_release);
+    g_purgeEntitiesOriginal.store(nullptr, std::memory_order_release);
+    g_entityPolicyOriginal.store(nullptr, std::memory_order_release);
+    g_observerOriginal.store(nullptr, std::memory_order_release);
+    g_rebindOriginal.store(nullptr, std::memory_order_release);
+    g_iteratorOriginal.store(nullptr, std::memory_order_release);
+    g_sourceOriginal.store(nullptr, std::memory_order_release);
+    g_resolveSourceOriginal.store(nullptr, std::memory_order_release);
+    g_predicateOriginal.store(nullptr, std::memory_order_release);
+    g_bindOriginal.store(nullptr, std::memory_order_release);
+    g_teardownOriginal.store(nullptr, std::memory_order_release);
+    g_actorOwner = nullptr;
+    g_actorBaseStorage = nullptr;
+    g_actorStrideStorage = nullptr;
+    g_sliceManager = nullptr;
+    g_currentBubble = nullptr;
+    g_rebindAddress = g_observerAddress = 0;
+    g_entityRecordBase = 0;
+    g_policyTrace = {};
+    g_glueBaseStorage = nullptr;
+    g_glueStrideStorage = nullptr;
     g_resolvePair = nullptr;
     g_validatePair = nullptr;
     g_datumBaseStorage = nullptr;
@@ -874,8 +1671,12 @@ bool uninstall() noexcept {
 /** @return True while both hooks are attached and accepting observations. */
 bool is_installed() noexcept {
     AcquireSRWLockShared(&g_lock);
-    const bool installed = g_handles[0].attached && g_handles[1].attached && g_handles[2].attached
-                           && g_handles[3].attached && g_accepting.load(std::memory_order_acquire);
+    const bool installed =
+        g_handles[0].attached && g_handles[1].attached && g_handles[2].attached
+        && g_handles[3].attached && g_handles[4].attached && g_handles[5].attached
+        && std::all_of(
+            g_handles.begin() + 6, g_handles.end(), [](const auto& h) { return h.attached; })
+        && g_accepting.load(std::memory_order_acquire);
     ReleaseSRWLockShared(&g_lock);
     return installed;
 }

+ 6 - 16
Sunrise/src/middleware/bap/activity_message/activity_entity_authority_parser.cpp

@@ -1,9 +1,8 @@
 /**
  * Exact parsers for client-to-host entity-authority msgs 26, 27, 29, 31, 32 and 33.
- * Fixed masks stay in wire byte order.
+ * Fixed u32 masks decode into canonical slot bytes.
  */
 
-#include <algorithm>
 #include <bit>
 
 #include "../../encoding/bit_reader.h"
@@ -39,8 +38,7 @@ namespace {
     if (release.selector > kMaximumSelector) {
         return false;
     }
-    std::copy_n(payload.begin() + kSelectorSize, entity_slots::kEncodedSize, release.mask.begin());
-    return true;
+    return entity_slots::decode_entity_slots(payload.subspan(kSelectorSize), release.mask);
 }
 
 } // namespace
@@ -57,7 +55,7 @@ bool parse_abandon(std::span<const std::byte> payload, Release& release) noexcep
         || !reader.read(kReasonWidth, stored) || !finish_padding(reader)) {
         return false;
     }
-    parsed.reason = static_cast<std::int32_t>(stored) + kReasonBias;
+    parsed.reason = static_cast<std::int32_t>(stored) - kReasonBias;
     parsed.hasReason = true;
     release = parsed;
     return true;
@@ -84,14 +82,8 @@ bool parse_request_purge(std::span<const std::byte> payload, PurgeRequest& reque
     if (!reader.read(kReasonWidth, stored)) {
         return false;
     }
-    parsed.reason = static_cast<std::int32_t>(stored) + kReasonBias;
-    for (std::byte& maskByte : parsed.mask) {
-        std::uint64_t decoded = 0;
-        if (!reader.read(entity_slots::kBitsPerMaskByte, decoded)) {
-            return false;
-        }
-        maskByte = static_cast<std::byte>(decoded);
-    }
+    parsed.reason = static_cast<std::int32_t>(stored) - kReasonBias;
+    if (!entity_slots::read_mask(reader, parsed.mask)) return false;
     if (!finish_padding(reader)) {
         return false;
     }
@@ -136,9 +128,7 @@ bool parse_query_answer(std::uint32_t messageType,
         offset += kSelectorSize;
     }
 
-    std::copy_n(payload.begin() + static_cast<std::ptrdiff_t>(offset),
-                entity_slots::kEncodedSize,
-                parsed.mask.begin());
+    if (!entity_slots::decode_entity_slots(payload.subspan(offset), parsed.mask)) return false;
     parsed.hasMask = true;
     answer = parsed;
     return true;

+ 24 - 13
Sunrise/src/middleware/bap/activity_message/activity_entity_slots_decoder.cpp

@@ -1,20 +1,31 @@
-#include <algorithm>
-
 #include "entity_slots.h"
 
 namespace sunrise::middleware::bap::activity_message::entity_slots {
-
-/** Decodes the fixed entity-slot prefix without changing its wire byte order. */
-bool decode_entity_slots(std::span<const std::byte> input, EntitySlotMask& mask) noexcept {
-    mask = {};
-    // The bound is only what the reads below need. An exact length is the client's
-    // business, not a rule to enforce here.
-    if (input.size() < kEncodedSize) {
-        return false;
+/**
+ * Reads each numeric word before expanding its low-order slot bytes.
+ * @param reader Wire reader at any bit offset; unchanged on failure.
+ * @param mask Receives canonical slot bytes; unchanged on failure.
+ * @return True when all 256 words are present.
+ */
+bool read_mask(encoding::bits::Reader& reader, EntitySlotMask& mask) noexcept {
+    auto candidateReader = reader;
+    EntitySlotMask candidate{};
+    for (std::size_t word = 0; word < kWordCount; ++word) {
+        std::uint64_t value{};
+        if (!candidateReader.read(kBitsPerMaskWord, value)) return false;
+        for (std::size_t byte = 0; byte < sizeof(std::uint32_t); ++byte)
+            candidate[word * sizeof(std::uint32_t) + byte] =
+                static_cast<std::byte>(value >> (byte * kBitsPerMaskByte));
     }
-
-    std::copy_n(input.begin(), kEncodedSize, mask.begin());
+    mask = candidate;
+    reader = candidateReader;
     return true;
 }
-
+/** Decodes the fixed big-endian word prefix into canonical slot bytes. */
+bool decode_entity_slots(std::span<const std::byte> input, EntitySlotMask& mask) noexcept {
+    mask = {};
+    if (input.size() < kEncodedSize) return false;
+    encoding::bits::Reader reader(input.first(kEncodedSize));
+    return read_mask(reader, mask);
+}
 } // namespace sunrise::middleware::bap::activity_message::entity_slots

+ 21 - 12
Sunrise/src/middleware/bap/activity_message/activity_entity_slots_encoder.cpp

@@ -1,21 +1,30 @@
-#include <algorithm>
-
 #include "entity_slots.h"
 
 namespace sunrise::middleware::bap::activity_message::entity_slots {
-
-/** Encodes one selected entity-slot lease mask without changing its wire byte order. */
+/**
+ * Emits each canonical mask word as one unsigned 32-bit scalar.
+ * @param writer Wire writer at any bit offset.
+ * @param mask Complete canonical slot bytes.
+ * @return False when the fixed array does not fit.
+ */
+bool write_mask(encoding::bits::Writer& writer,
+                std::span<const std::byte, kEncodedSize> mask) noexcept {
+    for (std::size_t word = 0; word < kWordCount; ++word) {
+        std::uint32_t value{};
+        for (std::size_t byte = 0; byte < sizeof(std::uint32_t); ++byte)
+            value |= std::to_integer<std::uint32_t>(mask[word * sizeof(std::uint32_t) + byte])
+                     << (byte * kBitsPerMaskByte);
+        if (!writer.write(value, kBitsPerMaskWord)) return false;
+    }
+    return true;
+}
+/** Encodes canonical slot bytes as 256 big-endian words. */
 bool encode_entity_slots(std::span<const std::byte, kEncodedSize> mask,
                          std::span<std::byte> output,
                          std::size_t& written) noexcept {
     written = {};
-    if (output.size() < kEncodedSize) {
-        return false;
-    }
-
-    std::copy(mask.begin(), mask.end(), output.begin());
-    written = kEncodedSize;
-    return true;
+    if (output.size() < kEncodedSize) return false;
+    encoding::bits::Writer writer(output.first(kEncodedSize));
+    return write_mask(writer, mask) && writer.finish(written);
 }
-
 } // namespace sunrise::middleware::bap::activity_message::entity_slots

+ 6 - 6
Sunrise/src/middleware/bap/activity_message/activity_host_control.h

@@ -38,18 +38,18 @@ inline constexpr std::size_t kPerfRequestKillByteCount = kPerfRequestKillBitCoun
 inline constexpr std::size_t kPerfRequestReflectBitCount = 1;
 inline constexpr std::size_t kPerfRequestReflectByteCount = 1;
 
-/** Three-bit bias-one values have a logical range of 1 through 8. */
-inline constexpr std::int8_t kMinimumReason = 1;
-inline constexpr std::int8_t kMaximumReason = 8;
+/** Three-bit bias-one values decode to -1 through 6. */
+inline constexpr std::int8_t kMinimumReason = -1;
+inline constexpr std::int8_t kMaximumReason = 6;
 
-/** Complete message-24 body. The mask remains in exact wire byte order. */
+/** Complete message-24 body. The mask uses canonical slot bytes. */
 struct ClaimAuthorityBody final {
     entity_slots::EntitySlotMask slots{};
     std::uint8_t epoch{};
     std::int8_t reason{};
 };
 
-/** Complete message-25 body. The mask remains in exact wire byte order. */
+/** Complete message-25 body. The mask uses canonical slot bytes. */
 struct PurgeAuthorityBody final {
     entity_slots::EntitySlotMask slots{};
     std::uint8_t epoch{};
@@ -88,7 +88,7 @@ struct PerfRequestReflectBody final {
 [[nodiscard]] bool decode_claim_authority(std::span<const std::byte> input,
                                           ClaimAuthorityBody& body) noexcept;
 /** @return True when the input is one complete message-24 body. */
-[[nodiscard]] bool validate_claim_authority(std::span<const std::byte> input) noexcept;
+[[nodiscard]] bool validate_retirement_authority(std::span<const std::byte> input) noexcept;
 
 /** Encodes one complete message-25 body without changing a refused output buffer. */
 [[nodiscard]] bool encode_purge_authority(const PurgeAuthorityBody& body,

+ 11 - 35
Sunrise/src/middleware/bap/activity_message/activity_host_control_codec.cpp

@@ -29,31 +29,6 @@ constexpr std::uint8_t kBoolWidth = 1;
            && padding == 0 && reader.remaining_bits() == 0;
 }
 
-/** Writes one complete 8,192-bit mask in its retained wire byte order. */
-[[nodiscard]] bool write_mask(bits::Writer& writer,
-                              const entity_slots::EntitySlotMask& mask) noexcept {
-    for (const std::byte value : mask) {
-        if (!writer.write(std::to_integer<std::uint8_t>(value), kByteWidth)) {
-            return false;
-        }
-    }
-    return true;
-}
-
-/** Reads one complete 8,192-bit mask without changing it after an underrun. */
-[[nodiscard]] bool read_mask(bits::Reader& reader, entity_slots::EntitySlotMask& mask) noexcept {
-    entity_slots::EntitySlotMask parsed{};
-    for (std::byte& value : parsed) {
-        std::uint64_t raw = 0;
-        if (!reader.read(kByteWidth, raw)) {
-            return false;
-        }
-        value = static_cast<std::byte>(raw);
-    }
-    mask = parsed;
-    return true;
-}
-
 /** Applies the signed midpoint bias before a 32-bit field is packed. */
 [[nodiscard]] constexpr std::uint32_t encode_signed32(std::int32_t value) noexcept {
     return std::bit_cast<std::uint32_t>(value) + kSigned32Bias;
@@ -106,8 +81,8 @@ bool encode_claim_authority(const ClaimAuthorityBody& body,
     }
     bits::Writer writer(output.first(kClaimAuthorityByteCount));
     const std::uint32_t storedReason =
-        static_cast<std::uint32_t>(static_cast<std::int32_t>(body.reason) - kReasonBias);
-    return writer.write(body.epoch, kByteWidth) && write_mask(writer, body.slots)
+        static_cast<std::uint32_t>(static_cast<std::int32_t>(body.reason) + kReasonBias);
+    return writer.write(body.epoch, kByteWidth) && entity_slots::write_mask(writer, body.slots)
            && writer.write(storedReason, kReasonWidth)
            && writer.bit_count() == kClaimAuthorityBitCount && writer.finish(written)
            && written == kClaimAuthorityByteCount;
@@ -122,18 +97,18 @@ bool decode_claim_authority(std::span<const std::byte> input, ClaimAuthorityBody
     ClaimAuthorityBody parsed{};
     std::uint64_t epoch = 0;
     std::uint64_t storedReason = 0;
-    if (!reader.read(kByteWidth, epoch) || !read_mask(reader, parsed.slots)
+    if (!reader.read(kByteWidth, epoch) || !entity_slots::read_mask(reader, parsed.slots)
         || !reader.read(kReasonWidth, storedReason) || !finish_padding(reader)) {
         return false;
     }
     parsed.epoch = static_cast<std::uint8_t>(epoch);
-    parsed.reason = static_cast<std::int8_t>(static_cast<std::int32_t>(storedReason) + kReasonBias);
+    parsed.reason = static_cast<std::int8_t>(static_cast<std::int32_t>(storedReason) - kReasonBias);
     body = parsed;
     return true;
 }
 
 /** Checks one exact message-24 body. */
-bool validate_claim_authority(std::span<const std::byte> input) noexcept {
+bool validate_retirement_authority(std::span<const std::byte> input) noexcept {
     ClaimAuthorityBody body{};
     return decode_claim_authority(input, body);
 }
@@ -148,10 +123,11 @@ bool encode_purge_authority(const PurgeAuthorityBody& body,
     }
     bits::Writer writer(output.first(kPurgeAuthorityByteCount));
     const std::uint32_t storedReason =
-        static_cast<std::uint32_t>(static_cast<std::int32_t>(body.reason) - kReasonBias);
+        static_cast<std::uint32_t>(static_cast<std::int32_t>(body.reason) + kReasonBias);
     return writer.write(storedReason, kReasonWidth) && writer.write(body.epoch, kByteWidth)
-           && write_mask(writer, body.slots) && writer.bit_count() == kPurgeAuthorityBitCount
-           && writer.finish(written) && written == kPurgeAuthorityByteCount;
+           && entity_slots::write_mask(writer, body.slots)
+           && writer.bit_count() == kPurgeAuthorityBitCount && writer.finish(written)
+           && written == kPurgeAuthorityByteCount;
 }
 
 /** Decodes one exact message-25 body. */
@@ -164,11 +140,11 @@ bool decode_purge_authority(std::span<const std::byte> input, PurgeAuthorityBody
     std::uint64_t epoch = 0;
     std::uint64_t storedReason = 0;
     if (!reader.read(kReasonWidth, storedReason) || !reader.read(kByteWidth, epoch)
-        || !read_mask(reader, parsed.slots) || !finish_padding(reader)) {
+        || !entity_slots::read_mask(reader, parsed.slots) || !finish_padding(reader)) {
         return false;
     }
     parsed.epoch = static_cast<std::uint8_t>(epoch);
-    parsed.reason = static_cast<std::int8_t>(static_cast<std::int32_t>(storedReason) + kReasonBias);
+    parsed.reason = static_cast<std::int8_t>(static_cast<std::int32_t>(storedReason) - kReasonBias);
     body = parsed;
     return true;
 }

+ 2 - 1
Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp

@@ -28,7 +28,8 @@ bool encode_replicate_membership(const MembershipSnapshot& snapshot,
                          && writer.write(1, 1) && write_region_block(writer, snapshot)
                          && writer.write(1, 1) && writer.write(occupied_member_mask(snapshot), 32)
                          && writer.write(1, 1) && writer.write(occupied_member_mask(snapshot), 32)
-                         && writer.write(0, 1) && writer.write(0, 1) && writer.write(0, 1);
+                         && writer.write(1, 1) && writer.write(active_view_mask(snapshot), 32)
+                         && writer.write(0, 1) && writer.write(0, 1);
     std::size_t encodedSize = 0;
     const std::size_t meaningfulBits = meaningful_bit_count(snapshot);
     if (!encoded || writer.bit_count() != meaningfulBits || !writer.finish(encodedSize)

+ 20 - 4
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

@@ -30,7 +30,8 @@ constexpr std::uint8_t kSlotTypeLifetime = 17;
 }
 
 /** Writes one MSB-first packed body without copying its padded final bits. */
-[[nodiscard]] bool write_packed(bits::Writer& writer, const AuthOverride& value) noexcept {
+template <typename PackedBody>
+[[nodiscard]] bool write_packed(bits::Writer& writer, const PackedBody& value) noexcept {
     bool encoded = true;
     std::size_t remaining = value.bitCount;
     for (std::size_t index = 0; encoded && remaining != 0; ++index) {
@@ -220,6 +221,16 @@ bool write_object_block(bits::Writer& writer,
     const bool emitSense = (flags & kSlotSenseFlag) != 0;
     const AuthOverride* const override =
         emitAuth ? matching_override(snapshot, objectTag, key, slotType, slotIndex) : nullptr;
+    const SenseOverride* sense = nullptr;
+    if (emitSense) {
+        for (const SenseOverride& candidate : snapshot.senseOverrides) {
+            if (candidate.objectTag == objectTag && candidate.key == key
+                && candidate.slotType == slotType && candidate.slotIndex == slotIndex) {
+                sense = &candidate;
+                break;
+            }
+        }
+    }
     // A mission-seed group is a placeholder with no default body. The spawn gate reads two of
     // them: the player key (this type-13) and the lifetime state (every type-17, because the
     // gate reads whichever registers first). Zeroing those strands the spawn.
@@ -229,7 +240,9 @@ bool write_object_block(bits::Writer& writer,
                                  : (emitAuth && (!missionSeedOnly || spawnBearing)
                                         ? auth_body_bits(snapshot, slotType, carriesPlayerKey)
                                         : 0);
-    const std::size_t remainder = (emitAuth ? 2U : 0U) + (emitSense ? 1U : 0U) + body;
+    // Sense adds its state flag, reset bit, complete delta and 32-bit counter.
+    const std::size_t senseBits = sense != nullptr ? 2U + sense->bitCount + kKeyWidth : 0U;
+    const std::size_t remainder = (emitAuth ? 2U : 0U) + (emitSense ? 1U : 0U) + body + senseBits;
     bool encoded = writer.write(1, kPresenceWidth) && writer.write(key, kKeyWidth)
                    && writer.write(std::uint32_t{slotType} + kSlotTypeBias, kSlotTypeWidth)
                    && writer.write(std::uint32_t{slotIndex} + kSlotIndexBias, kSlotIndexWidth)
@@ -245,9 +258,12 @@ bool write_object_block(bits::Writer& writer,
                           : write_auth_body(writer, snapshot, slotType, carriesPlayerKey);
         }
     }
-    // A sense-present bit of one costs 35 more bits, not one, so it is always sent absent.
     if (encoded && emitSense) {
-        encoded = writer.write(0, kPresenceWidth);
+        encoded = writer.write(sense != nullptr ? 1U : 0U, kPresenceWidth);
+        if (encoded && sense != nullptr) {
+            encoded = writer.write(0, kPresenceWidth) && writer.write(1, kPresenceWidth)
+                      && write_packed(writer, *sense) && writer.write(sense->counter, kKeyWidth);
+        }
     }
     return encoded && writer.bit_count() == start + remainder;
 }

+ 34 - 0
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp

@@ -198,6 +198,40 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
             return false;
         }
     }
+    if (snapshot.senseOverrides.size() > kAuthOverrideCapacity) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.senseOverrides.size(); ++index) {
+        const SenseOverride& value = snapshot.senseOverrides[index];
+        if (value.bitCount == 0 || value.byteCount != (value.bitCount + 7U) / 8U
+            || value.byteCount > value.body.size()) {
+            return false;
+        }
+        for (std::size_t earlier = 0; earlier < index; ++earlier) {
+            const SenseOverride& prior = snapshot.senseOverrides[earlier];
+            if (prior.objectTag == value.objectTag && prior.key == value.key
+                && prior.slotType == value.slotType && prior.slotIndex == value.slotIndex) {
+                return false;
+            }
+        }
+        std::size_t matches = 0;
+        for (const Group& group :
+             std::span(snapshot.roster.groups).first(snapshot.roster.groupCount)) {
+            if (group.key != value.key || group.objectTag != value.objectTag) {
+                continue;
+            }
+            for (std::size_t slot = 0; slot < group.slotTypes.size(); ++slot) {
+                if (group.slotTypes[slot] == value.slotType
+                    && group.slotIndices[slot] == value.slotIndex
+                    && (group.slotFlags[slot] & kSlotSenseFlag) != 0) {
+                    ++matches;
+                }
+            }
+        }
+        if (matches != 1) {
+            return false;
+        }
+    }
     return valid_client_sets(snapshot.roster);
 }
 

+ 1 - 1
Sunrise/src/middleware/bap/activity_message/entity_authority.h

@@ -29,7 +29,7 @@ inline constexpr std::uint8_t kMaximumSelector = 64;
 inline constexpr std::size_t kSelectorSize = sizeof(std::uint8_t);
 /** The reason on msg 26 and the leading field on msg 27 are 3 bits with bias 1. */
 inline constexpr std::uint8_t kReasonWidth = 3;
-/** The 3-bit reason decodes as wire plus 1, so the logical range is 1 to 8. */
+/** The 3-bit reason decodes as wire minus 1, so the logical range is -1 to 6. */
 inline constexpr std::int32_t kReasonBias = 1;
 /** Msg 26 is one selector byte, the mask, then the 3-bit reason. */
 inline constexpr std::size_t kAbandonBits =

+ 15 - 4
Sunrise/src/middleware/bap/activity_message/entity_slots.h

@@ -5,6 +5,9 @@
 #include <cstdint>
 #include <span>
 
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
 namespace sunrise::middleware::bap::activity_message::entity_slots {
 
 /** Activity message type 0 carries this payload in a server notification. */
@@ -17,14 +20,16 @@ inline constexpr std::size_t kWordCount = 256;
 inline constexpr std::size_t kEncodedSize = kWordCount * sizeof(std::uint32_t);
 /** Each mask byte carries 8 entity-slot lease flags. */
 inline constexpr std::size_t kBitsPerMaskByte = 8;
+/** Each reflected mask element is an unsigned 32-bit scalar. */
+inline constexpr std::uint8_t kBitsPerMaskWord = 32;
 /** The complete lease mask addresses 8,192 entity slots. */
 inline constexpr std::size_t kSlotCount = kEncodedSize * kBitsPerMaskByte;
 
-/** Wire-order bytes; slot i uses byte i / 8 and low bit i % 8. */
+/** Canonical slot bytes; slot i uses byte i / 8 and low bit i % 8. */
 using EntitySlotMask = std::array<std::byte, kEncodedSize>;
 
 /**
- * Decodes the fixed entity-slot prefix without changing its wire byte order.
+ * Decodes the fixed big-endian word prefix into canonical slot bytes.
  * @param input Type-21 payload holding the whole mask prefix.
  * @param mask Cleared first. Receives all mask bytes only on success.
  * @return True when the payload holds the whole fixed mask.
@@ -33,8 +38,8 @@ using EntitySlotMask = std::array<std::byte, kEncodedSize>;
                                        EntitySlotMask& mask) noexcept;
 
 /**
- * Encodes one selected entity-slot lease mask without changing its wire byte order.
- * @param mask Complete wire-order lease mask, chosen by the caller.
+ * Encodes canonical slot bytes as 256 big-endian words.
+ * @param mask Complete canonical lease mask, chosen by the caller.
  * @param output Caller-owned payload storage, unchanged on failure.
  * @param written Receives 1,024 on success, or zero on failure.
  * @return True when the whole fixed mask fits.
@@ -43,4 +48,10 @@ using EntitySlotMask = std::array<std::byte, kEncodedSize>;
                                        std::span<std::byte> output,
                                        std::size_t& written) noexcept;
 
+/** Reads the fixed u32 array at any bit offset into canonical slot bytes. */
+[[nodiscard]] bool read_mask(encoding::bits::Reader& reader, EntitySlotMask& mask) noexcept;
+/** Writes canonical slot bytes as fixed 32-bit scalar fields at any bit offset. */
+[[nodiscard]] bool write_mask(encoding::bits::Writer& writer,
+                              std::span<const std::byte, kEncodedSize> mask) noexcept;
+
 } // namespace sunrise::middleware::bap::activity_message::entity_slots

+ 13 - 7
Sunrise/src/middleware/bap/activity_message/replicate_membership.h

@@ -13,16 +13,16 @@ namespace sunrise::middleware::bap::activity_message::replicate_membership {
 
 /** Membership snapshots use activity message type 12. */
 inline constexpr std::uint32_t kMessageType = 12;
-/** One local player and no remote replication member is 30,045 meaningful bits. */
-inline constexpr std::size_t kMeaningfulBitCount = 30'045;
-/** The one-member snapshot is 3,756 bytes, the last carrying three zero padding bits. */
-inline constexpr std::size_t kEncodedSize = 3'756;
+/** One local player and an explicit empty view mask use 30,077 meaningful bits. */
+inline constexpr std::size_t kMeaningfulBitCount = 30'077;
+/** The one-member snapshot has three zero padding bits. */
+inline constexpr std::size_t kEncodedSize = 3'760;
 /** A remote row adds its channel, process identity, view identity, and player snapshot. */
 inline constexpr std::size_t kRemoteMemberBitDelta = 3'044;
-/** The complete two-member body carries 33,089 meaningful bits. */
-inline constexpr std::size_t kRemoteHostMeaningfulBitCount = 33'089;
+/** The complete two-member body carries 33,121 meaningful bits. */
+inline constexpr std::size_t kRemoteHostMeaningfulBitCount = 33'121;
 /** Byte extent of the two-member body, including its seven zero padding bits. */
-inline constexpr std::size_t kRemoteHostEncodedSize = 4'137;
+inline constexpr std::size_t kRemoteHostEncodedSize = 4'141;
 /** One filled descriptor makes its record 1,024 bits longer and shifts every later field. */
 inline constexpr std::size_t kDescriptorBitCount = gameplay::descriptor::kDescriptorSize * 8U;
 /** Byte size once one record carries a descriptor. */
@@ -143,6 +143,12 @@ occupied_member_mask(const MembershipSnapshot& snapshot) noexcept {
     return kLocalMemberMask | (snapshot.remoteViewMember.present ? kRemoteMemberMask : 0U);
 }
 
+/** The native view updater skips the own member and activates the advertised remote member. */
+[[nodiscard]] constexpr std::uint32_t
+active_view_mask(const MembershipSnapshot& snapshot) noexcept {
+    return snapshot.remoteViewMember.present ? kRemoteMemberMask : 0U;
+}
+
 /** @return Bits the local member's present region legs add. */
 [[nodiscard]] constexpr std::size_t
 region_leg_bit_count(const MembershipSnapshot& snapshot) noexcept {

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

@@ -9,6 +9,7 @@
 #include "activity_patch_epoch_parser.h"
 #include "definition.h"
 #include "squad_auth_body.h"
+#include "squad_sense_state.h"
 
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 
@@ -113,6 +114,18 @@ struct AuthOverride final {
     bool present{};
 };
 
+/** A full squad Sense baseline, including its delta root bit. */
+struct SenseOverride final {
+    std::array<std::byte, squad_sense::kMaximumByteCount> body{};
+    std::uint32_t objectTag{};
+    std::uint32_t key{};
+    std::uint32_t counter{};
+    std::size_t bitCount{};
+    std::size_t byteCount{};
+    std::uint16_t slotIndex{};
+    std::uint8_t slotType{};
+};
+
 /** Sub-blocks the delta's field 1 may carry. The wire array declares one element per bubble. */
 inline constexpr std::size_t kBubbleSubBlockCapacity = 64;
 /** Keys one sub-block may carry. Its wire key array declares 96. */
@@ -154,6 +167,7 @@ struct Snapshot final {
     Roster roster{};
     /** Exact typed bodies for slots already present in `roster`. */
     std::span<const AuthOverride> authOverrides{};
+    std::span<const SenseOverride> senseOverrides{};
     Grant grant{};
     /** Message 12's member record key. Zero leaves every type-13 block inert. */
     std::uint64_t playerKey{};

+ 249 - 0
Sunrise/src/middleware/bap/activity_message/squad_sense_state.cpp

@@ -0,0 +1,249 @@
+#include "squad_sense_state.h"
+
+#include <limits>
+
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::bap::activity_message::squad_sense {
+namespace {
+using sense_update::DecodedValue;
+using sense_update::ValueKind;
+/** Nested native schemas for the list, its signed counts, and quantized real array. */
+constexpr std::uint32_t kListSchema = 0x80807ECFU, kCountSchema = 0x80809491U;
+constexpr std::uint32_t kRealSchema = 0x80807ECDU;
+/** Root fields 0 through 5 carry these wire widths; field 4 is a quantized real. */
+constexpr std::array<std::uint8_t, kScalarCount> kScalarWidths{31, 31, 31, 6, 7, 31};
+constexpr std::size_t kRealField = 4;
+/** The two signed bytes use bias one; nested counts use the signed 32-bit minimum. */
+constexpr std::uint32_t kByteBias = 1, kCountBias = 0x80000000U;
+/** Root and both nested presence bits are always on the wire. */
+constexpr std::size_t kPresenceBits = 3;
+/** Nested count and real widths are fixed by their native schemas. */
+constexpr std::uint8_t kCountWidth = 4, kSignedWidth = 32, kRealWidth = 7;
+/** Required root fields occupy ordinals 6 through 10. */
+constexpr std::uint16_t kField6 = 6, kField7 = 7, kField8 = 8, kField9 = 9;
+constexpr std::uint16_t kInitializedField = 10;
+
+/** Validates a scalar's native identity before any persistent state changes. */
+bool matches(const DecodedValue& value,
+             std::uint32_t schema,
+             std::uint16_t ordinal,
+             std::uint32_t occurrence,
+             ValueKind kind,
+             std::uint8_t width,
+             bool optional) noexcept {
+    return value.schemaRow == schema && value.fieldOrdinal == ordinal
+           && value.occurrence == occurrence && value.kind == kind
+           && (value.present ? value.width == width : optional && value.width == 0);
+}
+
+/** Reads the decoder's exact field order and accounts for every retained wire bit. */
+class Cursor final {
+public:
+    explicit Cursor(std::span<const DecodedValue> values) noexcept : values_(values) {}
+
+    /**
+     * Refuses missing, reordered, or out-of-range scalar rows.
+     * @param schema Native schema hash.
+     * @param ordinal Native field ordinal.
+     * @param occurrence Array index.
+     * @param kind Scalar domain.
+     * @param width Native wire width.
+     * @param optional Whether absence is legal.
+     * @return The validated row or null.
+     */
+    const DecodedValue* take(std::uint32_t schema,
+                             std::uint16_t ordinal,
+                             std::uint32_t occurrence,
+                             ValueKind kind,
+                             std::uint8_t width,
+                             bool optional = false) noexcept {
+        if (values_.empty()
+            || !matches(values_.front(), schema, ordinal, occurrence, kind, width, optional))
+            return nullptr;
+        const auto* value = &values_.front();
+        values_ = values_.subspan(1);
+        bits += (optional ? 1U : 0U) + value->width;
+        return value;
+    }
+
+    bool next_schema(std::uint32_t schema) const noexcept {
+        return !values_.empty() && values_.front().schemaRow == schema;
+    }
+    bool empty() const noexcept {
+        return values_.empty();
+    }
+    std::size_t bits{kPresenceBits};
+
+private:
+    std::span<const DecodedValue> values_;
+};
+
+/**
+ * Absent optional values leave the last observed value unchanged.
+ * @param cursor Ordered decoded scalar rows.
+ * @param next Snapshot receiving the six root fields.
+ * @return False for a missing or invalid scalar.
+ */
+bool merge_scalars(Cursor& cursor, State& next) noexcept {
+    for (std::uint16_t index = 0; index < kScalarCount; ++index) {
+        const auto kind = index == kRealField ? ValueKind::real32 : ValueKind::signedInteger;
+        const auto* value = cursor.take(kSchema, index, 0, kind, kScalarWidths[index], true);
+        if (value == nullptr) return false;
+        if (!value->present) continue;
+        if (value->unsignedValue >= (std::uint64_t{1} << kScalarWidths[index])) return false;
+        next.scalars[index] = {static_cast<std::uint32_t>(value->unsignedValue), true};
+    }
+    return true;
+}
+
+/**
+ * The required fields accompany every nonempty squad delta.
+ * @param cursor Ordered decoded scalar rows.
+ * @param next Snapshot receiving required fields.
+ * @return False for a missing or invalid field.
+ */
+bool merge_required(Cursor& cursor, State& next) noexcept {
+    const auto* field6 = cursor.take(kSchema, kField6, 0, ValueKind::signedInteger, 2);
+    const auto* field7 = cursor.take(kSchema, kField7, 0, ValueKind::signedInteger, 3);
+    const auto* field8 = cursor.take(kSchema, kField8, 0, ValueKind::boolean, 1);
+    const auto* field9 = cursor.take(kSchema, kField9, 0, ValueKind::boolean, 1);
+    const auto* initialized = cursor.take(kSchema, kInitializedField, 0, ValueKind::boolean, 1);
+    if (field6 == nullptr || field7 == nullptr || field8 == nullptr || field9 == nullptr
+        || initialized == nullptr)
+        return false;
+    if (field6->signedValue < -1 || field6->signedValue > 2 || field7->signedValue < -1
+        || field7->signedValue > 6 || field8->unsignedValue > 1 || field9->unsignedValue > 1
+        || initialized->unsignedValue > 1)
+        return false;
+    next.field6 = static_cast<std::int8_t>(field6->signedValue);
+    next.field7 = static_cast<std::int8_t>(field7->signedValue);
+    next.field8 = field8->unsignedValue != 0;
+    next.field9 = field9->unsignedValue != 0;
+    next.initialized = initialized->unsignedValue != 0;
+    return true;
+}
+
+/**
+ * A shorter list removes its old tail; absent nested fields retain their snapshot values.
+ * @param cursor Ordered decoded scalar rows.
+ * @param next Snapshot receiving list and real changes.
+ * @return False for incomplete arrays or unsafe counts.
+ */
+bool merge_nested(Cursor& cursor, State& next) noexcept {
+    if (cursor.next_schema(kListSchema)) {
+        const auto* count = cursor.take(kListSchema, 0, 0, ValueKind::unsignedInteger, kCountWidth);
+        if (count == nullptr || count->unsignedValue > kCountCapacity) return false;
+        next.count = static_cast<std::uint8_t>(count->unsignedValue);
+        next.countsPresent = true;
+        next.counts.fill(0);
+        for (std::uint32_t index = 0; index < next.count; ++index) {
+            const auto* value =
+                cursor.take(kCountSchema, 0, index, ValueKind::signedInteger, kSignedWidth);
+            if (value == nullptr || value->signedValue < (std::numeric_limits<std::int32_t>::min)()
+                || value->signedValue > (std::numeric_limits<std::int32_t>::max)())
+                return false;
+            next.counts[index] = static_cast<std::int32_t>(value->signedValue);
+        }
+    }
+    if (cursor.next_schema(kRealSchema)) {
+        next.realsPresent = true;
+        for (std::uint32_t index = 0; index < kRealCount; ++index) {
+            const auto* value =
+                cursor.take(kRealSchema, 0, index, ValueKind::real32, kRealWidth, true);
+            if (value == nullptr) return false;
+            if (!value->present) continue;
+            if (value->unsignedValue >= (std::uint64_t{1} << kRealWidth)) return false;
+            next.reals[index] = {static_cast<std::uint32_t>(value->unsignedValue), true};
+        }
+    }
+    return cursor.empty();
+}
+} // namespace
+
+/**
+ * The initialized bit clears stale state before another snapshot can be published.
+ * @param state Persistent squad snapshot; unchanged on invalid input.
+ * @param object Complete decoded squad object with its wire counter.
+ * @param values Full packet value span indexed by the object.
+ * @return True when the delta was accepted, including an invalidating delta.
+ */
+bool merge(State& state,
+           const sense_update::DecodedObject& object,
+           std::span<const DecodedValue> values) noexcept {
+    if (object.status != sense_update::ObjectStatus::decoded || object.senseSchema != kSchema
+        || !object.hasGeneration || object.firstValue > values.size()
+        || object.valueCount > values.size() - object.firstValue)
+        return false;
+    if (object.deltaBits == 1 && object.valueCount == 0) {
+        state.counter = object.generationPlusOne;
+        return true;
+    }
+    Cursor cursor(values.subspan(object.firstValue, object.valueCount));
+    State next = state;
+    if (!merge_scalars(cursor, next) || !merge_required(cursor, next) || !merge_nested(cursor, next)
+        || cursor.bits != object.deltaBits)
+        return false;
+    if (!next.initialized)
+        next = {};
+    else
+        next.valid = true;
+    next.counter = object.generationPlusOne;
+    state = next;
+    return true;
+}
+
+/**
+ * A reset carries the full retained state with the original optional-field presence.
+ * @param state Initialized snapshot to publish.
+ * @param output Caller-owned body storage; contents unspecified on failure.
+ * @param bytes Receives used bytes, or zero on failure.
+ * @param bits Receives used bits, or zero on failure.
+ * @return False for invalid state or insufficient output space.
+ */
+bool encode(const State& state,
+            std::span<std::byte> output,
+            std::size_t& bytes,
+            std::size_t& bits) noexcept {
+    bytes = 0;
+    bits = 0;
+    if (!state.valid || !state.initialized || state.count > kCountCapacity || state.field6 < -1
+        || state.field6 > 2 || state.field7 < -1 || state.field7 > 6)
+        return false;
+    middleware::encoding::bits::Writer writer(output);
+    if (!writer.write(1, 1)) return false;
+    for (std::size_t index = 0; index < kScalarCount; ++index) {
+        const auto& value = state.scalars[index];
+        if (!writer.write(value.present, 1)) return false;
+        if (value.present
+            && (value.raw >= (std::uint64_t{1} << kScalarWidths[index])
+                || !writer.write(value.raw, kScalarWidths[index])))
+            return false;
+    }
+    if (!writer.write(static_cast<std::uint32_t>(state.field6) + kByteBias, 2)
+        || !writer.write(static_cast<std::uint32_t>(state.field7) + kByteBias, 3)
+        || !writer.write(state.field8, 1) || !writer.write(state.field9, 1)
+        || !writer.write(state.initialized, 1) || !writer.write(state.countsPresent, 1))
+        return false;
+    if (state.countsPresent) {
+        if (!writer.write(state.count, kCountWidth)) return false;
+        for (std::size_t index = 0; index < state.count; ++index)
+            if (!writer.write(static_cast<std::uint32_t>(state.counts[index]) + kCountBias,
+                              kSignedWidth))
+                return false;
+    }
+    if (!writer.write(state.realsPresent, 1)) return false;
+    if (state.realsPresent) {
+        for (const auto& value : state.reals) {
+            if (!writer.write(value.present, 1)) return false;
+            if (value.present
+                && (value.raw >= (std::uint64_t{1} << kRealWidth)
+                    || !writer.write(value.raw, kRealWidth)))
+                return false;
+        }
+    }
+    if (!writer.finish(bytes)) return false;
+    bits = writer.bit_count();
+    return true;
+}
+} // namespace sunrise::middleware::bap::activity_message::squad_sense

+ 53 - 0
Sunrise/src/middleware/bap/activity_message/squad_sense_state.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "sense_update.h"
+
+namespace sunrise::middleware::bap::activity_message::squad_sense {
+
+/** Native squad Sense schema and its fixed array capacities. */
+inline constexpr std::uint32_t kSchema = 0x80807ECCU;
+inline constexpr std::size_t kScalarCount = 6, kCountCapacity = 8, kRealCount = 24;
+/** Squad ClientRefs use type one; the largest full Sense body occupies 606 bits. */
+inline constexpr std::uint8_t kSlotType = 1;
+inline constexpr std::size_t kMaximumByteCount = 76;
+
+/** Presence keeps unknown fields at the client's schema defaults. */
+struct OptionalScalar final {
+    std::uint32_t raw{};
+    bool present{};
+};
+
+/** Quantized reals retain their wire values without a float round trip. */
+struct State final {
+    std::array<OptionalScalar, kScalarCount> scalars{};
+    std::array<std::int32_t, kCountCapacity> counts{};
+    std::array<OptionalScalar, kRealCount> reals{};
+    std::uint32_t counter{};
+    std::int8_t field6{};
+    std::int8_t field7{};
+    std::uint8_t count{};
+    bool field8{};
+    bool field9{};
+    bool initialized{};
+    bool countsPresent{};
+    bool realsPresent{};
+    bool valid{};
+};
+
+/** Merges one complete object from the full packet value span; failure leaves state unchanged. */
+[[nodiscard]] bool merge(State& state,
+                         const sense_update::DecodedObject& object,
+                         std::span<const sense_update::DecodedValue> values) noexcept;
+
+/** Encodes a full delta-root body; publication carries the counter separately. */
+[[nodiscard]] bool encode(const State& state,
+                          std::span<std::byte> output,
+                          std::size_t& bytes,
+                          std::size_t& bits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::squad_sense

+ 30 - 0
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec.h

@@ -41,6 +41,8 @@ struct FieldView final {
     std::uint8_t typeCode{};
     std::uint8_t presence{};
     std::uint8_t parameter2{};
+    std::uint32_t bitmapOffset{};
+    bool hasBitmapOffset{};
 };
 
 } // namespace runtime
@@ -93,6 +95,29 @@ enum class ValueRole : std::uint8_t {
     referenceValueTagB,
     referenceValueCompact,
     referenceValueIndex,
+    positionCompressed,
+    positionCodeX,
+    positionCodeY,
+    positionCodeZ,
+    positionPoint,
+    positionDirection,
+    opaqueScalarCode,
+    vectorUniform,
+    vectorUniformInteger,
+    vectorCodeX,
+    vectorCodeY,
+    vectorCodeZ,
+    vectorCodeW,
+    rotationAxisShortcut,
+    rotationAxisCode,
+    rotationAngleCode,
+};
+
+/** Position widths belong to the selected cell, independently of the reflection codec family. */
+struct PositionProfile final {
+    std::array<std::uint8_t, 3> axisBits{};
+    bool selectorPresent{};
+    bool hasWidths{};
 };
 
 using FindRuntimeSchema = bool (*)(const void*, std::uint32_t, runtime::SchemaView&) noexcept;
@@ -111,6 +136,11 @@ struct RuntimeSchemaResolver final {
     ResolveCommandPayload resolveCommandPayload{};
     ValidateRuntimeType validateType{};
     IsZeroBitRuntimeType isZeroBitType{};
+    const PositionProfile* positionProfile{};
+    bool (*recordPresence)(const void*, std::uint32_t, bool) noexcept {};
+    std::uint32_t firstFieldBit{};
+    std::uint8_t (*canonicalType)(const void*, std::uint8_t) noexcept {};
+    bool nativeCommandEmptyShortcut{};
 };
 
 /** Why one codec run stopped: complete, or the exact input it still needs. */

+ 203 - 8
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h

@@ -2,6 +2,7 @@
 
 #include <array>
 #include <bit>
+#include <cmath>
 #include <cstddef>
 #include <cstdint>
 #include <utility>
@@ -63,7 +64,8 @@ void fill_runtime_role_value(Value& value,
         || role == ValueRole::customIndexShort || role == ValueRole::nullable160Present
         || role == ValueRole::nullable160Compact || role == ValueRole::referenceValuePrimaryPresent
         || role == ValueRole::referenceValuePrimarySentinel
-        || role == ValueRole::referenceValuePresent || role == ValueRole::referenceValueCompact) {
+        || role == ValueRole::referenceValuePresent || role == ValueRole::referenceValueCompact
+        || role == ValueRole::vectorUniform || role == ValueRole::vectorUniformInteger) {
         value.kind = ValueKind::boolean;
     } else if (role == ValueRole::vectorX || role == ValueRole::vectorY
                || role == ValueRole::vectorZ || role == ValueRole::vectorW) {
@@ -78,7 +80,7 @@ public:
     RuntimeSchemaDecoder(const RuntimeSchemaResolver& resolver,
                          RuntimeBitReader& reader,
                          Sink& sink) noexcept
-        : resolver_(resolver), reader_(reader), sink_(sink) {}
+        : resolver_(resolver), reader_(reader), sink_(sink), bitmapBase_(resolver.firstFieldBit) {}
 
     /** Decodes one root and reports exact value and bit consumption. */
     [[nodiscard]] RuntimeWalkStatus full(const runtime::SchemaView& schema) noexcept {
@@ -86,6 +88,18 @@ public:
     }
 
 private:
+    /** A nested schema restores its caller's bitmap base on every return path. */
+    struct BitmapScope {
+        std::uint32_t& target;
+        std::uint32_t prior;
+        BitmapScope(std::uint32_t& value, std::uint32_t replacement) noexcept
+            : target(value), prior(value) {
+            target = replacement;
+        }
+        ~BitmapScope() {
+            target = prior;
+        }
+    };
     [[nodiscard]] bool step() noexcept {
         return walk_.step();
     }
@@ -245,6 +259,12 @@ private:
                 return RuntimeWalkStatus::malformed;
             }
             sink_.append(owner, field, occurrence, at, width, value, true, role);
+            if (resolver_.nativeCommandEmptyShortcut && role == ValueRole::commandDefault
+                && value != 0)
+                return RuntimeWalkStatus::complete;
+            if (resolver_.nativeCommandEmptyShortcut && role == ValueRole::commandTargetReference
+                && value != 0)
+                return RuntimeWalkStatus::unsupportedField;
             if (role == ValueRole::commandSelector) {
                 selector = static_cast<std::uint8_t>(value);
             }
@@ -322,10 +342,34 @@ private:
         return RuntimeWalkStatus::complete;
     }
 
-    /** Type 13 exposes three raw IEEE-754 components as separate values. */
+    /** Type 13 preserves raw floats or cell-profile quantized codes without a float round trip. */
     [[nodiscard]] RuntimeWalkStatus walk_vector3(const runtime::SchemaView& owner,
                                                  const runtime::FieldView& field,
                                                  std::uint32_t occurrence) noexcept {
+        const auto* profile = resolver_.positionProfile;
+        bool compressed = false;
+        if (profile != nullptr && profile->selectorPresent) {
+            const auto at = static_cast<std::uint32_t>(reader_.position());
+            std::uint64_t value = 0;
+            if (!reader_.read(1, value)) return RuntimeWalkStatus::malformed;
+            compressed = value != 0;
+            sink_.append(
+                owner, field, occurrence, at, 1, value, true, ValueRole::positionCompressed);
+        }
+        if (compressed) {
+            if (!profile->hasWidths) return RuntimeWalkStatus::unsupportedField;
+            constexpr std::array<ValueRole, 3> roles{
+                ValueRole::positionCodeX, ValueRole::positionCodeY, ValueRole::positionCodeZ};
+            for (std::size_t index = 0; index < roles.size(); ++index) {
+                const auto width = profile->axisBits[index];
+                if (width >= 32) return RuntimeWalkStatus::unsupportedField;
+                const auto at = static_cast<std::uint32_t>(reader_.position());
+                std::uint64_t value = 0;
+                if (!reader_.read(width, value)) return RuntimeWalkStatus::malformed;
+                sink_.append(owner, field, occurrence, at, width, value, true, roles[index]);
+            }
+            return RuntimeWalkStatus::complete;
+        }
         for (const ValueRole role : {ValueRole::vectorX, ValueRole::vectorY, ValueRole::vectorZ}) {
             const std::uint32_t at = static_cast<std::uint32_t>(reader_.position());
             std::uint64_t value = 0;
@@ -337,6 +381,39 @@ private:
         return RuntimeWalkStatus::complete;
     }
 
+    /** Type 15 distinguishes points, raw directions, and positions with explicit W. */
+    [[nodiscard]] RuntimeWalkStatus walk_vector4(const runtime::SchemaView& owner,
+                                                 const runtime::FieldView& field,
+                                                 std::uint32_t occurrence) noexcept {
+        const auto at = static_cast<std::uint32_t>(reader_.position());
+        std::uint64_t point = 0, direction = 0;
+        if (!reader_.read(1, point)) return RuntimeWalkStatus::malformed;
+        sink_.append(owner, field, occurrence, at, 1, point, true, ValueRole::positionPoint);
+        if (point == 0) {
+            if (!reader_.read(1, direction)) return RuntimeWalkStatus::malformed;
+            sink_.append(
+                owner, field, occurrence, at + 1, 1, direction, true, ValueRole::positionDirection);
+        }
+        if (direction != 0) {
+            for (const auto role : {ValueRole::vectorX, ValueRole::vectorY, ValueRole::vectorZ}) {
+                const auto position = static_cast<std::uint32_t>(reader_.position());
+                std::uint64_t raw = 0;
+                if (!reader_.read(32, raw)) return RuntimeWalkStatus::malformed;
+                sink_.append(owner, field, occurrence, position, 32, raw, true, role);
+            }
+        } else {
+            const auto status = walk_vector3(owner, field, occurrence);
+            if (status != RuntimeWalkStatus::complete) return status;
+        }
+        if (point == 0 && direction == 0) {
+            const auto position = static_cast<std::uint32_t>(reader_.position());
+            std::uint64_t raw = 0;
+            if (!reader_.read(32, raw)) return RuntimeWalkStatus::malformed;
+            sink_.append(owner, field, occurrence, position, 32, raw, true, ValueRole::vectorW);
+        }
+        return RuntimeWalkStatus::complete;
+    }
+
     /** Type 14 exposes structural codes so replay does not lose precision. */
     [[nodiscard]] RuntimeWalkStatus walk_compressed_vector(const runtime::SchemaView& owner,
                                                            const runtime::FieldView& field,
@@ -529,12 +606,112 @@ private:
         if (depth >= kMaximumRuntimeDepth || !step()) {
             return RuntimeWalkStatus::unsafeCount;
         }
+        if (resolver_.canonicalType != nullptr)
+            field.typeCode = resolver_.canonicalType(resolver_.context, field.typeCode);
         if (field.typeCode == 13) {
             return walk_vector3(owner, field, occurrence);
         }
-        if (field.typeCode == 14) {
+        if (field.typeCode == 16) {
+            const auto at = static_cast<std::uint32_t>(reader_.position());
+            std::uint64_t shortcut = 0, axis = 0, angle = 0;
+            if (!reader_.read(1, shortcut)) return RuntimeWalkStatus::malformed;
+            const std::uint8_t axisWidth = shortcut != 0 ? 1 : 19;
+            if (!reader_.read(axisWidth, axis) || !reader_.read(7, angle))
+                return RuntimeWalkStatus::malformed;
+            sink_.append(
+                owner, field, occurrence, at, 1, shortcut, true, ValueRole::rotationAxisShortcut);
+            sink_.append(owner,
+                         field,
+                         occurrence,
+                         at + 1,
+                         axisWidth,
+                         axis,
+                         true,
+                         ValueRole::rotationAxisCode);
+            sink_.append(owner,
+                         field,
+                         occurrence,
+                         at + 1 + axisWidth,
+                         7,
+                         angle,
+                         true,
+                         ValueRole::rotationAngleCode);
+            return RuntimeWalkStatus::complete;
+        }
+        if (field.typeCode == 42) {
+            const auto at = static_cast<std::uint32_t>(reader_.position());
+            std::uint64_t uniform = 0;
+            if (!reader_.read(1, uniform)) return RuntimeWalkStatus::malformed;
+            sink_.append(owner, field, occurrence, at, 1, uniform, true, ValueRole::vectorUniform);
+            const bool quantized = field.parameter2 > 0 && field.parameter2 < 32;
+            unsigned width = quantized ? field.parameter2 : 32;
+            if (uniform != 0 && quantized) {
+                std::uint64_t integer = 0;
+                if (!reader_.read(1, integer)) return RuntimeWalkStatus::malformed;
+                sink_.append(owner,
+                             field,
+                             occurrence,
+                             at + 1,
+                             1,
+                             integer,
+                             true,
+                             ValueRole::vectorUniformInteger);
+                if (integer != 0) {
+                    const float minimum =
+                        std::bit_cast<float>(static_cast<std::uint32_t>(field.biasOrDynamic));
+                    const float maximum =
+                        std::bit_cast<float>(static_cast<std::uint32_t>(field.widthOrCountOffset));
+                    const float range = maximum - minimum;
+                    if (!std::isfinite(range) || range < 0 || range >= 2147483648.0F)
+                        return RuntimeWalkStatus::unsupportedField;
+                    width = std::bit_width(static_cast<std::uint32_t>(range));
+                }
+            }
+            constexpr std::array roles{ValueRole::vectorCodeX,
+                                       ValueRole::vectorCodeY,
+                                       ValueRole::vectorCodeZ,
+                                       ValueRole::vectorCodeW};
+            for (unsigned index = 0; index < (uniform != 0 ? 1U : 4U); ++index) {
+                const auto offset = static_cast<std::uint32_t>(reader_.position());
+                std::uint64_t code = 0;
+                if (width != 0 && !reader_.read(static_cast<std::uint8_t>(width), code))
+                    return RuntimeWalkStatus::malformed;
+                sink_.append(owner,
+                             field,
+                             occurrence,
+                             offset,
+                             static_cast<std::uint8_t>(width),
+                             code,
+                             true,
+                             roles[index]);
+            }
+            return RuntimeWalkStatus::complete;
+        }
+        if (field.typeCode == 44 || field.typeCode == 45) {
+            const auto width =
+                field.typeCode == 45
+                    ? (field.parameter2 > 0 && field.parameter2 < 32 ? field.parameter2 : 32)
+                    : field.widthOrCountOffset;
+            const auto at = static_cast<std::uint32_t>(reader_.position());
+            std::uint64_t code = 0;
+            if (width <= 0 || width > 32 || !reader_.read(static_cast<std::uint8_t>(width), code))
+                return RuntimeWalkStatus::malformed;
+            sink_.append(owner,
+                         field,
+                         occurrence,
+                         at,
+                         static_cast<std::uint8_t>(width),
+                         code,
+                         true,
+                         ValueRole::opaqueScalarCode);
+            return RuntimeWalkStatus::complete;
+        }
+        if (field.typeCode == 14 || field.typeCode == 40) {
             return walk_compressed_vector(owner, field, occurrence);
         }
+        if (field.typeCode == 15) {
+            return walk_vector4(owner, field, occurrence);
+        }
         if (field.typeCode == 18) {
             return walk_entity_reference(owner, field, occurrence);
         }
@@ -634,9 +811,11 @@ private:
         const bool command = field.typeCode == 36;
         const bool entityReference = field.typeCode == 18;
         const bool schemaReference = field.typeCode == 23;
-        const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 22
-                            || field.typeCode == 19 || field.typeCode == 24 || field.typeCode == 25
-                            || field.typeCode == 26 || field.typeCode == 28 || field.typeCode == 43;
+        const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 15
+                            || field.typeCode == 16 || field.typeCode == 22 || field.typeCode == 19
+                            || field.typeCode == 24 || field.typeCode == 25 || field.typeCode == 26
+                            || field.typeCode == 28 || field.typeCode == 40 || field.typeCode == 42
+                            || field.typeCode == 43 || field.typeCode == 44 || field.typeCode == 45;
         const bool raw64 = field.typeCode == 35;
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);
@@ -644,7 +823,9 @@ private:
         // not also declare a nested schema row.
         const bool nestedSchemaKind =
             nested || command || entityReference || schemaReference || custom;
-        const bool structural = nestedSchemaKind || selected || zeroBit;
+        const bool unknownOptional = field.presence != 0 && !nestedSchemaKind && !selected
+                                     && !zeroBit && !raw64 && storage_width(field.typeCode) == 0;
+        const bool structural = nestedSchemaKind || selected || zeroBit || unknownOptional;
         if (!nestedSchemaKind && field.nestedSchemaRow != runtime::kAbsentRuntimeRow) {
             return RuntimeWalkStatus::unsupportedField;
         }
@@ -654,6 +835,15 @@ private:
             return RuntimeWalkStatus::unsupportedField;
         }
         for (std::uint32_t index = 0; index < repeats; ++index) {
+            const std::uint64_t relative = static_cast<std::uint64_t>(field.bitmapOffset)
+                                           * (owner.arrayLength != 0 ? index : 1U);
+            if (relative > (std::numeric_limits<std::uint32_t>::max)() - bitmapBase_)
+                return RuntimeWalkStatus::unsafeCount;
+            const auto fieldBit = bitmapBase_ + static_cast<std::uint32_t>(relative);
+            if (resolver_.recordPresence != nullptr && (field.presence != 0 || nested)
+                && !field.hasBitmapOffset)
+                return RuntimeWalkStatus::schemaUnavailable;
+            BitmapScope bitmapScope(bitmapBase_, fieldBit + (field.presence != 0 ? 1U : 0U));
             if (!step()) {
                 return RuntimeWalkStatus::unsafeCount;
             }
@@ -667,6 +857,9 @@ private:
                 if (!reader_.read(1, present)) {
                     return RuntimeWalkStatus::malformed;
                 }
+                if (resolver_.recordPresence != nullptr
+                    && !resolver_.recordPresence(resolver_.context, fieldBit, present != 0))
+                    return RuntimeWalkStatus::unsafeCount;
                 if (structural) {
                     sink_.append(owner,
                                  field,
@@ -684,6 +877,7 @@ private:
                     continue;
                 }
             }
+            if (unknownOptional) return RuntimeWalkStatus::unsupportedField;
             if (nested) {
                 const RuntimeWalkStatus status = walk_nested(owner, field, memory, depth);
                 if (status != RuntimeWalkStatus::complete) {
@@ -756,6 +950,7 @@ private:
     const RuntimeSchemaResolver& resolver_;
     RuntimeBitReader& reader_;
     Sink& sink_;
+    std::uint32_t bitmapBase_{};
     RuntimeWalkState walk_{};
 };
 

+ 160 - 6
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h

@@ -298,6 +298,12 @@ private:
             if (!writer_.write(value.unsignedValue, width)) {
                 return RuntimeWalkStatus::outputTooSmall;
             }
+            if (resolver_.nativeCommandEmptyShortcut && role == ValueRole::commandDefault
+                && value.unsignedValue != 0)
+                return RuntimeWalkStatus::complete;
+            if (resolver_.nativeCommandEmptyShortcut && role == ValueRole::commandTargetReference
+                && value.unsignedValue != 0)
+                return RuntimeWalkStatus::unsupportedField;
             if (role == ValueRole::commandSelector) {
                 selector = static_cast<std::uint8_t>(value.unsignedValue);
             }
@@ -364,10 +370,36 @@ private:
                    : RuntimeWalkStatus::outputTooSmall;
     }
 
-    /** Type 13 retains three raw IEEE-754 components in wire order. */
+    /** Type 13 replays the selected cell's quantized codes or raw float components. */
     [[nodiscard]] RuntimeWalkStatus write_vector3(const runtime::SchemaView& owner,
                                                   const runtime::FieldView& field,
                                                   std::uint32_t occurrence) noexcept {
+        const auto* profile = resolver_.positionProfile;
+        bool compressed = false;
+        if (profile != nullptr && profile->selectorPresent) {
+            RuntimeAuthoredValue value{};
+            if (!source_.take(owner, field, occurrence, ValueRole::positionCompressed, value)
+                || value.kind != ValueKind::unsignedInteger || value.unsignedValue > 1
+                || !writer_.write(value.unsignedValue, 1))
+                return RuntimeWalkStatus::missingValue;
+            compressed = value.unsignedValue != 0;
+        }
+        if (compressed) {
+            if (!profile->hasWidths) return RuntimeWalkStatus::unsupportedField;
+            constexpr std::array<ValueRole, 3> roles{
+                ValueRole::positionCodeX, ValueRole::positionCodeY, ValueRole::positionCodeZ};
+            for (std::size_t index = 0; index < roles.size(); ++index) {
+                const auto width = profile->axisBits[index];
+                if (width >= 32) return RuntimeWalkStatus::unsupportedField;
+                RuntimeAuthoredValue value{};
+                if (!source_.take(owner, field, occurrence, roles[index], value)
+                    || value.kind != ValueKind::unsignedInteger
+                    || value.unsignedValue >= (std::uint64_t{1} << width)
+                    || !writer_.write(value.unsignedValue, width))
+                    return RuntimeWalkStatus::missingValue;
+            }
+            return RuntimeWalkStatus::complete;
+        }
         for (const ValueRole role : {ValueRole::vectorX, ValueRole::vectorY, ValueRole::vectorZ}) {
             RuntimeAuthoredValue value{};
             if (!source_.take(owner, field, occurrence, role, value)
@@ -379,6 +411,43 @@ private:
         return RuntimeWalkStatus::complete;
     }
 
+    /** Type 15 preserves the native point, direction, and explicit-W selectors. */
+    [[nodiscard]] RuntimeWalkStatus write_vector4(const runtime::SchemaView& owner,
+                                                  const runtime::FieldView& field,
+                                                  std::uint32_t occurrence) noexcept {
+        RuntimeAuthoredValue point{}, direction{};
+        if (!source_.take(owner, field, occurrence, ValueRole::positionPoint, point)
+            || point.kind != ValueKind::unsignedInteger || point.unsignedValue > 1
+            || !writer_.write(point.unsignedValue, 1))
+            return RuntimeWalkStatus::missingValue;
+        if (point.unsignedValue == 0) {
+            if (!source_.take(owner, field, occurrence, ValueRole::positionDirection, direction)
+                || direction.kind != ValueKind::unsignedInteger || direction.unsignedValue > 1
+                || !writer_.write(direction.unsignedValue, 1))
+                return RuntimeWalkStatus::missingValue;
+        }
+        if (direction.unsignedValue != 0) {
+            for (const auto role : {ValueRole::vectorX, ValueRole::vectorY, ValueRole::vectorZ}) {
+                RuntimeAuthoredValue value{};
+                if (!source_.take(owner, field, occurrence, role, value)
+                    || value.kind != ValueKind::real32
+                    || !writer_.write(std::bit_cast<std::uint32_t>(value.realValue), 32))
+                    return RuntimeWalkStatus::missingValue;
+            }
+        } else {
+            const auto status = write_vector3(owner, field, occurrence);
+            if (status != RuntimeWalkStatus::complete) return status;
+        }
+        if (point.unsignedValue == 0 && direction.unsignedValue == 0) {
+            RuntimeAuthoredValue value{};
+            if (!source_.take(owner, field, occurrence, ValueRole::vectorW, value)
+                || value.kind != ValueKind::real32
+                || !writer_.write(std::bit_cast<std::uint32_t>(value.realValue), 32))
+                return RuntimeWalkStatus::missingValue;
+        }
+        return RuntimeWalkStatus::complete;
+    }
+
     /** Type 14 retains the zero shortcut or its direction and magnitude codes. */
     [[nodiscard]] RuntimeWalkStatus write_compressed_vector(const runtime::SchemaView& owner,
                                                             const runtime::FieldView& field,
@@ -563,12 +632,91 @@ private:
         if (depth >= kMaximumRuntimeDepth || !step()) {
             return RuntimeWalkStatus::unsafeCount;
         }
+        if (resolver_.canonicalType != nullptr)
+            field.typeCode = resolver_.canonicalType(resolver_.context, field.typeCode);
         if (field.typeCode == 13) {
             return write_vector3(owner, field, occurrence);
         }
-        if (field.typeCode == 14) {
+        if (field.typeCode == 16) {
+            RuntimeAuthoredValue shortcut{}, axis{}, angle{};
+            if (!source_.take(owner, field, occurrence, ValueRole::rotationAxisShortcut, shortcut)
+                || !source_.take(owner, field, occurrence, ValueRole::rotationAxisCode, axis)
+                || !source_.take(owner, field, occurrence, ValueRole::rotationAngleCode, angle)
+                || shortcut.kind != ValueKind::unsignedInteger || shortcut.unsignedValue > 1
+                || axis.kind != ValueKind::unsignedInteger
+                || angle.kind != ValueKind::unsignedInteger || angle.unsignedValue > 127)
+                return RuntimeWalkStatus::missingValue;
+            const std::uint8_t axisWidth = shortcut.unsignedValue != 0 ? 1 : 19;
+            if (axis.unsignedValue > mask(axisWidth)) return RuntimeWalkStatus::missingValue;
+            return writer_.write(shortcut.unsignedValue, 1)
+                           && writer_.write(axis.unsignedValue, axisWidth)
+                           && writer_.write(angle.unsignedValue, 7)
+                       ? RuntimeWalkStatus::complete
+                       : RuntimeWalkStatus::outputTooSmall;
+        }
+        if (field.typeCode == 42) {
+            RuntimeAuthoredValue uniform{};
+            if (!source_.take(owner, field, occurrence, ValueRole::vectorUniform, uniform)
+                || uniform.kind != ValueKind::boolean || uniform.unsignedValue > 1
+                || !writer_.write(uniform.unsignedValue, 1))
+                return RuntimeWalkStatus::missingValue;
+            const bool quantized = field.parameter2 > 0 && field.parameter2 < 32;
+            unsigned width = quantized ? field.parameter2 : 32;
+            if (uniform.unsignedValue != 0 && quantized) {
+                RuntimeAuthoredValue integer{};
+                if (!source_.take(
+                        owner, field, occurrence, ValueRole::vectorUniformInteger, integer)
+                    || integer.kind != ValueKind::boolean || integer.unsignedValue > 1
+                    || !writer_.write(integer.unsignedValue, 1))
+                    return RuntimeWalkStatus::missingValue;
+                if (integer.unsignedValue != 0) {
+                    const float minimum =
+                        std::bit_cast<float>(static_cast<std::uint32_t>(field.biasOrDynamic));
+                    const float maximum =
+                        std::bit_cast<float>(static_cast<std::uint32_t>(field.widthOrCountOffset));
+                    const float range = maximum - minimum;
+                    if (!std::isfinite(range) || range < 0 || range >= 2147483648.0F)
+                        return RuntimeWalkStatus::unsupportedField;
+                    width = std::bit_width(static_cast<std::uint32_t>(range));
+                }
+            }
+            constexpr std::array roles{ValueRole::vectorCodeX,
+                                       ValueRole::vectorCodeY,
+                                       ValueRole::vectorCodeZ,
+                                       ValueRole::vectorCodeW};
+            for (unsigned index = 0; index < (uniform.unsignedValue != 0 ? 1U : 4U); ++index) {
+                RuntimeAuthoredValue code{};
+                if (!source_.take(owner, field, occurrence, roles[index], code)
+                    || code.kind != ValueKind::unsignedInteger
+                    || code.unsignedValue > mask(static_cast<std::uint8_t>(width)))
+                    return RuntimeWalkStatus::missingValue;
+                if (width != 0
+                    && !writer_.write(code.unsignedValue, static_cast<std::uint8_t>(width)))
+                    return RuntimeWalkStatus::outputTooSmall;
+            }
+            return RuntimeWalkStatus::complete;
+        }
+        if (field.typeCode == 44 || field.typeCode == 45) {
+            const auto width =
+                field.typeCode == 45
+                    ? (field.parameter2 > 0 && field.parameter2 < 32 ? field.parameter2 : 32)
+                    : field.widthOrCountOffset;
+            RuntimeAuthoredValue code{};
+            if (width <= 0 || width > 32
+                || !source_.take(owner, field, occurrence, ValueRole::opaqueScalarCode, code)
+                || code.kind != ValueKind::unsignedInteger
+                || code.unsignedValue > mask(static_cast<std::uint8_t>(width)))
+                return RuntimeWalkStatus::missingValue;
+            return writer_.write(code.unsignedValue, static_cast<std::uint8_t>(width))
+                       ? RuntimeWalkStatus::complete
+                       : RuntimeWalkStatus::outputTooSmall;
+        }
+        if (field.typeCode == 14 || field.typeCode == 40) {
             return write_compressed_vector(owner, field, occurrence);
         }
+        if (field.typeCode == 15) {
+            return write_vector4(owner, field, occurrence);
+        }
         if (field.typeCode == 18) {
             return write_entity_reference(owner, field, occurrence);
         }
@@ -644,16 +792,21 @@ private:
         const bool command = field.typeCode == 36;
         const bool entityReference = field.typeCode == 18;
         const bool schemaReference = field.typeCode == 23;
-        const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 22
-                            || field.typeCode == 19 || field.typeCode == 24 || field.typeCode == 25
-                            || field.typeCode == 26 || field.typeCode == 28 || field.typeCode == 43;
+        const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 15
+                            || field.typeCode == 16 || field.typeCode == 22 || field.typeCode == 19
+                            || field.typeCode == 24 || field.typeCode == 25 || field.typeCode == 26
+                            || field.typeCode == 28 || field.typeCode == 40 || field.typeCode == 42
+                            || field.typeCode == 43 || field.typeCode == 44 || field.typeCode == 45;
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);
         // A selected field carries its schema handle on the wire, so it is structural but must
         // not also declare a nested schema row.
         const bool nestedSchemaKind =
             nested || command || entityReference || schemaReference || custom;
-        const bool structural = nestedSchemaKind || selected || zeroBit;
+        const bool unknownOptional = field.presence != 0 && !nestedSchemaKind && !selected
+                                     && !zeroBit && field.typeCode != 35
+                                     && storage_width(field.typeCode) == 0;
+        const bool structural = nestedSchemaKind || selected || zeroBit || unknownOptional;
         if (!nestedSchemaKind && field.nestedSchemaRow != runtime::kAbsentRuntimeRow) {
             return RuntimeWalkStatus::unsupportedField;
         }
@@ -687,6 +840,7 @@ private:
                     continue;
                 }
             }
+            if (unknownOptional) return RuntimeWalkStatus::unsupportedField;
             if (nested) {
                 const RuntimeWalkStatus status = walk_nested(owner, field, memory, depth);
                 if (status != RuntimeWalkStatus::complete) {

+ 238 - 0
Sunrise/src/middleware/content/packages/tables/entity_position_profile_extractor.cpp

@@ -0,0 +1,238 @@
+#include "entity_position_profile_extractor.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <limits>
+#include <map>
+
+namespace sunrise::middleware::content::packages::position_profiles {
+namespace {
+/** These class ids and strides belong to the installed package layouts. */
+constexpr std::uint32_t kMapRoot = 0x808091DE, kMap = 0x80809962, kBubble = 0x80807DAE;
+constexpr std::uint32_t kBounds = 0x8080964E, kScenario = 0x80809994, kAbsent = 0xFFFFFFFF;
+using Blob = std::vector<std::byte>;
+using Rows = state::gameplay::entity_position_profiles::Rows;
+template <class T> T value(std::span<const std::byte> bytes, std::size_t offset) {
+    T result{};
+    if (offset > bytes.size() || sizeof result > bytes.size() - offset) throw 0;
+    std::memcpy(&result, bytes.data() + offset, sizeof result);
+    return result;
+}
+struct Map final {
+    std::vector<std::uint32_t> hashes, owners;
+    std::vector<std::pair<std::uint16_t, std::array<std::uint8_t, 3>>> cells;
+    bool base{};
+};
+struct Extractor final {
+    std::span<const KeyTag> keys;
+    Read read;
+    void* context;
+    std::map<std::uint32_t, std::array<std::uint8_t, 3>> bounds;
+    Blob tag(std::uint32_t handle, std::uint32_t cls) {
+        Blob bytes;
+        if (!read(context, handle, cls, bytes)) throw 0;
+        return bytes;
+    }
+    /** Unresolved keys cannot substitute for a validated live tag. */
+    std::pair<std::uint32_t, Blob>
+    reference(std::span<const std::byte> bytes, std::size_t offset, std::uint32_t cls) {
+        auto handle = value<std::uint32_t>(bytes, offset);
+        if (handle == kAbsent) {
+            const auto key = value<std::uint64_t>(bytes, offset + 8);
+            const auto found =
+                std::lower_bound(keys.begin(), keys.end(), key, [](const KeyTag& row, auto target) {
+                    return row.key < target;
+                });
+            if (found == keys.end() || found->key != key || found->classId != cls) throw 0;
+            handle = found->tag;
+        }
+        return {handle, tag(handle, cls)};
+    }
+    std::vector<std::size_t> members(std::span<const std::byte> bytes,
+                                     std::size_t field,
+                                     std::size_t stride,
+                                     std::uint32_t cls) {
+        std::vector<std::size_t> offsets;
+        if (!array(bytes, field, stride, cls, offsets)) throw 0;
+        return offsets;
+    }
+    /** Every referenced bound must resolve before its union can be used. */
+    std::array<std::uint8_t, 3> bubble(std::span<const std::byte> root, std::size_t offset) {
+        auto [handle, bytes] = reference(root, offset, kBubble);
+        if (const auto cached = bounds.find(handle); cached != bounds.end()) return cached->second;
+        constexpr float maximum = (std::numeric_limits<float>::max)();
+        std::array<float, 3> low{maximum, maximum, maximum}, high{-maximum, -maximum, -maximum};
+        bool any = false;
+        for (auto member : members(bytes, 64, 16, 0x80809644)) {
+            const auto [boundTag, bound] = reference(bytes, member, kBounds);
+            (void)boundTag;
+            if (bound.size() != 96) throw 0;
+            std::array<float, 3> a{}, b{};
+            for (std::size_t axis = 0; axis < 3; ++axis) {
+                a[axis] = value<float>(bound, axis * 4);
+                b[axis] = value<float>(bound, 16 + axis * 4);
+            }
+            if (a == std::array<float, 3>{maximum, maximum, maximum}
+                && b == std::array<float, 3>{-maximum, -maximum, -maximum})
+                continue;
+            for (std::size_t axis = 0; axis < 3; ++axis) {
+                if (!std::isfinite(a[axis]) || !std::isfinite(b[axis]) || a[axis] > b[axis])
+                    throw 0;
+                low[axis] = (std::min)(low[axis], a[axis]);
+                high[axis] = (std::max)(high[axis], b[axis]);
+            }
+            any = true;
+        }
+        if (!any) throw 0;
+        std::array<std::uint8_t, 3> bits{};
+        for (std::size_t axis = 0; axis < 3; ++axis) {
+            const float extent = high[axis] - low[axis];
+            const float scaled = extent * 20.0F;
+            const double steps = std::ceil(static_cast<double>(scaled));
+            if (!std::isfinite(steps) || steps < 0 || steps > 2147483648.0) throw 0;
+            auto remaining = steps <= 1 ? 0U : static_cast<std::uint32_t>(steps - 1);
+            while (remaining) {
+                ++bits[axis];
+                remaining >>= 1;
+            }
+        }
+        bounds.emplace(handle, bits);
+        return bits;
+    }
+    /** Cell ownership must agree with the map's ordered bubble table. */
+    Map map(const NamedTag& name) {
+        const auto root = tag(name.tag, kMapRoot);
+        const auto definition = tag(value<std::uint32_t>(root, 8), kMap);
+        const auto refs = members(root, 16, 16, 0x80807D53);
+        const auto cells = members(definition, 16, 24, 0x808098C4);
+        const auto bubbles = members(definition, 32, 80, 0x808098C2);
+        if (refs.empty() || refs.size() != bubbles.size()) throw 0;
+        Map result;
+        result.base = name.basePackage;
+        for (std::size_t index = 0; index < bubbles.size(); ++index) {
+            if (value<std::uint32_t>(definition, bubbles[index] + 72) != index) throw 0;
+            result.hashes.push_back(value<std::uint32_t>(definition, bubbles[index]));
+        }
+        for (std::size_t index = 0; index < cells.size(); ++index) {
+            auto owner = value<std::uint32_t>(definition, cells[index] + 16);
+            if (owner == kAbsent && index < 256) {
+                for (std::size_t bubbleIndex = 0; bubbleIndex < bubbles.size(); ++bubbleIndex) {
+                    if ((value<std::uint8_t>(definition, bubbles[bubbleIndex] + 40 + index / 8)
+                         & (1U << (index % 8)))
+                        != 0) {
+                        owner = static_cast<std::uint32_t>(bubbleIndex);
+                        break;
+                    }
+                }
+            }
+            result.owners.push_back(owner);
+            if (index > 255 || owner == kAbsent) continue;
+            if (owner >= refs.size()) throw 0;
+            try {
+                result.cells.emplace_back(static_cast<std::uint16_t>(index),
+                                          bubble(root, refs[owner]));
+            } catch (...) {}
+        }
+        return result;
+    }
+    /** Ambiguous map signatures leave the entire scenario without guessed profiles. */
+    bool scenario(const NamedTag& name, const std::vector<Map>& maps, Rows& rows) {
+        const auto bytes = tag(name.tag, kScenario);
+        const auto bubbles = members(bytes, 80, 24, 0x8080924D);
+        std::vector<std::uint32_t> hashes;
+        std::vector<std::pair<std::uint32_t, std::uint32_t>> owners;
+        for (std::size_t index = 0; index < bubbles.size(); ++index) {
+            hashes.push_back(value<std::uint32_t>(bytes, bubbles[index]));
+            for (auto state : members(bytes, bubbles[index] + 8, 76, 0x8080924F))
+                owners.emplace_back(value<std::uint32_t>(bytes, state + 28),
+                                    static_cast<std::uint32_t>(index));
+        }
+        if (owners.empty()) return false;
+        std::vector<const Map*> matches;
+        for (const auto& candidate : maps) {
+            if (candidate.hashes == hashes
+                && std::all_of(owners.begin(), owners.end(), [&](auto owner) {
+                       return owner.first < candidate.owners.size()
+                              && candidate.owners[owner.first] == owner.second;
+                   }))
+                matches.push_back(&candidate);
+        }
+        if (std::any_of(
+                matches.begin(), matches.end(), [](auto candidate) { return candidate->base; }))
+            std::erase_if(matches, [](auto candidate) { return !candidate->base; });
+        if (matches.empty()) return false;
+        for (auto candidate : matches)
+            if (candidate->cells != matches.front()->cells) return false;
+        constexpr std::string_view suffix = ":scenario_client";
+        if (!name.name.ends_with(suffix)) return false;
+        const auto activity = name.name.substr(0, name.name.size() - suffix.size());
+        for (const auto& cell : matches.front()->cells)
+            rows.push_back({activity,
+                            cell.first,
+                            cell.second,
+                            static_cast<std::uint8_t>(matches.front()->owners[cell.first])});
+        return true;
+    }
+};
+} // namespace
+/** Array headers carry their marker four bytes before the relative target. */
+bool array(std::span<const std::byte> bytes,
+           std::size_t field,
+           std::size_t stride,
+           std::uint32_t elementClass,
+           std::vector<std::size_t>& offsets) noexcept {
+    offsets.clear();
+    try {
+        const auto count = value<std::uint64_t>(bytes, field);
+        if (count == 0) return true;
+        const auto relative = value<std::int64_t>(bytes, field + 8);
+        if (relative < 0 || static_cast<std::uint64_t>(relative) > bytes.size() || count > 1048576)
+            return false;
+        const auto header = field + 8 + static_cast<std::size_t>(relative);
+        if (header < 4 || header > bytes.size() || bytes.size() - header < 16
+            || value<std::uint32_t>(bytes, header - 4) != 0x80809FBD
+            || value<std::uint32_t>(bytes, header + 8) != elementClass || stride == 0
+            || count > (bytes.size() - header - 16) / stride)
+            return false;
+        for (std::size_t index = 0; index < count; ++index)
+            offsets.push_back(header + 16 + index * stride);
+        return true;
+    } catch (...) {
+        offsets.clear();
+        return false;
+    }
+}
+/** Only complete map/scenario joins publish widths; unresolved cells remain absent. */
+bool extract(std::span<const NamedTag> names,
+             std::span<const KeyTag> keys,
+             Read read,
+             void* context,
+             Rows& rows) noexcept {
+    rows.clear();
+    if (!read) return false;
+    try {
+        Extractor extractor{keys, read, context, {}};
+        std::vector<Map> maps;
+        for (const auto& name : names)
+            if (name.classId == kMapRoot) {
+                try {
+                    maps.push_back(extractor.map(name));
+                } catch (...) {}
+            }
+        for (const auto& name : names)
+            if (name.classId == kScenario) {
+                try {
+                    (void)extractor.scenario(name, maps, rows);
+                } catch (...) {}
+            }
+        std::sort(rows.begin(), rows.end(), [](const auto& a, const auto& b) {
+            return a.activity < b.activity || (a.activity == b.activity && a.cell < b.cell);
+        });
+        return state::gameplay::entity_position_profiles::validate(rows);
+    } catch (...) {
+        rows.clear();
+        return false;
+    }
+}
+} // namespace sunrise::middleware::content::packages::position_profiles

+ 30 - 0
Sunrise/src/middleware/content/packages/tables/entity_position_profile_extractor.h

@@ -0,0 +1,30 @@
+#pragma once
+#include <span>
+#include <string>
+#include <vector>
+
+#include "../../../../state/gameplay/external/entity_position_profiles.h"
+namespace sunrise::middleware::content::packages::position_profiles {
+struct NamedTag final {
+    std::string name;
+    std::uint32_t tag{}, classId{};
+    bool basePackage{};
+};
+struct KeyTag final {
+    std::uint64_t key{};
+    std::uint32_t tag{}, classId{};
+};
+using Read = bool (*)(void*, std::uint32_t, std::uint32_t, std::vector<std::byte>&) noexcept;
+/** Reads the package array marker, element class, and full extent. */
+[[nodiscard]] bool array(std::span<const std::byte> bytes,
+                         std::size_t field,
+                         std::size_t stride,
+                         std::uint32_t elementClass,
+                         std::vector<std::size_t>& offsets) noexcept;
+/** Joins exact scenario cell owners to complete package bounds. */
+[[nodiscard]] bool extract(std::span<const NamedTag> names,
+                           std::span<const KeyTag> keys,
+                           Read read,
+                           void* context,
+                           state::gameplay::entity_position_profiles::Rows& rows) noexcept;
+} // namespace sunrise::middleware::content::packages::position_profiles

Разница между файлами не показана из-за своего большого размера
+ 652 - 123
Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp


+ 151 - 1
Sunrise/src/middleware/gameplay/external/composite_entity_codec.h

@@ -5,36 +5,103 @@
 #include <cstdint>
 #include <span>
 
+#include "../../../state/gameplay/external/entity_identity.h"
 #include "actor_command_runtime_codec.h"
 #include "external_entity_codec.h"
 #include "sobject_payload_codec.h"
 
 namespace sunrise::middleware::gameplay::external {
 
+using PositionProfile = middleware::bap::activity_message::wire_schema::PositionProfile;
+/** Cell widths come from the admitted activity's package bounds, never the client runtime. */
+using ResolvePositionProfile = bool (*)(const void* context,
+                                        const state::gameplay::entity_identity::Source& source,
+                                        std::uint16_t cell,
+                                        PositionProfile& output) noexcept;
+
+/** One native-compiled field group and its bitmap guard within a component. */
+struct SobjectDecodeEntry final {
+    std::uint32_t schemaHandle{}, repeatCount{}, guardBit{}, firstFieldBit{}, fieldBitStride{};
+};
+struct SobjectDecodePlan final {
+    std::span<const SobjectDecodeEntry> entries{};
+    std::uint32_t bitmapBits{};
+    bool active{};
+};
+using ResolveSobjectPlan = bool (*)(const void*,
+                                    std::uint32_t componentTag,
+                                    std::uint32_t schemaTag,
+                                    SobjectDecodePlan&) noexcept;
+using ResolveSobjectSchemaLayout = bool (*)(const void*,
+                                            std::uint32_t schemaHandle,
+                                            std::uint32_t& serializedSize) noexcept;
+using ResolveSobjectFieldLayout = bool (*)(const void*,
+                                           std::uint32_t schemaHandle,
+                                           std::uint32_t fieldOrdinal,
+                                           std::uint32_t& bitmapOffset) noexcept;
+using ResolveAdditionalSchema =
+    bool (*)(const void*,
+             std::uint32_t schemaHandle,
+             middleware::bap::activity_message::wire_schema::runtime::SchemaView&) noexcept;
+using ResolveAdditionalField =
+    bool (*)(const void*,
+             std::uint32_t fieldRow,
+             middleware::bap::activity_message::wire_schema::runtime::FieldView&,
+             std::uint32_t& nestedHandle) noexcept;
+
 /** One server process retains at most this many live channel-2 session mirrors. */
 inline constexpr std::size_t kCompositeEntitySessionCapacity = 16;
 
 /** One committed channel-2 identity used to resolve update-only records. */
 struct EntityBaselineSlot final {
+    std::uint8_t allocationEpoch{};
+    bool hasAllocationEpoch{};
+    std::uint64_t allocationDomain{};
+    std::uint64_t serialDomain{};
     std::uint32_t rsatTag{};
     std::uint8_t allocationSequence{};
     std::uint8_t incarnation{};
     EntityType type{EntityType::sobject};
     bool occupied{};
+    /** Tombstones retain lifetime and packet ordering after removal. */
+    bool known{};
+    bool hasPacketSequence{};
+    std::uint16_t packetSequence{};
+    std::uint64_t packetOrdinal{};
+    bool sobjectPlacement{true};
+    EntityToken anchor{};
+    bool anchorPresent{};
+    std::uint64_t anchorOrder{};
 };
 
 /** Caller-owned state is scoped to one peer session and simulation view. */
 struct EntityBaselineRegistry final {
+    std::uint8_t allocationEpoch{};
+    bool hasAllocationEpoch{};
+    std::uint64_t allocationDomain{};
     state::activity_sdk::Snapshot catalog{};
     std::array<EntityBaselineSlot, kMaximumEntitySlot + 1U> slots{};
+    std::uint64_t anchorOrder{};
 };
 
 /** One compare-and-commit registry change staged from an accepted batch. */
-struct EntityBaselineMutation final {
+struct EntityBaselineChange {
     EntityBaselineSlot expected{};
     EntityBaselineSlot replacement{};
     std::uint16_t slot{};
+};
+struct EntityBaselineMutation final : EntityBaselineChange {
     bool valid{};
+    bool hasChanges{};
+    std::uint16_t ignoredRecordMask{};
+    std::uint8_t expectedAllocationEpoch{}, replacementAllocationEpoch{};
+    bool expectedHasAllocationEpoch{}, replacementHasAllocationEpoch{};
+    std::uint64_t expectedAllocationDomain{}, replacementAllocationDomain{};
+    state::gameplay::entity_identity::Source source{};
+    bool scoped{};
+    std::array<EntityBaselineChange, kEntityBatchCapacity - 1> additionalChanges{};
+    std::uint8_t additionalChangeCount{};
+    std::uint64_t expectedAnchorOrder{}, replacementAnchorOrder{};
 };
 
 /** Stable caller-owned dependencies for the composite payload callbacks. */
@@ -52,6 +119,28 @@ struct CompositeEntityCodecContext final {
     EntityBaselineRegistry* registry{};
     SobjectPositionCompression positionCompression{SobjectPositionCompression::disabled};
     bool ready{};
+    PositionProfile positionProfile{};
+    ResolvePositionProfile resolvePosition{};
+    const void* positionContext{};
+    state::gameplay::entity_identity::Source source{};
+    ResolveSobjectPlan resolvePlan{};
+    ResolveSobjectSchemaLayout resolveSchemaLayout{};
+    ResolveSobjectFieldLayout resolveFieldLayout{};
+    ResolveAdditionalSchema resolveAdditionalSchema{};
+    ResolveAdditionalField resolveAdditionalField{};
+    const void* planContext{};
+#if defined(SUNRISE_ACTIVITY_SDK_TESTING)
+    /** Offline replay may inspect a refused schema without publishing diagnostic runtime state. */
+    void (*schemaFailure)(
+        std::uint32_t schema,
+        std::uint32_t field,
+        const middleware::bap::activity_message::wire_schema::RuntimeDecodeResult&,
+        const middleware::bap::activity_message::wire_schema::RuntimeDecodedValue*) noexcept {};
+    void (*schemaValues)(
+        std::uint32_t schema,
+        std::span<
+            const middleware::bap::activity_message::wire_schema::RuntimeDecodedValue>) noexcept {};
+#endif
 };
 
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
@@ -66,6 +155,8 @@ struct CompositeEntityCatalogFixture final {
     std::span<const state::activity_sdk::format::RuntimeSchema> runtimeSchemas{};
     std::span<const state::activity_sdk::format::RuntimeField> runtimeFields{};
     std::span<const state::activity_sdk::format::RuntimeTypeDefinition> runtimeTypes{};
+    ResolveSobjectPlan resolvePlan{};
+    const void* planContext{};
 };
 
 /** Builds a codec over deterministic in-memory v32 rows without publishing a pack. */
@@ -81,6 +172,7 @@ struct CompositeEntitySession final {
     EntityBaselineRegistry registry{};
     CompositeEntityCodecContext codec{};
     std::uint64_t groupSessionId{};
+    state::gameplay::entity_identity::Source source{};
     bool occupied{};
 };
 
@@ -89,6 +181,15 @@ struct CompositeEntitySessionStore final {
     state::activity_sdk::Snapshot catalog{};
     std::array<CompositeEntitySession, kCompositeEntitySessionCapacity> sessions{};
     SobjectPositionCompression positionCompression{SobjectPositionCompression::disabled};
+    PositionProfile positionProfile{};
+    ResolvePositionProfile resolvePosition{};
+    const void* positionContext{};
+    ResolveSobjectPlan resolvePlan{};
+    ResolveSobjectSchemaLayout resolveSchemaLayout{};
+    ResolveSobjectFieldLayout resolveFieldLayout{};
+    ResolveAdditionalSchema resolveAdditionalSchema{};
+    ResolveAdditionalField resolveAdditionalField{};
+    const void* planContext{};
 };
 
 /** Pins and validates the current v32 entity reflection catalog. */
@@ -117,6 +218,10 @@ make_composite_entity_payload_codec(const CompositeEntityCodecContext& context)
     std::span<middleware::bap::activity_message::wire_schema::RuntimeDecodedValue> values,
     middleware::bap::activity_message::wire_schema::RuntimeDecodeResult& result) noexcept;
 
+/** Loads the RSAT tag from a validated composite SObject create mirror. */
+[[nodiscard]] bool composite_sobject_rsat(const TypePayload& payload,
+                                          std::uint32_t& output) noexcept;
+
 /** Validates one decoded batch and stages its single registry change. */
 [[nodiscard]] bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
                                                   const EntityBatch& batch,
@@ -126,6 +231,9 @@ make_composite_entity_payload_codec(const CompositeEntityCodecContext& context)
 [[nodiscard]] bool commit_accepted_entity_batch(EntityBaselineRegistry& registry,
                                                 const EntityBaselineMutation& mutation) noexcept;
 
+/** Resets large stores in place without a stack-sized temporary. */
+void reset_composite_entity_sessions(CompositeEntitySessionStore& store) noexcept;
+
 /** Pins the shared catalog used by every later session in this store. */
 [[nodiscard]] bool
 initialize_composite_entity_sessions(CompositeEntitySessionStore& store,
@@ -146,4 +254,46 @@ initialize_composite_entity_sessions(CompositeEntitySessionStore& store,
 void reset_composite_entity_session(CompositeEntitySessionStore& store,
                                     std::uint64_t groupSessionId) noexcept;
 
+/** Removes baselines for only the exact retired source. */
+void reset_scoped_entity_session(CompositeEntitySessionStore& store,
+                                 const state::gameplay::entity_identity::Source& source) noexcept;
+
+/** Delivered retirement preserves tombstones and never touches a newer allocation. */
+[[nodiscard]] std::size_t retire_scoped_entity_baselines(
+    CompositeEntitySessionStore& store,
+    const state::gameplay::entity_identity::Source& source,
+    std::span<const state::gameplay::entity_identity::RetiredLifetime> lifetimes) noexcept;
+
+/** A host epoch resets native serial admission without discarding prior lifetime evidence. */
+[[nodiscard]] bool
+advance_scoped_entity_epoch(CompositeEntitySessionStore& store,
+                            const state::gameplay::entity_identity::Source& source,
+                            std::uint8_t expected,
+                            std::uint8_t next,
+                            std::uint64_t nextDomain) noexcept;
+
+/** Scoped production entry points isolate peer channels and view generations. */
+[[nodiscard]] bool
+prepare_scoped_entity_batch(CompositeEntitySessionStore& store,
+                            const state::gameplay::entity_identity::Source& source,
+                            const EntityBatch& batch,
+                            std::uint16_t packetSequence,
+                            bool hasPacketSequence,
+                            std::uint64_t packetOrdinal,
+                            EntityBaselineMutation& output) noexcept;
+[[nodiscard]] bool
+commit_scoped_entity_batch(CompositeEntitySessionStore& store,
+                           const state::gameplay::entity_identity::Source& source,
+                           const EntityBaselineMutation& mutation) noexcept;
+
+/** Scoped production entry points isolate peer channels and view generations. */
+[[nodiscard]] bool read_scoped_entity_batch(CompositeEntitySessionStore& store,
+                                            const state::gameplay::entity_identity::Source& source,
+                                            middleware::encoding::bits::Reader& reader,
+                                            EntityBatch& output) noexcept;
+[[nodiscard]] bool
+accept_scoped_entity_batch(CompositeEntitySessionStore& store,
+                           const state::gameplay::entity_identity::Source& source,
+                           const EntityBatch& batch) noexcept;
+
 } // namespace sunrise::middleware::gameplay::external

+ 201 - 0
Sunrise/src/middleware/gameplay/external/entity_identity_metadata.cpp

@@ -0,0 +1,201 @@
+#include "entity_identity_metadata.h"
+
+#include <array>
+#include <limits>
+
+namespace sunrise::middleware::gameplay::external {
+namespace {
+namespace wire = middleware::bap::activity_message::wire_schema;
+namespace format = state::activity_sdk::format;
+using Metadata = state::gameplay::entity_identity::Metadata;
+/** Exact baseline roots and the raw eight-byte player-broadcast child schema. */
+constexpr std::uint32_t kSquadSchema = 0x80809C42U, kPlayerSchema = 0x80806ABDU;
+constexpr std::uint32_t kPlayerBytesSchema = 0x80809F7BU;
+/** The squad baseline's decoded struct offsets and wire widths. */
+constexpr std::array<std::uint32_t, 3> kSquadOffsets{0, 4, 6};
+constexpr std::array<std::uint8_t, 3> kSquadWidths{32, 7, 16};
+/** A player baseline is eight unsigned wire bytes, with no numeric identity assumption. */
+constexpr std::size_t kPlayerBytes = 8;
+constexpr std::uint8_t kByteWidth = 8;
+
+/**
+ * Only one exact native baseline definition selects metadata semantics.
+ * @param catalog Pinned SDK rows.
+ * @param type Wire entity type.
+ * @param expected Verified schema handle.
+ * @return True for one exact matching definition.
+ */
+bool has_schema(const state::activity_sdk::Snapshot& catalog,
+                EntityType type,
+                std::uint32_t expected) noexcept {
+    bool found = false;
+    for (const auto& row : catalog->entity_type_definitions()) {
+        if (row.entityType != static_cast<std::uint32_t>(type)) continue;
+        if (found || row.baselineSchema != expected
+            || (row.flags & format::kEntityTypeDefinitionExact) == 0)
+            return false;
+        found = true;
+    }
+    return found;
+}
+
+/**
+ * A squad identity needs all three exact fields and no sentinel values.
+ * @param catalog Pinned field rows.
+ * @param values Complete decoded baseline scalars.
+ * @param output Receives the complete ClientRef.
+ * @return True when the reference is usable.
+ */
+bool squad_metadata(const state::activity_sdk::Snapshot& catalog,
+                    std::span<const wire::RuntimeDecodedValue> values,
+                    Metadata& output) noexcept {
+    if (values.size() != kSquadOffsets.size()) return false;
+    std::array<std::int64_t, kSquadOffsets.size()> scalars{};
+    std::array<bool, kSquadOffsets.size()> seen{};
+    for (const auto& value : values) {
+        if (value.schemaHandle != kSquadSchema || value.role != wire::ValueRole::scalar
+            || !value.present || value.occurrence != 0
+            || value.fieldRow >= catalog->runtime_fields().size())
+            return false;
+        const auto offset = catalog->runtime_fields()[value.fieldRow].structOffset;
+        bool matched = false;
+        for (std::size_t index = 0; index < kSquadOffsets.size(); ++index) {
+            if (offset != kSquadOffsets[index]) continue;
+            if (seen[index] || value.width != kSquadWidths[index]) return false;
+            if (index == 0) {
+                if (value.kind != wire::ValueKind::unsignedInteger || value.unsignedValue == 0
+                    || value.unsignedValue >= format::kAbsentIndex)
+                    return false;
+                scalars[index] = static_cast<std::int64_t>(value.unsignedValue);
+            } else {
+                if (value.kind != wire::ValueKind::signedInteger || value.signedValue < 0
+                    || value.signedValue > (index == 1 ? 126 : 32767))
+                    return false;
+                scalars[index] = value.signedValue;
+            }
+            seen[index] = true;
+            matched = true;
+        }
+        if (!matched) return false;
+    }
+    output.squad = {static_cast<std::uint32_t>(scalars[0]),
+                    static_cast<std::uint16_t>(scalars[2]),
+                    static_cast<std::uint8_t>(scalars[1])};
+    output.hasSquad = true;
+    return true;
+}
+
+/**
+ * Raw player bytes keep their reflected order without an endian conversion.
+ * @param values Complete decoded baseline scalars.
+ * @param output Receives all eight bytes.
+ * @return True when each byte occurrence appears exactly once.
+ */
+bool player_metadata(std::span<const wire::RuntimeDecodedValue> values, Metadata& output) noexcept {
+    if (values.size() != kPlayerBytes) return false;
+    std::array<bool, kPlayerBytes> seen{};
+    for (const auto& value : values) {
+        if (value.schemaHandle != kPlayerBytesSchema || value.role != wire::ValueRole::scalar
+            || !value.present || value.kind != wire::ValueKind::unsignedInteger
+            || value.width != kByteWidth || value.unsignedValue > 0xFFU
+            || value.occurrence >= seen.size() || seen[value.occurrence])
+            return false;
+        seen[value.occurrence] = true;
+        output.playerBroadcast[value.occurrence] = static_cast<std::byte>(value.unsignedValue);
+    }
+    output.hasPlayerBroadcast = true;
+    return true;
+}
+} // namespace
+
+/** Ambiguous or incomplete class joins leave the object type unknown. */
+bool extract_sobject_object_type(std::span<const state::activity_sdk::format::ActorClass> classes,
+                                 std::uint32_t rsatTag,
+                                 std::uint8_t& output) noexcept {
+    output = 0;
+    if (rsatTag == 0 || rsatTag == 0xFFFFFFFFU) return false;
+    const state::activity_sdk::format::ActorClass* selected = nullptr;
+    for (const auto& row : classes) {
+        if (row.rsatTag != rsatTag) continue;
+        if (selected || row.definitionTag == 0 || row.definitionTag == 0xFFFFFFFFU
+            || row.rsatReverseDefinitionTag != row.definitionTag || row.objectType > 0xFFU)
+            return false;
+        selected = &row;
+    }
+    if (!selected) return false;
+    output = static_cast<std::uint8_t>(selected->objectType);
+    return true;
+}
+
+/**
+ * Unknown metadata stays absent while transport retains the generic entity identity.
+ * @param catalog Pinned SDK catalog used to decode the baseline.
+ * @param record Accepted create record.
+ * @param output Receives known metadata, cleared on failure.
+ * @param status Optional exact extraction result.
+ * @return True when the supported metadata was extracted, including an empty test identity.
+ */
+bool extract_entity_identity_metadata(const state::activity_sdk::Snapshot& catalog,
+                                      const EntityRecord& record,
+                                      Metadata& output,
+                                      MetadataStatus* status) noexcept {
+    output = {};
+    const auto finish = [status](MetadataStatus value) noexcept {
+        if (status != nullptr) *status = value;
+        return value == MetadataStatus::complete;
+    };
+    if ((record.flags & entityCreate) == 0) return finish(MetadataStatus::notCreate);
+    Metadata candidate{};
+    if (record.type == EntityType::sobject) {
+        if (!composite_sobject_rsat(record.baseline, candidate.rsatTag))
+            return finish(MetadataStatus::malformedPayload);
+        candidate.hasRsat = true;
+        if (catalog)
+            candidate.hasObjectType = extract_sobject_object_type(
+                catalog->actor_classes(), candidate.rsatTag, candidate.objectType);
+
+    } else if (record.type == EntityType::test) {
+        return finish(MetadataStatus::complete);
+    } else {
+        if (catalog == nullptr) return finish(MetadataStatus::missingCatalog);
+        const auto schema = record.type == EntityType::squad ? kSquadSchema : kPlayerSchema;
+        if ((record.type != EntityType::squad && record.type != EntityType::playerBroadcast)
+            || !has_schema(catalog, record.type, schema))
+            return finish(MetadataStatus::unsupportedSchema);
+        std::array<wire::RuntimeDecodedValue, wire::kRuntimeValueCapacity> values{};
+        wire::RuntimeDecodeResult result{};
+        if (!decode_composite_entity_payload(
+                catalog, record.type, TypePayloadPart::baseline, record.baseline, values, result))
+            return finish(MetadataStatus::malformedPayload);
+        const auto decoded = std::span(values).first(result.valueCount);
+        if (!(record.type == EntityType::squad ? squad_metadata(catalog, decoded, candidate)
+                                               : player_metadata(decoded, candidate)))
+            return finish(MetadataStatus::invalidReference);
+    }
+    output = candidate;
+    return finish(MetadataStatus::complete);
+}
+/**
+ * Unobserved source components preserve the retained relation.
+ * @param record Accepted record with a staged type payload.
+ * @param output Receives the known relation or clear; cleared on failure.
+ * @return True only for a valid staged source relation in an SObject update.
+ */
+bool extract_actor_source_reference(
+    const EntityRecord& record,
+    state::gameplay::entity_identity::ActorSourceReference& output) noexcept {
+    output = {};
+    if (record.type != EntityType::sobject || !(record.flags & entityUpdate)
+        || (record.flags & entityRemove))
+        return false;
+    const auto& source = record.update.actorSource;
+    if (!source.known) return false;
+    if (source.present
+        && (source.key == 0 || source.key == 0xFFFFFFFFU || source.type > 126
+            || source.index > 32767))
+        return false;
+    if (source.present) output = source;
+    output.known = true;
+    return true;
+}
+} // namespace sunrise::middleware::gameplay::external

+ 36 - 0
Sunrise/src/middleware/gameplay/external/entity_identity_metadata.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include "../../../state/gameplay/external/entity_identity.h"
+#include "composite_entity_codec.h"
+
+namespace sunrise::middleware::gameplay::external {
+
+/** Metadata failure never authorizes dropping an otherwise accepted entity observation. */
+enum class MetadataStatus : std::uint8_t {
+    complete,
+    notCreate,
+    missingCatalog,
+    unsupportedSchema,
+    malformedPayload,
+    invalidReference
+};
+
+/** Only a unique RSAT and reverse-definition join supplies a packaged object type. */
+[[nodiscard]] bool
+extract_sobject_object_type(std::span<const state::activity_sdk::format::ActorClass> classes,
+                            std::uint32_t rsatTag,
+                            std::uint8_t& output) noexcept;
+
+/** Extracts only verified baseline identities; output is cleared on failure. */
+[[nodiscard]] bool
+extract_entity_identity_metadata(const state::activity_sdk::Snapshot& catalog,
+                                 const EntityRecord& record,
+                                 state::gameplay::entity_identity::Metadata& output,
+                                 MetadataStatus* status = nullptr) noexcept;
+
+/** Reads only the source relation staged by a complete SObject update decoder. */
+[[nodiscard]] bool extract_actor_source_reference(
+    const EntityRecord& record,
+    state::gameplay::entity_identity::ActorSourceReference& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::external

+ 275 - 65
Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp

@@ -35,6 +35,149 @@ struct PayloadPlan {
     std::size_t baselineBits{};
     std::size_t updateBits{};
 };
+using BatchPlan = std::array<PayloadPlan, kEntityBatchCapacity>;
+
+/** Earlier records in this packet supply baselines without committing global state. */
+struct BatchCodecContext {
+    const TypePayloadCodec* source{};
+    const EntityBatch* batch{};
+    std::size_t prefixCount{};
+};
+
+/** Finds the most recent same-packet create unless a remove ended that lifetime. */
+static const EntityRecord* preceding_create(const BatchCodecContext& context,
+                                            const EntityToken& token) noexcept {
+    for (std::size_t index = context.prefixCount; index != 0; --index) {
+        const auto& record = entity_record_at(*context.batch, index - 1);
+        if (record.token.slot != token.slot) continue;
+        if (record.token.incarnation != token.incarnation) return nullptr;
+        if ((record.flags & entityCreate) != 0) return &record;
+    }
+    return nullptr;
+}
+
+static bool
+batch_resolve_type(const void* raw, const EntityToken& token, EntityType& output) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    if (const auto* created = preceding_create(context, token)) {
+        output = created->type;
+        return true;
+    }
+    return context.source->resolveType != nullptr
+           && context.source->resolveType(context.source->context, token, output);
+}
+
+/** Adds the latest preceding create to update-only callback input. */
+static const TypePayload* batch_baseline(const BatchCodecContext& context,
+                                         const EntityToken& token,
+                                         TypePayloadPart part,
+                                         const TypePayload* baseline) noexcept {
+    if (baseline == nullptr && part == TypePayloadPart::update) {
+        if (const auto* created = preceding_create(context, token)) return &created->baseline;
+    }
+    return baseline;
+}
+
+/** Reads against the packet's earlier baselines without publishing them. */
+static bool batch_read_payload(const void* raw,
+                               const EntityToken& token,
+                               EntityType type,
+                               TypePayloadPart part,
+                               const TypePayload* baseline,
+                               encoding::bits::Reader& reader,
+                               TypePayload& output) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    return context.source->read != nullptr
+           && context.source->read(context.source->context,
+                                   token,
+                                   type,
+                                   part,
+                                   batch_baseline(context, token, part, baseline),
+                                   reader,
+                                   output);
+}
+
+/** Replays updates against the same earlier baseline used during decode. */
+static bool batch_write_payload(const void* raw,
+                                const EntityToken& token,
+                                EntityType type,
+                                TypePayloadPart part,
+                                const TypePayload* baseline,
+                                const TypePayload& payload,
+                                encoding::bits::Writer& writer) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    return context.source->write != nullptr
+           && context.source->write(context.source->context,
+                                    token,
+                                    type,
+                                    part,
+                                    batch_baseline(context, token, part, baseline),
+                                    payload,
+                                    writer);
+}
+
+static bool batch_anchor_group(const void* raw,
+                               const EntityToken& anchor,
+                               std::span<EntityToken> output,
+                               std::size_t& count) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    return context.source->resolveAnchorGroup != nullptr
+           && context.source->resolveAnchorGroup(context.source->context, anchor, output, count);
+}
+
+/** Cell selection and the earlier baseline are scoped to this callback invocation. */
+static bool batch_read_cell_payload(const void* raw,
+                                    const EntityToken& token,
+                                    EntityType type,
+                                    TypePayloadPart part,
+                                    const TypePayload* baseline,
+                                    std::uint16_t cell,
+                                    encoding::bits::Reader& reader,
+                                    TypePayload& output) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    return context.source->readForCell(context.source->context,
+                                       token,
+                                       type,
+                                       part,
+                                       batch_baseline(context, token, part, baseline),
+                                       cell,
+                                       reader,
+                                       output);
+}
+
+/** Cell-aware replay uses the same earlier baseline and package profile as decode. */
+static bool batch_write_cell_payload(const void* raw,
+                                     const EntityToken& token,
+                                     EntityType type,
+                                     TypePayloadPart part,
+                                     const TypePayload* baseline,
+                                     const TypePayload& payload,
+                                     std::uint16_t cell,
+                                     encoding::bits::Writer& writer) noexcept {
+    const auto& context = *static_cast<const BatchCodecContext*>(raw);
+    return context.source->writeForCell(context.source->context,
+                                        token,
+                                        type,
+                                        part,
+                                        batch_baseline(context, token, part, baseline),
+                                        payload,
+                                        cell,
+                                        writer);
+}
+
+/** Borrows a packet prefix only for one synchronous read or write pass. */
+static TypePayloadCodec batch_codec(BatchCodecContext& context) noexcept {
+    auto codec = *context.source;
+    codec.context = &context;
+    codec.resolveType = batch_resolve_type;
+    codec.read = batch_read_payload;
+    codec.write = batch_write_payload;
+    codec.resolveAnchorGroup = batch_anchor_group;
+    codec.readForCell = context.source->readForCell == nullptr ? nullptr : batch_read_cell_payload;
+    codec.writeForCell =
+        context.source->writeForCell == nullptr ? nullptr : batch_write_cell_payload;
+    return codec;
+}
 
 /** @return True when a token fits its 17-bit wire identity. */
 [[nodiscard]] bool valid_token(const EntityToken& token) noexcept {
@@ -51,15 +194,9 @@ struct PayloadPlan {
     return type < EntityType::count;
 }
 
-/** @return True for create/update records or the exact administrative remove form. */
+/** Records may carry only cell, anchor, or lifecycle state, including an unchanged child. */
 [[nodiscard]] bool valid_flags(std::uint16_t flags) noexcept {
-    if ((flags & ~kAllowedFlags) != 0) {
-        return false;
-    }
-    if (flags == entityRemove) {
-        return true;
-    }
-    return (flags & entityRemove) == 0 && (flags & (entityCreate | entityUpdate)) != 0;
+    return (flags & ~kAllowedFlags) == 0;
 }
 
 /** @return The declared callback limit for one type-payload part. */
@@ -81,14 +218,10 @@ struct PayloadPlan {
     if (!valid_token(record.token) || !valid_cell(record.cell) || !valid_flags(record.flags)) {
         return false;
     }
-    if (record.flags == entityRemove) {
-        return !record.anchorPresent && !record.trailingState && record.allocationSequence == 0
-               && record.baseline.byteCount == 0 && record.update.byteCount == 0;
-    }
     if (!valid_type(record.type) || (record.anchorPresent && (record.flags & entityAnchor) == 0)
         || ((record.flags & entityAnchor) != 0 && record.anchorPresent
             && !valid_token(record.anchor))
-        || record.trailingState) {
+        || (record.trailingState && (record.flags & entityRemove) == 0)) {
         return false;
     }
     if ((record.flags & entityCreate) == 0
@@ -137,13 +270,25 @@ struct PayloadPlan {
                                    std::size_t& bitCount) noexcept {
     const TypePayload& payload = part_payload(record, part);
     const TypePayload* baseline = part_baseline(record, part);
-    if (codec.write == nullptr || !valid_payload(codec, part, payload)) {
+    if ((codec.write == nullptr && codec.writeForCell == nullptr)
+        || !valid_payload(codec, part, payload)) {
         return false;
     }
     encoding::bits::Writer writer = encoding::bits::Writer::measuring();
     std::size_t ignored = 0;
-    if (!codec.write(codec.context, record.token, record.type, part, baseline, payload, writer)
-        || !writer.finish(ignored) || writer.bit_count() > payload_limit(codec, part)) {
+    const bool encoded =
+        codec.writeForCell != nullptr
+            ? codec.writeForCell(codec.context,
+                                 record.token,
+                                 record.type,
+                                 part,
+                                 baseline,
+                                 payload,
+                                 record.cell,
+                                 writer)
+            : codec.write(
+                  codec.context, record.token, record.type, part, baseline, payload, writer);
+    if (!encoded || !writer.finish(ignored) || writer.bit_count() > payload_limit(codec, part)) {
         return false;
     }
     bitCount = writer.bit_count();
@@ -160,9 +305,20 @@ struct PayloadPlan {
     const TypePayload* baseline = part_baseline(record, part);
     const std::size_t before = writer.bit_count();
     std::size_t ignored = 0;
-    return codec.write != nullptr
-           && codec.write(codec.context, record.token, record.type, part, baseline, payload, writer)
-           && writer.finish(ignored) && writer.bit_count() >= before
+    const bool encoded =
+        codec.writeForCell != nullptr
+            ? codec.writeForCell(codec.context,
+                                 record.token,
+                                 record.type,
+                                 part,
+                                 baseline,
+                                 payload,
+                                 record.cell,
+                                 writer)
+            : codec.write != nullptr
+                  && codec.write(
+                      codec.context, record.token, record.type, part, baseline, payload, writer);
+    return encoded && writer.finish(ignored) && writer.bit_count() >= before
            && writer.bit_count() - before == expectedBits;
 }
 
@@ -172,17 +328,22 @@ struct PayloadPlan {
                                 EntityType type,
                                 TypePayloadPart part,
                                 const TypePayload* baseline,
+                                std::uint16_t cell,
                                 encoding::bits::Reader& reader,
                                 TypePayload& output) noexcept {
     const std::size_t limit = payload_limit(codec, part);
-    if (codec.read == nullptr || limit > kMaximumTypePayloadBits) {
+    if ((codec.read == nullptr && codec.readForCell == nullptr)
+        || limit > kMaximumTypePayloadBits) {
         return false;
     }
     const std::size_t before = reader.remaining_bits();
     TypePayload candidate{};
     std::uint64_t ignored = 0;
-    if (!codec.read(codec.context, token, type, part, baseline, reader, candidate)
-        || !reader.read(0, ignored) || candidate.byteCount > candidate.state.size()) {
+    const bool decoded =
+        codec.readForCell != nullptr
+            ? codec.readForCell(codec.context, token, type, part, baseline, cell, reader, candidate)
+            : codec.read(codec.context, token, type, part, baseline, reader, candidate);
+    if (!decoded || !reader.read(0, ignored) || candidate.byteCount > candidate.state.size()) {
         return false;
     }
     const std::size_t after = reader.remaining_bits();
@@ -207,7 +368,7 @@ resolve_type(const TypePayloadCodec& codec, const EntityToken& token, EntityType
 
 /** Builds a complete payload plan before the channel-2 header is touched. */
 [[nodiscard]] bool
-prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPlan& plan) noexcept {
+prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan& plan) noexcept {
     plan = {};
     if (!valid_cell(batch.currentCell) || batch.auxiliaryCount > batch.auxiliaryTokens.size()) {
         return false;
@@ -217,18 +378,41 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
             return false;
         }
     }
-    if (!batch.recordPresent) {
-        return true;
-    }
-    if (!valid_record(batch.record)) {
-        return false;
-    }
-    if ((batch.record.flags & entityCreate) != 0
-        && !measure_payload(codec, batch.record, TypePayloadPart::baseline, plan.baselineBits)) {
-        return false;
+    if (batch.additionalRecordCount >= kEntityBatchCapacity
+        || (!batch.recordPresent && batch.additionalRecordCount != 0))
+        return false;
+    std::array<EntityToken, kEntityBatchCapacity> anchorTokens{};
+    std::size_t anchorCount = 0, anchorIndex = 0;
+    BatchCodecContext context{&codec, &batch};
+    const auto effective = batch_codec(context);
+    for (std::size_t index = 0; index < entity_record_count(batch); ++index) {
+        context.prefixCount = index;
+        const auto& record = entity_record_at(batch, index);
+        if (!valid_record(record)) return false;
+        if (record.implicitToken) {
+            if (record.anchorGroupStart) {
+                if (anchorIndex != anchorCount || codec.resolveAnchorGroup == nullptr
+                    || !codec.resolveAnchorGroup(
+                        codec.context, record.streamAnchor, anchorTokens, anchorCount)
+                    || anchorCount == 0 || anchorCount > anchorTokens.size())
+                    return false;
+                anchorIndex = 0;
+            }
+            if (anchorIndex >= anchorCount || anchorTokens[anchorIndex].slot != record.token.slot
+                || anchorTokens[anchorIndex].incarnation != record.token.incarnation)
+                return false;
+            ++anchorIndex;
+        } else if (record.anchorGroupStart || anchorIndex != anchorCount)
+            return false;
+        if ((record.flags & entityCreate) != 0
+            && !measure_payload(
+                effective, record, TypePayloadPart::baseline, plan[index].baselineBits))
+            return false;
+        if ((record.flags & entityUpdate) != 0
+            && !measure_payload(effective, record, TypePayloadPart::update, plan[index].updateBits))
+            return false;
     }
-    return (batch.record.flags & entityUpdate) == 0
-           || measure_payload(codec, batch.record, TypePayloadPart::update, plan.updateBits);
+    return anchorIndex == anchorCount;
 }
 
 /** Writes the five explicit flag bits or the update shortcut. */
@@ -334,8 +518,8 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
     if (!write_record_cell(writer, currentCell, record.cell)) {
         return false;
     }
-    if (record.flags == entityRemove
-        && !writer.write(kStrictRemoveBodyBits, kSubrecordLengthWidth)) {
+    if ((record.flags & (entityCreate | entityRemove)) == entityRemove
+        && !writer.write(plan.updateBits + kStrictRemoveBodyBits, kSubrecordLengthWidth)) {
         return false;
     }
     if ((record.flags & entityCreate) != 0
@@ -370,12 +554,15 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
     if (!read_record_cell(reader, currentCell, candidate.cell)) {
         return false;
     }
-    if ((candidate.flags & (entityCreate | entityRemove)) == entityRemove) {
-        std::uint64_t bitLength = 0;
-        if (!reader.read(kSubrecordLengthWidth, bitLength) || bitLength != kStrictRemoveBodyBits) {
+    std::uint64_t bitLength = 0;
+    const bool boundedTerminal = (candidate.flags & (entityCreate | entityRemove)) == entityRemove;
+    if (boundedTerminal) {
+        if (!reader.read(kSubrecordLengthWidth, bitLength) || bitLength < kStrictRemoveBodyBits
+            || bitLength > kMaximumTypePayloadBits + kStrictRemoveBodyBits) {
             return false;
         }
     }
+    const auto bodyRemaining = reader.remaining_bits();
     if ((candidate.flags & entityCreate) != 0) {
         std::uint64_t allocationSequence = 0;
         std::uint64_t type = 0;
@@ -391,11 +578,12 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
                              candidate.type,
                              TypePayloadPart::baseline,
                              nullptr,
+                             candidate.cell,
                              reader,
                              candidate.baseline)) {
             return false;
         }
-    } else if ((candidate.flags & entityUpdate) != 0
+    } else if (((candidate.flags & entityRemove) == 0 || (candidate.flags & entityUpdate) != 0)
                && !resolve_type(codec, candidate.token, candidate.type)) {
         return false;
     }
@@ -405,14 +593,15 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
                          candidate.type,
                          TypePayloadPart::update,
                          (candidate.flags & entityCreate) != 0 ? &candidate.baseline : nullptr,
+                         candidate.cell,
                          reader,
                          candidate.update)) {
         return false;
     }
-    if ((candidate.flags & entityRemove) != 0
-        && (!read_flag(reader, candidate.trailingState) || candidate.trailingState)) {
+    if ((candidate.flags & entityRemove) != 0 && !read_flag(reader, candidate.trailingState)) {
         return false;
     }
+    if (boundedTerminal && bodyRemaining - reader.remaining_bits() != bitLength) return false;
     if (!valid_record(candidate)) {
         return false;
     }
@@ -424,7 +613,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
 [[nodiscard]] bool write_batch_fields(encoding::bits::Writer& writer,
                                       const TypePayloadCodec& codec,
                                       const EntityBatch& batch,
-                                      const PayloadPlan& plan) noexcept {
+                                      const BatchPlan& plan) noexcept {
     if (!writer.write(batch.auxiliaryCount, kAuxiliaryCountWidth)) {
         return false;
     }
@@ -438,11 +627,17 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
         || (currentCellPresent && !writer.write(batch.currentCell, kEntityCellWidth))) {
         return false;
     }
-    if (batch.recordPresent
-        && (!write_flag(writer, false) || !write_flag(writer, true)
-            || !write_token(writer, batch.record.token)
-            || !write_record(writer, codec, batch.record, batch.currentCell, plan))) {
-        return false;
+    BatchCodecContext context{&codec, &batch};
+    const auto effective = batch_codec(context);
+    for (std::size_t index = 0; index < entity_record_count(batch); ++index) {
+        context.prefixCount = index;
+        const auto& record = entity_record_at(batch, index);
+        if (!record.implicitToken || record.anchorGroupStart) {
+            if (!write_flag(writer, false) || !write_flag(writer, !record.implicitToken)
+                || !write_token(writer, record.implicitToken ? record.streamAnchor : record.token))
+                return false;
+        }
+        if (!write_record(writer, effective, record, batch.currentCell, plan[index])) return false;
     }
     return write_flag(writer, true);
 }
@@ -451,7 +646,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
 [[nodiscard]] bool read_batch_fields(encoding::bits::Reader& reader,
                                      const TypePayloadCodec& codec,
                                      EntityBatch& output) noexcept {
-    EntityBatch candidate{};
+    EntityBatch& candidate = output;
     std::uint64_t auxiliaryCount = 0;
     if (!reader.read(kAuxiliaryCountWidth, auxiliaryCount)
         || auxiliaryCount > candidate.auxiliaryTokens.size()) {
@@ -474,23 +669,38 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
         }
         candidate.currentCell = static_cast<std::uint16_t>(cell);
     }
-    bool laneEnded = false;
-    if (!read_flag(reader, laneEnded)) {
-        return false;
-    }
-    if (!laneEnded) {
+    std::size_t recordCount = 0;
+    BatchCodecContext context{&codec, &candidate};
+    const auto effective = batch_codec(context);
+    for (;;) {
+        bool laneEnded = false;
+        if (!read_flag(reader, laneEnded)) return false;
+        if (laneEnded) break;
         bool directToken = false;
-        if (!read_flag(reader, directToken) || !directToken
-            || !read_token(reader, candidate.record.token)
-            || !read_record(reader, codec, candidate.currentCell, candidate.record)) {
-            return false;
-        }
-        candidate.recordPresent = true;
-        if (!read_flag(reader, laneEnded) || !laneEnded) {
+        EntityToken selected{};
+        if (!read_flag(reader, directToken) || !read_token(reader, selected)) return false;
+        std::array<EntityToken, kEntityBatchCapacity> tokens{};
+        std::size_t count = 1;
+        tokens[0] = selected;
+        if (!directToken
+            && (codec.resolveAnchorGroup == nullptr
+                || !codec.resolveAnchorGroup(codec.context, selected, tokens, count) || count == 0
+                || count > tokens.size()))
             return false;
+        if (count > kEntityBatchCapacity - recordCount) return false;
+        for (std::size_t index = 0; index < count; ++index) {
+            context.prefixCount = recordCount;
+            auto& record = entity_record_at(candidate, recordCount++);
+            record.token = tokens[index];
+            if (!read_record(reader, effective, candidate.currentCell, record)) return false;
+            record.implicitToken = !directToken;
+            record.anchorGroupStart = !directToken && index == 0;
+            if (record.anchorGroupStart) record.streamAnchor = selected;
         }
     }
-    output = candidate;
+    candidate.recordPresent = recordCount != 0;
+    candidate.additionalRecordCount =
+        static_cast<std::uint8_t>(recordCount == 0 ? 0 : recordCount - 1);
     return true;
 }
 
@@ -498,7 +708,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPl
 [[nodiscard]] bool write_frame_fields(encoding::bits::Writer& writer,
                                       const TypePayloadCodec& codec,
                                       const ExternalEntityFrame& frame,
-                                      const PayloadPlan& plan) noexcept {
+                                      const BatchPlan& plan) noexcept {
     if (!write_flag(writer, frame.commonPresent)
         || (frame.commonPresent && !write_common_state(writer, frame.common))) {
         return false;
@@ -551,7 +761,7 @@ bool read_entity_batch(encoding::bits::Reader& reader,
 bool write_entity_batch(encoding::bits::Writer& writer,
                         const TypePayloadCodec& codec,
                         const EntityBatch& batch) noexcept {
-    PayloadPlan plan{};
+    BatchPlan plan{};
     if (!prepare_batch(codec, batch, plan)) {
         return false;
     }
@@ -581,7 +791,7 @@ bool read_external_entity_frame(encoding::bits::Reader& reader,
 bool write_external_entity_frame(encoding::bits::Writer& writer,
                                  const TypePayloadCodec& codec,
                                  const ExternalEntityFrame& frame) noexcept {
-    PayloadPlan plan{};
+    BatchPlan plan{};
     if (!prepare_batch(codec, frame.entities, plan)) {
         return false;
     }

+ 56 - 3
Sunrise/src/middleware/gameplay/external/external_entity_codec.h

@@ -4,14 +4,17 @@
 #include <cstddef>
 #include <cstdint>
 
+#include "../../../state/gameplay/external/entity_identity.h"
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/bit_writer.h"
 #include "common_state.h"
 
 namespace sunrise::middleware::gameplay::external {
 
-/** The entity batch carries zero or one record. */
-inline constexpr std::size_t kEntityBatchCapacity = 1;
+/** Fixed server storage bounds one decoded entity lane to sixteen records. */
+inline constexpr std::size_t kEntityBatchCapacity = 16;
+/** The native prelude carries a one-bit auxiliary count. */
+inline constexpr std::size_t kEntityAuxiliaryCapacity = 1;
 /** A token slot is the low 13 bits of the 17-bit wire token. */
 inline constexpr std::uint16_t kMaximumEntitySlot = 0x1FFF;
 /** A token incarnation is the high four bits of the 17-bit wire token. */
@@ -57,6 +60,7 @@ struct EntityToken {
 struct TypePayload {
     std::array<std::byte, kTypePayloadStateCapacity> state{};
     std::uint16_t byteCount{};
+    state::gameplay::entity_identity::ActorSourceReference actorSource{};
 };
 
 /** One generic channel-2 record and its callback-owned type state. */
@@ -71,17 +75,45 @@ struct EntityRecord {
     EntityType type{EntityType::sobject};
     bool anchorPresent{};
     bool trailingState{};
+    /** An anchored group omits per-record tokens and follows the receiver's retained hierarchy. */
+    EntityToken streamAnchor{};
+    bool implicitToken{};
+    bool anchorGroupStart{};
 };
 
 /** One bounded channel-2 batch. */
 struct EntityBatch {
-    std::array<EntityToken, kEntityBatchCapacity> auxiliaryTokens{};
+    std::uint8_t allocationEpoch{};
+    bool hasAllocationEpoch{};
+    std::uint64_t allocationDomain{};
+    std::uint16_t ignoredRecordMask{};
+    std::array<EntityToken, kEntityAuxiliaryCapacity> auxiliaryTokens{};
     EntityRecord record{};
+    std::array<EntityRecord, kEntityBatchCapacity - 1> additionalRecords{};
     std::uint16_t currentCell{kNoEntityCell};
     std::uint8_t auxiliaryCount{};
     bool recordPresent{};
+    std::uint8_t additionalRecordCount{};
 };
 
+[[nodiscard]] inline std::size_t entity_record_count(const EntityBatch& batch) noexcept {
+    return batch.recordPresent ? 1U + batch.additionalRecordCount : 0U;
+}
+[[nodiscard]] inline const EntityRecord& entity_record_at(const EntityBatch& batch,
+                                                          std::size_t index) noexcept {
+    return index == 0 ? batch.record : batch.additionalRecords[index - 1];
+}
+[[nodiscard]] inline EntityRecord& entity_record_at(EntityBatch& batch,
+                                                    std::size_t index) noexcept {
+    return index == 0 ? batch.record : batch.additionalRecords[index - 1];
+}
+
+/** Resolves the exact existing depth-first token order of one native anchored group. */
+using ResolveAnchorGroup = bool (*)(const void* context,
+                                    const EntityToken& anchor,
+                                    std::span<EntityToken> output,
+                                    std::size_t& count) noexcept;
+
 /** Resolves the existing type needed by an update-only record. */
 using ResolveEntityType = bool (*)(const void* context,
                                    const EntityToken& token,
@@ -105,6 +137,24 @@ using WriteTypePayload = bool (*)(const void* context,
                                   const TypePayload& payload,
                                   encoding::bits::Writer& writer) noexcept;
 
+/** Cell-aware callbacks select package position widths before decoding a type body. */
+using ReadCellTypePayload = bool (*)(const void* context,
+                                     const EntityToken& token,
+                                     EntityType type,
+                                     TypePayloadPart part,
+                                     const TypePayload* baseline,
+                                     std::uint16_t cell,
+                                     encoding::bits::Reader& reader,
+                                     TypePayload& output) noexcept;
+using WriteCellTypePayload = bool (*)(const void* context,
+                                      const EntityToken& token,
+                                      EntityType type,
+                                      TypePayloadPart part,
+                                      const TypePayload* baseline,
+                                      const TypePayload& payload,
+                                      std::uint16_t cell,
+                                      encoding::bits::Writer& writer) noexcept;
+
 /** Bounded type-payload callbacks. Empty callbacks are the safe scriptless fallback. */
 struct TypePayloadCodec {
     const void* context{};
@@ -113,6 +163,9 @@ struct TypePayloadCodec {
     WriteTypePayload write{};
     std::size_t maximumBaselineBits{};
     std::size_t maximumUpdateBits{};
+    ResolveAnchorGroup resolveAnchorGroup{};
+    ReadCellTypePayload readForCell{};
+    WriteCellTypePayload writeForCell{};
 };
 
 /** Common state plus the fixed empty channel-0, channel-1, and channel-3 profile. */

+ 58 - 5
Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp

@@ -1,5 +1,6 @@
 #include "activity_sdk_squad_runtime.h"
 
+#include <algorithm>
 #include <cstddef>
 #include <limits>
 #include <string_view>
@@ -368,14 +369,60 @@ struct PreparedSquad final {
     return Status::ready;
 }
 
+/** Opt-in needs one exact actor class for every positively requested member. */
+state::gameplay::squad_entity_retirement::Eligibility
+retirement_eligibility(const sdk::BoundView& view,
+                       std::uint32_t squadRow,
+                       std::span<const std::int32_t> counts,
+                       const host::ScriptableTarget& target,
+                       bool enabled) noexcept {
+    state::gameplay::squad_entity_retirement::Eligibility result{};
+    result.squad = {target.registryKey, target.slotIndex, target.slotType};
+    if (!enabled || !view.catalog || target.slotType != 1) return result;
+    const auto& catalog = *view.catalog;
+    const auto squads = catalog.squads();
+    if (squadRow >= squads.size()) return result;
+    const auto members = sdk::squad_members(catalog, squads[squadRow]);
+    const auto classes = catalog.actor_classes();
+    if (members.size() != counts.size()) return result;
+    std::uint32_t selected = 0;
+    for (std::size_t i = 0; i < members.size(); ++i) {
+        if (counts[i] <= 0) continue;
+        if ((members[i].flags & format::kSquadMemberActorClassExact) == 0
+            || members[i].actorClassIndex >= classes.size())
+            return result;
+        const auto rsat = classes[members[i].actorClassIndex].rsatTag;
+        if (rsat == 0 || (selected != 0 && selected != rsat)) return result;
+        selected = rsat;
+    }
+    if (selected == 0 || std::count_if(classes.begin(), classes.end(), [&](const auto& actor) {
+                             return actor.rsatTag == selected;
+                         }) != 1)
+        return result;
+    const auto occurrences = catalog.occurrences();
+    const auto bubbles = catalog.bubbles();
+    if (squads[squadRow].occurrenceIndex >= occurrences.size()) return result;
+    const auto& occurrence = occurrences[squads[squadRow].occurrenceIndex];
+    if (occurrence.bubbleIndex >= bubbles.size()
+        || bubbles[occurrence.bubbleIndex].bubbleOrdinal >= 64)
+        return result;
+    result.rsatTag = selected;
+    result.bubble = static_cast<std::uint8_t>(bubbles[occurrence.bubbleIndex].bubbleOrdinal);
+    result.enabled = result.bubble < 64;
+    return result;
+}
+
 } // namespace
 
+/** Availability checks never register reuse before Auth delivery. */
 Status availability(const sdk::BoundView& view,
                     std::uint32_t squadRow,
                     std::span<const std::int32_t> requestedCounts,
                     squad_auth::Mode mode,
-                    std::optional<std::uint32_t> nameHash) noexcept {
+                    std::optional<std::uint32_t> nameHash,
+                    bool retireOnReturn) noexcept {
     (void)nameHash;
+    (void)retireOnReturn;
     PreparedSquad prepared{};
     return prepare(view, squadRow, requestedCounts, mode, prepared);
 }
@@ -385,7 +432,8 @@ Status place(const sdk::BoundView& view,
              std::uint32_t squadRow,
              std::span<const std::int32_t> requestedCounts,
              squad_auth::Mode mode,
-             std::optional<std::uint32_t> nameHash) noexcept {
+             std::optional<std::uint32_t> nameHash,
+             bool retireOnReturn) noexcept {
     PreparedSquad prepared{};
     const Status status = prepare(view, squadRow, requestedCounts, mode, prepared);
     if (status != Status::ready) {
@@ -401,7 +449,9 @@ Status place(const sdk::BoundView& view,
             prepared.effectiveRegion,
             prepared.activityClientGeneration,
             nullptr,
-            prepared.authoredProfile)) {
+            prepared.authoredProfile,
+            retirement_eligibility(
+                view, squadRow, requestedCounts, prepared.target, retireOnReturn))) {
         return Status::queued;
     }
     return Status::refused;
@@ -413,7 +463,8 @@ Status place_reserved(const sdk::BoundView& view,
                       std::span<const std::int32_t> requestedCounts,
                       squad_auth::Mode mode,
                       const host::ScriptableOutputReservation& reservation,
-                      std::optional<std::uint32_t> nameHash) noexcept {
+                      std::optional<std::uint32_t> nameHash,
+                      bool retireOnReturn) noexcept {
     PreparedSquad prepared{};
     const Status status = prepare(view, squadRow, requestedCounts, mode, prepared);
     if (status != Status::ready) {
@@ -429,7 +480,9 @@ Status place_reserved(const sdk::BoundView& view,
             prepared.effectiveRegion,
             prepared.activityClientGeneration,
             &reservation,
-            prepared.authoredProfile)) {
+            prepared.authoredProfile,
+            retirement_eligibility(
+                view, squadRow, requestedCounts, prepared.target, retireOnReturn))) {
         return Status::queued;
     }
     return Status::refused;

+ 6 - 3
Sunrise/src/server/activity/activity_sdk_squad_runtime.h

@@ -38,14 +38,16 @@ enum class Status : std::uint8_t {
                                   std::uint32_t squadRow,
                                   std::span<const std::int32_t> requestedCounts,
                                   middleware::bap::activity_message::squad_auth::Mode mode,
-                                  std::optional<std::uint32_t> nameHash = std::nullopt) noexcept;
+                                  std::optional<std::uint32_t> nameHash = std::nullopt,
+                                  bool retireOnReturn = false) noexcept;
 
 /** Queues one exact generated SDK squad request through the existing activity msg-5 route. */
 [[nodiscard]] Status place(const state::activity_sdk::BoundView& view,
                            std::uint32_t squadRow,
                            std::span<const std::int32_t> requestedCounts,
                            middleware::bap::activity_message::squad_auth::Mode mode,
-                           std::optional<std::uint32_t> nameHash = std::nullopt) noexcept;
+                           std::optional<std::uint32_t> nameHash = std::nullopt,
+                           bool retireOnReturn = false) noexcept;
 
 /** Queues only through the exact unarmed Host revision owned by Mission State. */
 [[nodiscard]] Status place_reserved(const state::activity_sdk::BoundView& view,
@@ -53,7 +55,8 @@ enum class Status : std::uint8_t {
                                     std::span<const std::int32_t> requestedCounts,
                                     middleware::bap::activity_message::squad_auth::Mode mode,
                                     const host::ScriptableOutputReservation& reservation,
-                                    std::optional<std::uint32_t> nameHash = std::nullopt) noexcept;
+                                    std::optional<std::uint32_t> nameHash = std::nullopt,
+                                    bool retireOnReturn = false) noexcept;
 
 /** @return Stable concise text for one squad runtime result. */
 [[nodiscard]] const char* status_name(Status status) noexcept;

+ 73 - 0
Sunrise/src/server/activity/host_runtime.cpp

@@ -800,6 +800,54 @@ retain_sense_observations(Instance& instance, const SenseInput& input, std::uint
     return true;
 }
 
+/** Merges complete squad deltas without discarding fields absent from a later report. */
+void retain_squad_sense(Instance& instance, const SenseInput& input) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    namespace squadSense = middleware::bap::activity_message::squad_sense;
+    const sense::DecodedPacket& packet = input.decoded;
+    if (input.sourceGeneration == 0 || input.sourceGeneration < instance.squadSenseSourceGeneration
+        || packet.status == sense::DecodeStatus::malformed || packet.valuesTruncated
+        || packet.objectsTruncated || packet.objectCount > packet.objects.size()
+        || packet.valueCount > packet.values.size()) {
+        return;
+    }
+    if (input.sourceGeneration != instance.squadSenseSourceGeneration) {
+        instance.squadSense.clear();
+        instance.squadSenseSourceGeneration = input.sourceGeneration;
+    }
+    for (const sense::DecodedObject& object : std::span(packet.objects).first(packet.objectCount)) {
+        if (object.slotType != squadSense::kSlotType || object.senseSchema != squadSense::kSchema
+            || object.status != sense::ObjectStatus::decoded || !object.hasGeneration) {
+            continue;
+        }
+        auto found = std::ranges::find_if(instance.squadSense, [&](const SquadSenseRecord& record) {
+            return same_sense_key(record.key, object);
+        });
+        squadSense::State merged =
+            found == instance.squadSense.end() ? squadSense::State{} : found->state;
+        // An uninitialized replica cannot replace the squad's recovery state.
+        if (!squadSense::merge(merged, object, std::span(packet.values).first(packet.valueCount))
+            || !merged.valid) {
+            continue;
+        }
+        if (found != instance.squadSense.end()) {
+            found->state = merged;
+        } else if (instance.squadSense.size() < kScriptableGuardCapacity) {
+            const SenseObservationKey key{object.registryKey,
+                                          object.objectTag,
+                                          object.senseSchema,
+                                          object.schemaRow,
+                                          object.slotIndex,
+                                          object.slotType};
+            try {
+                instance.squadSense.push_back({key, merged});
+            } catch (const std::bad_alloc&) {
+                return;
+            }
+        }
+    }
+}
+
 /** Applies one copied msg-6 decode summary. */
 void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
     Instance* const instance = find_instance(input.binding);
@@ -810,6 +858,7 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
     touch(*instance);
     ++instance->view.senseCount;
     trace_scene_sense(*instance, input);
+    retain_squad_sense(*instance, input);
     static_cast<void>(retain_sense_observations(*instance, input, now));
     Event event{};
     event.binding = input.binding;
@@ -1615,6 +1664,30 @@ bool mission_input_client_message_snapshot(std::uint64_t sequence,
     return copied;
 }
 
+/** Copies initialized recovery state for one exact ActivityClient generation. */
+bool snapshot_squad_sense(const state::activity::SessionBinding& binding,
+                          std::uint64_t sourceGeneration,
+                          const SenseObservationKey& key,
+                          middleware::bap::activity_message::squad_sense::State& output) noexcept {
+    output = {};
+    AcquireSRWLockShared(&g_lock);
+    const Instance* const instance = find_instance(binding);
+    bool found = false;
+    if (instance != nullptr && instance->squadSenseSourceGeneration == sourceGeneration) {
+        for (const SquadSenseRecord& record : instance->squadSense) {
+            if (record.key.registryKey == key.registryKey && record.key.objectTag == key.objectTag
+                && record.key.senseSchema == key.senseSchema && record.key.slotType == key.slotType
+                && record.key.slotIndex == key.slotIndex && record.state.valid) {
+                output = record.state;
+                found = true;
+                break;
+            }
+        }
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return found;
+}
+
 /** Copies the latest complete Sense observations for one exact activity generation. */
 bool snapshot_sense_observations(const state::activity::SessionBinding& binding,
                                  SenseObservationSnapshot& output) noexcept {

+ 21 - 11
Sunrise/src/server/activity/host_runtime.h

@@ -1,5 +1,4 @@
 #pragma once
-
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -14,9 +13,11 @@
 #include "../../middleware/bap/activity_message/sense_update.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../middleware/bap/activity_message/squad_auth_body.h"
+#include "../../middleware/bap/activity_message/squad_sense_state.h"
 #include "../../state/activity/definition.h"
 #include "../../state/activity/receipts/definition.h"
 #include "../../state/build_data/scenarios/definition.h"
+#include "../../state/gameplay/external/squad_entity_retirement.h"
 
 namespace sunrise::server::activity::host {
 
@@ -301,6 +302,13 @@ struct SenseObservationKey final {
     std::uint8_t slotType{};
 };
 
+/** Copies the squad's merged recovery state for this exact ActivityClient generation. */
+[[nodiscard]] bool
+snapshot_squad_sense(const state::activity::SessionBinding& binding,
+                     std::uint64_t sourceGeneration,
+                     const SenseObservationKey& key,
+                     middleware::bap::activity_message::squad_sense::State& output) noexcept;
+
 /** One latest complete msg-6 object observation with values in its owning snapshot. */
 struct SenseObservation final {
     state::activity::SessionBinding binding{};
@@ -723,6 +731,7 @@ struct PendingIncident final {
 
 /** Immutable typed body retained byte-for-byte until exact transport staging. */
 struct PendingScriptableOverride final {
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement{};
     std::array<std::byte,
                middleware::bap::activity_message::sensor_auth_update::kAuthOverrideByteCapacity>
         body{};
@@ -925,16 +934,17 @@ request_type31_override(const state::activity::SessionBinding& binding,
     const ScriptableOutputReservation* reservation = nullptr) noexcept;
 
 /** Queues one squad placement intent for an exact package-derived ClientRef. */
-[[nodiscard]] bool
-request_squad_override(const state::activity::SessionBinding& binding,
-                       const ScriptableTarget& target,
-                       const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
-                       std::span<const std::int32_t> requestedCounts,
-                       middleware::bap::activity_message::squad_auth::Mode mode,
-                       std::uint64_t expectedActivityClientGeneration,
-                       std::optional<std::uint32_t> nameHash = std::nullopt,
-                       const ScriptableOutputReservation* reservation = nullptr,
-                       std::array<std::int8_t, 4> authoredProfile = {}) noexcept;
+[[nodiscard]] bool request_squad_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
+    std::span<const std::int32_t> requestedCounts,
+    middleware::bap::activity_message::squad_auth::Mode mode,
+    std::uint64_t expectedActivityClientGeneration,
+    std::optional<std::uint32_t> nameHash = std::nullopt,
+    const ScriptableOutputReservation* reservation = nullptr,
+    std::array<std::int8_t, 4> authoredProfile = {},
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement = {}) noexcept;
 
 /** Queues one generation-bound activity lifetime state through the serialized output slot. */
 [[nodiscard]] bool

+ 9 - 0
Sunrise/src/server/activity/host_runtime_internal.h

@@ -25,6 +25,7 @@ struct IncidentRequest final {
 
 /** One queued typed ClientRef request; its counter is assigned by the reducer. */
 struct ScriptableRequest final {
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement{};
     state::activity::SessionBinding binding{};
     ScriptableTarget target{};
     state::build_data::scenarios::RosterGroup stateLocalRosterGroup{};
@@ -137,10 +138,18 @@ struct SceneSenseTrace final {
     bool capacityReported{};
 };
 
+/** Merged client-reported recovery state for one squad slot. */
+struct SquadSenseRecord final {
+    SenseObservationKey key{};
+    middleware::bap::activity_message::squad_sense::State state{};
+};
+
 /** Mutable per-instance storage kept behind the runtime lock. */
 struct Instance final {
     InstanceSnapshot view{};
     SenseObservationSnapshot senseObservations{};
+    std::vector<SquadSenseRecord> squadSense{};
+    std::uint64_t squadSenseSourceGeneration{};
     SceneSenseTrace sceneSenseTrace{};
     std::array<ScriptableGuard, kScriptableGuardCapacity> scriptableGuards{};
     /** Latest delivered body for every full ClientRef, re-emitted by every later msg-5 body. */

+ 28 - 11
Sunrise/src/server/activity/host_runtime_scriptable.cpp

@@ -6,6 +6,7 @@
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../state/activity/mission/runtime.h"
 #include "../../state/activity/runtime.h"
+#include "../gameplay/squad_entity_retirement.h"
 #include "host_runtime_internal.h"
 
 namespace sunrise::server::activity::host {
@@ -169,8 +170,9 @@ valid_state_local_group(const ScriptableTarget& target,
 /** @return True when a transport acknowledgement names the retained body byte-for-byte. */
 [[nodiscard]] bool same_pending(const PendingScriptableOverride& left,
                                 const PendingScriptableOverride& right) noexcept {
-    return left.revision == right.revision && left.kind == right.kind
-           && same_target(left.target, right.target) && left.generation == right.generation
+    return left.squadRetirement == right.squadRetirement && left.revision == right.revision
+           && left.kind == right.kind && same_target(left.target, right.target)
+           && left.generation == right.generation
            && left.expectedActivityClientGeneration == right.expectedActivityClientGeneration
            && left.sequence == right.sequence && left.dialogueSequence == right.dialogueSequence
            && left.dialogueCue == right.dialogueCue && left.bitCount == right.bitCount
@@ -363,6 +365,7 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
     pending.revision = request.expectedRevision == 0 ? instance->view.scriptableRevision + 1
                                                      : request.expectedRevision;
     pending.kind = request.kind;
+    pending.squadRetirement = request.squadRetirement;
     pending.expectedActivityClientGeneration = request.expectedActivityClientGeneration;
     std::size_t written = 0;
     std::size_t writtenBits = 0;
@@ -980,16 +983,25 @@ bool request_state_local_dialogue_override(
 }
 
 /** Queues one squad placement intent for an exact package-derived ClientRef. */
-bool request_squad_override(const state::activity::SessionBinding& binding,
-                            const ScriptableTarget& target,
-                            const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
-                            std::span<const std::int32_t> requestedCounts,
-                            squad::Mode mode,
-                            std::uint64_t expectedActivityClientGeneration,
-                            std::optional<std::uint32_t> nameHash,
-                            const ScriptableOutputReservation* reservation,
-                            std::array<std::int8_t, 4> authoredProfile) noexcept {
+bool request_squad_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
+    std::span<const std::int32_t> requestedCounts,
+    squad::Mode mode,
+    std::uint64_t expectedActivityClientGeneration,
+    std::optional<std::uint32_t> nameHash,
+    const ScriptableOutputReservation* reservation,
+    std::array<std::int8_t, 4> authoredProfile,
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement) noexcept {
     const auto rawMode = static_cast<std::uint8_t>(mode);
+    if (squadRetirement.enabled
+        && (squadRetirement.squad.key != target.registryKey
+            || squadRetirement.squad.index != target.slotIndex
+            || squadRetirement.squad.type != target.slotType || squadRetirement.rsatTag == 0
+            || squadRetirement.bubble >= 64
+            || !std::ranges::any_of(requestedCounts, [](auto count) { return count > 0; })))
+        return false;
     if (target.slotType != squad::kSlotType || target.authSchema != squad::kSchema
         || (target.stateLocalRoster
                 ? stateLocalRosterGroup == nullptr
@@ -1012,6 +1024,7 @@ bool request_squad_override(const state::activity::SessionBinding& binding,
     std::ranges::copy(requestedCounts, request.requestedCounts.begin());
     request.requestedCountLength = requestedCounts.size();
     request.squadAuthoredProfile = authoredProfile;
+    request.squadRetirement = squadRetirement;
     request.nameHash = nameHash;
     request.expectedActivityClientGeneration = expectedActivityClientGeneration;
     request.squadMode = mode;
@@ -1297,6 +1310,8 @@ void note_scriptable_transport_staged(const state::activity::SessionBinding& bin
             ReleaseSRWLockExclusive(&g_lock);
             return;
         }
+        server::gameplay::squad_entity_retirement::record_delivered_target(
+            binding, sourceGeneration, pending);
         advance_staged_guard(guard, pending);
         if (pending.kind == ScriptableOverrideKind::lifetime) {
             // Latch the state so every later msg 5 keeps reporting it.
@@ -1317,6 +1332,8 @@ void note_scriptable_transport_staged(const state::activity::SessionBinding& bin
                 ++g_refusedControls;
                 continue;
             }
+            server::gameplay::squad_entity_retirement::record_delivered_target(
+                binding, sourceGeneration, queued);
             advance_staged_guard(queuedGuard, queued);
             event.scriptableRevision = queued.revision;
             append_event(event);

+ 3 - 1
Sunrise/src/server/activity/mission/mission_script_lua_squad_api.cpp

@@ -93,7 +93,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         || definition.memberCount > kSquadMemberCapacity) {
         return luaL_error(state, "activity squad is stale or invalid");
     }
-    static constexpr std::array<std::string_view, 2> kDeclared{"counts", "mode"};
+    static constexpr std::array<std::string_view, 3> kDeclared{
+        "counts", "mode", "retire_on_return"};
     refuse_unknown_arguments(state, kDeclared);
     CallFrame& frame = active_frame(state);
     Intent intent{};
@@ -116,6 +117,7 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
     SquadModeHandle mode{};
     static_cast<void>(optional_argument(state, "mode", kSquadModeMetatable, mode));
     intent.squadMode = mode.mode;
+    intent.squadRetireOnReturn = optional_boolean_argument(state, "retire_on_return", false);
     return queue_intent(state, frame, intent);
 }
 

+ 7 - 2
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -325,8 +325,13 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
             }
             return;
         }
-        const squads::Status status =
-            squads::place_reserved(instance.view, intent.firstRow, counts, mode, reservation);
+        const squads::Status status = squads::place_reserved(instance.view,
+                                                             intent.firstRow,
+                                                             counts,
+                                                             mode,
+                                                             reservation,
+                                                             std::nullopt,
+                                                             intent.squadRetireOnReturn);
         if (status == squads::Status::queued) {
             await_host_commit(instance, now, "squad_enqueued");
         } else if (!abandon_reserved_delivery(instance, reservation)) {

+ 8 - 4
Sunrise/src/server/bap/bap_route.cpp

@@ -1154,8 +1154,10 @@ bool request_replication_epoch(const state::activity::SessionBinding& binding,
     AcquireSRWLockExclusive(&g_lock);
     std::size_t count = 0;
     Session* const session = unique_mutable_activity_link_locked(binding, count);
-    bool queued = session != nullptr && expectedGeneration != 0
-                  && session->activity.bindingGeneration == expectedGeneration;
+    bool queued =
+        session != nullptr && expectedGeneration != 0
+        && session->activity.bindingGeneration == expectedGeneration
+        && generation == static_cast<std::uint8_t>(session->activity.replicationEpoch + 1U);
     if (queued) {
         ReplicationEpochPublication& request = session->activityReplicationEpoch;
         queued = !request.pending
@@ -1643,7 +1645,8 @@ bool request_activity_squad_override(
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation,
-    std::array<std::int8_t, 4> authoredProfile) noexcept {
+    std::array<std::int8_t, 4> authoredProfile,
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement) noexcept {
     AcquireSRWLockExclusive(&g_lock);
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
@@ -1663,7 +1666,8 @@ bool request_activity_squad_override(
                                                                   expectedGeneration,
                                                                   nameHash,
                                                                   reservation,
-                                                                  authoredProfile);
+                                                                  authoredProfile,
+                                                                  squadRetirement);
     ReleaseSRWLockExclusive(&g_lock);
     return queued;
 }

+ 58 - 0
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -124,6 +124,7 @@ struct SenseResolverContext final {
     return sense_update::TargetStatus::resolved;
 }
 
+/** A Sense slot must resolve within the exact published object group. */
 [[nodiscard]] sense_update::TargetStatus
 resolve_sense_slot(const void* raw,
                    const sense_update::GroupTarget& group,
@@ -471,6 +472,8 @@ void report_message(std::uint32_t messageType,
             request.sessionId, returned, plan.entitySlotMutation)) {
         return false;
     }
+    plan.returnedEntitySlots = decoded;
+    plan.hasReturnedEntitySlots = true;
     plan.sessionId = request.sessionId;
     plan.delivery = Delivery::none;
     plan.mutationDomain = MutationDomain::entitySlots;
@@ -642,6 +645,55 @@ constexpr std::array<FramingRoute, 19> kFramingRoutes{{
     return true;
 }
 
+/** A purge answer preserves the requesting client's complete mask and reason. */
+[[nodiscard]] bool prepare_authority_purge(const ActivityClientBinding& binding,
+                                           const RosterDecodeMap& rosterDecode,
+                                           IngressAdapter adapter,
+                                           const service::Request& request,
+                                           ActivityPlan& plan,
+                                           bool& hasTransaction) noexcept {
+    service::entity_authority::PurgeRequest purge{};
+    const bool parsed = service::entity_authority::parse_request_purge(request.payload, purge);
+    if (!frame_only(binding, rosterDecode, adapter, request)) return false;
+    if (!parsed) return true;
+    plan.sessionId = request.sessionId;
+    plan.authorityPurge.body.slots = purge.mask;
+    plan.authorityPurge.body.reason = static_cast<std::int8_t>(purge.reason);
+    plan.authorityPurge.body.epoch = static_cast<std::uint8_t>(binding.replicationEpoch + 1U);
+    plan.authorityPurge.sourceGeneration = binding.bindingGeneration;
+    plan.authorityPurge.pending = true;
+    plan.delivery = Delivery::purgeNotification;
+    plan.mutationDomain = MutationDomain::authorityPurge;
+    hasTransaction = true;
+    return true;
+}
+
+/** A valid abdication changes ownership only after its frame commits. */
+[[nodiscard]] bool prepare_authority_abdication(const ActivityClientBinding& binding,
+                                                const RosterDecodeMap& rosterDecode,
+                                                IngressAdapter adapter,
+                                                const service::Request& request,
+                                                ActivityPlan& plan,
+                                                bool& hasTransaction) noexcept {
+    service::entity_authority::Release release{};
+    const bool parsed = service::entity_authority::parse_abdicate(request.payload, release);
+    if (!frame_only(binding, rosterDecode, adapter, request)) {
+        return false;
+    }
+    if (!parsed) {
+        return true;
+    }
+    plan.sessionId = request.sessionId;
+    plan.authorityAbdication.sourceGeneration = binding.bindingGeneration;
+    plan.authorityAbdication.entities = release.mask;
+    plan.authorityAbdication.bubble = release.selector;
+    plan.authorityAbdication.pending = true;
+    plan.delivery = Delivery::none;
+    plan.mutationDomain = MutationDomain::authorityAbdication;
+    hasTransaction = true;
+    return true;
+}
+
 /** Retains one exact msg-29 acknowledgement until its authenticated frame commits. */
 [[nodiscard]] bool prepare_authority_reset_acknowledgement(const ActivityClientBinding& binding,
                                                            const RosterDecodeMap& rosterDecode,
@@ -750,6 +802,12 @@ bool process(const ActivityClientBinding& binding,
     case IngressAdapter::authorityResetAcknowledgement:
         return prepare_authority_reset_acknowledgement(
             binding, rosterDecode, adapter, request, plan, hasTransaction);
+    case IngressAdapter::authorityAbdicate:
+        return prepare_authority_abdication(
+            binding, rosterDecode, adapter, request, plan, hasTransaction);
+    case IngressAdapter::authorityRequestPurge:
+        return prepare_authority_purge(
+            binding, rosterDecode, adapter, request, plan, hasTransaction);
     case IngressAdapter::authorityQueryAnswer:
         return prepare_authority_query_answer(
             binding, rosterDecode, adapter, request, plan, hasTransaction);

+ 23 - 0
Sunrise/src/server/bap/encrypted/activity_message/definition.h

@@ -2,6 +2,7 @@
 
 #include <cstdint>
 
+#include "../../../../middleware/bap/activity_message/activity_host_control.h"
 #include "../../../../middleware/bap/activity_message/activity_patch_epoch_parser.h"
 #include "../../../../middleware/bap/activity_message/entity_authority.h"
 #include "../../../../state/activity/membership/activity_membership_query.h"
@@ -30,6 +31,7 @@ enum class Delivery : std::uint8_t {
     authoritativeNotifications,
     /** The roster, answering the patch epoch its body has to echo back. */
     rosterNotification,
+    purgeNotification,
 };
 
 /** State transaction family staged by one activity service request. */
@@ -43,6 +45,8 @@ enum class MutationDomain : std::uint8_t {
     authorityQuery,
     /** Reset acknowledgements are retained only by their exact connection generation. */
     authorityReset,
+    authorityAbdication,
+    authorityPurge,
 };
 
 /** Connection binding change staged by an activity join. */
@@ -94,17 +98,36 @@ struct AuthorityResetIngress final {
     bool pending{};
 };
 
+/** The client relinquishes one bubble's authority on message 33. */
+struct AuthorityAbdicationIngress final {
+    middleware::bap::activity_message::entity_slots::EntitySlotMask entities{};
+    std::uint64_t sourceGeneration{};
+    std::uint8_t bubble{};
+    bool pending{};
+};
+
+/** A purge request retains its exact mask and the next shared replication epoch. */
+struct AuthorityPurgeIngress final {
+    middleware::bap::activity_message::host_control::PurgeAuthorityBody body{};
+    std::uint64_t sourceGeneration{};
+    bool pending{};
+};
+
 /** Scalar and mask data kept after the sensitive svc8 payload view expires. */
 struct ActivityPlan final {
     std::uint32_t correlation{};
     std::uint64_t sessionId{};
     state::activity::entity_slots::PendingMutation entitySlotMutation{};
+    state::activity::bubble_authority::EntitySlotMask returnedEntitySlots{};
+    bool hasReturnedEntitySlots{};
     state::activity::membership::PendingMutation membershipMutation{};
     JoinIngressDiagnostic joinIngress{};
     ClientStateIngress clientState{};
     EntitySlotsRequestedIngress entitySlotsRequested{};
     AuthorityQueryIngress authorityQuery{};
     AuthorityResetIngress authorityReset{};
+    AuthorityAbdicationIngress authorityAbdication{};
+    AuthorityPurgeIngress authorityPurge{};
     middleware::bap::activity_message::patch_epoch::PatchEpoch patchEpoch{};
     /** Exact target generation whose destination the staged msg1 must encode. */
     state::activity::SessionBinding targetBinding{};

+ 12 - 3
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -510,17 +510,26 @@ Framed frame_authority_release(const message::Request& request, bool expectReaso
            request.messageType,
            static_cast<unsigned>(decoded.selector),
            decoded.hasReason ? decoded.reason : 0);
+    for (std::size_t offset = 0; offset < decoded.mask.size(); offset += sizeof(std::uint32_t)) {
+        const std::uint32_t word = middleware::encoding::read_u32_le(
+            std::span(decoded.mask).subspan(offset).first<sizeof(std::uint32_t)>());
+        if (word != 0)
+            report(core::log::Level::debug,
+                   "ev=activity stage=authority_mask type=%u selector=%u word=%zu bits=0x%08X",
+                   request.messageType,
+                   static_cast<unsigned>(decoded.selector),
+                   offset / sizeof(std::uint32_t),
+                   word);
+    }
     return {Verdict::framed, payload_bits(request)};
 }
 
-/** Frames one purge request. Nothing answers it. */
+/** Frames one purge request before its transactional message-25 answer. */
 Framed frame_request_purge(const message::Request& request) noexcept {
     authority::PurgeRequest decoded{};
     if (!authority::parse_request_purge(request.payload, decoded)) {
         return {report_malformed("purge", request), 0};
     }
-    // The answer would have to name the exact next authority generation, which nothing here
-    // tracks, and the consumer asserts on any other value.
     report(core::log::Level::debug,
            "ev=activity stage=purge result=noted reason=%d mask_bytes=%zu",
            decoded.reason,

+ 71 - 11
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp

@@ -1,11 +1,13 @@
 #include "activity_transaction_notifications.h"
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/secure_channel/runtime.h"
 #include "../../../gameplay/gameplay_advertisement.h"
 #include "../push/activity/activity_arrival.h"
 #include "../push/activity/activity_global_state_push.h"
 #include "../push/activity/activity_membership_push.h"
 #include "../push/activity/activity_message_push.h"
+#include "../push/activity/activity_notification_frame.h"
 #include "../push/activity/activity_roster_push.h"
 #include "../push/activity/activity_world_globals_push.h"
 #include "../push/activity/internal.h"
@@ -60,7 +62,8 @@ namespace {
                                  std::span<const std::byte, state::kAesKeySize> key,
                                  std::array<std::byte, state::kBapNonceSize>& nonce,
                                  std::span<std::byte> response,
-                                 std::size_t& written) noexcept {
+                                 std::size_t& written,
+                                 bool allowEntityRetirement) noexcept {
     bool staged = push::activity::append_global_state_notification(
                       scratch, session.activity.session, key, nonce, response, written)
                   && push::activity::append_world_globals_notification(
@@ -77,8 +80,17 @@ namespace {
     // delivered-body record cannot see. Its bubble field names the slice set the client holds.
     const push::activity::RefreshReport refresh{activity.membershipMutation.bubbleIndex,
                                                 activity.membershipMutation.requestedRevision};
-    return push::activity::append_roster_notification(
-               session, scratch, key, nonce, response, written, nullptr, nullptr, true, &refresh)
+    return push::activity::append_roster_notification(session,
+                                                      scratch,
+                                                      key,
+                                                      nonce,
+                                                      response,
+                                                      written,
+                                                      nullptr,
+                                                      nullptr,
+                                                      true,
+                                                      &refresh,
+                                                      allowEntityRetirement)
            || staged;
 }
 
@@ -102,7 +114,8 @@ namespace {
                                        std::span<const std::byte, state::kAesKeySize> key,
                                        std::array<std::byte, state::kBapNonceSize>& nonce,
                                        std::span<std::byte> response,
-                                       std::size_t& written) noexcept {
+                                       std::size_t& written,
+                                       bool allowEntityRetirement) noexcept {
     bool staged = false;
     bool stagedMembership = false;
     bool held = false;
@@ -129,8 +142,17 @@ namespace {
         // The client reports the region it now holds once its slice set is instantiated, and the
         // roster is that report's answer. It is solicited, so it is never skipped as a repeat,
         // including while the slice set is still instantiating.
-        staged = push::activity::append_roster_notification(
-                     session, scratch, key, nonce, response, written, nullptr, &region, true)
+        staged = push::activity::append_roster_notification(session,
+                                                            scratch,
+                                                            key,
+                                                            nonce,
+                                                            response,
+                                                            written,
+                                                            nullptr,
+                                                            &region,
+                                                            true,
+                                                            nullptr,
+                                                            allowEntityRetirement)
                  || staged;
     }
     // A delta that owed nothing is not a failure, and a public link never owes the block above.
@@ -158,13 +180,40 @@ bool stage_notifications(Session& session,
                          std::span<const std::byte, state::kAesKeySize> key,
                          std::array<std::byte, state::kBapNonceSize>& nonce,
                          std::span<std::byte> response,
-                         std::size_t& written) noexcept {
+                         std::size_t& written,
+                         bool allowEntityRetirement) noexcept {
     // Each encoder refuses an absent session itself, so a plan that delivers nothing needs no
     // session at all. Message type 52 is the one that arrives on an unallocated link.
     if (activity.delivery == activity_message::Delivery::joinNotifications) {
         return push::activity::append_join_notifications(
             scratch, session, activity, key, nonce, response, written);
     }
+    if (activity.delivery == activity_message::Delivery::purgeNotification) {
+        namespace control = middleware::bap::activity_message::host_control;
+        const auto& purge = activity.authorityPurge;
+        if (!purge.pending || purge.sourceGeneration != session.activity.bindingGeneration
+            || activity.sessionId != session.activity.session.sessionId
+            || activity_link_count_locked(session.activity.session) != 1
+            || purge.body.epoch
+                   != static_cast<std::uint8_t>(session.activity.replicationEpoch + 1U)) {
+            return false;
+        }
+        std::array<std::byte, control::kPurgeAuthorityByteCount> body{};
+        std::size_t bodySize = 0;
+        if (!control::encode_purge_authority(purge.body, body, bodySize)
+            || !push::activity::append_notification_frame(scratch,
+                                                          activity.sessionId,
+                                                          control::kPurgeAuthorityMessageType,
+                                                          std::span(body).first(bodySize),
+                                                          key,
+                                                          nonce,
+                                                          response,
+                                                          written)) {
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        return true;
+    }
     if (activity.delivery == activity_message::Delivery::rosterNotification) {
         // The client's inbound dispatch table has no entry for a type-52 response, so that
         // message is a one-way report and owes nothing on its own.
@@ -173,8 +222,17 @@ bool stage_notifications(Session& session,
         }
         // The epoch it reports is what an earlier answer was missing, so that answer goes now. The
         // epoch comes from this message; the connection's own copy is published after this runs.
-        return push::activity::append_roster_notification(
-            session, scratch, key, nonce, response, written, &activity.patchEpoch, nullptr, true);
+        return push::activity::append_roster_notification(session,
+                                                          scratch,
+                                                          key,
+                                                          nonce,
+                                                          response,
+                                                          written,
+                                                          &activity.patchEpoch,
+                                                          nullptr,
+                                                          true,
+                                                          nullptr,
+                                                          allowEntityRetirement);
     }
     if (activity.delivery == activity_message::Delivery::entitySlotNotification) {
         return push::activity::append_entity_slot_notification(scratch,
@@ -195,10 +253,12 @@ bool stage_notifications(Session& session,
             scratch, session, activity, key, nonce, response, written);
     }
     if (activity.delivery == activity_message::Delivery::refreshNotifications) {
-        return stage_refresh(session, scratch, activity, key, nonce, response, written);
+        return stage_refresh(
+            session, scratch, activity, key, nonce, response, written, allowEntityRetirement);
     }
     if (activity.delivery == activity_message::Delivery::authoritativeNotifications) {
-        return stage_authoritative(session, scratch, activity, key, nonce, response, written);
+        return stage_authoritative(
+            session, scratch, activity, key, nonce, response, written, allowEntityRetirement);
     }
     return activity.delivery == activity_message::Delivery::none;
 }

+ 2 - 1
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.h

@@ -26,6 +26,7 @@ namespace sunrise::server::bap::encrypted::activity_transaction {
                                        std::span<const std::byte, state::kAesKeySize> key,
                                        std::array<std::byte, state::kBapNonceSize>& nonce,
                                        std::span<std::byte> response,
-                                       std::size_t& written) noexcept;
+                                       std::size_t& written,
+                                       bool allowEntityRetirement = true) noexcept;
 
 } // namespace sunrise::server::bap::encrypted::activity_transaction

+ 81 - 2
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -2,13 +2,17 @@
 
 #include <algorithm>
 #include <array>
+#include <bit>
 #include <cstdio>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/encoding/byte_order.h"
 #include "../../../middleware/secure_channel/runtime.h"
+#include "../../../state/activity/bubble_authority/runtime.h"
 #include "../../../state/runtime/runtime.h"
 #include "../../activity/host_runtime.h"
+#include "../../gameplay/peer/peer_transport.h"
+#include "../../gameplay/squad_entity_retirement.h"
 #include "../activity_authority_query_owner.h"
 #include "../activity_authority_reset_owner.h"
 #include "../internal.h"
@@ -120,6 +124,24 @@ void submit_committed_authority_answer(Session& session,
     }
 }
 
+/** An old ActivityClient cannot relinquish the replacement client's grant. */
+void submit_committed_authority_abdication(Session& session,
+                                           const activity_message::ActivityPlan& plan) noexcept {
+    if (plan.mutationDomain != activity_message::MutationDomain::authorityAbdication
+        || !plan.authorityAbdication.pending
+        || plan.authorityAbdication.sourceGeneration != session.activity.bindingGeneration
+        || plan.sessionId != session.activity.session.sessionId) {
+        return;
+    }
+    state::activity::bubble_authority::record_abdication(
+        plan.sessionId, plan.authorityAbdication.bubble, &plan.authorityAbdication.entities);
+    server::gameplay::squad_entity_retirement::observe_abdication(
+        session.activity.session,
+        session.activity.bindingGeneration,
+        plan.authorityAbdication.bubble,
+        plan.authorityAbdication.entities);
+}
+
 /** Applies one reset acknowledgement only after its authenticated service frame commits. */
 void submit_committed_authority_reset(Session& session,
                                       const activity_message::ActivityPlan& plan) noexcept {
@@ -283,6 +305,9 @@ bool consume(Session& session,
             // The transaction still commits. A push that cannot be built is one lost message, and
             // dropping the commit with it would strand the client's reported state for the session.
             diagnostics::report_failure(frame.serviceId, "notify");
+            if (activityPlan->mutationDomain == activity_message::MutationDomain::authorityPurge) {
+                handled = false;
+            }
         }
     }
     const bool mutatesAccount =
@@ -323,9 +348,14 @@ bool consume(Session& session,
     if (handled && processesBody) {
         // State changes become visible only after every requested frame and caller byte fit.
         // A refused commit sends nothing, so the frame's own reason is the only record of why.
-        const bool fits = framedSize <= response.size();
         const char* commitReason = "none";
-        handled = fits && transactions::commit(outcome, publication, commitReason);
+        server::gameplay::entity_identities::PublicationLease entityLease;
+        const bool retirementValid =
+            push::activity::begin_staged_roster_publication(session, entityLease);
+        const bool fits = framedSize <= response.size();
+        if (!retirementValid) commitReason = "entity_retirement_stale";
+        handled =
+            fits && retirementValid && transactions::commit(outcome, publication, commitReason);
         if (!handled) {
             diagnostics::report_failure(
                 frame.serviceId, "commit", fits ? commitReason : "frame_capacity");
@@ -333,6 +363,7 @@ bool consume(Session& session,
         if (handled) {
             std::copy_n(scratch.framed.begin(), framedSize, response.begin());
             written = framedSize;
+            entityLease.release();
             // The caller copy finishes before connection fields are published.
             session.sendNonce = nextSendNonce;
             if (publishesQueuez) {
@@ -349,6 +380,54 @@ bool consume(Session& session,
                 submit_committed_entity_slots_requested(*activityPlan);
                 submit_committed_authority_reset(session, *activityPlan);
                 submit_committed_authority_answer(session, *activityPlan);
+                submit_committed_authority_abdication(session, *activityPlan);
+                if (activityPlan->hasReturnedEntitySlots
+                    && activityPlan->sessionId == session.activity.session.sessionId) {
+                    server::gameplay::squad_entity_retirement::returned_slots(
+                        session.activity.session,
+                        session.activity.bindingGeneration,
+                        activityPlan->returnedEntitySlots);
+                }
+                if (activityPlan->mutationDomain
+                    == activity_message::MutationDomain::authorityPurge) {
+                    const auto previousEpoch = session.activity.replicationEpoch;
+                    session.activity.replicationEpoch = activityPlan->authorityPurge.body.epoch;
+                    const auto updatedViews = server::gameplay::peer::commit_replication_epoch(
+                        session.activity.session,
+                        session.activity.bindingGeneration,
+                        previousEpoch,
+                        session.activity.replicationEpoch);
+                    state::activity::bubble_authority::record_purge(
+                        activityPlan->sessionId, activityPlan->authorityPurge.body.slots);
+                    server::gameplay::squad_entity_retirement::returned_slots(
+                        session.activity.session,
+                        session.activity.bindingGeneration,
+                        activityPlan->authorityPurge.body.slots);
+                    if (session.activityReplicationEpoch.pending
+                        && session.activityReplicationEpoch.generation
+                               == session.activity.replicationEpoch) {
+                        session.activityReplicationEpoch.pending = false;
+                    }
+                    unsigned slots = 0;
+                    for (const std::byte byte : activityPlan->authorityPurge.body.slots) {
+                        slots +=
+                            static_cast<unsigned>(std::popcount(std::to_integer<unsigned>(byte)));
+                    }
+                    std::array<char, core::log::kLineCapacity> line{};
+                    const int count =
+                        std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=purge result=published epoch=%u reason=%d "
+                                      "slots=%u views=%zu",
+                                      static_cast<unsigned>(session.activity.replicationEpoch),
+                                      static_cast<int>(activityPlan->authorityPurge.body.reason),
+                                      slots,
+                                      updatedViews);
+                    if (count > 0)
+                        core::log::write(core::log::Channel::server,
+                                         core::log::Level::debug,
+                                         {line.data(), static_cast<std::size_t>(count)});
+                }
             }
             // Any delivered activity notification resets the client's silence timer, so the
             // fallback keepalive is delayed. A roster-only answer is excluded: it owes neither the

+ 5 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -97,6 +97,7 @@ void discard_staged_replication_epoch(Session& session) noexcept {
 void commit_staged_replication_epoch(Session& session) noexcept {
     ReplicationEpochPublication& request = session.activityReplicationEpoch;
     if (request.staged && request.bindingGeneration == session.activity.bindingGeneration) {
+        session.activity.replicationEpoch = request.generation;
         request.pending = false;
     }
     request.staged = false;
@@ -150,7 +151,9 @@ void commit_staged_replication_epoch(Session& session) noexcept {
                                  std::size_t framedSize,
                                  const std::array<std::byte, state::kBapNonceSize>& nextSendNonce,
                                  bool published) noexcept {
-    if (!published || framedSize == 0 || framedSize > response.size()) {
+    server::gameplay::entity_identities::PublicationLease entityLease;
+    if (!published || framedSize == 0 || framedSize > response.size()
+        || !begin_staged_roster_publication(session, entityLease)) {
         // Nothing left, so a roster staged into the discarded body is offered again next push.
         discard_staged_roster(session);
         discard_staged_advertisement(session);
@@ -164,6 +167,7 @@ void commit_staged_replication_epoch(Session& session) noexcept {
         response[index] = scratch.framed[index];
     }
     written = framedSize;
+    entityLease.release();
     session.sendNonce = nextSendNonce;
     // Settled only here: the grant and the state byte may move only on a delivered frame.
     commit_staged_roster(session);

+ 98 - 3
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp

@@ -11,12 +11,15 @@
 #include <string_view>
 
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/bap/activity_message/activity_host_control.h"
 #include "../../../../../middleware/bap/activity_message/scriptable_auth_body.h"
 #include "../../../../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/activity/bubble_authority/runtime.h"
 #include "../../../../../state/activity/runtime.h"
 #include "../../../../activity/host_runtime.h"
+#include "../../../../gameplay/peer/peer_transport.h"
+#include "../../../../gameplay/squad_entity_retirement.h"
 #include "activity_notification_frame.h"
 #include "internal.h"
 
@@ -259,11 +262,13 @@ bool append_roster_notification(
     const middleware::bap::activity_message::patch_epoch::PatchEpoch* epoch,
     const EffectiveRegion* exactRegion,
     bool solicited,
-    const RefreshReport* refresh) noexcept {
+    const RefreshReport* refresh,
+    bool allowEntityRetirement) noexcept {
     if (written > response.size()) {
         return false;
     }
     const auto initialLeases = session.activityRosterGroupLeases;
+    const bool initialRosterOwedForEpoch = session.activityRosterOwedForEpoch;
     const std::uint8_t initialRosterSends = session.activityRosterSends;
     const std::uint8_t initialRosterState = session.activityRosterState;
     const std::uint8_t initialRegionEpoch = session.activityRosterRegionEpoch;
@@ -408,10 +413,14 @@ bool append_roster_notification(
     // checked as signed; an out-of-range value becomes negative and fails.
     const std::int32_t pendingRegion =
         state::activity::membership::reported_region(session.activity.session.sessionId);
+    const bool enteringBubble = session.activityRosterRegionBubble >= 0
+                                && session.activityRosterRegionBubble != initialRegionBubble;
     const std::int32_t grantRegion =
-        pendingRegion >= 0 ? pendingRegion : static_cast<std::int32_t>(snapshot.region);
+        enteringBubble
+            ? static_cast<std::int32_t>(snapshot.region)
+            : (pendingRegion >= 0 ? pendingRegion : static_cast<std::int32_t>(snapshot.region));
     if (state::activity::bubble_authority::select_grant(
-            session.activity.session.sessionId, grantRegion, grant)) {
+            session.activity.session.sessionId, grantRegion, grant, enteringBubble)) {
         snapshot.hasGrant = true;
         snapshot.grant.bubble = grant.bubble;
         snapshot.grant.token = grant.token;
@@ -419,6 +428,14 @@ bool append_roster_notification(
 
     const std::size_t initialWritten = written;
     auto initialNonce = nonce;
+    server::gameplay::squad_entity_retirement::RetirementPlan entityRetirement{};
+    const auto retirementPriorEpoch = session.activity.replicationEpoch;
+    const auto& epochRequest = session.activityReplicationEpoch;
+    const auto retirementBaseEpoch =
+        epochRequest.staged && epochRequest.bindingGeneration == session.activity.bindingGeneration
+            ? epochRequest.generation
+            : retirementPriorEpoch;
+    const auto retirementEpoch = static_cast<std::uint8_t>(retirementBaseEpoch + 1U);
     std::size_t messageSize = 0;
     RosterDecodeMap decodeMap{};
     const MissionSeedLease& stagedMissionSeed = session.activityMissionSeed;
@@ -465,6 +482,28 @@ bool append_roster_notification(
             session, snapshot, name, 0, kNoGrant, RosterOutcome::unchanged, bodyHash, forced);
         return false;
     }
+    if (encoded && snapshot.hasGrant && enteringBubble && allowEntityRetirement
+        && server::gameplay::squad_entity_retirement::prepare_retirement(
+            session.activity.session,
+            session.activity.bindingGeneration,
+            grant.bubble,
+            entityRetirement)) {
+        namespace control = middleware::bap::activity_message::host_control;
+        const control::PurgeAuthorityBody retirement{
+            .slots = entityRetirement.entities, .epoch = retirementEpoch, .reason = 0};
+        std::array<std::byte, control::kPurgeAuthorityByteCount> retirementBytes{};
+        std::size_t retirementSize{};
+        encoded = control::encode_purge_authority(retirement, retirementBytes, retirementSize)
+                  && append_notification_frame(scratch,
+                                               session.activity.session.sessionId,
+                                               control::kPurgeAuthorityMessageType,
+                                               std::span(retirementBytes).first(retirementSize),
+                                               key,
+                                               nonce,
+                                               response,
+                                               written);
+        if (encoded) middleware::secure_channel::advance_nonce(nonce);
+    }
     encoded = encoded
               && append_notification_frame(scratch,
                                            session.activity.session.sessionId,
@@ -488,9 +527,14 @@ bool append_roster_notification(
         // discarded, so they are held here and settled by `commit_staged_roster` or
         // `discard_staged_roster`.
         session.activityRosterStaged.grant = grant;
+        session.activityRosterStaged.entityRetirement = entityRetirement;
+        session.activityRosterStaged.retirementPriorEpoch = retirementPriorEpoch;
+        session.activityRosterStaged.retirementBaseEpoch = retirementBaseEpoch;
+        session.activityRosterStaged.retirementEpoch = retirementEpoch;
         session.activityRosterStaged.decodeMap = decodeMap;
         session.activityRosterStaged.bindingGeneration = session.activity.bindingGeneration;
         session.activityRosterStaged.priorLeases = initialLeases;
+        session.activityRosterStaged.priorRosterOwedForEpoch = initialRosterOwedForEpoch;
         session.activityRosterStaged.priorSends = initialRosterSends;
         session.activityRosterStaged.priorState = initialRosterState;
         session.activityRosterStaged.priorRegionEpoch = initialRegionEpoch;
@@ -567,6 +611,31 @@ bool append_roster_notification(
     return encoded;
 }
 
+bool validate_staged_roster(const Session& session) noexcept {
+    const auto& staged = session.activityRosterStaged;
+    return !staged.staged || !staged.entityRetirement.pending
+           || (staged.bindingGeneration == session.activity.bindingGeneration
+               && staged.retirementPriorEpoch == session.activity.replicationEpoch
+               && server::gameplay::squad_entity_retirement::validate_retirement(
+                   session.activity.session,
+                   session.activity.bindingGeneration,
+                   staged.entityRetirement));
+}
+
+/** Retained identities cannot change between final validation and the caller copy. */
+bool begin_staged_roster_publication(
+    const Session& session, server::gameplay::entity_identities::PublicationLease& lease) noexcept {
+    const auto& staged = session.activityRosterStaged;
+    return !staged.staged || !staged.entityRetirement.pending
+           || (staged.bindingGeneration == session.activity.bindingGeneration
+               && staged.retirementPriorEpoch == session.activity.replicationEpoch
+               && server::gameplay::squad_entity_retirement::begin_retirement_publication(
+                   session.activity.session,
+                   session.activity.bindingGeneration,
+                   staged.entityRetirement,
+                   lease));
+}
+
 /** Settles a staged roster body that reached the caller. */
 void commit_staged_roster(Session& session) noexcept {
     if (!session.activityRosterStaged.staged) {
@@ -582,6 +651,32 @@ void commit_staged_roster(Session& session) noexcept {
     // fixed map here exposes either the prior delivered roster or this complete delivered roster.
     session.activityRosterDecode = session.activityRosterStaged.decodeMap;
     if (session.activityRosterStaged.hasGrant) {
+        if (session.activityRosterStaged.entityRetirement.pending) {
+            server::gameplay::squad_entity_retirement::commit_retirement(
+                session.activityRosterStaged.entityRetirement);
+            const auto& staged = session.activityRosterStaged;
+            session.activity.replicationEpoch = staged.retirementEpoch;
+            if (staged.retirementBaseEpoch != staged.retirementPriorEpoch)
+                static_cast<void>(server::gameplay::peer::commit_replication_epoch(
+                    session.activity.session,
+                    session.activity.bindingGeneration,
+                    staged.retirementPriorEpoch,
+                    staged.retirementBaseEpoch));
+            static_cast<void>(
+                server::gameplay::peer::commit_replication_epoch(session.activity.session,
+                                                                 session.activity.bindingGeneration,
+                                                                 staged.retirementBaseEpoch,
+                                                                 staged.retirementEpoch));
+            state::activity::bubble_authority::record_purge(session.activity.session.sessionId,
+                                                            staged.entityRetirement.entities);
+            auto& request = session.activityReplicationEpoch;
+            if (request.bindingGeneration == session.activity.bindingGeneration
+                && (request.generation == staged.retirementBaseEpoch
+                    || request.generation == staged.retirementEpoch)) {
+                request.pending = false;
+                request.staged = false;
+            }
+        }
         state::activity::bubble_authority::record_grant(session.activity.session.sessionId,
                                                         session.activityRosterStaged.grant);
     }

+ 10 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.h

@@ -3,6 +3,7 @@
 #include <cstddef>
 #include <span>
 
+#include "../../../../gameplay/entity_identities.h"
 #include "../../../internal.h"
 #include "../../activity_message/definition.h"
 
@@ -37,7 +38,8 @@ struct RefreshReport;
     const middleware::bap::activity_message::patch_epoch::PatchEpoch* epoch = nullptr,
     const EffectiveRegion* exactRegion = nullptr,
     bool solicited = false,
-    const RefreshReport* refresh = nullptr) noexcept;
+    const RefreshReport* refresh = nullptr,
+    bool allowEntityRetirement = true) noexcept;
 
 /**
  * Settles a staged roster body that reached the caller.
@@ -53,4 +55,11 @@ void commit_staged_roster(Session& session) noexcept;
  */
 void discard_staged_roster(Session& session) noexcept;
 
+/** A claim must still match its captured source before the carrying frames are published. */
+[[nodiscard]] bool validate_staged_roster(const Session& session) noexcept;
+
+/** A claim's identities stay fixed until the carrying frames reach caller storage. */
+[[nodiscard]] bool begin_staged_roster_publication(
+    const Session& session, server::gameplay::entity_identities::PublicationLease& lease) noexcept;
+
 } // namespace sunrise::server::bap::encrypted::push::activity

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

@@ -982,6 +982,7 @@ void rollback_staged_roster_state(Session& session) noexcept {
     session.activityRosterState = staged.priorState;
     session.activityRosterRegionEpoch = staged.priorRegionEpoch;
     session.activityRosterRegionBubble = staged.priorRegionBubble;
+    session.activityRosterOwedForEpoch = staged.priorRosterOwedForEpoch;
     session.activityRosterStaged = {};
 }
 
@@ -1409,6 +1410,36 @@ build_roster_snapshot(Session& session,
     if (pendingStateLocal && pendingGroupPosition >= snapshot.roster.groupCount) {
         return refuse_override("pending_group_position");
     }
+    std::size_t senseCount = 0;
+    for (const message::AuthOverride& auth : snapshot.authOverrides) {
+        if (auth.slotType != 1) {
+            continue;
+        }
+        server::activity::host::SenseObservationKey key{};
+        key.registryKey = auth.key;
+        key.objectTag = auth.objectTag;
+        key.senseSchema = 0x80807ECCU;
+        key.slotIndex = auth.slotIndex;
+        key.slotType = auth.slotType;
+        middleware::bap::activity_message::squad_sense::State recovered{};
+        if (!server::activity::host::snapshot_squad_sense(
+                session.activity.session, session.activity.bindingGeneration, key, recovered)) {
+            continue;
+        }
+        message::SenseOverride& sense = scratch.rosterSenseOverrides[senseCount];
+        sense = {};
+        if (!middleware::bap::activity_message::squad_sense::encode(
+                recovered, sense.body, sense.byteCount, sense.bitCount)) {
+            return refuse_override("squad_sense");
+        }
+        sense.key = auth.key;
+        sense.objectTag = auth.objectTag;
+        sense.slotIndex = auth.slotIndex;
+        sense.slotType = auth.slotType;
+        sense.counter = recovered.counter;
+        ++senseCount;
+    }
+    snapshot.senseOverrides = std::span(scratch.rosterSenseOverrides).first(senseCount);
     return RosterOutcome::published;
 }
 

+ 8 - 0
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -198,6 +198,14 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
             reason = "authority_reset";
             return plan->authorityReset.pending;
         }
+        if (plan->mutationDomain == activity_message::MutationDomain::authorityAbdication) {
+            reason = "authority_abdication";
+            return plan->authorityAbdication.pending;
+        }
+        if (plan->mutationDomain == activity_message::MutationDomain::authorityPurge) {
+            reason = "authority_purge";
+            return plan->authorityPurge.pending;
+        }
         // The retained patch epoch is connection state, so it commits nothing here.
         reason = "mutation_domain";
         return plan->mutationDomain == activity_message::MutationDomain::patchEpoch;

+ 8 - 0
Sunrise/src/server/bap/internal.h

@@ -15,6 +15,7 @@
 #include "../../state/activity/definition.h"
 #include "../../state/activity_sdk/runtime.h"
 #include "../../state/build_data/scenarios/definition.h"
+#include "../../state/gameplay/external/squad_entity_retirement.h"
 #include "../../state/runtime/state.h"
 #include "../activity/host_runtime.h"
 #include "activity_authority_query_owner.h"
@@ -52,6 +53,9 @@ struct Scratch {
     std::array<middleware::bap::activity_message::sensor_auth_update::AuthOverride,
                middleware::bap::activity_message::sensor_auth_update::kAuthOverrideCapacity>
         rosterAuthOverrides{};
+    std::array<middleware::bap::activity_message::sensor_auth_update::SenseOverride,
+               middleware::bap::activity_message::sensor_auth_update::kAuthOverrideCapacity>
+        rosterSenseOverrides{};
     /** SDK-authored scene inputs staged before their exact msg-5 targets are installed. */
     std::array<state::activity_sdk::AuthoredSceneSeed,
                middleware::bap::activity_message::sensor_auth_update::kAuthOverrideCapacity>
@@ -107,6 +111,10 @@ inline constexpr std::size_t kRosterGroupLeaseCapacity =
  */
 struct RosterPublication {
     state::activity::bubble_authority::Grant grant{};
+    state::gameplay::squad_entity_retirement::RetirementPlan entityRetirement{};
+    /** Epochs remain staged until both retirement and roster frames reach the caller. */
+    std::uint8_t retirementPriorEpoch{}, retirementBaseEpoch{}, retirementEpoch{};
+    bool priorRosterOwedForEpoch{};
     /** Exact decode identities carried by this staged complete roster snapshot. */
     RosterDecodeMap decodeMap{};
     /** Exact typed body carried by this staged roster, if any. */

+ 2 - 1
Sunrise/src/server/bap/runtime.h

@@ -354,7 +354,8 @@ activity_authority_reset_snapshot(const state::activity::SessionBinding& binding
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation = nullptr,
-    std::array<std::int8_t, 4> authoredProfile = {}) noexcept;
+    std::array<std::int8_t, 4> authoredProfile = {},
+    state::gameplay::squad_entity_retirement::Eligibility squadRetirement = {}) noexcept;
 
 /** Cancels one exact typed override revision while excluding activity-link publication. */
 [[nodiscard]] bool

+ 34 - 28
Sunrise/src/server/gameplay/actor_command_policy_lane.cpp

@@ -231,27 +231,22 @@ struct SquadClientRefLayout final {
 
 } // namespace
 
-/**
- * Applies one retry-safe actor projection.
- * @param groupSessionId Group session the channel-2 batch arrived on.
- * @param batch One committed entity record, or an empty batch.
- * @return True when the registry holds the record, so the peer may commit its wrapper.
- */
-bool accept_entity_batch(std::uint64_t groupSessionId,
-                         const external::EntityBatch& batch) noexcept {
-    if (groupSessionId == 0 || !batch.recordPresent) {
-        return groupSessionId != 0;
+/** Optional policy projection retains only its supported entity metadata. */
+static bool accept_entity_record(std::uint64_t groupSessionId,
+                                 const external::EntityRecord& record) noexcept {
+    if (groupSessionId == 0) {
+        return false;
     }
     if (InterlockedIncrement(&g_entityRecordDiagnostics) <= 128) {
         report(core::log::Level::debug,
                "ev=actor_policy stage=entity_record type=%u flags=0x%X slot=%u incarnation=%u "
                "baseline=%u update=%u",
-               static_cast<unsigned>(batch.record.type),
-               static_cast<unsigned>(batch.record.flags),
-               static_cast<unsigned>(batch.record.token.slot),
-               static_cast<unsigned>(batch.record.token.incarnation),
-               static_cast<unsigned>(batch.record.baseline.byteCount),
-               static_cast<unsigned>(batch.record.update.byteCount));
+               static_cast<unsigned>(record.type),
+               static_cast<unsigned>(record.flags),
+               static_cast<unsigned>(record.token.slot),
+               static_cast<unsigned>(record.token.incarnation),
+               static_cast<unsigned>(record.baseline.byteCount),
+               static_cast<unsigned>(record.update.byteCount));
     }
     external::ActorEntityCatalog published{};
     if (!external::published_actor_entity_catalog(published)) {
@@ -272,17 +267,17 @@ bool accept_entity_batch(std::uint64_t groupSessionId,
         ReleaseSRWLockExclusive(&g_lock);
         return false;
     }
-    if (batch.record.type == external::EntityType::squad) {
-        const bool accepted = accept_squad_record(*session, batch.record);
+    if (record.type == external::EntityType::squad) {
+        const bool accepted = accept_squad_record(*session, record);
         ReleaseSRWLockExclusive(&g_lock);
         return accepted;
     }
-    if (batch.record.type != external::EntityType::sobject) {
+    if (record.type != external::EntityType::sobject) {
         ReleaseSRWLockExclusive(&g_lock);
         return true;
     }
     external::ActorEntityCatalog catalog{session->catalog, session->catalog->actor_classes()};
-    if (batch.record.token.slot >= session->actors.slots.size()) {
+    if (record.token.slot >= session->actors.slots.size()) {
         ReleaseSRWLockExclusive(&g_lock);
         return false;
     }
@@ -299,20 +294,20 @@ bool accept_entity_batch(std::uint64_t groupSessionId,
     const state::activity_sdk::Snapshot priorCatalog = session->actors.catalog;
     const format::ActorClass* const priorClassData = session->actors.classData;
     const std::size_t priorClassCount = session->actors.classCount;
-    const external::ActorEntitySlot priorSlot = session->actors.slots[batch.record.token.slot];
+    const external::ActorEntitySlot priorSlot = session->actors.slots[record.token.slot];
     const external::ActorEntityApplyResult result =
-        external::apply_actor_entity_record(session->actors, catalog, batch.record);
+        external::apply_actor_entity_record(session->actors, catalog, record);
     bool policyCommandQueued = false;
     bool accepted = result != external::ActorEntityApplyResult::invalid
                     && result != external::ActorEntityApplyResult::staleToken;
     if (result == external::ActorEntityApplyResult::actorRemoved) {
-        remove_target_state(*session, batch.record.token);
+        remove_target_state(*session, record.token);
     } else if (result == external::ActorEntityApplyResult::actorCreated && session->policyActive) {
         std::uint32_t actorClassIndex = format::kAbsentIndex;
-        if (selected_entity_class(*session, batch.record.token, actorClassIndex)) {
+        if (selected_entity_class(*session, record.token, actorClassIndex)) {
             policyCommandQueued = queue_command(*session,
                                                 actorClassIndex,
-                                                batch.record.token,
+                                                record.token,
                                                 session->policyValue,
                                                 OutputPurpose::policyCommand);
             accepted = policyCommandQueued;
@@ -323,19 +318,30 @@ bool accept_entity_batch(std::uint64_t groupSessionId,
         session->actors.catalog = priorCatalog;
         session->actors.classData = priorClassData;
         session->actors.classCount = priorClassCount;
-        session->actors.slots[batch.record.token.slot] = priorSlot;
+        session->actors.slots[record.token.slot] = priorSlot;
     }
     ReleaseSRWLockExclusive(&g_lock);
     if (policyCommandQueued) {
         report(core::log::Level::info,
                "ev=actor_policy stage=command result=queued group=0x%016llX slot=%u incarnation=%u",
                static_cast<unsigned long long>(groupSessionId),
-               static_cast<unsigned>(batch.record.token.slot),
-               static_cast<unsigned>(batch.record.token.incarnation));
+               static_cast<unsigned>(record.token.slot),
+               static_cast<unsigned>(record.token.incarnation));
     }
     return accepted;
 }
 
+/** Policy projection visits every record after transport acceptance. */
+bool accept_entity_batch(std::uint64_t groupSessionId,
+                         const external::EntityBatch& batch) noexcept {
+    if (groupSessionId == 0) return false;
+    bool accepted = true;
+    for (std::size_t index = 0; index < external::entity_record_count(batch); ++index)
+        accepted = accept_entity_record(groupSessionId, external::entity_record_at(batch, index))
+                   && accepted;
+    return accepted;
+}
+
 /**
  * Retains all new damage replays transactionally for one exact group session.
  * @param groupSessionId Group session the lane arrived on.

+ 164 - 13
Sunrise/src/server/gameplay/actor_command_policy_transport.cpp

@@ -3,12 +3,17 @@
 #include <algorithm>
 #include <array>
 #include <limits>
+#include <memory>
+#include <new>
 
 #include "../../middleware/gameplay/external/actor_command_runtime_codec.h"
 #include "../../middleware/gameplay/external/composite_entity_codec.h"
 #include "../../middleware/gameplay/external/simulation_event_runtime_codec.h"
+#include "../../state/gameplay/external/rsat_decode_plans.h"
 #include "actor_command_policy.h"
 #include "actor_command_policy_internal.h"
+#include "entity_identities.h"
+#include "entity_position_profile_provider.h"
 #include "gameplay_log.h"
 
 namespace sunrise::server::gameplay::actor_command_policy {
@@ -19,9 +24,33 @@ namespace wire = middleware::bap::activity_message::wire_schema;
 
 SRWLOCK g_transportLock = SRWLOCK_INIT;
 external::CompositeEntitySessionStore g_entitySessions{};
+state::gameplay::rsat_decode_plans::Cache g_entityPlans;
 bool g_entityIngressReady{};
 volatile LONG g_lane0DecodeFailures{};
 
+/** The installed plan cache must match the admitted SDK and its extraction manifest. */
+[[nodiscard]] bool load_entity_plans() noexcept {
+    namespace plans = state::gameplay::rsat_decode_plans;
+    try {
+        if (g_entitySessions.catalog == nullptr) return false;
+        plans::Digest sdk{};
+        const auto identity = g_entitySessions.catalog->sdk_build_sha256();
+        if (identity.size() != sdk.size()) return false;
+        std::copy(identity.begin(), identity.end(), sdk.begin());
+        if (!g_entityPlans.load_installed(g_entitySessions.catalog->artifact_directory(), sdk))
+            return false;
+        g_entitySessions.resolvePlan = plans::resolve_plan;
+        g_entitySessions.resolveSchemaLayout = plans::resolve_schema_layout;
+        g_entitySessions.resolveFieldLayout = plans::resolve_field_layout;
+        g_entitySessions.resolveAdditionalSchema = plans::resolve_additional_schema;
+        g_entitySessions.resolveAdditionalField = plans::resolve_additional_field;
+        g_entitySessions.planContext = &g_entityPlans;
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
 /** Validates one reflected event body against the current immutable SDK catalog. */
 [[nodiscard]] bool read_event_payload(const void*,
                                       std::uint8_t eventType,
@@ -168,7 +197,7 @@ void reset_lane0_adapter(const void*, std::uint64_t groupSessionId) noexcept {
 
 /** Decodes channel 2 against only the named group's committed baselines. */
 [[nodiscard]] bool read_entity_adapter(const void* context,
-                                       std::uint64_t groupSessionId,
+                                       const state::gameplay::entity_identity::Source& source,
                                        middleware::encoding::bits::Reader& reader,
                                        external::EntityBatch& batch) noexcept {
     if (context == nullptr) {
@@ -176,25 +205,133 @@ void reset_lane0_adapter(const void*, std::uint64_t groupSessionId) noexcept {
     }
     auto& store = *const_cast<external::CompositeEntitySessionStore*>(
         static_cast<const external::CompositeEntitySessionStore*>(context));
-    return external::read_composite_entity_batch(store, groupSessionId, reader, batch);
+    AcquireSRWLockExclusive(&g_transportLock);
+    const bool accepted = external::read_scoped_entity_batch(store, source, reader, batch);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    return accepted;
+}
+
+/** Preflight must finish before any other lane commits. */
+[[nodiscard]] bool prepare_entity_adapter(const void* context,
+                                          const state::gameplay::entity_identity::Source& source,
+                                          const external::EntityBatch& batch,
+                                          std::uint16_t sequence,
+                                          bool hasSequence,
+                                          std::uint64_t ordinal,
+                                          external::EntityBaselineMutation& mutation) noexcept {
+    if (context == nullptr) return false;
+    auto& store = *const_cast<external::CompositeEntitySessionStore*>(
+        static_cast<const external::CompositeEntitySessionStore*>(context));
+    AcquireSRWLockExclusive(&g_transportLock);
+    const bool accepted = external::prepare_scoped_entity_batch(
+        store, source, batch, sequence, hasSequence, ordinal, mutation);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    return accepted;
+}
+
+/** The peer lock prevents source changes between preflight and commit. */
+[[nodiscard]] bool
+commit_entity_adapter(const void* context,
+                      const state::gameplay::entity_identity::Source& source,
+                      const external::EntityBaselineMutation& mutation) noexcept {
+    if (context == nullptr) return false;
+    auto& store = *const_cast<external::CompositeEntitySessionStore*>(
+        static_cast<const external::CompositeEntitySessionStore*>(context));
+    AcquireSRWLockExclusive(&g_transportLock);
+    const bool accepted = external::commit_scoped_entity_batch(store, source, mutation);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    return accepted;
+}
+
+/** Observers run only after the complete external packet's acceptance boundary. */
+void observed_entity_adapter(const void* context,
+                             const state::gameplay::entity_identity::Source& source,
+                             const external::EntityBatch& batch,
+                             std::uint16_t packetSequence,
+                             bool hasPacketSequence,
+                             std::uint64_t ordinal,
+                             std::uint64_t tick) noexcept {
+    if (context == nullptr) return;
+    AcquireSRWLockShared(&g_transportLock);
+    const auto catalog =
+        static_cast<const external::CompositeEntitySessionStore*>(context)->catalog;
+    ReleaseSRWLockShared(&g_transportLock);
+    entity_identities::observe(source,
+                               batch,
+                               catalog,
+                               packetSequence,
+                               hasPacketSequence,
+                               ordinal,
+                               tick,
+                               batch.allocationEpoch,
+                               batch.hasAllocationEpoch,
+                               batch.allocationDomain);
+    const external::EntityBatch* projected = &batch;
+    std::unique_ptr<external::EntityBatch> filtered;
+    if (batch.ignoredRecordMask != 0) {
+        filtered.reset(new (std::nothrow) external::EntityBatch{});
+        if (!filtered) return;
+        *filtered = batch;
+        std::size_t count = 0;
+        for (std::size_t index = 0; index < external::entity_record_count(batch); ++index)
+            if ((batch.ignoredRecordMask & (1U << index)) == 0)
+                external::entity_record_at(*filtered, count++) =
+                    external::entity_record_at(batch, index);
+        if (count == 0) return;
+        filtered->recordPresent = true;
+        filtered->additionalRecordCount = static_cast<std::uint8_t>(count - 1);
+        filtered->ignoredRecordMask = 0;
+        projected = filtered.get();
+    }
+    if (!accept_entity_batch(source.groupSessionId, *projected)) {
+        report(core::log::Level::debug,
+               "ev=entity_identity stage=actor_projection result=unavailable");
+    }
+}
+
+/** Host epoch changes reset serial admission without erasing retained allocation evidence. */
+void advance_entity_epoch_adapter(const void* context,
+                                  const state::gameplay::entity_identity::Source& source,
+                                  std::uint8_t expected,
+                                  std::uint8_t next,
+                                  std::uint64_t domain) noexcept {
+    if (context == nullptr) return;
+    auto& store = *const_cast<external::CompositeEntitySessionStore*>(
+        static_cast<const external::CompositeEntitySessionStore*>(context));
+    AcquireSRWLockExclusive(&g_transportLock);
+    const bool advanced =
+        external::advance_scoped_entity_epoch(store, source, expected, next, domain);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    if (!advanced)
+        report(core::log::Level::debug, "ev=entity_identity stage=allocation_epoch result=stale");
 }
 
-/** Commits the baseline mirror before applying the retry-safe actor projection. */
-[[nodiscard]] bool accepted_composite_entity_adapter(const void* context,
-                                                     std::uint64_t groupSessionId,
-                                                     const external::EntityBatch& batch) noexcept {
-    return external::accept_composite_entity_batch(context, groupSessionId, batch)
-           && accept_entity_batch(groupSessionId, batch);
+/** Delivered retirements cannot erase a replacement that reused the same network slot. */
+std::size_t retire_entity_adapter(
+    const void* context,
+    const state::gameplay::entity_identity::Source& source,
+    std::span<const state::gameplay::entity_identity::RetiredLifetime> lifetimes) noexcept {
+    if (context == nullptr) return 0;
+    auto& store = *const_cast<external::CompositeEntitySessionStore*>(
+        static_cast<const external::CompositeEntitySessionStore*>(context));
+    AcquireSRWLockExclusive(&g_transportLock);
+    const auto retired = external::retire_scoped_entity_baselines(store, source, lifetimes);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    return retired;
 }
 
 /** Removes every update-only baseline owned by one replaced group view. */
-void reset_entity_adapter(const void* context, std::uint64_t groupSessionId) noexcept {
+void reset_entity_adapter(const void* context,
+                          const state::gameplay::entity_identity::Source& source) noexcept {
     if (context == nullptr) {
         return;
     }
     auto& store = *const_cast<external::CompositeEntitySessionStore*>(
         static_cast<const external::CompositeEntitySessionStore*>(context));
-    external::reset_composite_entity_session(store, groupSessionId);
+    AcquireSRWLockExclusive(&g_transportLock);
+    external::reset_scoped_entity_session(store, source);
+    ReleaseSRWLockExclusive(&g_transportLock);
+    entity_identities::reset_source(source);
 }
 
 } // namespace
@@ -211,16 +348,25 @@ void internal::shutdown_entity_transport() noexcept {
     peer::install_entity_codec({}, nullptr, nullptr);
     AcquireSRWLockExclusive(&g_transportLock);
     g_entityIngressReady = false;
-    g_entitySessions = {};
+    external::reset_composite_entity_sessions(g_entitySessions);
     ReleaseSRWLockExclusive(&g_transportLock);
+    entity_identities::reset();
 }
 
 /** Installs one bounded baseline store shared only through session-aware callbacks. */
 bool install_entity_transport() noexcept {
     (void)InterlockedExchange(&g_lane0DecodeFailures, 0);
+    peer::install_entity_transport({});
+    entity_identities::reset();
     AcquireSRWLockExclusive(&g_transportLock);
+    g_entityIngressReady = false;
     const bool initialized = external::initialize_composite_entity_sessions(
-        g_entitySessions, external::SobjectPositionCompression::disabled);
+                                 g_entitySessions, external::SobjectPositionCompression::disabled)
+                             && load_entity_plans();
+    if (initialized) {
+        g_entitySessions.resolvePosition = &resolve_entity_position_profile;
+        g_entitySessions.positionContext = nullptr;
+    }
     ReleaseSRWLockExclusive(&g_transportLock);
     if (!initialized) {
         return false;
@@ -229,13 +375,18 @@ bool install_entity_transport() noexcept {
     peer::EntityTransport transport{};
     transport.context = &g_entitySessions;
     transport.read = &read_entity_adapter;
-    transport.accepted = &accepted_composite_entity_adapter;
+    transport.prepare = &prepare_entity_adapter;
+    transport.commit = &commit_entity_adapter;
     transport.reset = &reset_entity_adapter;
+    transport.retire = &retire_entity_adapter;
+    transport.advanceEpoch = &advance_entity_epoch_adapter;
+    transport.observed = &observed_entity_adapter;
     peer::install_entity_transport(transport);
     peer::install_entity_codec({}, nullptr, nullptr);
     AcquireSRWLockExclusive(&g_transportLock);
     g_entityIngressReady = true;
     ReleaseSRWLockExclusive(&g_transportLock);
+    report(core::log::Level::info, "ev=entity_identity stage=transport result=ready");
     return true;
 }
 

+ 253 - 0
Sunrise/src/server/gameplay/entity_identities.cpp

@@ -0,0 +1,253 @@
+#include "entity_identities.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstdio>
+
+#include "../../core/logging/log.h"
+#include "../../middleware/gameplay/external/entity_identity_metadata.h"
+
+namespace sunrise::server::gameplay::entity_identities {
+namespace {
+SRWLOCK g_lock{SRWLOCK_INIT};
+identities::Store g_store{};
+} // namespace
+
+/** Missing semantic metadata stays unknown while the accepted envelope identity is retained. */
+static identities::Observation
+make_observation(const middleware::gameplay::external::EntityRecord& record,
+                 const state::activity_sdk::Snapshot& catalog,
+                 std::uint16_t packetSequence,
+                 bool hasPacketSequence,
+                 std::uint64_t ordinal,
+                 std::uint64_t tick,
+                 std::uint16_t recordIndex) noexcept {
+    namespace external = middleware::gameplay::external;
+    identities::Observation observation{};
+    observation.token = {record.token.slot, record.token.incarnation};
+    observation.anchor = {record.anchor.slot, record.anchor.incarnation};
+    observation.cell = record.cell;
+    observation.recordFlags = record.flags;
+    observation.trailingState = record.trailingState;
+    observation.allocationSequence = record.allocationSequence;
+    observation.type = static_cast<std::uint8_t>(record.type);
+    observation.anchorChanged = (record.flags & external::entityAnchor) != 0;
+    observation.anchorPresent = record.anchorPresent;
+    observation.packetSequence = packetSequence;
+    observation.hasPacketSequence = hasPacketSequence;
+    observation.hasPacketOrdinal = hasPacketSequence;
+    observation.packetOrdinal = ordinal;
+    observation.packetRecordIndex = recordIndex;
+    observation.tick = tick;
+    if ((record.flags & external::entityCreate) != 0) {
+        observation.action = (record.flags & external::entityRemove) != 0
+                                 ? identities::Action::createAndRemove
+                                 : identities::Action::create;
+        static_cast<void>(
+            external::extract_entity_identity_metadata(catalog, record, observation.metadata));
+    } else if ((record.flags & external::entityRemove) != 0) {
+        observation.action = identities::Action::remove;
+    }
+    static_cast<void>(external::extract_actor_source_reference(record, observation.actorSource));
+    return observation;
+}
+
+/** Observation logs describe only the outcome of an atomic packet commit. */
+static void log_observation(const identities::Source& source,
+                            const middleware::gameplay::external::EntityRecord& record,
+                            const identities::Observation& observation,
+                            identities::Result result) noexcept {
+    if (result != identities::Result::updated && result != identities::Result::unchanged) {
+        constexpr const char* resultNames[]{"created",
+                                            "updated",
+                                            "removed",
+                                            "unchanged",
+                                            "missing",
+                                            "stale",
+                                            "conflict",
+                                            "invalid",
+                                            "capacity"};
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=entity_identity result=%s activity=0x%llX group=0x%llX peer=%llu channel=%llu "
+            "view=%llu owner=%llu slot=%u "
+            "incarnation=%u type=%u rsat=0x%08X metadata=%u",
+            resultNames[static_cast<unsigned>(result)],
+            static_cast<unsigned long long>(source.activitySessionId),
+            static_cast<unsigned long long>(source.groupSessionId),
+            static_cast<unsigned long long>(source.peerGeneration),
+            static_cast<unsigned long long>(source.channelGeneration),
+            static_cast<unsigned long long>(source.viewGeneration),
+            static_cast<unsigned long long>(source.activityClientGeneration),
+            record.token.slot,
+            static_cast<unsigned>(record.token.incarnation),
+            static_cast<unsigned>(record.type),
+            observation.metadata.rsatTag,
+            static_cast<unsigned>(observation.metadata.hasRsat || observation.metadata.hasSquad
+                                  || observation.metadata.hasPlayerBroadcast));
+        if (count > 0)
+            core::log::write(
+                core::log::Channel::server,
+                core::log::Level::debug,
+                {line.data(), std::min(static_cast<std::size_t>(count), line.size() - 1)});
+    }
+}
+
+/** Every accepted record contributes identity evidence after the packet commits. */
+void observe(const identities::Source& source,
+             const middleware::gameplay::external::EntityBatch& batch,
+             const state::activity_sdk::Snapshot& catalog,
+             std::uint16_t packetSequence,
+             bool hasPacketSequence,
+             std::uint64_t ordinal,
+             std::uint64_t tick,
+             std::uint8_t allocationEpoch,
+             bool hasAllocationEpoch,
+             std::uint64_t allocationDomain) noexcept {
+    namespace external = middleware::gameplay::external;
+    const auto count = external::entity_record_count(batch);
+    if (count == 0 || count > identities::kObservationBatchCapacity) return;
+    std::array<identities::Observation, identities::kObservationBatchCapacity> observations{};
+    std::array<identities::Result, identities::kObservationBatchCapacity> results{};
+    std::array<std::size_t, identities::kObservationBatchCapacity> retained{};
+    std::size_t admitted = 0;
+    for (std::size_t index = 0; index < count; ++index) {
+        if ((batch.ignoredRecordMask & (1U << index)) != 0) continue;
+        retained[admitted] = index;
+        auto& observation = observations[admitted++];
+        observation = make_observation(external::entity_record_at(batch, index),
+                                       catalog,
+                                       packetSequence,
+                                       hasPacketSequence,
+                                       ordinal,
+                                       tick,
+                                       static_cast<std::uint16_t>(index));
+        observation.allocationEpoch = allocationEpoch;
+        observation.hasAllocationEpoch = hasAllocationEpoch;
+        observation.allocationDomain = allocationDomain;
+    }
+    if (admitted == 0) return;
+    AcquireSRWLockExclusive(&g_lock);
+    const auto result = g_store.observe_batch(
+        source, std::span(observations).first(admitted), std::span(results).first(admitted));
+    for (std::size_t index = 0; index < admitted; ++index)
+        log_observation(source,
+                        external::entity_record_at(batch, retained[index]),
+                        observations[index],
+                        result == identities::Result::updated ? results[index] : result);
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Queries a copied identity under the registry lock. */
+identities::Result lookup(const identities::Source& source,
+                          identities::Token token,
+                          identities::Identity& output) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const auto result = g_store.lookup(source, token, output);
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+
+/** Enumerates sources only within one exact activity generation. */
+std::size_t sources(std::uint64_t activitySessionId,
+                    std::uint64_t activityRevision,
+                    std::span<identities::Source> output) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const auto count = g_store.sources(activitySessionId, activityRevision, output);
+    ReleaseSRWLockShared(&g_lock);
+    return count;
+}
+
+/** Host advancement preserves every retained row and changes only serial admission. */
+bool advance_epoch(const identities::Source& source,
+                   std::uint8_t expected,
+                   std::uint8_t next,
+                   std::uint64_t nextDomain) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const bool advanced = g_store.advance_epoch(source, expected, next, nextDomain);
+    ReleaseSRWLockExclusive(&g_lock);
+    return advanced;
+}
+
+/** A replaced replication view invalidates observations for its group. */
+void reset_group(std::uint64_t groupSessionId) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_store.reset_group(groupSessionId);
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Removes only the retired peer and view's identity evidence. */
+void reset_source(const identities::Source& source) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_store.reset_source(source);
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Ends every source lifetime when the transport is replaced or stopped. */
+void reset() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_store.reset();
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Copies the full source under the registry lock. */
+identities::Result snapshot_source(const identities::Source& source,
+                                   std::vector<identities::Identity>& output) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const auto result = g_store.snapshot_source(source, output);
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+PublicationLease::~PublicationLease() {
+    release();
+}
+/** Delivered retirement tombstones only matching allocations under the registry lock. */
+std::size_t retire(const identities::Source& source,
+                   std::span<const identities::RetiredLifetime> lifetimes) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const auto result = g_store.retire(source, lifetimes);
+    ReleaseSRWLockExclusive(&g_lock);
+    return result;
+}
+void PublicationLease::release() noexcept {
+    if (!held_) return;
+    held_ = false;
+    ReleaseSRWLockShared(&g_lock);
+}
+/**
+ * Freezes the identity source while the caller validates and copies a publication.
+ * @param source Exact source captured by the pending operation.
+ * @param output Receives all source slots.
+ * @param lease Receives the held lock on success; must be empty on entry.
+ * @return Unchanged on success, or the snapshot failure without a held lock.
+ */
+identities::Result begin_publication(const identities::Source& source,
+                                     std::vector<identities::Identity>& output,
+                                     PublicationLease& lease) noexcept {
+    if (lease.held_) {
+        output.clear();
+        return identities::Result::invalid;
+    }
+    AcquireSRWLockShared(&g_lock);
+    std::array<identities::Source, identities::kSourceCapacity> sources{};
+    const auto count = g_store.sources(source.activitySessionId, source.activityRevision, sources);
+    std::size_t matches = 0;
+    for (std::size_t index = 0; index < (std::min)(count, sources.size()); ++index)
+        if (sources[index].activityClientGeneration == source.activityClientGeneration) ++matches;
+    if (count > sources.size() || matches != 1) {
+        output.clear();
+        ReleaseSRWLockShared(&g_lock);
+        return identities::Result::invalid;
+    }
+    const auto result = g_store.snapshot_source(source, output);
+    if (result == identities::Result::unchanged)
+        lease.held_ = true;
+    else
+        ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+} // namespace sunrise::server::gameplay::entity_identities

+ 71 - 0
Sunrise/src/server/gameplay/entity_identities.h

@@ -0,0 +1,71 @@
+#pragma once
+
+#include "../../middleware/gameplay/external/external_entity_codec.h"
+#include "../../state/activity_sdk/runtime.h"
+#include "../../state/gameplay/external/entity_identity.h"
+
+namespace sunrise::server::gameplay::entity_identities {
+
+namespace identities = state::gameplay::entity_identity;
+
+/** Release before peer or host callbacks; accepted identity batches take the exclusive lock. */
+class PublicationLease final {
+public:
+    PublicationLease() noexcept = default;
+    ~PublicationLease();
+    PublicationLease(const PublicationLease&) = delete;
+    PublicationLease& operator=(const PublicationLease&) = delete;
+    void release() noexcept;
+    [[nodiscard]] bool held() const noexcept {
+        return held_;
+    }
+
+private:
+    friend identities::Result begin_publication(const identities::Source&,
+                                                std::vector<identities::Identity>&,
+                                                PublicationLease&) noexcept;
+    bool held_{};
+};
+
+/** Pins exact-source identities until the publication copies its staged bytes. */
+[[nodiscard]] identities::Result begin_publication(const identities::Source& source,
+                                                   std::vector<identities::Identity>& output,
+                                                   PublicationLease& lease) noexcept;
+
+/** Observations enter only after the complete external packet is accepted. */
+void observe(const identities::Source& source,
+             const middleware::gameplay::external::EntityBatch& batch,
+             const state::activity_sdk::Snapshot& catalog,
+             std::uint16_t packetSequence,
+             bool hasPacketSequence,
+             std::uint64_t ordinal,
+             std::uint64_t tick,
+             std::uint8_t allocationEpoch = 0,
+             bool hasAllocationEpoch = false,
+             std::uint64_t allocationDomain = 0) noexcept;
+
+/** Exact-source lookup never selects another peer's entity with the same slot number. */
+[[nodiscard]] identities::Result lookup(const identities::Source& source,
+                                        identities::Token token,
+                                        identities::Identity& output) noexcept;
+/** Returns the source count; output storage may hold a bounded prefix. */
+[[nodiscard]] std::size_t sources(std::uint64_t activitySessionId,
+                                  std::uint64_t activityRevision,
+                                  std::span<identities::Source> output) noexcept;
+/** Copies all exact-source slots atomically, including tombstones and conflicts. */
+[[nodiscard]] identities::Result
+snapshot_source(const identities::Source& source,
+                std::vector<identities::Identity>& output) noexcept;
+/** Advances only the exact source allocation domain after a committed host purge. */
+[[nodiscard]] bool advance_epoch(const identities::Source&,
+                                 std::uint8_t expected,
+                                 std::uint8_t next,
+                                 std::uint64_t nextDomain) noexcept;
+void reset_group(std::uint64_t groupSessionId) noexcept;
+/** Call only after publication and after releasing the identity lease. */
+[[nodiscard]] std::size_t retire(const identities::Source& source,
+                                 std::span<const identities::RetiredLifetime> lifetimes) noexcept;
+void reset_source(const identities::Source& source) noexcept;
+void reset() noexcept;
+
+} // namespace sunrise::server::gameplay::entity_identities

+ 40 - 0
Sunrise/src/server/gameplay/entity_position_profile_provider.cpp

@@ -0,0 +1,40 @@
+#include "entity_position_profile_provider.h"
+
+#include <string_view>
+
+#include "../../state/activity/runtime.h"
+#include "../../state/gameplay/external/entity_position_profiles.h"
+
+namespace sunrise::server::gameplay {
+
+/**
+ * The admitted activity selects immutable package bounds without reading the client runtime.
+ * @param context Unused callback context.
+ * @param source Admitted source generation.
+ * @param cell Native entity cell index.
+ * @param output Receives the selector grammar and any validated widths.
+ * @return True; an unknown profile still permits the raw-position selector arm.
+ */
+bool resolve_entity_position_profile(
+    const void*,
+    const state::gameplay::entity_identity::Source& source,
+    std::uint16_t cell,
+    middleware::gameplay::external::PositionProfile& output) noexcept {
+    output = {};
+    output.selectorPresent = true;
+    state::activity::SessionBinding binding{};
+    if (!state::activity::snapshot_binding(source.activitySessionId, binding)
+        || binding.createdRevision != source.activityRevision)
+        return true;
+    const auto& destination = binding.destination;
+    if (destination.packageNameLength == 0
+        || destination.packageNameLength > destination.packageName.size())
+        return true;
+    const std::string_view activity(reinterpret_cast<const char*>(destination.packageName.data()),
+                                    destination.packageNameLength);
+    output.hasWidths =
+        state::gameplay::entity_position_profiles::lookup(activity, cell, output.axisBits);
+    return true;
+}
+
+} // namespace sunrise::server::gameplay

+ 14 - 0
Sunrise/src/server/gameplay/entity_position_profile_provider.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include "../../middleware/gameplay/external/composite_entity_codec.h"
+
+namespace sunrise::server::gameplay {
+
+/** The global selector remains present when this source has no validated cell widths. */
+[[nodiscard]] bool
+resolve_entity_position_profile(const void* context,
+                                const state::gameplay::entity_identity::Source& source,
+                                std::uint16_t cell,
+                                middleware::gameplay::external::PositionProfile& output) noexcept;
+
+} // namespace sunrise::server::gameplay

+ 4 - 0
Sunrise/src/server/gameplay/gameplay_runtime.cpp

@@ -6,11 +6,13 @@
 #include "endpoint/gameplay_endpoint.h"
 #include "group/group_host.h"
 #include "peer/peer_transport.h"
+#include "squad_entity_retirement.h"
 
 namespace sunrise::server::gameplay {
 
 /** Binds the gameplay endpoint for the configured topology. */
 bool initialize() noexcept {
+    squad_entity_retirement::reset();
     association::reset();
     dtls::reset();
     peer::reset();
@@ -20,6 +22,7 @@ bool initialize() noexcept {
         return true;
     }
     actor_command_policy::shutdown();
+    squad_entity_retirement::reset();
     return false;
 }
 
@@ -36,6 +39,7 @@ void service(std::uint64_t now) noexcept {
 void shutdown() noexcept {
     endpoint::shutdown();
     actor_command_policy::shutdown();
+    squad_entity_retirement::reset();
     peer::reset();
     group::reset();
     dtls::reset();

+ 313 - 90
Sunrise/src/server/gameplay/peer/peer_established.cpp

@@ -1,6 +1,10 @@
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
+#include <cstdio>
+#include <memory>
+#include <new>
 
 #include "../../../middleware/encoding/bit_reader.h"
 #include "../../../middleware/encoding/bit_writer.h"
@@ -36,6 +40,133 @@ constexpr std::uint64_t kResendInterval = 250;
 /** Reflected root 0x80806AE6 after its lane-presence bit. */
 constexpr std::size_t kPlayerSnapshotBits = 1373;
 
+/** Capture at most eight unique failures within the endpoint's 1500-byte datagram bound. */
+constexpr std::size_t kRejectedPacketLimit = 8, kRejectedPacketCapacity = 1500;
+/** A 256-byte hex chunk leaves room for event fields in the 1024-byte log line. */
+constexpr std::size_t kRejectedHexChunk = 256;
+
+struct RejectedPacket final {
+    gp::entity_identity::Source source{};
+    std::array<std::byte, kRejectedPacketCapacity> bytes{};
+    std::size_t size{};
+};
+
+SRWLOCK g_rejectedPacketLock{SRWLOCK_INIT};
+std::array<RejectedPacket, kRejectedPacketLimit> g_rejectedPackets{};
+std::size_t g_rejectedPacketCount{};
+wire::EstablishedPacket g_rejectedPacketHeader{};
+
+/**
+ * Logs complete payload bytes in numbered chunks without truncating a packet.
+ * @param capture
+ * Process-local capture number.
+ * @param payload Complete decrypted datagram.
+ */
+void log_rejected_hex(std::size_t capture, std::span<const std::byte> payload) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    for (std::size_t offset = 0; offset < payload.size(); offset += kRejectedHexChunk) {
+        const auto count = (std::min)(kRejectedHexChunk, payload.size() - offset);
+        const int prefix = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=gameplay stage=rejected_packet_hex capture=%zu offset=%zu bytes=%zu hex=",
+            capture,
+            offset,
+            count);
+        if (prefix <= 0 || static_cast<std::size_t>(prefix) >= line.size()) return;
+        auto length = static_cast<std::size_t>(prefix);
+        if (!core::log::append_hex(line, length, payload.subspan(offset, count))) return;
+        core::log::write(
+            core::log::Channel::server, core::log::Level::debug, {line.data(), length});
+    }
+}
+
+/**
+ * Retains a bounded replay sample only while server debug logging is enabled.
+ * @param source
+ * Source already admitted for this decode.
+ * @param payload Complete decrypted datagram.
+ * @param
+ * externalOffset Start of the external handler in bits.
+ * @param laneOffset Start of the rejected
+ * entity lane in bits.
+ * @param stoppedOffset Reader position at failure in bits.
+ */
+void log_rejected_entity_packet(const gp::entity_identity::Source& source,
+                                std::span<const std::byte> payload,
+                                std::size_t externalOffset,
+                                std::size_t laneOffset,
+                                std::size_t stoppedOffset) noexcept {
+    if (!core::log::accepts(core::log::Channel::server, core::log::Level::debug) || payload.empty()
+        || payload.size() > kRejectedPacketCapacity)
+        return;
+    AcquireSRWLockExclusive(&g_rejectedPacketLock);
+    bool duplicate = false;
+    for (std::size_t index = 0; index < g_rejectedPacketCount; ++index) {
+        const auto& prior = g_rejectedPackets[index];
+        if (prior.source == source && prior.size == payload.size()
+            && std::equal(payload.begin(), payload.end(), prior.bytes.begin())) {
+            duplicate = true;
+            break;
+        }
+    }
+    if (duplicate || g_rejectedPacketCount == kRejectedPacketLimit) {
+        ReleaseSRWLockExclusive(&g_rejectedPacketLock);
+        return;
+    }
+    auto& saved = g_rejectedPackets[g_rejectedPacketCount];
+    saved.source = source;
+    saved.size = payload.size();
+    std::copy(payload.begin(), payload.end(), saved.bytes.begin());
+    const auto capture = ++g_rejectedPacketCount;
+    const bool headerValid = wire::decode_established(payload, true, g_rejectedPacketHeader);
+    const auto sequence = g_rejectedPacketHeader.ack.outboundHead;
+    const auto hasSequence = g_rejectedPacketHeader.ack.outboundHeadPresent;
+    const auto guard = g_rejectedPacketHeader.connectionSequenceLow2;
+    ReleaseSRWLockExclusive(&g_rejectedPacketLock);
+    report(core::log::Level::debug,
+           "ev=gameplay stage=rejected_packet capture=%zu reason=lane2 bytes=%zu "
+           "external_bit=%zu lane_bit=%zu stopped_bit=%zu header=%u sequence=%u has_sequence=%u "
+           "guard=%u",
+           capture,
+           payload.size(),
+           externalOffset,
+           laneOffset,
+           stoppedOffset,
+           headerValid ? 1U : 0U,
+           static_cast<unsigned>(sequence),
+           hasSequence ? 1U : 0U,
+           static_cast<unsigned>(guard));
+    report(core::log::Level::debug,
+           "ev=gameplay stage=rejected_packet_source capture=%zu activity=0x%llX revision=%llu "
+           "client_generation=%llu group=0x%llX peer=%llu channel=%llu view=%llu "
+           "address=0x%08X port=%u local_port=%u local_sequence=%u remote_sequence=%u",
+           capture,
+           static_cast<unsigned long long>(source.activitySessionId),
+           static_cast<unsigned long long>(source.activityRevision),
+           static_cast<unsigned long long>(source.activityClientGeneration),
+           static_cast<unsigned long long>(source.groupSessionId),
+           static_cast<unsigned long long>(source.peerGeneration),
+           static_cast<unsigned long long>(source.channelGeneration),
+           static_cast<unsigned long long>(source.viewGeneration),
+           source.address,
+           static_cast<unsigned>(source.port),
+           static_cast<unsigned>(source.localPort),
+           source.localConnectionSequence,
+           source.remoteConnectionSequence);
+    log_rejected_hex(capture, payload);
+}
+
+/** Packet ordinals share the receive ring's half-range ordering. */
+std::uint64_t packet_ordinal(const gp::PeerLink& peer, std::uint16_t sequence) noexcept {
+    if (!peer.ringInitialized) return gp::kPacketSequenceModulus + sequence;
+    const auto forward =
+        (sequence + gp::kPacketSequenceModulus - peer.receiveHead) % gp::kPacketSequenceModulus;
+    return forward < gp::kPacketSequenceHalf
+               ? peer.receiveOrdinal + forward
+               : peer.receiveOrdinal - (gp::kPacketSequenceModulus - forward);
+}
+
 /**
  * Records one received packet sequence in the acknowledgement history.
  * @param peer Peer receiving the packet.
@@ -43,6 +174,7 @@ constexpr std::size_t kPlayerSnapshotBits = 1373;
  */
 void record_sequence(gp::PeerLink& peer, std::uint16_t sequence) noexcept {
     if (!peer.ringInitialized) {
+        peer.receiveOrdinal = packet_ordinal(peer, sequence);
         peer.ringInitialized = true;
         peer.receiveHead = sequence;
         peer.received = {};
@@ -70,6 +202,7 @@ void record_sequence(gp::PeerLink& peer, std::uint16_t sequence) noexcept {
         shifted[index] = source < peer.received.size() && peer.received[source];
     }
     peer.received = shifted;
+    peer.receiveOrdinal += advance;
     peer.receiveHead = sequence;
 }
 
@@ -142,13 +275,16 @@ using middleware::gameplay::external::read_flag;
 [[nodiscard]] ExternalReadResult
 read_external(std::span<const std::byte> payload,
               std::size_t bitOffset,
-              std::uint64_t groupSessionId,
+              const gp::entity_identity::Source& source,
               const middleware::gameplay::external::Lane0Codec& lane0,
               const Lane0Transport& lane0Transport,
               const middleware::gameplay::external::TypePayloadCodec& entities,
               ParsedExternal& output) noexcept {
+    output.commonPresent = false;
     bits::Reader reader(payload);
-    ParsedExternal candidate{};
+    const std::unique_ptr<ParsedExternal> candidateStorage(new (std::nothrow) ParsedExternal{});
+    if (!candidateStorage) return ExternalReadResult::lane2;
+    ParsedExternal& candidate = *candidateStorage;
     bool lanePresent = false;
     bool externalPresent = false;
     if (!reader.skip(bitOffset) || !read_flag(reader, externalPresent) || !externalPresent) {
@@ -159,6 +295,8 @@ read_external(std::span<const std::byte> payload,
             && !middleware::gameplay::external::read_common_state(reader, candidate.common))) {
         return ExternalReadResult::common;
     }
+    output.commonPresent = candidate.commonPresent;
+    output.common = candidate.common;
     if (lane0Transport.write != nullptr) {
         if (!middleware::gameplay::external::read_simulation_event_lane(
                 reader, lane0Transport.payloadCodec, candidate.lane0)) {
@@ -177,11 +315,13 @@ read_external(std::span<const std::byte> payload,
             candidate.lane1)) {
         return ExternalReadResult::lane1;
     }
+    const auto lane2Offset = payload.size() * 8U - reader.remaining_bits();
     if (g_entityTransport.read != nullptr
-            ? !g_entityTransport.read(
-                  g_entityTransport.context, groupSessionId, reader, candidate.entities)
+            ? !g_entityTransport.read(g_entityTransport.context, source, reader, candidate.entities)
             : !middleware::gameplay::external::read_entity_batch(
                   reader, entities, candidate.entities)) {
+        log_rejected_entity_packet(
+            source, payload, bitOffset, lane2Offset, payload.size() * 8U - reader.remaining_bits());
         return ExternalReadResult::lane2;
     }
     if (!read_player_lane(reader)) {
@@ -317,7 +457,9 @@ void consume_established(const gp::Endpoint& from,
     bool externalExpected = false;
     bool externalValid = true;
     const char* externalFailure = "none";
-    ParsedExternal external{};
+    const std::unique_ptr<ParsedExternal> externalStorage(new (std::nothrow) ParsedExternal{});
+    if (!externalStorage) return;
+    ParsedExternal& external = *externalStorage;
     state::activity::SessionBinding commonBinding{};
     std::uint64_t commonOwnerGeneration = 0;
     std::uint8_t commonRequestedGeneration = 0;
@@ -327,37 +469,117 @@ void consume_established(const gp::Endpoint& from,
     DisplacedExternals completed{};
     std::size_t completedCount = 0;
     std::uint8_t expectedGuard = 0;
+    std::array<wire::AssembledMessage, kMessageReportCapacity> bodies{};
+    std::array<bool, kMessageReportCapacity> deferredView{};
+    gp::entity_identity::Source ingress{};
     AcquireSRWLockExclusive(&g_lock);
     gp::PeerLink* peer = find_locked(from);
-    std::array<wire::AssembledMessage, kMessageReportCapacity> bodies{};
     if (peer != nullptr) {
-        peerFound = true;
+        ingress = entity_source(*peer);
         expectedGuard = wire::connection_sequence_low2(peer->remoteConnectionSequence);
         guardAccepted = packet.connectionSequenceLow2 == expectedGuard;
-        const auto phase = peer->viewReceptor.phase();
-        externalExpected = phase == gp::external::view_receptor::Phase::provisional
-                           || phase == gp::external::view_receptor::Phase::accepted;
+    }
+    if (guardAccepted) {
+        if (packet.ack.outboundHeadPresent) {
+            record_sequence(*peer, packet.ack.outboundHead);
+        }
+        clearedPacket = peer->outbound.sentInPacket;
+        queueCleared = apply_acknowledgement(*peer, packet.ack);
+        peer->acknowledgementOwed = true;
+        peer->lastTick = now;
+        largeDropped = wire::accept_records(packet.large, peer->large);
+        largeNext = peer->large.nextSequence;
+        largeFirst = packet.large.count == 0 ? 0 : packet.large.records[0].sequence;
+        wire::accept_records(packet.small, peer->small);
+        wire::AssembledMessage message{};
+        while (wire::drain_message(peer->large, message)) {
+            apply_message(*peer, message);
+            if (deliveredCount < delivered.size()) {
+                delivered[deliveredCount] = message.id;
+                bodies[deliveredCount] = message;
+                ++deliveredCount;
+            }
+        }
+        while (wire::drain_message(peer->small, message)) {
+            apply_message(*peer, message);
+            if (deliveredCount < delivered.size()) {
+                delivered[deliveredCount] = message.id;
+                bodies[deliveredCount] = message;
+                ++deliveredCount;
+            }
+        }
+        stage = static_cast<unsigned>(peer->stage);
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    // Reliable controls establish the view used by this packet's external payload.
+    for (std::size_t index = 0; index < deliveredCount; ++index) {
+        report(core::log::Level::info,
+               "ev=gameplay stage=message result=ok id=%u peerstage=%u",
+               static_cast<unsigned>(delivered[index]),
+               stage);
+        // The connect establish belongs to this layer and apply_message already took it, so
+        // handing it to the group layer would only report it as undecoded on every connection.
+        const wire::AssembledMessage& body = bodies[index];
+        if (body.id == static_cast<std::uint8_t>(wire::ConnectId::establish)) {
+            continue;
+        }
+        // Group handling runs outside the lock because answering takes it again.
+        bits::Reader reader({body.bytes.data(), gp::kReassemblyCapacity});
+        namespace viewWire = middleware::gameplay::group;
+        if (body.id == viewWire::kViewMessageId) {
+            auto stageReader = reader;
+            viewWire::ViewEstablishment transition{};
+            if (stageReader.skip(body.bodyBitOffset) && viewWire::read_view(stageReader, transition)
+                && transition.kind == 5) {
+                // Stage 5 may depend on common state carried later in this same packet.
+                deferredView[index] = true;
+                continue;
+            }
+        }
+        if (reader.skip(body.bodyBitOffset) && !group::consume(from, body.id, reader, now)) {
+            report(core::log::Level::debug,
+                   "ev=gameplay stage=message result=undecoded id=%u",
+                   static_cast<unsigned>(body.id));
+        }
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    peer = find_locked(from);
+    const bool sameChannel = peer != nullptr && peer->peerGeneration == ingress.peerGeneration
+                             && peer->channelGeneration == ingress.channelGeneration
+                             && peer->localConnectionSequence == ingress.localConnectionSequence
+                             && peer->remoteConnectionSequence == ingress.remoteConnectionSequence;
+    guardAccepted = guardAccepted && sameChannel;
+
+    if (peer != nullptr) {
+        peerFound = true;
+        expectedGuard = wire::connection_sequence_low2(peer->remoteConnectionSequence);
+        guardAccepted =
+            guardAccepted && sameChannel && packet.connectionSequenceLow2 == expectedGuard;
+        externalExpected = peer->viewReceptor.accepts_inbound_entities();
         if (guardAccepted && externalExpected) {
             externalGroupSessionId = peer->externalGroupSessionId;
+            const auto source = entity_source(*peer);
+            const auto ordinal = packet_ordinal(*peer, packet.ack.outboundHead);
             const ExternalReadResult externalRead = read_external(payload,
                                                                   packet.externalBitOffset,
-                                                                  externalGroupSessionId,
+                                                                  source,
                                                                   g_lane0Codec,
                                                                   g_lane0Transport,
                                                                   g_entityCodec,
                                                                   external);
             externalValid = externalRead == ExternalReadResult::accepted;
             externalFailure = external_result_name(externalRead);
-            if (externalValid && external.commonPresent) {
+            if (external.commonPresent) {
                 commonCandidate = peer->commonReconciler;
                 const auto result = commonCandidate.observe(external.common);
-                externalValid =
+                const bool commonValid =
                     result == gp::external::common_reconciler::ObserveResult::initialAccepted
                     || result
                            == gp::external::common_reconciler::ObserveResult::
                                awaitingRequestedGeneration
                     || result == gp::external::common_reconciler::ObserveResult::ready;
-                if (!externalValid) {
+                if (!commonValid) {
+                    externalValid = false;
                     externalFailure = "reconcile";
                 }
                 commonRequest =
@@ -367,21 +589,42 @@ void consume_established(const gp::Endpoint& from,
                     commonBinding = peer->activityBinding;
                     commonOwnerGeneration = commonCandidate.owner_generation();
                 }
-                commonCandidatePresent = externalValid;
+                commonCandidatePresent = commonValid;
+                if (commonValid) {
+                    static_cast<void>(commonCandidate.qualify_entities(
+                        &external.common, ordinal, packet.ack.outboundHeadPresent));
+                    peer->commonReconciler = commonCandidate;
+                }
             }
-            if (externalValid && externalGroupSessionId != 0 && external.entities.recordPresent) {
-                externalValid = g_entityTransport.accepted != nullptr
-                                    ? g_entityTransport.accepted(g_entityTransport.context,
-                                                                 externalGroupSessionId,
-                                                                 external.entities)
-                                    : g_entityAccepted == nullptr
-                                          || g_entityAccepted(g_entityAcceptedContext,
-                                                              externalGroupSessionId,
-                                                              external.entities);
-                if (!externalValid) {
-                    externalFailure = "lane2_accept";
+            if (externalValid) {
+                if (!commonCandidatePresent) commonCandidate = peer->commonReconciler;
+                const bool currentEntityEpoch = commonCandidate.qualify_entities(
+                    external.commonPresent ? &external.common : nullptr,
+                    ordinal,
+                    packet.ack.outboundHeadPresent);
+                if (external.entities.recordPresent && !currentEntityEpoch) {
+                    externalValid = false;
+                    externalFailure = "entity_epoch";
+                } else {
+                    external.entities.hasAllocationEpoch = currentEntityEpoch;
+                    external.entities.allocationEpoch = commonCandidate.requested_generation();
+                    external.entities.allocationDomain = commonCandidate.allocation_domain();
+                    commonCandidatePresent = true;
                 }
             }
+            middleware::gameplay::external::EntityBaselineMutation entityMutation{};
+            if (externalValid && external.entities.recordPresent
+                && g_entityTransport.prepare != nullptr) {
+                externalValid = g_entityTransport.commit != nullptr
+                                && g_entityTransport.prepare(g_entityTransport.context,
+                                                             source,
+                                                             external.entities,
+                                                             packet.ack.outboundHead,
+                                                             packet.ack.outboundHeadPresent,
+                                                             ordinal,
+                                                             entityMutation);
+                if (!externalValid) externalFailure = "lane2_prepare";
+            }
             if (externalValid && externalGroupSessionId != 0
                 && g_lane0Transport.accepted != nullptr) {
                 externalValid = g_lane0Transport.accepted(
@@ -390,61 +633,52 @@ void consume_established(const gp::Endpoint& from,
                     externalFailure = "lane0_accept";
                 }
             }
+            // The peer lock keeps a prepared baseline stable until commit.
+            if (externalValid && externalGroupSessionId != 0 && external.entities.recordPresent) {
+                externalValid = g_entityTransport.commit != nullptr
+                                    ? g_entityTransport.commit(
+                                          g_entityTransport.context, source, entityMutation)
+                                    : g_entityAccepted == nullptr
+                                          || g_entityAccepted(g_entityAcceptedContext,
+                                                              externalGroupSessionId,
+                                                              external.entities);
+                if (!externalValid) externalFailure = "lane2_accept";
+            }
             if (externalValid && commonCandidatePresent) {
                 peer->commonReconciler = commonCandidate;
             }
+            if (externalValid && external.entities.recordPresent
+                && g_entityTransport.observed != nullptr) {
+                external.entities.ignoredRecordMask = entityMutation.ignoredRecordMask;
+                g_entityTransport.observed(g_entityTransport.context,
+                                           source,
+                                           external.entities,
+                                           packet.ack.outboundHead,
+                                           packet.ack.outboundHeadPresent,
+                                           ordinal,
+                                           now);
+            }
         }
     }
+    // Authenticated transport receipts remain valid when an inbound application lane is refused.
     if (guardAccepted) {
-        // Reliable queues drain even when a later external component rejects its body.
-        if (externalValid) {
-            for (auto& contribution : peer->externalContributions) {
-                if (!contribution.occupied) {
-                    continue;
-                }
-                const wire::AckOutcome outcome =
-                    wire::acknowledgement_outcome(packet.ack, contribution.packetSequence);
-                if (outcome == wire::AckOutcome::unresolved) {
-                    continue;
-                }
-                completed[completedCount++] = {
-                    contribution.groupSessionId, contribution.transmissionId, outcome};
-                if (outcome == wire::AckOutcome::received && contribution.commonPresent
-                    && contribution.viewGeneration == peer->viewGeneration) {
-                    peer->commonCommitted = true;
-                }
-                contribution = {};
+        for (auto& contribution : peer->externalContributions) {
+            if (!contribution.occupied) {
+                continue;
             }
-        }
-        if (packet.ack.outboundHeadPresent) {
-            record_sequence(*peer, packet.ack.outboundHead);
-        }
-        clearedPacket = peer->outbound.sentInPacket;
-        queueCleared = apply_acknowledgement(*peer, packet.ack);
-        peer->acknowledgementOwed = true;
-        peer->lastTick = now;
-        largeDropped = wire::accept_records(packet.large, peer->large);
-        largeNext = peer->large.nextSequence;
-        largeFirst = packet.large.count == 0 ? 0 : packet.large.records[0].sequence;
-        wire::accept_records(packet.small, peer->small);
-        wire::AssembledMessage message{};
-        while (wire::drain_message(peer->large, message)) {
-            apply_message(*peer, message);
-            if (deliveredCount < delivered.size()) {
-                delivered[deliveredCount] = message.id;
-                bodies[deliveredCount] = message;
-                ++deliveredCount;
+            const wire::AckOutcome outcome =
+                wire::acknowledgement_outcome(packet.ack, contribution.packetSequence);
+            if (outcome == wire::AckOutcome::unresolved) {
+                continue;
             }
-        }
-        while (wire::drain_message(peer->small, message)) {
-            apply_message(*peer, message);
-            if (deliveredCount < delivered.size()) {
-                delivered[deliveredCount] = message.id;
-                bodies[deliveredCount] = message;
-                ++deliveredCount;
+            completed[completedCount++] = {
+                contribution.groupSessionId, contribution.transmissionId, outcome};
+            if (outcome == wire::AckOutcome::received && contribution.commonPresent
+                && contribution.viewGeneration == peer->viewGeneration) {
+                peer->commonCommitted = true;
             }
+            contribution = {};
         }
-        stage = static_cast<unsigned>(peer->stage);
     }
     ReleaseSRWLockExclusive(&g_lock);
     if (!peerFound) {
@@ -452,7 +686,8 @@ void consume_established(const gp::Endpoint& from,
     }
     if (!guardAccepted) {
         report(core::log::Level::debug,
-               "ev=gameplay stage=packet result=drop reason=channel_low2 got=%u expect=%u",
+               "ev=gameplay stage=packet result=drop reason=%s got=%u expect=%u",
+               sameChannel ? "channel_low2" : "channel_changed",
                static_cast<unsigned>(packet.connectionSequenceLow2),
                static_cast<unsigned>(expectedGuard));
         return;
@@ -463,33 +698,21 @@ void consume_established(const gp::Endpoint& from,
                externalFailure,
                static_cast<unsigned long long>(externalGroupSessionId));
     }
-    if (externalValid && commonRequest && externalGroupSessionId != 0) {
+    if (commonRequest && externalGroupSessionId != 0) {
         queue_common_request(from,
                              externalGroupSessionId,
                              commonBinding,
                              commonOwnerGeneration,
                              commonRequestedGeneration);
     }
-    notify_external_outcomes(completed, completedCount);
     for (std::size_t index = 0; index < deliveredCount; ++index) {
-        report(core::log::Level::info,
-               "ev=gameplay stage=message result=ok id=%u peerstage=%u",
-               static_cast<unsigned>(delivered[index]),
-               stage);
-        // The connect establish belongs to this layer and apply_message already took it, so
-        // handing it to the group layer would only report it as undecoded on every connection.
-        const wire::AssembledMessage& body = bodies[index];
-        if (body.id == static_cast<std::uint8_t>(wire::ConnectId::establish)) {
-            continue;
-        }
-        // Group handling runs outside the lock because answering takes it again.
+        if (!deferredView[index]) continue;
+        const auto& body = bodies[index];
         bits::Reader reader({body.bytes.data(), gp::kReassemblyCapacity});
-        if (reader.skip(body.bodyBitOffset) && !group::consume(from, body.id, reader, now)) {
-            report(core::log::Level::debug,
-                   "ev=gameplay stage=message result=undecoded id=%u",
-                   static_cast<unsigned>(body.id));
-        }
+        if (reader.skip(body.bodyBitOffset))
+            static_cast<void>(group::consume(from, body.id, reader, now));
     }
+    notify_external_outcomes(completed, completedCount);
     if (queueCleared) {
         report(core::log::Level::info,
                "ev=gameplay stage=sendqueue result=cleared packet=%u base=%u entries=%u",

+ 4 - 0
Sunrise/src/server/gameplay/peer/peer_out_of_band.cpp

@@ -103,6 +103,7 @@ void answer_connect(const gp::Endpoint& from,
     std::size_t displacedCount = 0;
     std::array<std::uint64_t, gp::kSessionsPerLink> resetSessions{};
     std::size_t resetSessionCount = 0;
+    gp::entity_identity::Source resetSource{};
 
     AcquireSRWLockExclusive(&g_lock);
     // Keyed by endpoint. The client holds one channel per host peer, so a second link would stamp
@@ -118,6 +119,8 @@ void answer_connect(const gp::Endpoint& from,
     }
     const bool fresh = peer != nullptr && (peer->stage == gp::PeerStage::absent || rebuilt);
     if (fresh) {
+        resetSource = entity_source(*peer);
+        invalidate_entity_identity_locked(resetSource);
         // The sessions outlive the channel. The client rebuilds one channel under every group
         // session it holds and rejoins none of them, so dropping them here strands each one.
         const std::array<std::uint64_t, gp::kSessionsPerLink> held =
@@ -165,6 +168,7 @@ void answer_connect(const gp::Endpoint& from,
     ReleaseSRWLockExclusive(&g_lock);
     notify_external_outcomes(displaced, displacedCount);
     reset_transports(resetSessions.data(), resetSessionCount);
+    reset_entity_source(resetSource);
     if (peer == nullptr) {
         report(core::log::Level::warn, "ev=gameplay stage=connect result=fail reason=capacity");
         return;

+ 138 - 8
Sunrise/src/server/gameplay/peer/peer_transport.cpp

@@ -6,6 +6,7 @@
 #include "../../../middleware/gameplay/peer/established_packet.h"
 #include "../association/association_host.h"
 #include "../dtls/dtls_host.h"
+#include "../entity_identities.h"
 #include "peer_transport_internal.h"
 
 namespace sunrise::server::gameplay::peer {
@@ -91,25 +92,58 @@ void notify_external_outcomes(const DisplacedExternals& completed, std::size_t c
     }
 }
 
-/** Tells both session-aware transports that a group session lost its channel state. */
+/** Tells the lane-0 transport that a group session lost its channel state. */
 void reset_transports(const std::uint64_t* sessions, std::size_t count) noexcept {
     if (g_lane0Transport.reset != nullptr) {
         for (std::size_t index = 0; index < count; ++index) {
             g_lane0Transport.reset(g_lane0Transport.context, sessions[index]);
         }
     }
-    if (g_entityTransport.reset != nullptr) {
-        for (std::size_t index = 0; index < count; ++index) {
-            g_entityTransport.reset(g_entityTransport.context, sessions[index]);
-        }
-    }
 }
 
-/** Tells both session-aware transports that one group session lost its channel state. */
+/** Tells the lane-0 transport that one group session lost its channel state. */
 void reset_transports(std::uint64_t sessionId) noexcept {
     reset_transports(&sessionId, 1);
 }
 
+/** Source retirement follows peer-to-identity lock order and waits for publication leases. */
+void invalidate_entity_identity_locked(const gp::entity_identity::Source& source) noexcept {
+    if (source.groupSessionId != 0) entity_identities::reset_source(source);
+}
+
+/** Retires only the captured source, never a replacement sharing its group. */
+void reset_entity_source(const gp::entity_identity::Source& source) noexcept {
+    if (source.groupSessionId == 0) return;
+    AcquireSRWLockShared(&g_lock);
+    if (g_entityTransport.reset != nullptr) {
+        g_entityTransport.reset(g_entityTransport.context, source);
+    }
+    ReleaseSRWLockShared(&g_lock);
+}
+
+/**
+ * Copies the admitted source while its peer is locked.
+ * @param peer Peer whose lifecycle is
+ * being read or changed.
+ * @return Exact current entity source.
+ */
+gp::entity_identity::Source entity_source(const gp::PeerLink& peer) noexcept {
+    gp::entity_identity::Source source{};
+    source.activitySessionId = peer.activityBinding.sessionId;
+    source.activityRevision = peer.activityBinding.createdRevision;
+    source.activityClientGeneration = peer.commonReconciler.owner_generation();
+    source.groupSessionId = peer.externalGroupSessionId;
+    source.peerGeneration = peer.peerGeneration;
+    source.channelGeneration = peer.channelGeneration;
+    source.viewGeneration = peer.viewGeneration;
+    source.address = peer.endpoint.address;
+    source.port = peer.endpoint.port;
+    source.localPort = peer.endpoint.localPort;
+    source.localConnectionSequence = peer.localConnectionSequence;
+    source.remoteConnectionSequence = peer.remoteConnectionSequence;
+    return source;
+}
+
 /** @return Peer for one endpoint, or null. Callers already hold the lock. */
 gp::PeerLink* find_locked(const gp::Endpoint& from) noexcept {
     for (gp::PeerLink& peer : g_peers) {
@@ -200,6 +234,19 @@ void install_entity_transport(const EntityTransport& transport) noexcept {
     ReleaseSRWLockExclusive(&g_lock);
 }
 
+/** Retirement is serialized with packet decode before entering the codec's own lock. */
+std::size_t retire_entity_baselines(
+    const state::gameplay::entity_identity::Source& source,
+    std::span<const state::gameplay::entity_identity::RetiredLifetime> lifetimes) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const auto retired =
+        g_entityTransport.retire == nullptr
+            ? 0
+            : g_entityTransport.retire(g_entityTransport.context, source, lifetimes);
+    ReleaseSRWLockExclusive(&g_lock);
+    return retired;
+}
+
 /** Installs the process-lifetime channel-2 codec and accepted-record sink. */
 void install_entity_codec(
     const middleware::gameplay::external::TypePayloadCodec& codec,
@@ -301,6 +348,7 @@ ViewStageResult receive_view_stage(const gp::Endpoint& from,
     DisplacedExternals displaced{};
     std::size_t displacedCount = 0;
     std::uint64_t resetSessionId = 0;
+    gp::entity_identity::Source resetSource{};
     AcquireSRWLockExclusive(&g_lock);
     gp::PeerLink* const peer = find_locked(from);
     if (peer == nullptr) {
@@ -310,6 +358,8 @@ ViewStageResult receive_view_stage(const gp::Endpoint& from,
     auto& receptor = peer->viewReceptor;
     bool accepted = false;
     if (receptor.phase() == gp::external::view_receptor::Phase::closed) {
+        resetSource = entity_source(*peer);
+        invalidate_entity_identity_locked(resetSource);
         ++peer->viewGeneration;
         if (peer->viewGeneration == 0) {
             ++peer->viewGeneration;
@@ -354,6 +404,7 @@ ViewStageResult receive_view_stage(const gp::Endpoint& from,
     if (resetSessionId != 0) {
         reset_transports(resetSessionId);
     }
+    reset_entity_source(resetSource);
     return accepted ? ViewStageResult::accepted : ViewStageResult::refused;
 }
 
@@ -414,8 +465,11 @@ bool open_external_common(
     DisplacedExternals displaced{};
     std::size_t displacedCount = 0;
     std::uint64_t resetSessionId = 0;
+    gp::entity_identity::Source resetSource{};
     AcquireSRWLockExclusive(&g_lock);
     gp::PeerLink* const peer = find_locked(endpoint);
+    const auto previousSource =
+        peer != nullptr ? entity_source(*peer) : gp::entity_identity::Source{};
     const bool same =
         peer != nullptr && peer->externalGroupSessionId == groupSessionId
         && peer->activityBinding.sessionId == activity.sessionId
@@ -423,12 +477,24 @@ bool open_external_common(
         && peer->commonReconciler.owner_generation() == activityClientGeneration
         && peer->commonReconciler.phase() != gp::external::common_reconciler::Phase::closed
         && peer->commonReconciler.phase() != gp::external::common_reconciler::Phase::failed;
+    auto nextReconciler =
+        peer != nullptr ? peer->commonReconciler : gp::external::common_reconciler::Reconciler{};
     const bool opened =
         same
         || (peer != nullptr && peer->viewGeneration != 0
-            && peer->commonReconciler.open_known(
+            && nextReconciler.open_known(
                 activity.sessionId, patchEpoch, activityClientGeneration, replicationEpoch));
     if (opened) {
+        auto nextSource = previousSource;
+        nextSource.activitySessionId = activity.sessionId;
+        nextSource.activityRevision = activity.createdRevision;
+        nextSource.activityClientGeneration = nextReconciler.owner_generation();
+        nextSource.groupSessionId = groupSessionId;
+        if (nextSource != previousSource) {
+            resetSource = previousSource;
+            invalidate_entity_identity_locked(resetSource);
+        }
+        peer->commonReconciler = nextReconciler;
         peer->activityBinding = activity;
         if (!same) {
             resetSessionId = peer->externalGroupSessionId;
@@ -443,9 +509,54 @@ bool open_external_common(
     if (resetSessionId != 0) {
         reset_transports(resetSessionId);
     }
+    reset_entity_source(resetSource);
     return opened;
 }
 
+/**
+ * Advances matching views without replacing their entity source or baseline store.
+ * @param
+ * activity Exact admitted activity binding.
+ * @param activityClientGeneration Owner of the
+ * committed host operation.
+ * @param expectedEpoch Previously authored epoch.
+ * @param nextEpoch
+ * Epoch carried by the committed operation.
+ * @return Number of views whose epoch advanced.
+ */
+std::size_t commit_replication_epoch(const state::activity::SessionBinding& activity,
+                                     std::uint64_t activityClientGeneration,
+                                     std::uint8_t expectedEpoch,
+                                     std::uint8_t nextEpoch) noexcept {
+    if (activity.sessionId == 0 || activity.createdRevision == 0 || activityClientGeneration == 0)
+        return 0;
+    std::size_t advanced = 0;
+    AcquireSRWLockExclusive(&g_lock);
+    for (gp::PeerLink& peer : g_peers) {
+        if (peer.stage == gp::PeerStage::absent || peer.externalGroupSessionId == 0
+            || peer.activityBinding.sessionId != activity.sessionId
+            || peer.activityBinding.createdRevision != activity.createdRevision
+            || peer.commonReconciler.owner_generation() != activityClientGeneration
+            || !peer.commonReconciler.advance_host_epoch(expectedEpoch, nextEpoch))
+            continue;
+        const auto source = entity_source(peer);
+        const auto domain = peer.commonReconciler.allocation_domain();
+        if (g_entityTransport.advanceEpoch != nullptr)
+            g_entityTransport.advanceEpoch(
+                g_entityTransport.context, source, expectedEpoch, nextEpoch, domain);
+        static_cast<void>(
+            entity_identities::advance_epoch(source, expectedEpoch, nextEpoch, domain));
+        peer.commonCommitted = false;
+        for (auto& contribution : peer.externalContributions) {
+            contribution.commonPresent = false;
+        }
+        peer.acknowledgementOwed = true;
+        ++advanced;
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    return advanced;
+}
+
 /** Reports whether every native outbound gate is open. */
 bool external_outbound_eligible(std::uint64_t groupSessionId) noexcept {
     AcquireSRWLockShared(&g_lock);
@@ -510,9 +621,14 @@ bool link_identity(std::uint64_t sessionId, LinkIdentity& output) noexcept {
 void drop(std::uint64_t sessionId) noexcept {
     DisplacedExternals displaced{};
     std::size_t displacedCount = 0;
+    gp::entity_identity::Source resetSource{};
     AcquireSRWLockExclusive(&g_lock);
     gp::PeerLink* const peer = find_session_locked(sessionId);
     if (peer != nullptr) {
+        if (peer->externalGroupSessionId == sessionId) {
+            resetSource = entity_source(*peer);
+            invalidate_entity_identity_locked(resetSource);
+        }
         // The channel outlives the session. A leave names one region, and the client keeps playing
         // the other over the same channel.
         for (std::uint64_t& slot : peer->sessions) {
@@ -521,6 +637,7 @@ void drop(std::uint64_t sessionId) noexcept {
             }
         }
         if (peer->externalGroupSessionId == sessionId) {
+            resetSource = entity_source(*peer);
             displacedCount = collect_displaced_locked(*peer, displaced);
             peer->externalGroupSessionId = 0;
             peer->activityBinding = {};
@@ -532,15 +649,20 @@ void drop(std::uint64_t sessionId) noexcept {
     ReleaseSRWLockExclusive(&g_lock);
     notify_external_outcomes(displaced, displacedCount);
     reset_transports(sessionId);
+    reset_entity_source(resetSource);
 }
 
 /** Drops every link at one endpoint, which is what a connect-closed names. */
 void drop_endpoint(const gp::Endpoint& endpoint) noexcept {
     std::array<std::uint64_t, gp::kAssociationCapacity * gp::kSessionsPerLink> sessions{};
     std::size_t sessionCount = 0;
+    std::array<gp::entity_identity::Source, gp::kAssociationCapacity> sources{};
+    std::size_t sourceCount = 0;
     AcquireSRWLockExclusive(&g_lock);
     for (gp::PeerLink& peer : g_peers) {
         if (peer.stage != gp::PeerStage::absent && peer.endpoint == endpoint) {
+            sources[sourceCount++] = entity_source(peer);
+            invalidate_entity_identity_locked(sources[sourceCount - 1]);
             for (const std::uint64_t sessionId : peer.sessions) {
                 if (sessionId != 0) {
                     sessions[sessionCount++] = sessionId;
@@ -551,14 +673,20 @@ void drop_endpoint(const gp::Endpoint& endpoint) noexcept {
     }
     ReleaseSRWLockExclusive(&g_lock);
     reset_transports(sessions.data(), sessionCount);
+    for (std::size_t index = 0; index < sourceCount; ++index)
+        reset_entity_source(sources[index]);
 }
 
 /** Drops every peer. */
 void reset() noexcept {
     std::array<std::uint64_t, gp::kAssociationCapacity * gp::kSessionsPerLink> sessions{};
     std::size_t sessionCount = 0;
+    std::array<gp::entity_identity::Source, gp::kAssociationCapacity> sources{};
+    std::size_t sourceCount = 0;
     AcquireSRWLockExclusive(&g_lock);
     for (gp::PeerLink& peer : g_peers) {
+        sources[sourceCount++] = entity_source(peer);
+        invalidate_entity_identity_locked(sources[sourceCount - 1]);
         for (const std::uint64_t sessionId : peer.sessions) {
             if (sessionId != 0) {
                 sessions[sessionCount++] = sessionId;
@@ -568,6 +696,8 @@ void reset() noexcept {
     }
     ReleaseSRWLockExclusive(&g_lock);
     reset_transports(sessions.data(), sessionCount);
+    for (std::size_t index = 0; index < sourceCount; ++index)
+        reset_entity_source(sources[index]);
 }
 
 } // namespace sunrise::server::gameplay::peer

+ 41 - 4
Sunrise/src/server/gameplay/peer/peer_transport.h

@@ -7,11 +7,13 @@
 
 #include "../../../middleware/encoding/bit_reader.h"
 #include "../../../middleware/encoding/bit_writer.h"
+#include "../../../middleware/gameplay/external/composite_entity_codec.h"
 #include "../../../middleware/gameplay/external/external_entity_codec.h"
 #include "../../../middleware/gameplay/external/simulation_event_codec.h"
 #include "../../../middleware/gameplay/group/view_message.h"
 #include "../../../middleware/gameplay/peer/established_packet.h"
 #include "../../../state/gameplay/definition.h"
+#include "../../../state/gameplay/external/entity_identity.h"
 
 namespace sunrise::server::gameplay::peer {
 
@@ -50,18 +52,47 @@ void install_lane0_transport(const Lane0Transport& transport) noexcept;
 struct EntityTransport final {
     const void* context{};
     bool (*read)(const void*,
-                 std::uint64_t,
+                 const state::gameplay::entity_identity::Source&,
                  middleware::encoding::bits::Reader&,
                  middleware::gameplay::external::EntityBatch&) noexcept {};
-    bool (*accepted)(const void*,
+    bool (*prepare)(const void*,
+                    const state::gameplay::entity_identity::Source&,
+                    const middleware::gameplay::external::EntityBatch&,
+                    std::uint16_t,
+                    bool,
+                    std::uint64_t,
+                    middleware::gameplay::external::EntityBaselineMutation&) noexcept {};
+    bool (*commit)(const void*,
+                   const state::gameplay::entity_identity::Source&,
+                   const middleware::gameplay::external::EntityBaselineMutation&) noexcept {};
+    void (*reset)(const void*, const state::gameplay::entity_identity::Source&) noexcept {};
+    /** A derived observer cannot reject an already accepted external packet. */
+    void (*observed)(const void*,
+                     const state::gameplay::entity_identity::Source&,
+                     const middleware::gameplay::external::EntityBatch&,
+                     std::uint16_t,
+                     bool,
                      std::uint64_t,
-                     const middleware::gameplay::external::EntityBatch&) noexcept {};
-    void (*reset)(const void*, std::uint64_t) noexcept {};
+                     std::uint64_t) noexcept {};
+    std::size_t (*retire)(
+        const void*,
+        const state::gameplay::entity_identity::Source&,
+        std::span<const state::gameplay::entity_identity::RetiredLifetime>) noexcept {};
+    void (*advanceEpoch)(const void*,
+                         const state::gameplay::entity_identity::Source&,
+                         std::uint8_t,
+                         std::uint8_t,
+                         std::uint64_t) noexcept {};
 };
 
 /** Installs one process-lifetime, session-aware channel-2 transport. */
 void install_entity_transport(const EntityTransport& transport) noexcept;
 
+/** Retires delivered allocations under the same peer-before-codec lock order as receive. */
+[[nodiscard]] std::size_t retire_entity_baselines(
+    const state::gameplay::entity_identity::Source& source,
+    std::span<const state::gameplay::entity_identity::RetiredLifetime> lifetimes) noexcept;
+
 /** Installs the process-lifetime channel-2 codec and accepted-record sink. */
 void install_entity_codec(
     const middleware::gameplay::external::TypePayloadCodec& codec,
@@ -200,6 +231,12 @@ open_external_common(const state::gameplay::Endpoint& endpoint,
                      std::uint64_t activityClientGeneration,
                      std::uint8_t replicationEpoch) noexcept;
 
+/** Synchronizes views only after an exact host-authored epoch transition commits. */
+[[nodiscard]] std::size_t commit_replication_epoch(const state::activity::SessionBinding& activity,
+                                                   std::uint64_t activityClientGeneration,
+                                                   std::uint8_t expectedEpoch,
+                                                   std::uint8_t nextEpoch) noexcept;
+
 /** Reports whether all native gates permit one outbound external body. */
 [[nodiscard]] bool external_outbound_eligible(std::uint64_t groupSessionId) noexcept;
 

+ 15 - 3
Sunrise/src/server/gameplay/peer/peer_transport_internal.h

@@ -73,15 +73,27 @@ using DisplacedExternals =
 void notify_external_outcomes(const DisplacedExternals& completed, std::size_t count) noexcept;
 
 /**
- * Tells both session-aware transports that a group session lost its channel state.
- * @param sessions Session ids copied out under the lock.
+ * Tells the lane-0 transport that a group session lost its channel state.
+ * @param sessions
+ * Session ids copied out under the lock.
  * @param count Entries in use.
  */
 void reset_transports(const std::uint64_t* sessions, std::size_t count) noexcept;
 
-/** Tells both session-aware transports that one group session lost its channel state. */
+/** Tells the lane-0 transport that one group session lost its channel state. */
 void reset_transports(std::uint64_t sessionId) noexcept;
 
+/** Copies the exact source before its peer, view, or ActivityClient changes. */
+[[nodiscard]] state::gameplay::entity_identity::Source
+entity_source(const state::gameplay::PeerLink& peer) noexcept;
+
+/** Retires identity evidence before a source changes; the caller holds the peer lock. */
+void invalidate_entity_identity_locked(
+    const state::gameplay::entity_identity::Source& source) noexcept;
+
+/** Retires only the captured source, never a replacement sharing its group. */
+void reset_entity_source(const state::gameplay::entity_identity::Source& source) noexcept;
+
 /** @return Peer for one endpoint, or null. Callers already hold the lock. */
 [[nodiscard]] state::gameplay::PeerLink*
 find_locked(const state::gameplay::Endpoint& from) noexcept;

+ 331 - 0
Sunrise/src/server/gameplay/squad_entity_retirement.cpp

@@ -0,0 +1,331 @@
+#include "squad_entity_retirement.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <cstdio>
+
+#include "../../core/logging/log.h"
+#include "../../state/gameplay/external/entity_object_types.h"
+#include "../../state/gameplay/external/entity_position_profiles.h"
+#include "../activity/host_runtime.h"
+#include "entity_identities.h"
+#include "peer/peer_transport.h"
+
+namespace sunrise::server::gameplay::squad_entity_retirement {
+namespace {
+namespace policy = state::gameplay::squad_entity_retirement;
+namespace identities = state::gameplay::entity_identity;
+SRWLOCK g_lock{SRWLOCK_INIT};
+policy::Store g_store;
+struct Target final {
+    std::uint64_t session{}, revision{}, generation{};
+    policy::Eligibility eligibility{};
+};
+std::vector<Target> g_targets;
+/** Exactly one admitted current view must own the ActivityClient generation. */
+bool snapshot(const state::activity::SessionBinding& binding,
+              std::uint64_t generation,
+              identities::Source& source,
+              std::vector<identities::Identity>& rows) noexcept {
+    source = {};
+    rows.clear();
+    if (!generation || !state::activity::binding_matches(binding)) return false;
+    std::array<identities::Source, identities::kSourceCapacity> sources{};
+    const auto count =
+        entity_identities::sources(binding.sessionId, binding.createdRevision, sources);
+    if (count > sources.size()) return false;
+    std::size_t matches = 0;
+    for (std::size_t i = 0; i < count; ++i)
+        if (sources[i].activityClientGeneration == generation) {
+            source = sources[i];
+            ++matches;
+        }
+    return matches == 1
+           && entity_identities::snapshot_source(source, rows) == identities::Result::unchanged
+           && state::gameplay::entity_object_types::enrich_snapshot(rows);
+}
+/** Reports bounded release evidence without granting unknown identities any authority. */
+void report_released(const policy::Mask& mask,
+                     std::span<const identities::Identity> rows) noexcept {
+    if (!core::log::accepts(core::log::Channel::server, core::log::Level::debug)) return;
+    constexpr std::size_t kMaximumReportedSlots = 64;
+    std::size_t shown = 0;
+    for (std::size_t slot = 0; slot < rows.size() && shown < kMaximumReportedSlots; ++slot) {
+        if ((std::to_integer<unsigned>(mask[slot / 8]) & (1U << (slot % 8))) == 0) continue;
+        ++shown;
+        const auto& row = rows[slot];
+        const auto& actor = row.actorSource;
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "squad_entity_retirement stage=released slot=%zu known=%u present=%u conflict=%u "
+            "inc=%u alloc=%u type=%u cell=%u parent_known=%u parent=%d rsat=0x%08X "
+            "object_type=%d source_known=%u source_present=%u source_key=0x%08X "
+            "source_type=%u source_index=%u",
+            slot,
+            unsigned(row.known),
+            unsigned(row.present),
+            unsigned(row.conflicted),
+            unsigned(row.token.incarnation),
+            unsigned(row.allocationSequence),
+            unsigned(row.type),
+            unsigned(row.cell),
+            unsigned(row.anchorKnown),
+            row.anchorPresent ? int(row.anchor.slot) : -1,
+            row.metadata.rsatTag,
+            row.metadata.hasObjectType ? int(row.metadata.objectType) : -1,
+            unsigned(actor.known),
+            unsigned(actor.present),
+            actor.key,
+            unsigned(actor.type),
+            unsigned(actor.index));
+        if (count > 0)
+            core::log::write(
+                core::log::Channel::server,
+                core::log::Level::debug,
+                {line.data(), (std::min)(static_cast<std::size_t>(count), line.size() - 1)});
+    }
+}
+
+/** Missing parents must remain visible when they block a complete retire tree. */
+void report_hierarchy_gaps(std::span<const identities::Identity> rows) noexcept {
+    if (!core::log::accepts(core::log::Channel::server, core::log::Level::debug)) return;
+    constexpr std::size_t kMaximumReportedGaps = 64;
+    std::size_t shown = 0;
+    for (std::size_t slot = 0; slot < rows.size() && shown < kMaximumReportedGaps; ++slot) {
+        const auto& row = rows[slot];
+        if (!row.present) continue;
+        const char* reason = nullptr;
+        if (!row.known || row.conflicted || !row.anchorKnown || row.token.slot != slot)
+            reason = "identity";
+        else if (row.anchorPresent
+                 && (row.anchor.slot >= rows.size() || !rows[row.anchor.slot].present))
+            reason = "missing_parent";
+        else if (row.anchorPresent && rows[row.anchor.slot].token != row.anchor)
+            reason = "parent_lifetime";
+        if (reason == nullptr) continue;
+        ++shown;
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(line.data(),
+                                        line.size(),
+                                        "squad_entity_retirement stage=hierarchy_gap slot=%zu "
+                                        "reason=%s parent=%d parent_inc=%u",
+                                        slot,
+                                        reason,
+                                        row.anchorPresent ? int(row.anchor.slot) : -1,
+                                        unsigned(row.anchor.incarnation));
+        if (count > 0)
+            core::log::write(
+                core::log::Channel::server,
+                core::log::Level::debug,
+                {line.data(), (std::min)(static_cast<std::size_t>(count), line.size() - 1)});
+    }
+}
+
+/** Logs the complete selected count and a bounded prefix of slot indices. */
+void report(const char* stage,
+            bool result,
+            std::uint8_t bubble,
+            const policy::Mask* mask = nullptr) {
+    std::array<char, core::log::kLineCapacity> line{};
+    std::size_t selected = 0;
+    if (mask)
+        for (auto byte : *mask)
+            for (unsigned bit = 0; bit < 8; ++bit)
+                selected += (std::to_integer<unsigned>(byte) >> bit) & 1U;
+    const int prefix =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "squad_entity_retirement stage=%s bubble=%u accepted=%u selected=%zu slots=",
+                      stage,
+                      unsigned(bubble),
+                      result ? 1U : 0U,
+                      selected);
+    if (prefix <= 0) return;
+    std::size_t length = static_cast<std::size_t>(prefix), shown = 0;
+    if (mask)
+        for (std::size_t slot = 0; slot < identities::kSlotCapacity && shown < 32; ++slot) {
+            if ((std::to_integer<unsigned>((*mask)[slot / 8]) & (1U << (slot % 8))) == 0) continue;
+            const int count = std::snprintf(
+                line.data() + length, line.size() - length, "%s%zu", shown ? "," : "", slot);
+            if (count <= 0 || static_cast<std::size_t>(count) >= line.size() - length) break;
+            length += static_cast<std::size_t>(count);
+            ++shown;
+        }
+    if (shown == 0) line[length++] = '-';
+    core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
+}
+} // namespace
+/** Delivered squad choices replace eligibility for their exact authored target. */
+void record_delivered_target(const state::activity::SessionBinding& binding,
+                             std::uint64_t generation,
+                             const activity::host::PendingScriptableOverride& pending) noexcept {
+    if (!generation || pending.target.slotType != 1
+        || pending.expectedActivityClientGeneration != generation)
+        return;
+    AcquireSRWLockExclusive(&g_lock);
+    try {
+        const auto& selected = pending.squadRetirement;
+        const auto prior = std::find_if(g_targets.begin(), g_targets.end(), [&](const Target& row) {
+            return row.session == binding.sessionId && row.revision == binding.createdRevision
+                   && row.generation == generation
+                   && row.eligibility.squad.key == pending.target.registryKey
+                   && row.eligibility.squad.index == pending.target.slotIndex;
+        });
+        const bool enabled = pending.kind == activity::host::ScriptableOverrideKind::squad
+                             && selected.enabled && selected.squad.key == pending.target.registryKey
+                             && selected.squad.index == pending.target.slotIndex
+                             && selected.squad.type == 1;
+        const bool unchanged =
+            prior != g_targets.end() && enabled && prior->eligibility == selected;
+        if (!unchanged) {
+            g_store.invalidate_target(
+                binding.sessionId,
+                generation,
+                {pending.target.registryKey, pending.target.slotIndex, pending.target.slotType});
+            std::erase_if(g_targets, [&](const Target& row) {
+                return row.session == binding.sessionId
+                       && (row.revision != binding.createdRevision || row.generation != generation
+                           || (row.eligibility.squad.key == pending.target.registryKey
+                               && row.eligibility.squad.index == pending.target.slotIndex));
+            });
+            /** An active source cannot own more actor targets than native entity slots. */
+            constexpr std::size_t kMaximumTargets =
+                identities::kSourceCapacity * identities::kSlotCapacity;
+            if (enabled && g_targets.size() < kMaximumTargets)
+                g_targets.push_back(
+                    {binding.sessionId, binding.createdRevision, generation, selected});
+        }
+    } catch (...) {
+        g_store.invalidate_target(
+            binding.sessionId,
+            generation,
+            {pending.target.registryKey, pending.target.slotIndex, pending.target.slotType});
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+/** Only package-mapped, positively authored trees enter a captured release. */
+void observe_abdication(const state::activity::SessionBinding& binding,
+                        std::uint64_t generation,
+                        std::uint8_t bubble,
+                        const state::activity::bubble_authority::EntitySlotMask& mask) noexcept {
+    identities::Source source{};
+    std::vector<identities::Identity> rows;
+    if (!snapshot(binding, generation, source, rows)) {
+        report("capture_source", false, bubble);
+        return;
+    }
+    const auto& destination = binding.destination;
+    report_released(mask, rows);
+    report_hierarchy_gaps(rows);
+    if (destination.packageNameLength == 0
+        || destination.packageNameLength > destination.packageName.size())
+        return;
+    const std::string_view name(reinterpret_cast<const char*>(destination.packageName.data()),
+                                destination.packageNameLength);
+    policy::CellBubbles cells{};
+    cells.fill(-1);
+    for (std::size_t i = 0; i < cells.size(); ++i) {
+        std::uint8_t owner{};
+        if (state::gameplay::entity_position_profiles::lookup_bubble(
+                name, static_cast<std::uint16_t>(i), owner))
+            cells[i] = owner;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    bool accepted = false;
+    try {
+        std::vector<policy::Eligibility> eligible;
+        for (const auto& row : g_targets)
+            if (row.session == binding.sessionId && row.revision == binding.createdRevision
+                && row.generation == generation)
+                eligible.push_back(row.eligibility);
+        accepted = g_store.capture(source, bubble, mask, rows, eligible, cells);
+    } catch (...) {}
+    ReleaseSRWLockExclusive(&g_lock);
+    report("capture", accepted, bubble);
+}
+void returned_slots(const state::activity::SessionBinding& binding,
+                    std::uint64_t generation,
+                    const state::activity::bubble_authority::EntitySlotMask& mask) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_store.returned_slots(binding.sessionId, generation, mask);
+    ReleaseSRWLockExclusive(&g_lock);
+}
+/** A fresh atomic identity snapshot must still match each captured tree. */
+bool prepare_retirement(const state::activity::SessionBinding& binding,
+                        std::uint64_t generation,
+                        std::uint8_t bubble,
+                        RetirementPlan& output) noexcept {
+    output = {};
+    identities::Source source{};
+    std::vector<identities::Identity> rows;
+    if (!snapshot(binding, generation, source, rows)) return false;
+    AcquireSRWLockShared(&g_lock);
+    const bool ready = g_store.prepare(source, bubble, rows, output);
+    ReleaseSRWLockShared(&g_lock);
+    report("prepare", ready, bubble, &output.entities);
+    return ready;
+}
+/** The exact identity view remains pinned until its transport response has been copied. */
+bool begin_retirement_publication(const state::activity::SessionBinding& binding,
+                                  std::uint64_t generation,
+                                  const RetirementPlan& plan,
+                                  entity_identities::PublicationLease& lease) noexcept {
+    if (lease.held()) return false;
+    if (!plan.pending || plan.source.activitySessionId != binding.sessionId
+        || plan.source.activityRevision != binding.createdRevision
+        || plan.source.activityClientGeneration != generation
+        || !state::activity::binding_matches(binding))
+        return false;
+    std::vector<identities::Identity> rows;
+    if (entity_identities::begin_publication(plan.source, rows, lease)
+        != identities::Result::unchanged)
+        return false;
+    if (!state::gameplay::entity_object_types::enrich_snapshot(rows)) {
+        lease.release();
+        return false;
+    }
+    RetirementPlan current{};
+    AcquireSRWLockShared(&g_lock);
+    const bool valid = g_store.prepare(plan.source, plan.bubble, rows, current)
+                       && current.source == plan.source && current.entities == plan.entities
+                       && current.lifetimes == plan.lifetimes
+                       && current.lifetimeCount == plan.lifetimeCount
+                       && current.revision == plan.revision && current.bubble == plan.bubble;
+    ReleaseSRWLockShared(&g_lock);
+    if (!valid) lease.release();
+    return valid;
+}
+/** A stale prepared retirement cannot enter a later transport publication. */
+bool validate_retirement(const state::activity::SessionBinding& binding,
+                         std::uint64_t generation,
+                         const RetirementPlan& plan) noexcept {
+    RetirementPlan current{};
+    return plan.pending && prepare_retirement(binding, generation, plan.bubble, current)
+           && current.source == plan.source && current.entities == plan.entities
+           && current.lifetimes == plan.lifetimes && current.lifetimeCount == plan.lifetimeCount
+           && current.revision == plan.revision && current.bubble == plan.bubble;
+}
+/** Retires exact lifetimes only after their carrying publication and identity lease have ended. */
+void commit_retirement(const RetirementPlan& plan) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const bool committed = g_store.commit(plan);
+    if (committed)
+        g_store.returned_slots(
+            plan.source.activitySessionId, plan.source.activityClientGeneration, plan.entities);
+    ReleaseSRWLockExclusive(&g_lock);
+    if (committed) {
+        static_cast<void>(entity_identities::retire(plan.source, plan.retired_lifetimes()));
+        static_cast<void>(peer::retire_entity_baselines(plan.source, plan.retired_lifetimes()));
+    }
+    report("commit", committed, plan.bubble, &plan.entities);
+}
+void reset() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_store.reset();
+    g_targets.clear();
+    ReleaseSRWLockExclusive(&g_lock);
+}
+} // namespace sunrise::server::gameplay::squad_entity_retirement

+ 42 - 0
Sunrise/src/server/gameplay/squad_entity_retirement.h

@@ -0,0 +1,42 @@
+#pragma once
+#include "../../state/activity/runtime.h"
+#include "../../state/gameplay/external/squad_entity_retirement.h"
+namespace sunrise::server::gameplay::entity_identities {
+class PublicationLease;
+}
+namespace sunrise::server::activity::host {
+struct PendingScriptableOverride;
+}
+namespace sunrise::server::gameplay::squad_entity_retirement {
+using RetirementPlan = state::gameplay::squad_entity_retirement::RetirementPlan;
+/** The authenticated abdication freezes exact identities before a later renewal can retire them. */
+void observe_abdication(const state::activity::SessionBinding&,
+                        std::uint64_t generation,
+                        std::uint8_t bubble,
+                        const state::activity::bubble_authority::EntitySlotMask&) noexcept;
+/** Returned slots cannot remain eligible for an earlier release. */
+void returned_slots(const state::activity::SessionBinding&,
+                    std::uint64_t generation,
+                    const state::activity::bubble_authority::EntitySlotMask&) noexcept;
+/** Renewal preparation never consumes retained entities. */
+[[nodiscard]] bool prepare_retirement(const state::activity::SessionBinding&,
+                                      std::uint64_t generation,
+                                      std::uint8_t bubble,
+                                      RetirementPlan&) noexcept;
+/** Publication must revalidate the exact staged source, mask, and revision. */
+[[nodiscard]] bool validate_retirement(const state::activity::SessionBinding&,
+                                       std::uint64_t generation,
+                                       const RetirementPlan&) noexcept;
+/** Pins exact identity state through transport publication after the last policy validation. */
+[[nodiscard]] bool begin_retirement_publication(const state::activity::SessionBinding&,
+                                                std::uint64_t generation,
+                                                const RetirementPlan&,
+                                                entity_identities::PublicationLease&) noexcept;
+/** Commits only after the complete carrying transport publication succeeds. */
+void commit_retirement(const RetirementPlan&) noexcept;
+/** Authored opt-in becomes eligible only after its positive Auth body was delivered. */
+void record_delivered_target(const state::activity::SessionBinding&,
+                             std::uint64_t generation,
+                             const activity::host::PendingScriptableOverride&) noexcept;
+void reset() noexcept;
+} // namespace sunrise::server::gameplay::squad_entity_retirement

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

@@ -4,8 +4,12 @@
 #include <cstddef>
 #include <cstdint>
 
+#include "../../../middleware/bap/activity_message/entity_slots.h"
+
 namespace sunrise::state::activity::bubble_authority {
 
+using EntitySlotMask = middleware::bap::activity_message::entity_slots::EntitySlotMask;
+
 /** 64 usable bubbles and one first-send fallback own grant tokens. */
 inline constexpr std::size_t kAuthoritySlotCount = 65;
 /** Bubble 64 is sent with the first usable bubble and never derived from a slice set. */
@@ -28,6 +32,9 @@ struct Grant final {
 /** Persistent grant-token mirrors owned by one activity session. */
 struct AuthorityState final {
     std::array<std::uint16_t, kAuthoritySlotCount> grantTokens{};
+    std::array<bool, kAuthoritySlotCount> held{};
+    /** Released entities remain pending until their exact claim is delivered. */
+    std::array<EntitySlotMask, kAuthoritySlotCount> releasedEntities{};
 };
 
 } // namespace sunrise::state::activity::bubble_authority

+ 44 - 5
Sunrise/src/state/activity/bubble_authority/runtime.h

@@ -7,16 +7,17 @@
 namespace sunrise::state::activity::bubble_authority {
 
 /**
- * Picks the bubble to hand this session, if one is owed.
- * A bubble is granted once. The token is a change against the client's own mirror, so re-sending
- * the same token for a bubble already granted does nothing, rather than being an error.
+ * Renews released authority only when the client enters that bubble again.
  * @param sessionId Joined activity session.
  * @param sliceSetIndex Slice set the client is in, or the destination's own.
  * @param grant Gets the bubble and its token.
+ * @param enteringBubble Whether this snapshot enters a different held bubble.
  * @return True when a bubble is owed.
  */
-[[nodiscard]] bool
-select_grant(std::uint64_t sessionId, std::int32_t sliceSetIndex, Grant& grant) noexcept;
+[[nodiscard]] bool select_grant(std::uint64_t sessionId,
+                                std::int32_t sliceSetIndex,
+                                Grant& grant,
+                                bool enteringBubble = false) noexcept;
 
 /**
  * Records a bubble as granted so it is not granted twice.
@@ -25,6 +26,44 @@ select_grant(std::uint64_t sessionId, std::int32_t sliceSetIndex, Grant& grant)
  */
 void record_grant(std::uint64_t sessionId, const Grant& grant) noexcept;
 
+/**
+ * Releases authority while preserving the token the client last received.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble named by the accepted abdication.
+ * @param mask Optional exact released entities, merged with earlier reports.
+ */
+void record_abdication(std::uint64_t sessionId,
+                       std::uint8_t bubble,
+                       const EntitySlotMask* mask = nullptr) noexcept;
+
+/**
+ * Copies the released entities awaiting a claim in one bubble.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble whose released entities are requested.
+ * @param output Receives the exact pending mask, cleared on failure.
+ * @return True when at least one released entity is pending.
+ */
+[[nodiscard]] bool snapshot_released_entities(std::uint64_t sessionId,
+                                              std::uint8_t bubble,
+                                              EntitySlotMask& output) noexcept;
+
+/**
+ * Removes only the released entities covered by a delivered claim.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble receiving the claim.
+ * @param mask Exact mask delivered to the client.
+ */
+void record_claim(std::uint64_t sessionId,
+                  std::uint8_t bubble,
+                  const EntitySlotMask& mask) noexcept;
+
+/**
+ * Removes delivered purge bits from every bubble in one session.
+ * @param sessionId Joined activity session.
+ * @param mask Exact entity mask delivered by the purge.
+ */
+void record_purge(std::uint64_t sessionId, const EntitySlotMask& mask) noexcept;
+
 /**
  * Drops every grant recorded for one session, so the next roster push grants again.
  * A join resets the client's roster container. Keeping the old grant set would leave the new

+ 118 - 6
Sunrise/src/state/activity/bubble_authority/transactions/activity_bubble_authority_grant.cpp

@@ -1,5 +1,7 @@
 #include <Windows.h>
 
+#include <limits>
+
 #include "../../../runtime/storage/internal.h"
 #include "../../transactions/internal.h"
 #include "../runtime.h"
@@ -7,7 +9,10 @@
 namespace sunrise::state::activity::bubble_authority {
 
 /** Picks the bubble to hand this session, if one is owed. */
-bool select_grant(std::uint64_t sessionId, std::int32_t sliceSetIndex, Grant& grant) noexcept {
+bool select_grant(std::uint64_t sessionId,
+                  std::int32_t sliceSetIndex,
+                  Grant& grant,
+                  bool enteringBubble) noexcept {
     grant = {};
     if (sessionId == kAbsentSessionId || sliceSetIndex < 0
         || sliceSetIndex > kMaximumGrantSliceSetIndex) {
@@ -18,11 +23,16 @@ bool select_grant(std::uint64_t sessionId, std::int32_t sliceSetIndex, Grant& gr
     AcquireSRWLockShared(&runtime::storage::g_stateLock);
     const ActivityState& state = runtime::storage::g_state.activity;
     const std::size_t target = activity::transactions::find_session(state, sessionId);
-    if (target != kInvalidSessionSlot && bubble < kFallbackBubble
-        && state.sessions[target].bubbleAuthority.grantTokens[bubble] == 0) {
-        grant.bubble = bubble;
-        grant.token = kInitialGrantToken;
-        owed = true;
+    if (target != kInvalidSessionSlot && bubble < kFallbackBubble) {
+        const AuthorityState& authority = state.sessions[target].bubbleAuthority;
+        const std::uint16_t previous = authority.grantTokens[bubble];
+        if (previous == 0 || (enteringBubble && !authority.held[bubble])) {
+            grant.bubble = bubble;
+            grant.token = previous == (std::numeric_limits<std::uint16_t>::max)()
+                              ? kInitialGrantToken
+                              : static_cast<std::uint16_t>(previous + 1U);
+            owed = true;
+        }
     }
     ReleaseSRWLockShared(&runtime::storage::g_stateLock);
     return owed;
@@ -38,6 +48,108 @@ 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.held[grant.bubble] = true;
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+}
+
+/**
+ * Releases only the reported bubble without changing its last grant token.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble named by the accepted abdication.
+ * @param mask Optional released entities to merge into the pending claim.
+ */
+void record_abdication(std::uint64_t sessionId,
+                       std::uint8_t bubble,
+                       const EntitySlotMask* mask) 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) {
+        AuthorityState& authority = state.sessions[target].bubbleAuthority;
+        authority.held[bubble] = false;
+        if (mask != nullptr) {
+            auto& released = authority.releasedEntities[bubble];
+            for (std::size_t index = 0; index < released.size(); ++index) {
+                released[index] |= (*mask)[index];
+            }
+        }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+}
+
+/**
+ * A claim snapshots the complete pending mask without consuming it.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble whose pending entities are requested.
+ * @param output Receives the pending mask, cleared on failure.
+ * @return True when the mask contains any released entities.
+ */
+bool snapshot_released_entities(std::uint64_t sessionId,
+                                std::uint8_t bubble,
+                                EntitySlotMask& output) noexcept {
+    output = {};
+    if (sessionId == kAbsentSessionId || bubble >= kAuthoritySlotCount) {
+        return false;
+    }
+    AcquireSRWLockShared(&runtime::storage::g_stateLock);
+    const ActivityState& state = runtime::storage::g_state.activity;
+    const std::size_t target = activity::transactions::find_session(state, sessionId);
+    if (target != kInvalidSessionSlot) {
+        output = state.sessions[target].bubbleAuthority.releasedEntities[bubble];
+    }
+    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    for (const auto value : output) {
+        if (value != std::byte{}) return true;
+    }
+    return false;
+}
+
+/**
+ * A delivered claim preserves unrelated entities released after its snapshot.
+ * @param sessionId Joined activity session.
+ * @param bubble Bubble receiving the claim.
+ * @param mask Exact delivered mask to subtract from pending entities.
+ */
+void record_claim(std::uint64_t sessionId,
+                  std::uint8_t bubble,
+                  const EntitySlotMask& mask) 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) {
+        auto& released = state.sessions[target].bubbleAuthority.releasedEntities[bubble];
+        for (std::size_t index = 0; index < released.size(); ++index) {
+            released[index] &= ~mask[index];
+        }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+}
+
+/**
+ * A committed purge removes only its delivered bits across this session's bubbles.
+ * @param sessionId Joined activity session.
+ * @param mask Exact delivered purge mask.
+ */
+void record_purge(std::uint64_t sessionId, const EntitySlotMask& mask) noexcept {
+    if (sessionId == kAbsentSessionId) {
+        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) {
+        for (auto& released : state.sessions[target].bubbleAuthority.releasedEntities) {
+            for (std::size_t index = 0; index < released.size(); ++index) {
+                released[index] &= ~mask[index];
+            }
+        }
     }
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 }

+ 1 - 0
Sunrise/src/state/activity/mission/activity_mission_state.cpp

@@ -50,6 +50,7 @@ namespace {
            && left.authBitCount == right.authBitCount
            && left.effectiveRegion == right.effectiveRegion && left.slotIndex == right.slotIndex
            && left.deviceValue == right.deviceValue && left.squadMode == right.squadMode
+           && left.squadRetireOnReturn == right.squadRetireOnReturn
            && left.squadCount == right.squadCount && left.deviceChannel == right.deviceChannel
            && left.slotType == right.slotType && left.authByteCount == right.authByteCount
            && left.lifetimeState == right.lifetimeState && left.deviceSnap == right.deviceSnap

+ 1 - 0
Sunrise/src/state/activity/mission/definition.h

@@ -113,6 +113,7 @@ struct TypedIntent final {
     std::uint16_t authBitCount{};
     std::uint16_t slotIndex{};
     std::uint8_t squadMode{};
+    bool squadRetireOnReturn{};
     std::uint8_t squadCount{};
     std::uint8_t deviceChannel{};
     std::uint8_t slotType{};

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

@@ -6,6 +6,7 @@
 
 #include "../../core/filesystem/path.h"
 #include "../content/content_catalog.h"
+#include "../gameplay/external/entity_position_profiles.h"
 #include "abilities/ability_bucket_catalog.h"
 #include "cache/internal.h"
 #include "collectibles/collectible_catalog.h"
@@ -41,6 +42,8 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     AcquireSRWLockExclusive(&persistenceState.lock);
     runtime::persistence::clear_locked(persistenceState);
     runtime::clear_catalogs();
+    gameplay::entity_position_profiles::reset();
+    gameplay::entity_object_types::reset();
     if (module == nullptr) {
         ReleaseSRWLockExclusive(&persistenceState.lock);
         return true;
@@ -121,9 +124,15 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
                                  domains.vendorDefinitions,
                                  domains.vendorSaleRows,
                                  domains.vendorInstalledRows))
-        || !hash_names::replace(domains.hashNames)) {
+        || !hash_names::replace(domains.hashNames)
+        || !gameplay::entity_position_profiles::restore(domains.positionProfiles,
+                                                        domains.positionFingerprint)
+        || !gameplay::entity_object_types::restore(domains.objectTypes,
+                                                   domains.positionFingerprint)) {
         // No domain remains published when any catalog rejects the cache transaction.
         runtime::clear_catalogs();
+        gameplay::entity_position_profiles::reset();
+        gameplay::entity_object_types::reset();
         runtime::persistence::clear_locked(persistenceState);
         ReleaseSRWLockExclusive(&persistenceState.lock);
         return false;
@@ -144,6 +153,8 @@ void shutdown() noexcept {
     runtime::persistence::Context& persistenceState = runtime::persistence::context();
     AcquireSRWLockExclusive(&persistenceState.lock);
     runtime::clear_catalogs();
+    gameplay::entity_position_profiles::reset();
+    gameplay::entity_object_types::reset();
     runtime::persistence::clear_locked(persistenceState);
     ReleaseSRWLockExclusive(&persistenceState.lock);
 }

+ 16 - 8
Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp

@@ -15,7 +15,7 @@ namespace {
            && counts.materialRequirementSets != 0 && counts.socketPlugRules != 0
            && counts.socketPlugPools != 0 && counts.inventoryBuckets != 0
            && counts.socketEntryLists != 0 && counts.progressions != 0 && counts.scenarios != 0
-           && counts.rosterGroups != 0;
+           && counts.rosterGroups != 0 && counts.positionProfiles != 0 && counts.objectTypes != 0;
 }
 
 /** @return True when every count fits the output storage. */
@@ -42,7 +42,9 @@ namespace {
            && counts.vendorIndex <= output.vendorIndex.size()
            && counts.vendorDefinitions <= output.vendorDefinitions.size()
            && counts.vendorSaleRows <= output.vendorSaleRows.size()
-           && counts.vendorInstalledRows <= output.vendorInstalledRows.size();
+           && counts.vendorInstalledRows <= output.vendorInstalledRows.size()
+           && counts.positionProfiles <= output.positionProfiles.size()
+           && counts.objectTypes <= output.objectTypes.size();
 }
 
 /** @return The header's row counts, as platform sizes. */
@@ -71,6 +73,8 @@ namespace {
         header.vendorDefinitionCount,
         header.vendorSaleRowCount,
         header.vendorInstalledRowCount,
+        header.positionProfileCount,
+        header.objectTypeCount,
     };
 }
 
@@ -128,7 +132,8 @@ LoadStatus load(const wchar_t* path,
                 records::DomainCounts& counts) noexcept {
     counts = {};
     read::clear(output);
-    if (path == nullptr || expectedBuild.imageSize == 0 || output.constants == nullptr) {
+    if (path == nullptr || expectedBuild.imageSize == 0 || output.constants == nullptr
+        || output.positionFingerprint == nullptr) {
         return LoadStatus::invalid;
     }
     const HANDLE file = CreateFileW(path,
@@ -173,11 +178,13 @@ LoadStatus load(const wchar_t* path,
     const records::DomainCounts pendingCounts = counts_of(header);
     std::uint64_t expectedSize = 0;
     std::uint64_t checksum = 0;
-    bool valid = required_domains_present(pendingCounts) && counts_fit(pendingCounts, output)
-                 && read::expected_size(pendingCounts, expectedSize)
-                 && static_cast<std::uint64_t>(actualSize.QuadPart) == expectedSize
-                 && read::read_payload(file, header.constants, pendingCounts, output, checksum)
-                 && checksum == header.payloadChecksum;
+    bool valid =
+        required_domains_present(pendingCounts) && counts_fit(pendingCounts, output)
+        && read::expected_size(pendingCounts, expectedSize)
+        && static_cast<std::uint64_t>(actualSize.QuadPart) == expectedSize
+        && read::read_payload(
+            file, header.constants, header.positionFingerprint, pendingCounts, output, checksum)
+        && checksum == header.payloadChecksum;
     const LoadStatus status = close_with(file, valid ? LoadStatus::loaded : LoadStatus::invalid);
     if (status != LoadStatus::loaded) {
         // Counts and rows commit together only after the file handle closes cleanly.
@@ -187,6 +194,7 @@ LoadStatus load(const wchar_t* path,
     counts = pendingCounts;
     // Header scalars commit with the counts, on the same clean-close path as the record arrays.
     *output.constants = header.constants;
+    *output.positionFingerprint = header.positionFingerprint;
     return LoadStatus::loaded;
 }
 

+ 21 - 2
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -57,6 +57,12 @@ read_domain(HANDLE file, std::span<Value> output, std::uint64_t& checksum) noexc
 
 /** Clears every output span so a failed read cannot expose partial records. */
 void clear(records::MutableDomains output) noexcept {
+    std::fill(output.positionProfiles.begin(),
+              output.positionProfiles.end(),
+              gameplay::entity_position_profiles::Row{});
+    if (output.positionFingerprint) *output.positionFingerprint = {};
+    std::fill(
+        output.objectTypes.begin(), output.objectTypes.end(), gameplay::entity_object_types::Row{});
     if (output.constants != nullptr) {
         *output.constants = {};
     }
@@ -127,16 +133,20 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
            && add_records(counts.vendorDefinitions, sizeof(records::VendorDefinitionRecord), size)
            && add_records(counts.vendorSaleRows, sizeof(records::VendorSaleRowRecord), size)
            && add_records(
-               counts.vendorInstalledRows, sizeof(records::VendorInstalledRowRecord), size);
+               counts.vendorInstalledRows, sizeof(records::VendorInstalledRowRecord), size)
+           && add_records(counts.positionProfiles, sizeof(records::PositionProfileRecord), size)
+           && add_records(counts.objectTypes, sizeof(records::ObjectTypeRecord), size);
 }
 
 /** Reads every payload array and checks the decoded domains as one transaction. */
 bool read_payload(HANDLE file,
                   const records::InvestmentConstants& constants,
+                  const gameplay::entity_position_profiles::Fingerprint& fingerprint,
                   const records::DomainCounts& counts,
                   records::MutableDomains output,
                   std::uint64_t& checksum) noexcept {
-    checksum = records::checksum_value(records::kChecksumOffsetBasis, constants);
+    checksum = records::checksum_value(
+        records::checksum_value(records::kChecksumOffsetBasis, constants), fingerprint);
     bool valid =
         read_domain<records::NamedRecord>(file, output.named.first(counts.named), checksum);
     valid =
@@ -205,6 +215,12 @@ bool read_payload(HANDLE file,
     valid = valid
             && read_domain<records::VendorInstalledRowRecord>(
                 file, output.vendorInstalledRows.first(counts.vendorInstalledRows), checksum);
+    valid = valid
+            && read_domain<records::PositionProfileRecord>(
+                file, output.positionProfiles.first(counts.positionProfiles), checksum);
+    valid = valid
+            && read_domain<records::ObjectTypeRecord>(
+                file, output.objectTypes.first(counts.objectTypes), checksum);
     if (!valid) {
         return false;
     }
@@ -233,6 +249,9 @@ bool read_payload(HANDLE file,
         output.vendorDefinitions.first(counts.vendorDefinitions),
         output.vendorSaleRows.first(counts.vendorSaleRows),
         output.vendorInstalledRows.first(counts.vendorInstalledRows),
+        output.positionProfiles.first(counts.positionProfiles),
+        fingerprint,
+        output.objectTypes.first(counts.objectTypes),
     });
 }
 

+ 1 - 0
Sunrise/src/state/build_data/cache/read/cache_payload_reader.h

@@ -44,6 +44,7 @@ void clear(records::MutableDomains output) noexcept;
  */
 [[nodiscard]] bool read_payload(HANDLE file,
                                 const records::InvestmentConstants& constants,
+                                const gameplay::entity_position_profiles::Fingerprint& fingerprint,
                                 const records::DomainCounts& counts,
                                 records::MutableDomains output,
                                 std::uint64_t& checksum) noexcept;

+ 14 - 2
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -154,7 +154,9 @@ template <typename Value, typename Less>
            && counts.vendorIndex <= domains.vendorIndex.size()
            && counts.vendorDefinitions <= domains.vendorDefinitions.size()
            && counts.vendorSaleRows <= domains.vendorSaleRows.size()
-           && counts.vendorInstalledRows <= domains.vendorInstalledRows.size();
+           && counts.vendorInstalledRows <= domains.vendorInstalledRows.size()
+           && counts.positionProfiles <= domains.positionProfiles.size()
+           && counts.objectTypes <= domains.objectTypes.size();
 }
 
 } // namespace
@@ -164,6 +166,14 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
     if (!counts_fit(domains, counts)) {
         return false;
     }
+    const auto objectTypes = domains.objectTypes.first(counts.objectTypes);
+    std::sort(objectTypes.begin(), objectTypes.end(), [](const auto& a, const auto& b) {
+        return a.rsatTag < b.rsatTag;
+    });
+    const auto positions = domains.positionProfiles.first(counts.positionProfiles);
+    std::sort(positions.begin(), positions.end(), [](const auto& a, const auto& b) {
+        return a.activity < b.activity || (a.activity == b.activity && a.cell < b.cell);
+    });
     const auto named = domains.named.first(counts.named);
     const auto items = domains.items.first(counts.items);
     const auto collectibles = domains.collectibles.first(counts.collectibles);
@@ -197,7 +207,9 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
 
 /** Checks the structure rules, the sort order, and every cross-domain item reference. */
 bool valid_domains(Domains domains) noexcept {
-    if (domains.constants.extracted != 1U || domains.named.empty() || domains.items.empty()
+    if (!gameplay::entity_object_types::validate(domains.objectTypes)
+        || !gameplay::entity_position_profiles::validate(domains.positionProfiles)
+        || domains.constants.extracted != 1U || domains.named.empty() || domains.items.empty()
         || domains.collectibles.empty() || domains.materialRequirementSets.empty()
         || domains.socketPlugRules.empty() || domains.socketPlugPools.empty()
         || domains.inventoryBuckets.empty() || domains.socketEntryLists.empty()

+ 60 - 0
Sunrise/src/state/build_data/cache/records/cache_entity_metadata_records.cpp

@@ -0,0 +1,60 @@
+#include <algorithm>
+
+#include "codec.h"
+namespace sunrise::state::build_data::cache::records {
+/** The shared cache stores each profile in one zero-padded canonical record. */
+bool encode(const gameplay::entity_position_profiles::Row& value,
+            PositionProfileRecord& record) noexcept {
+    record = {};
+    if (!gameplay::entity_position_profiles::validate(std::span(&value, 1))) return false;
+    std::copy(value.activity.begin(), value.activity.end(), record.activity.begin());
+    record.nameLength = static_cast<std::uint8_t>(value.activity.size());
+    record.cell = value.cell;
+    record.bubble = value.bubble;
+    record.axisBits = value.axisBits;
+    return true;
+}
+/** Malformed padding and invalid native widths never reach the published catalogue. */
+bool decode(const PositionProfileRecord& record,
+            gameplay::entity_position_profiles::Row& value) noexcept {
+    value = {};
+    if (record.reserved || record.nameLength == 0 || record.nameLength >= record.activity.size())
+        return false;
+    const auto end = record.activity.begin() + record.nameLength;
+    if (std::find(record.activity.begin(), end, '\0') != end
+        || !std::all_of(end, record.activity.end(), [](char byte) { return byte == 0; }))
+        return false;
+    try {
+        value.activity.assign(record.activity.data(), record.nameLength);
+        value.cell = record.cell;
+        value.bubble = record.bubble;
+        value.axisBits = record.axisBits;
+        return gameplay::entity_position_profiles::validate(std::span(&value, 1));
+    } catch (...) {
+        value = {};
+        return false;
+    }
+}
+/** Only validated package-class rows enter the shared payload. */
+bool encode(const gameplay::entity_object_types::Row& value, ObjectTypeRecord& record) noexcept {
+    record = {};
+    if (!gameplay::entity_object_types::validate(std::span(&value, 1))) return false;
+    record.rsatTag = value.rsatTag;
+    record.definitionTag = value.definitionTag;
+    record.objectType = value.objectType;
+    return true;
+}
+/** Canonical padding and native type bounds apply on every cache read. */
+bool decode(const ObjectTypeRecord& record, gameplay::entity_object_types::Row& value) noexcept {
+    value = {};
+    if (!std::all_of(record.reserved.begin(), record.reserved.end(), [](auto byte) {
+            return byte == std::byte{};
+        }))
+        return false;
+    const gameplay::entity_object_types::Row row{
+        record.rsatTag, record.definitionTag, record.objectType};
+    if (!gameplay::entity_object_types::validate(std::span(&row, 1))) return false;
+    value = row;
+    return true;
+}
+} // namespace sunrise::state::build_data::cache::records

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

@@ -4,6 +4,13 @@
 #include "format.h"
 
 namespace sunrise::state::build_data::cache::records {
+[[nodiscard]] bool encode(const gameplay::entity_object_types::Row&, ObjectTypeRecord&) noexcept;
+[[nodiscard]] bool decode(const ObjectTypeRecord&, gameplay::entity_object_types::Row&) noexcept;
+
+[[nodiscard]] bool encode(const gameplay::entity_position_profiles::Row&,
+                          PositionProfileRecord&) noexcept;
+[[nodiscard]] bool decode(const PositionProfileRecord&,
+                          gameplay::entity_position_profiles::Row&) noexcept;
 
 /**
  * @param value Runtime row to pack.

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

@@ -4,6 +4,8 @@
 #include <span>
 
 #include "../../../content/content_catalog.h"
+#include "../../../gameplay/external/entity_object_types.h"
+#include "../../../gameplay/external/entity_position_profiles.h"
 #include "../../abilities/definition.h"
 #include "../../collectibles/collectible_catalog.h"
 #include "../../hash_names/definition.h"
@@ -46,6 +48,8 @@ struct DomainCounts {
     std::size_t vendorDefinitions{};
     std::size_t vendorSaleRows{};
     std::size_t vendorInstalledRows{};
+    std::size_t positionProfiles{};
+    std::size_t objectTypes{};
 };
 
 /** Fixed caller storage used while decoding the cache domains. */
@@ -75,6 +79,9 @@ struct MutableDomains {
     std::span<vendors::Definition> vendorDefinitions;
     std::span<vendors::SaleRow> vendorSaleRows;
     std::span<vendors::InstalledRow> vendorInstalledRows;
+    std::span<gameplay::entity_position_profiles::Row> positionProfiles;
+    gameplay::entity_position_profiles::Fingerprint* positionFingerprint{};
+    std::span<gameplay::entity_object_types::Row> objectTypes;
 };
 
 /** Read-only complete views used for the checks and for cache encoding. */
@@ -103,6 +110,9 @@ struct Domains {
     std::span<const vendors::Definition> vendorDefinitions;
     std::span<const vendors::SaleRow> vendorSaleRows;
     std::span<const vendors::InstalledRow> vendorInstalledRows;
+    std::span<const gameplay::entity_position_profiles::Row> positionProfiles;
+    gameplay::entity_position_profiles::Fingerprint positionFingerprint{};
+    std::span<const gameplay::entity_object_types::Row> objectTypes;
 };
 
 } // namespace sunrise::state::build_data::cache::records

+ 25 - 3
Sunrise/src/state/build_data/cache/records/format.h

@@ -6,6 +6,7 @@
 #include <span>
 
 #include "../../../content/content_catalog.h"
+#include "../../../gameplay/external/entity_position_profiles.h"
 #include "../../abilities/definition.h"
 #include "../../collectibles/collectible_catalog.h"
 #include "../../constants/definition.h"
@@ -29,7 +30,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Bump it when a stored shape changes, and when the extraction filling it changes what it writes.
  * A cached row survives a code change, so a corrected walk keeps publishing the old rows.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 45;
+inline constexpr std::uint32_t kCacheFormatVersion = 47;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -88,10 +89,30 @@ struct Header {
     std::uint32_t vendorDefinitionCount{};
     std::uint32_t vendorSaleRowCount{};
     std::uint32_t vendorInstalledRowCount{};
+    std::uint32_t positionProfileCount{};
+    std::uint32_t objectTypeCount{};
+    gameplay::entity_position_profiles::Fingerprint positionFingerprint{};
     InvestmentConstants constants{};
     std::uint64_t payloadChecksum{};
 };
 
+/** Reciprocal package object-class classification stored in the shared cache. */
+struct ObjectTypeRecord {
+    std::uint32_t rsatTag{}, definitionTag{};
+    std::uint8_t objectType{};
+    std::array<std::byte, 3> reserved{};
+};
+
+/** Disk form of one exact package-derived activity cell and its position grammar. */
+struct PositionProfileRecord {
+    std::array<char, gameplay::entity_position_profiles::kNameCapacity> activity{};
+    std::uint16_t cell{};
+    std::array<std::uint8_t, 3> axisBits{};
+    std::uint8_t bubble{};
+    std::uint8_t nameLength{};
+    std::uint8_t reserved{};
+};
+
 /** Disk form of one named mapping. Its length field has a fixed width. */
 struct NamedRecord {
     std::array<char, content::kDefinitionNameCapacity> name{};
@@ -422,8 +443,9 @@ static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t));
 static_assert(sizeof(InvestmentConstants)
               == constants::kCharacterStatRowCount + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(Header)
-              == kCacheMagic.size() + 26 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
-                     + sizeof(InvestmentConstants));
+              == kCacheMagic.size() + 28 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
+                     + sizeof(InvestmentConstants)
+                     + sizeof(gameplay::entity_position_profiles::Fingerprint));
 static_assert(sizeof(SpawnPointRecord)
               == spawn_sets::kPositionComponents * sizeof(float) + sizeof(std::uint32_t)
                      + sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));

+ 3 - 1
Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp

@@ -34,7 +34,9 @@ namespace {
            && domains.vendorIndex.size() <= kMaximumCount
            && domains.vendorDefinitions.size() <= kMaximumCount
            && domains.vendorSaleRows.size() <= kMaximumCount
-           && domains.vendorInstalledRows.size() <= kMaximumCount;
+           && domains.vendorInstalledRows.size() <= kMaximumCount
+           && domains.positionProfiles.size() <= kMaximumCount
+           && domains.objectTypes.size() <= kMaximumCount;
 }
 
 /** @return True when the requested final-name rule is one of the declared values. */

+ 9 - 3
Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp

@@ -62,7 +62,9 @@ template <typename Record, typename Value>
 
 /** Computes one checksum across every array in its fixed file order. */
 bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcept {
-    checksum = records::checksum_value(records::kChecksumOffsetBasis, domains.constants);
+    checksum = records::checksum_value(
+        records::checksum_value(records::kChecksumOffsetBasis, domains.constants),
+        domains.positionFingerprint);
     return checksum_domain<records::NamedRecord>(domains.named, checksum)
            && checksum_domain<records::ItemRecord>(domains.items, checksum)
            && checksum_domain<records::CollectibleRecord>(domains.collectibles, checksum)
@@ -87,7 +89,9 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep
            && checksum_domain<records::VendorDefinitionRecord>(domains.vendorDefinitions, checksum)
            && checksum_domain<records::VendorSaleRowRecord>(domains.vendorSaleRows, checksum)
            && checksum_domain<records::VendorInstalledRowRecord>(domains.vendorInstalledRows,
-                                                                 checksum);
+                                                                 checksum)
+           && checksum_domain<records::PositionProfileRecord>(domains.positionProfiles, checksum)
+           && checksum_domain<records::ObjectTypeRecord>(domains.objectTypes, checksum);
 }
 
 /** Writes every array in the same order used by the payload checksum. */
@@ -115,7 +119,9 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept {
            && write_domain<records::VendorIndexRecord>(file, domains.vendorIndex)
            && write_domain<records::VendorDefinitionRecord>(file, domains.vendorDefinitions)
            && write_domain<records::VendorSaleRowRecord>(file, domains.vendorSaleRows)
-           && write_domain<records::VendorInstalledRowRecord>(file, domains.vendorInstalledRows);
+           && write_domain<records::VendorInstalledRowRecord>(file, domains.vendorInstalledRows)
+           && write_domain<records::PositionProfileRecord>(file, domains.positionProfiles)
+           && write_domain<records::ObjectTypeRecord>(file, domains.objectTypes);
 }
 
 } // namespace sunrise::state::build_data::cache::writer

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

@@ -129,6 +129,9 @@ enum class WriteStatus {
         static_cast<std::uint32_t>(domains.vendorDefinitions.size()),
         static_cast<std::uint32_t>(domains.vendorSaleRows.size()),
         static_cast<std::uint32_t>(domains.vendorInstalledRows.size()),
+        static_cast<std::uint32_t>(domains.positionProfiles.size()),
+        static_cast<std::uint32_t>(domains.objectTypes.size()),
+        domains.positionFingerprint,
         domains.constants,
         checksum,
     };

+ 2 - 0
Sunrise/src/state/build_data/runtime.h

@@ -24,6 +24,8 @@
 #include "vendors/definition.h"
 
 namespace sunrise::state::build_data {
+/** Marks regenerated package data for the next complete shared-cache publication. */
+void invalidate_cache() noexcept;
 
 /**
  * Loads the one build-data cache next to the module, when there is one.

+ 32 - 1
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -63,6 +63,7 @@ to_record(const constants::InvestmentConstants& value) noexcept {
     counts = {};
     const cache::records::MutableDomains scratch = scratch_domains(state);
     *scratch.constants = to_record(constants::snapshot());
+    gameplay::entity_object_types::Fingerprint objectFingerprint{};
     return content::snapshot(scratch.named, counts.named)
            && items::snapshot(scratch.items, counts.items)
            && collectibles::snapshot(scratch.collectibles, counts.collectibles)
@@ -92,6 +93,11 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && vendors::snapshot_sale_rows(scratch.vendorSaleRows, counts.vendorSaleRows)
            && vendors::snapshot_installed_rows(scratch.vendorInstalledRows,
                                                counts.vendorInstalledRows)
+           && gameplay::entity_position_profiles::snapshot(
+               scratch.positionProfiles, counts.positionProfiles, *scratch.positionFingerprint)
+           && gameplay::entity_object_types::snapshot(
+               scratch.objectTypes, counts.objectTypes, objectFingerprint)
+           && objectFingerprint == *scratch.positionFingerprint
            && cache::records::canonicalize(scratch, counts);
 }
 
@@ -110,7 +116,8 @@ bool all_domains_ready() noexcept {
            && material_requirement_sets_ready() && inventory_bucket_descriptors_ready()
            && socket_entry_lists_ready() && ability_buckets_ready()
            && progression_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready()
-           && hash_names_ready() && constants::find(published);
+           && hash_names_ready() && gameplay::entity_position_profiles::available()
+           && gameplay::entity_object_types::available() && constants::find(published);
 }
 
 /** Gives mutable views over every fixed snapshot buffer. */
@@ -177,6 +184,9 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto vendorInstalledRows =
         ensure_scratch<vendors::InstalledRow, vendors::kInstalledRowCapacity>(
             state.vendorInstalledRowScratch);
+    const auto positionProfiles = ensure_scratch<gameplay::entity_position_profiles::Row,
+                                                 gameplay::entity_position_profiles::kMaximumRows>(
+        state.positionProfileScratch);
     return {
         &state.constantsScratch,
         named,
@@ -202,6 +212,10 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         vendorDefinitions,
         vendorSaleRows,
         vendorInstalledRows,
+        positionProfiles,
+        &state.positionFingerprint,
+        ensure_scratch<gameplay::entity_object_types::Row,
+                       gameplay::entity_object_types::kMaximumRows>(state.objectTypeScratch),
     };
 }
 
@@ -239,6 +253,9 @@ void release_scratch_locked(Context& state) noexcept {
     release_bank(state.vendorDefinitionScratch);
     release_bank(state.vendorSaleRowScratch);
     release_bank(state.vendorInstalledRowScratch);
+    release_bank(state.positionProfileScratch);
+    release_bank(state.objectTypeScratch);
+    state.positionFingerprint = {};
     state.constantsScratch = {};
 }
 
@@ -300,6 +317,11 @@ cache::records::Domains occupied_domains(Context& state,
         std::span<const vendors::SaleRow>{state.vendorSaleRowScratch.data(), counts.vendorSaleRows},
         std::span<const vendors::InstalledRow>{state.vendorInstalledRowScratch.data(),
                                                counts.vendorInstalledRows},
+        std::span<const gameplay::entity_position_profiles::Row>{
+            state.positionProfileScratch.data(), counts.positionProfiles},
+        state.positionFingerprint,
+        std::span<const gameplay::entity_object_types::Row>{state.objectTypeScratch.data(),
+                                                            counts.objectTypes},
     };
 }
 
@@ -337,6 +359,15 @@ bool persist_if_complete_locked(Context& state) noexcept {
 
 namespace sunrise::state::build_data {
 
+/** Newly extracted package data replaces the shared cache only after all domains are ready. */
+void invalidate_cache() noexcept {
+    auto& state = runtime::persistence::context();
+    AcquireSRWLockExclusive(&state.lock);
+    state.persisted = false;
+    state.replaceStaleCache = true;
+    ReleaseSRWLockExclusive(&state.lock);
+}
+
 /** @return True only when every domain is ready and any needed cache write works. */
 bool persist() noexcept {
     runtime::persistence::Context& state = runtime::persistence::context();

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

@@ -52,6 +52,9 @@ struct Context {
     std::vector<vendors::Definition> vendorDefinitionScratch{};
     std::vector<vendors::SaleRow> vendorSaleRowScratch{};
     std::vector<vendors::InstalledRow> vendorInstalledRowScratch{};
+    std::vector<gameplay::entity_position_profiles::Row> positionProfileScratch{};
+    std::vector<gameplay::entity_object_types::Row> objectTypeScratch{};
+    gameplay::entity_position_profiles::Fingerprint positionFingerprint{};
     cache::records::InvestmentConstants constantsScratch{};
     core::path::Buffer cacheDirectory;
     core::path::Buffer cachePath;

+ 2 - 0
Sunrise/src/state/gameplay/definition.h

@@ -209,6 +209,8 @@ struct PeerLink {
     bool remoteAddressPresent{};
     /** Newest packet sequence received from this peer. */
     std::uint16_t receiveHead{};
+    /** Expanded receive sequence orders sparse entity updates across packet wraps. */
+    std::uint64_t receiveOrdinal{};
     bool ringInitialized{};
     /** Entry `i` is the packet `i + 1` before the head. The head itself carries no entry. */
     std::array<bool, kAckHistory> received{};

+ 542 - 0
Sunrise/src/state/gameplay/external/entity_identity.cpp

@@ -0,0 +1,542 @@
+#include "entity_identity.h"
+
+#include <algorithm>
+#include <new>
+
+namespace sunrise::state::gameplay::entity_identity {
+namespace {
+
+/** Identity serials use eight bits and token incarnations use four. */
+constexpr std::uint16_t kAllocationModulus = 256;
+constexpr std::uint8_t kIncarnationLimit = 16, kTypeLimit = 4;
+/** Wire flag four carries the terminal record's trailing state bit. */
+constexpr std::uint16_t kTerminalFlag = 4;
+
+bool valid_source(const Source& source) noexcept {
+    return source.activitySessionId != 0 && source.activityRevision != 0
+           && source.activityClientGeneration != 0 && source.groupSessionId != 0
+           && source.peerGeneration != 0 && source.channelGeneration != 0
+           && source.viewGeneration != 0;
+}
+
+bool valid_token(Token token) noexcept {
+    return token.slot < kSlotCapacity && token.incarnation < kIncarnationLimit;
+}
+
+bool newer(std::uint16_t next, std::uint16_t previous, std::uint16_t modulus) noexcept {
+    const auto distance = static_cast<std::uint16_t>((next + modulus - previous) % modulus);
+    return distance != 0 && distance < modulus / 2;
+}
+
+bool valid_actor_source(const Observation& value) noexcept {
+    const auto& source = value.actorSource;
+    if (!source.known) return !source.present;
+    if (value.type != 0 || value.action == Action::remove) return false;
+    return !source.present
+           || (source.key != 0 && source.key != 0xFFFFFFFFU && source.type <= 126
+               && source.index <= 32767);
+}
+
+bool valid_observation(const Observation& value) noexcept {
+    return valid_token(value.token) && value.type < kTypeLimit && valid_actor_source(value)
+           && (!value.hasPacketSequence || value.packetSequence < kPacketModulus)
+           && (!value.anchorChanged || !value.anchorPresent || valid_token(value.anchor))
+           && (value.action == Action::create || value.action == Action::update
+               || value.action == Action::remove || value.action == Action::createAndRemove);
+}
+
+/** Compares retained state without transport observation timestamps. */
+bool same_state(const Identity& left, const Identity& right) noexcept {
+    return left.token == right.token && left.anchor == right.anchor
+           && left.metadata == right.metadata && left.actorSource == right.actorSource
+           && left.cell == right.cell && left.recordFlags == right.recordFlags
+           && left.allocationSequence == right.allocationSequence
+           && left.serialDomain == right.serialDomain
+           && left.allocationDomain == right.allocationDomain
+           && left.allocationEpoch == right.allocationEpoch
+           && left.hasAllocationEpoch == right.hasAllocationEpoch && left.type == right.type
+           && left.present == right.present && left.anchorKnown == right.anchorKnown
+           && left.anchorPresent == right.anchorPresent
+           && left.trailingState == right.trailingState;
+}
+
+/**
+ * Stages one observation without changing the committed slot.
+ * @param current Last identity or tombstone.
+ * @param value Accepted wire observation.
+ * @param next Receives the candidate, including conflict state.
+ * @return The mutation result; stale and missing leave the slot unchanged.
+ */
+Result stage(const Identity& current,
+             const Observation& value,
+             Identity& next,
+             std::uint64_t allocationDomain) noexcept {
+    next = current;
+    const bool ordinalOrder = current.known && value.hasPacketOrdinal && current.hasPacketOrdinal;
+    if (ordinalOrder
+        && (value.packetOrdinal < current.packetOrdinal
+            || (value.packetOrdinal == current.packetOrdinal
+                && value.packetRecordIndex < current.packetRecordIndex)))
+        return Result::stale;
+    if (!ordinalOrder && current.known && value.hasPacketSequence && current.hasPacketSequence
+        && value.packetSequence != current.packetSequence
+        && !newer(value.packetSequence, current.packetSequence, kPacketModulus)) {
+        return Result::stale;
+    }
+    bool replacement = !current.known;
+    if (value.action == Action::create || value.action == Action::createAndRemove) {
+        const bool resetSerial =
+            value.hasAllocationEpoch
+            && (!current.hasAllocationEpoch || current.serialDomain != allocationDomain);
+        if (value.hasAllocationEpoch && value.allocationSequence == 0) return Result::stale;
+        if (current.known && !resetSerial) {
+            const bool duplicate = current.token == value.token
+                                   && current.allocationSequence == value.allocationSequence;
+            if (duplicate) {
+                if (!current.present) return Result::stale;
+                if (current.type != value.type || current.metadata != value.metadata) {
+                    next.conflicted = true;
+                    return Result::conflict;
+                }
+                if (current.conflicted) return Result::conflict;
+            } else {
+                if (!newer(
+                        value.allocationSequence, current.allocationSequence, kAllocationModulus))
+                    return Result::stale;
+                replacement = true;
+            }
+        }
+        const bool sameLiveType = current.known && current.present && !current.conflicted
+                                  && current.token == value.token && current.type == value.type
+                                  && current.metadata == value.metadata;
+        const bool epochDuplicate =
+            resetSerial && sameLiveType && current.allocationSequence == value.allocationSequence;
+        const bool preserveLive = resetSerial && sameLiveType;
+        replacement = replacement || (resetSerial && !epochDuplicate);
+        if (replacement) {
+            next = preserveLive ? current : Identity{};
+            next.known = true;
+            next.present = true;
+            next.token = value.token;
+            next.type = value.type;
+            next.allocationSequence = value.allocationSequence;
+            next.allocationEpoch = value.allocationEpoch;
+            next.hasAllocationEpoch = value.hasAllocationEpoch;
+            next.allocationDomain = value.hasAllocationEpoch ? allocationDomain : 0;
+            next.metadata = value.metadata;
+            next.anchorKnown = true;
+        }
+        next.serialDomain = value.hasAllocationEpoch ? allocationDomain : 0;
+    } else {
+        if (!current.known) return Result::missing;
+        if (current.token != value.token) return Result::stale;
+        if (current.conflicted) return Result::conflict;
+        if (!current.present && value.action != Action::remove) return Result::missing;
+        if (value.action == Action::remove) next.present = false;
+    }
+    if (value.actorSource.known) {
+        next.actorSource = value.actorSource.present ? value.actorSource : ActorSourceReference{};
+        next.actorSource.known = true;
+    }
+    if (value.action == Action::createAndRemove) next.present = false;
+    next.cell = value.cell;
+    next.recordFlags = value.recordFlags;
+    if ((value.recordFlags & kTerminalFlag) != 0) next.trailingState = value.trailingState;
+    if (value.anchorChanged) {
+        next.anchorKnown = true;
+        next.anchorPresent = value.anchorPresent;
+        next.anchor = value.anchorPresent ? value.anchor : Token{};
+    }
+    const bool samePacket =
+        ordinalOrder ? value.packetOrdinal == current.packetOrdinal
+                           && value.packetRecordIndex == current.packetRecordIndex
+                     : current.known && value.hasPacketSequence && current.hasPacketSequence
+                           && value.packetSequence == current.packetSequence;
+    if (samePacket) {
+        if (!same_state(current, next)) {
+            next = current;
+            next.conflicted = true;
+            return Result::conflict;
+        }
+        if (value.hasPacketOrdinal && !current.hasPacketOrdinal) {
+            next.hasPacketOrdinal = true;
+            next.packetOrdinal = value.packetOrdinal;
+            next.packetRecordIndex = value.packetRecordIndex;
+        }
+        return Result::unchanged;
+    }
+    next.tick = value.tick;
+    if (value.hasPacketOrdinal) {
+        next.hasPacketOrdinal = true;
+        next.packetOrdinal = value.packetOrdinal;
+        next.packetRecordIndex = value.packetRecordIndex;
+    } else if (replacement && current.known) {
+        next.hasPacketOrdinal = current.hasPacketOrdinal;
+        next.packetOrdinal = current.packetOrdinal;
+        next.packetRecordIndex = current.packetRecordIndex;
+    }
+    if (value.hasPacketSequence) {
+        next.hasPacketSequence = true;
+        next.packetSequence = value.packetSequence;
+    } else if (replacement && current.known) {
+        next.hasPacketSequence = current.hasPacketSequence;
+        next.packetSequence = current.packetSequence;
+    }
+    if (value.action == Action::createAndRemove) return Result::removed;
+    if (replacement) return Result::created;
+    if (value.action == Action::remove)
+        return current.present ? Result::removed : Result::unchanged;
+    if (same_state(current, next) && next.hasPacketSequence == current.hasPacketSequence
+        && next.packetSequence == current.packetSequence
+        && next.hasPacketOrdinal == current.hasPacketOrdinal
+        && next.packetOrdinal == current.packetOrdinal
+        && next.packetRecordIndex == current.packetRecordIndex)
+        return Result::unchanged;
+    return Result::updated;
+}
+} // namespace
+
+/**
+ * Delivered retirements leave tombstones without erasing a newer allocation.
+ * @param source
+ * Exact admitted source generation.
+ * @param lifetimes Allocations named by the published
+ * retirement.
+ * @return Number of live identities retired; invalid inputs change nothing.
+ */
+std::size_t Store::retire(const Source& source,
+                          std::span<const RetiredLifetime> lifetimes) noexcept {
+    if (!valid_source(source) || lifetimes.size() > kSlotCapacity
+        || std::any_of(lifetimes.begin(), lifetimes.end(), [](const auto& lifetime) {
+               return !valid_token(lifetime.token);
+           }))
+        return 0;
+    auto partition = std::find_if(partitions_.begin(), partitions_.end(), [&](const auto& value) {
+        return value.occupied && value.source == source;
+    });
+    if (partition == partitions_.end()) return 0;
+    std::size_t retired = 0;
+    for (const auto& lifetime : lifetimes) {
+        auto& current = partition->slots[lifetime.token.slot];
+        if (!current.known || !current.present || current.token != lifetime.token
+            || current.allocationSequence != lifetime.allocationSequence
+            || current.allocationEpoch != lifetime.allocationEpoch
+            || current.allocationDomain != lifetime.allocationDomain)
+            continue;
+        current.present = false;
+        current.revision = ++revision_;
+        ++retired;
+    }
+    return retired;
+}
+
+/**
+ * Commits a bounded identity mutation after validation and allocation succeed.
+ * @param source Exact admitted source generation.
+ * @param observation Accepted wire record.
+ * @return The committed result, or the reason the record was refused.
+ */
+Result Store::observe(const Source& source, const Observation& observation) noexcept {
+    if (!valid_source(source) || !valid_observation(observation)
+        || (observation.hasAllocationEpoch && observation.allocationDomain == 0))
+        return Result::invalid;
+    auto partition = std::find_if(partitions_.begin(), partitions_.end(), [&](const auto& value) {
+        return value.occupied && value.source == source;
+    });
+    if (partition != partitions_.end() && partition->hasAllocationEpoch
+        && (!observation.hasAllocationEpoch
+            || observation.allocationEpoch != partition->allocationEpoch
+            || observation.allocationDomain != partition->allocationDomain))
+        return Result::stale;
+    const Identity empty{};
+    const Identity& current =
+        partition == partitions_.end() ? empty : partition->slots[observation.token.slot];
+    if (observation.actorSource.known && observation.action != Action::create
+        && observation.action != Action::createAndRemove && current.known && current.type != 0)
+        return Result::invalid;
+    Identity next{};
+    const Result result = stage(current, observation, next, observation.allocationDomain);
+    if (result == Result::missing || result == Result::stale
+        || (result == Result::unchanged && next.hasPacketSequence == current.hasPacketSequence
+            && next.packetSequence == current.packetSequence
+            && next.hasPacketOrdinal == current.hasPacketOrdinal
+            && next.packetOrdinal == current.packetOrdinal
+            && next.packetRecordIndex == current.packetRecordIndex)
+        || (result == Result::conflict && current.conflicted))
+        return result;
+    if (partition == partitions_.end()) {
+        partition = std::find_if(partitions_.begin(), partitions_.end(), [](const auto& value) {
+            return !value.occupied;
+        });
+        if (partition == partitions_.end()) return Result::capacity;
+        try {
+            std::vector<Identity> slots(kSlotCapacity);
+            partition->slots = std::move(slots);
+        } catch (const std::bad_alloc&) {
+            return Result::capacity;
+        }
+        partition->source = source;
+        partition->occupied = true;
+    }
+    if (observation.hasAllocationEpoch) {
+        partition->allocationDomain = observation.allocationDomain;
+        partition->hasAllocationEpoch = true;
+        partition->allocationEpoch = observation.allocationEpoch;
+    }
+    next.revision = ++revision_;
+    partition->slots[observation.token.slot] = next;
+    return result;
+}
+
+/** A packet stages all records before terminal roots retire their known descendants. */
+Result Store::observe_batch(const Source& source,
+                            std::span<const Observation> observations,
+                            std::span<Result> results) noexcept {
+    if (!valid_source(source) || observations.empty()
+        || observations.size() > kObservationBatchCapacity || results.size() < observations.size())
+        return Result::invalid;
+    auto partition = std::find_if(partitions_.begin(), partitions_.end(), [&](const auto& value) {
+        return value.occupied && value.source == source;
+    });
+    const auto& packetEpoch = observations.front();
+    if (partition != partitions_.end() && partition->hasAllocationEpoch
+        && (!packetEpoch.hasAllocationEpoch
+            || packetEpoch.allocationEpoch != partition->allocationEpoch
+            || packetEpoch.allocationDomain != partition->allocationDomain))
+        return Result::stale;
+    if (packetEpoch.hasAllocationEpoch && packetEpoch.allocationDomain == 0) return Result::invalid;
+    struct Change {
+        std::uint16_t slot{};
+        Identity value{};
+    };
+    std::array<Change, kObservationBatchCapacity> changes{};
+    std::array<Result, kObservationBatchCapacity> stagedResults{};
+    std::size_t changeCount = 0;
+    const Identity empty{};
+    const auto current = [&](std::uint16_t slot) -> const Identity& {
+        for (std::size_t index = 0; index < changeCount; ++index)
+            if (changes[index].slot == slot) return changes[index].value;
+        return partition == partitions_.end() ? empty : partition->slots[slot];
+    };
+    const auto assign = [&](std::uint16_t slot, const Identity& value) {
+        std::size_t index = 0;
+        for (; index < changeCount && changes[index].slot != slot; ++index) {}
+        if (index == changeCount) {
+            if (changeCount == changes.size()) return false;
+            changes[changeCount++].slot = slot;
+        }
+        changes[index].value = value;
+        return true;
+    };
+    for (std::size_t index = 0; index < observations.size(); ++index) {
+        const auto& observation = observations[index];
+        if (!valid_observation(observation)) return Result::invalid;
+        const auto& packet = observations.front();
+        if (observation.hasAllocationEpoch != packet.hasAllocationEpoch
+            || observation.allocationEpoch != packet.allocationEpoch
+            || observation.allocationDomain != packet.allocationDomain
+            || observation.hasPacketSequence != packet.hasPacketSequence
+            || observation.packetSequence != packet.packetSequence
+            || observation.hasPacketOrdinal != packet.hasPacketOrdinal
+            || observation.packetOrdinal != packet.packetOrdinal
+            || (index != 0
+                && observation.packetRecordIndex <= observations[index - 1].packetRecordIndex))
+            return Result::invalid;
+        const auto& before = current(observation.token.slot);
+        Observation value = observation;
+        if (value.action == Action::createAndRemove) value.action = Action::create;
+        if (value.action == Action::remove && before.present) value.action = Action::update;
+        if (value.actorSource.known && value.action != Action::create && before.known
+            && before.type != 0)
+            return Result::invalid;
+        Identity next{};
+        const auto result = stage(before, value, next, value.allocationDomain);
+        if (result == Result::missing || result == Result::stale || result == Result::conflict
+            || result == Result::invalid || result == Result::capacity)
+            return result;
+        if (!assign(observation.token.slot, next)) return Result::capacity;
+        stagedResults[index] = result;
+    }
+    std::array<Token, kObservationBatchCapacity> terminals{};
+    std::size_t terminalCount = 0;
+    const auto add_terminal = [&](Token token) {
+        for (std::size_t index = 0; index < terminalCount; ++index)
+            if (terminals[index] == token) return true;
+        if (terminalCount == terminals.size()) return false;
+        terminals[terminalCount++] = token;
+        return true;
+    };
+    for (std::size_t index = 0; index < observations.size(); ++index) {
+        const auto& observation = observations[index];
+        if (observation.action == Action::remove || observation.action == Action::createAndRemove) {
+            if (!add_terminal(observation.token)) return Result::capacity;
+            stagedResults[index] = Result::removed;
+        }
+    }
+    const auto& order = observations.back();
+    for (std::size_t index = 0; index < terminalCount; ++index) {
+        const auto token = terminals[index];
+        const auto& before = current(token.slot);
+        if (!before.known || !before.present || before.token != token) continue;
+        if (before.hasPacketOrdinal && order.hasPacketOrdinal
+            && before.packetOrdinal > order.packetOrdinal)
+            return Result::stale;
+        for (std::size_t slot = 0; slot < kSlotCapacity; ++slot) {
+            const auto& child = current(static_cast<std::uint16_t>(slot));
+            if (child.known && child.present && child.anchorPresent && child.anchor == token
+                && !add_terminal(child.token))
+                return Result::capacity;
+        }
+        Identity retired = before;
+        retired.present = false;
+        retired.tick = order.tick;
+        if (order.hasPacketOrdinal) {
+            retired.hasPacketOrdinal = true;
+            retired.packetOrdinal = order.packetOrdinal;
+            retired.packetRecordIndex = order.packetRecordIndex;
+        }
+        if (order.hasPacketSequence) {
+            retired.hasPacketSequence = true;
+            retired.packetSequence = order.packetSequence;
+        }
+        if (!assign(token.slot, retired)) return Result::capacity;
+    }
+    if (partition == partitions_.end()) {
+        partition = std::find_if(partitions_.begin(), partitions_.end(), [](const auto& value) {
+            return !value.occupied;
+        });
+        if (partition == partitions_.end()) return Result::capacity;
+        try {
+            partition->slots.resize(kSlotCapacity);
+        } catch (const std::bad_alloc&) {
+            return Result::capacity;
+        }
+        partition->source = source;
+        partition->occupied = true;
+    }
+    if (packetEpoch.hasAllocationEpoch) {
+        partition->allocationDomain = packetEpoch.allocationDomain;
+        partition->hasAllocationEpoch = true;
+        partition->allocationEpoch = packetEpoch.allocationEpoch;
+    }
+    for (std::size_t index = 0; index < changeCount; ++index) {
+        changes[index].value.revision = ++revision_;
+        partition->slots[changes[index].slot] = changes[index].value;
+    }
+    for (std::size_t index = 0; index < observations.size(); ++index) {
+        const auto& value = partition->slots[observations[index].token.slot];
+        if (value.known && !value.present && value.token == observations[index].token)
+            stagedResults[index] = Result::removed;
+    }
+    std::copy_n(stagedResults.begin(), observations.size(), results.begin());
+    return Result::updated;
+}
+
+/**
+ * Returns identity evidence without granting any authority over the entity.
+ * @param source
+ * Exact admitted source generation.
+ * @param token Expected incarnation of the slot.
+ * @param output Receives the known identity or tombstone; cleared for a missing source.
+ * @return Unchanged for live identity, removed for a tombstone, or its refusal state.
+ */
+Result Store::lookup(const Source& source, Token token, Identity& output) const noexcept {
+    output = {};
+    if (!valid_source(source) || !valid_token(token)) return Result::invalid;
+    const auto partition =
+        std::find_if(partitions_.begin(), partitions_.end(), [&](const auto& row) {
+            return row.occupied && row.source == source;
+        });
+    if (partition == partitions_.end()) return Result::missing;
+    output = partition->slots[token.slot];
+    if (!output.known) return Result::missing;
+    if (output.token != token) return Result::stale;
+    if (output.conflicted) return Result::conflict;
+    return output.present ? Result::unchanged : Result::removed;
+}
+
+/**
+ * Copies every slot from one source, including tombstones and conflicts.
+ * @param source Exact admitted source generation.
+ * @param output Receives all slots, or is cleared on failure.
+ * @return Unchanged for a copied source, missing, invalid, or capacity on failure.
+ */
+Result Store::snapshot_source(const Source& source, std::vector<Identity>& output) const noexcept {
+    output.clear();
+    if (!valid_source(source)) return Result::invalid;
+    const auto partition =
+        std::find_if(partitions_.begin(), partitions_.end(), [&](const auto& row) {
+            return row.occupied && row.source == source;
+        });
+    if (partition == partitions_.end()) return Result::missing;
+    try {
+        output = partition->slots;
+    } catch (const std::bad_alloc&) {
+        output.clear();
+        return Result::capacity;
+    }
+    return Result::unchanged;
+}
+
+/**
+ * Counts all matching sources even when the caller's output is smaller.
+ * @param activitySessionId Activity owning the sources.
+ * @param activityRevision Exact activity generation.
+ * @param output Receives the bounded prefix of matching sources.
+ * @return Total matching source count.
+ */
+std::size_t Store::sources(std::uint64_t activitySessionId,
+                           std::uint64_t activityRevision,
+                           std::span<Source> output) const noexcept {
+    std::size_t count = 0;
+    for (const Partition& partition : partitions_) {
+        if (partition.occupied && partition.source.activitySessionId == activitySessionId
+            && partition.source.activityRevision == activityRevision) {
+            if (count < output.size()) output[count] = partition.source;
+            ++count;
+        }
+    }
+    return count;
+}
+
+/** Changes only the serial domain of the exact admitted source. */
+bool Store::advance_epoch(const Source& source,
+                          std::uint8_t expected,
+                          std::uint8_t next,
+                          std::uint64_t nextDomain) noexcept {
+    if (!valid_source(source) || nextDomain == 0
+        || next != static_cast<std::uint8_t>(expected + 1U))
+        return false;
+    for (auto& partition : partitions_)
+        if (partition.occupied && partition.source == source) {
+            if (partition.hasAllocationEpoch
+                && (partition.allocationEpoch != expected
+                    || partition.allocationDomain != nextDomain - 1))
+                return false;
+            partition.allocationDomain = nextDomain;
+            partition.allocationEpoch = next;
+            partition.hasAllocationEpoch = true;
+            return true;
+        }
+    return false;
+}
+
+void Store::reset_group(std::uint64_t groupSessionId) noexcept {
+    for (Partition& partition : partitions_) {
+        if (partition.occupied && partition.source.groupSessionId == groupSessionId) partition = {};
+    }
+}
+
+void Store::reset_source(const Source& source) noexcept {
+    for (Partition& partition : partitions_) {
+        if (partition.occupied && partition.source == source) partition = {};
+    }
+}
+
+void Store::reset() noexcept {
+    for (Partition& partition : partitions_)
+        partition = {};
+    revision_ = 0;
+}
+
+} // namespace sunrise::state::gameplay::entity_identity

+ 151 - 0
Sunrise/src/state/gameplay/external/entity_identity.h

@@ -0,0 +1,151 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+namespace sunrise::state::gameplay::entity_identity {
+
+/** Native entity and packet sequence domains. */
+inline constexpr std::size_t kSlotCapacity = 8192;
+inline constexpr std::size_t kSourceCapacity = 16;
+/** One atomic packet, including terminal descendants, has bounded server storage. */
+inline constexpr std::size_t kObservationBatchCapacity = 16;
+inline constexpr std::uint16_t kPacketModulus = 1024;
+inline constexpr std::uint16_t kNoCell = 0xFFFF;
+
+/** A source is one admitted activity, peer channel, and replication view. */
+struct Source final {
+    std::uint64_t activitySessionId{}, activityRevision{}, groupSessionId{};
+    std::uint64_t activityClientGeneration{};
+    std::uint64_t peerGeneration{}, channelGeneration{}, viewGeneration{};
+    std::uint32_t address{}, localConnectionSequence{}, remoteConnectionSequence{};
+    std::uint16_t port{}, localPort{};
+    bool operator==(const Source&) const = default;
+};
+
+/** Slot incarnations prevent a reused network index from aliasing an earlier entity. */
+struct Token final {
+    std::uint16_t slot{};
+    std::uint8_t incarnation{};
+    bool operator==(const Token&) const = default;
+};
+
+/** A host retirement names an allocation, never an index alone. */
+struct RetiredLifetime final {
+    Token token{};
+    std::uint8_t allocationSequence{}, allocationEpoch{};
+    std::uint64_t allocationDomain{};
+    bool operator==(const RetiredLifetime&) const = default;
+};
+
+/** Exact authored squad reference carried by a network-squad baseline. */
+struct SquadReference final {
+    std::uint32_t key{};
+    std::uint16_t index{};
+    std::uint8_t type{};
+    bool operator==(const SquadReference&) const = default;
+};
+
+/** A decoded actor source relation is mutable and does not classify the entity. */
+struct ActorSourceReference final {
+    std::uint32_t key{};
+    std::uint16_t index{};
+    std::uint8_t type{};
+    bool known{}, present{};
+    bool operator==(const ActorSourceReference&) const = default;
+};
+
+/** Unknown semantic fields remain absent rather than becoming player or NPC classifications. */
+struct Metadata final {
+    std::uint32_t rsatTag{};
+    SquadReference squad{};
+    std::array<std::byte, 8> playerBroadcast{};
+    std::uint8_t objectType{};
+    bool hasRsat{}, hasSquad{}, hasPlayerBroadcast{}, hasObjectType{};
+    bool operator==(const Metadata&) const = default;
+};
+
+enum class Action : std::uint8_t { create, update, remove, createAndRemove };
+
+/** One fully accepted wire record; the transport owns its source and packet sequence. */
+struct Observation final {
+    std::uint64_t allocationDomain{};
+    Token token{}, anchor{};
+    Metadata metadata{};
+    ActorSourceReference actorSource{};
+    std::uint64_t tick{}, packetOrdinal{};
+    std::uint16_t packetRecordIndex{};
+    std::uint16_t packetSequence{}, cell{kNoCell}, recordFlags{};
+    std::uint8_t allocationSequence{}, allocationEpoch{}, type{};
+    bool hasAllocationEpoch{};
+    Action action{Action::update};
+    bool hasPacketSequence{}, hasPacketOrdinal{}, anchorChanged{}, anchorPresent{}, trailingState{};
+};
+
+/** Observed identity is evidence, not authority to delete or claim an entity. */
+struct Identity final {
+    std::uint64_t allocationDomain{}, serialDomain{};
+    Token token{}, anchor{};
+    Metadata metadata{};
+    ActorSourceReference actorSource{};
+    std::uint64_t revision{}, tick{}, packetOrdinal{};
+    std::uint16_t packetRecordIndex{};
+    std::uint16_t packetSequence{}, cell{kNoCell}, recordFlags{};
+    std::uint8_t allocationSequence{}, allocationEpoch{}, type{};
+    bool hasAllocationEpoch{};
+    bool known{}, present{}, conflicted{}, hasPacketSequence{}, hasPacketOrdinal{}, anchorKnown{},
+        anchorPresent{}, trailingState{};
+};
+
+enum class Result : std::uint8_t {
+    created,
+    updated,
+    removed,
+    unchanged,
+    missing,
+    stale,
+    conflict,
+    invalid,
+    capacity
+};
+
+/** Caller synchronization covers both observation and lookup operations. */
+class Store final {
+public:
+    [[nodiscard]] std::size_t retire(const Source& source,
+                                     std::span<const RetiredLifetime> lifetimes) noexcept;
+    [[nodiscard]] Result observe(const Source& source, const Observation& observation) noexcept;
+    [[nodiscard]] Result observe_batch(const Source& source,
+                                       std::span<const Observation> observations,
+                                       std::span<Result> results) noexcept;
+    [[nodiscard]] Result lookup(const Source& source, Token token, Identity& output) const noexcept;
+    [[nodiscard]] std::size_t sources(std::uint64_t activitySessionId,
+                                      std::uint64_t activityRevision,
+                                      std::span<Source> output) const noexcept;
+    [[nodiscard]] Result snapshot_source(const Source& source,
+                                         std::vector<Identity>& output) const noexcept;
+    /** A committed purge advances allocation serials without deleting retained identities. */
+    [[nodiscard]] bool advance_epoch(const Source&,
+                                     std::uint8_t expected,
+                                     std::uint8_t next,
+                                     std::uint64_t nextDomain) noexcept;
+    void reset_group(std::uint64_t groupSessionId) noexcept;
+    void reset_source(const Source& source) noexcept;
+    void reset() noexcept;
+
+private:
+    struct Partition final {
+        Source source{};
+        std::vector<Identity> slots{};
+        bool occupied{}, hasAllocationEpoch{};
+        std::uint8_t allocationEpoch{};
+        std::uint64_t allocationDomain{};
+    };
+    std::array<Partition, kSourceCapacity> partitions_{};
+    std::uint64_t revision_{};
+};
+
+} // namespace sunrise::state::gameplay::entity_identity

+ 126 - 0
Sunrise/src/state/gameplay/external/entity_object_types.cpp

@@ -0,0 +1,126 @@
+#include "entity_object_types.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+namespace sunrise::state::gameplay::entity_object_types {
+namespace {
+SRWLOCK g_lock{SRWLOCK_INIT};
+Rows g_rows;
+Fingerprint g_fingerprint{};
+bool g_confirmed{};
+} // namespace
+/** Only sorted, unique, reciprocal package records enter the role catalogue. */
+bool validate(std::span<const Row> rows) noexcept {
+    if (rows.empty() || rows.size() > kMaximumRows) return false;
+    std::uint32_t previous = 0;
+    for (const auto& row : rows) {
+        if (!row.rsatTag || row.rsatTag == 0xFFFFFFFFU || !row.definitionTag
+            || row.definitionTag == 0xFFFFFFFFU || row.objectType > kMaximumObjectType
+            || row.rsatTag <= previous)
+            return false;
+        previous = row.rsatTag;
+    }
+    return true;
+}
+/** Package extraction publishes a complete immutable catalogue. */
+bool publish(Rows rows, const Fingerprint& fingerprint) noexcept {
+    if (!validate(rows)) return false;
+    AcquireSRWLockExclusive(&g_lock);
+    g_rows.swap(rows);
+    g_fingerprint = fingerprint;
+    g_confirmed = true;
+    ReleaseSRWLockExclusive(&g_lock);
+    return true;
+}
+/** Shared-cache rows remain hidden until the installed manifest is confirmed. */
+bool restore(std::span<const Row> rows, const Fingerprint& fingerprint) noexcept {
+    if (!validate(rows)) return false;
+    try {
+        Rows copy(rows.begin(), rows.end());
+        AcquireSRWLockExclusive(&g_lock);
+        g_rows.swap(copy);
+        g_fingerprint = fingerprint;
+        g_confirmed = false;
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+/** A mismatched package estate cannot supply an object classification. */
+bool confirm(const Fingerprint& fingerprint) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const bool valid = !g_rows.empty() && g_fingerprint == fingerprint;
+    if (!valid) {
+        g_rows.clear();
+        g_fingerprint = {};
+    }
+    g_confirmed = valid;
+    ReleaseSRWLockExclusive(&g_lock);
+    return valid;
+}
+bool available() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const bool valid = g_confirmed && !g_rows.empty();
+    ReleaseSRWLockShared(&g_lock);
+    return valid;
+}
+/** The shared writer receives the same fingerprint as the extracted rows. */
+bool snapshot(std::span<Row> output, std::size_t& count, Fingerprint& fingerprint) noexcept {
+    count = 0;
+    fingerprint = {};
+    AcquireSRWLockShared(&g_lock);
+    const bool valid = g_confirmed && !g_rows.empty() && output.size() >= g_rows.size();
+    if (valid) {
+        std::copy(g_rows.begin(), g_rows.end(), output.begin());
+        count = g_rows.size();
+        fingerprint = g_fingerprint;
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return valid;
+}
+/** Packet classification reads only a confirmed package-derived row. */
+bool lookup(std::uint32_t rsat, Row& output) noexcept {
+    output = {};
+    AcquireSRWLockShared(&g_lock);
+    const auto found =
+        std::lower_bound(g_rows.begin(), g_rows.end(), rsat, [](const Row& row, auto tag) {
+            return row.rsatTag < tag;
+        });
+    const bool valid = g_confirmed && found != g_rows.end() && found->rsatTag == rsat;
+    if (valid) output = *found;
+    ReleaseSRWLockShared(&g_lock);
+    return valid;
+}
+/** Late package facts never rewrite canonical network identities or source relations. */
+bool enrich_snapshot(std::span<entity_identity::Identity> rows) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    bool valid = true;
+    if (g_confirmed)
+        for (auto& row : rows) {
+            if (!row.known || !row.present || row.type != 0 || !row.metadata.hasRsat) continue;
+            const auto found = std::lower_bound(
+                g_rows.begin(),
+                g_rows.end(),
+                row.metadata.rsatTag,
+                [](const Row& candidate, auto tag) { return candidate.rsatTag < tag; });
+            if (found == g_rows.end() || found->rsatTag != row.metadata.rsatTag) continue;
+            if (row.metadata.hasObjectType && row.metadata.objectType != found->objectType) {
+                valid = false;
+                break;
+            }
+            row.metadata.objectType = found->objectType;
+            row.metadata.hasObjectType = true;
+        }
+    ReleaseSRWLockShared(&g_lock);
+    return valid;
+}
+void reset() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_rows.clear();
+    g_fingerprint = {};
+    g_confirmed = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+} // namespace sunrise::state::gameplay::entity_object_types

+ 30 - 0
Sunrise/src/state/gameplay/external/entity_object_types.h

@@ -0,0 +1,30 @@
+#pragma once
+#include <span>
+#include <vector>
+
+#include "entity_identity.h"
+#include "entity_position_profiles.h"
+namespace sunrise::state::gameplay::entity_object_types {
+using Fingerprint = entity_position_profiles::Fingerprint;
+/** Bounds one installed package-class catalogue and its shared-cache scratch. */
+inline constexpr std::size_t kMaximumRows = 16384;
+/** The native object-type table ends at system, type 28. */
+inline constexpr std::uint8_t kMaximumObjectType = 28;
+/** Each row has a reciprocal package RSAT/class link and a native object-type byte. */
+struct Row final {
+    std::uint32_t rsatTag{}, definitionTag{};
+    std::uint8_t objectType{};
+    bool operator==(const Row&) const = default;
+};
+using Rows = std::vector<Row>;
+[[nodiscard]] bool validate(std::span<const Row>) noexcept;
+[[nodiscard]] bool publish(Rows, const Fingerprint&) noexcept;
+[[nodiscard]] bool restore(std::span<const Row>, const Fingerprint&) noexcept;
+[[nodiscard]] bool confirm(const Fingerprint&) noexcept;
+[[nodiscard]] bool available() noexcept;
+[[nodiscard]] bool snapshot(std::span<Row>, std::size_t&, Fingerprint&) noexcept;
+[[nodiscard]] bool lookup(std::uint32_t rsatTag, Row&) noexcept;
+/** Derives only missing class facts on a policy-owned copy of accepted identities. */
+[[nodiscard]] bool enrich_snapshot(std::span<entity_identity::Identity>) noexcept;
+void reset() noexcept;
+} // namespace sunrise::state::gameplay::entity_object_types

+ 146 - 0
Sunrise/src/state/gameplay/external/entity_position_profiles.cpp

@@ -0,0 +1,146 @@
+#include "entity_position_profiles.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <span>
+
+namespace sunrise::state::gameplay::entity_position_profiles {
+namespace {
+SRWLOCK g_lock{SRWLOCK_INIT};
+Rows g_rows;
+Fingerprint g_fingerprint{};
+bool g_confirmed{};
+constexpr std::size_t kMaximumName = kNameCapacity - 1;
+} // namespace
+/** Only sorted, unique activity/cell records enter the immutable catalogue. */
+bool validate(std::span<const Row> rows) noexcept {
+    if (rows.empty() || rows.size() > kMaximumRows) return false;
+    const Row* previous = nullptr;
+    for (const auto& row : rows) {
+        if (row.activity.empty() || row.activity.size() > kMaximumName || row.cell > 255
+            || row.bubble >= 64 || row.activity.find('\0') != std::string::npos
+            || std::any_of(
+                row.axisBits.begin(), row.axisBits.end(), [](auto width) { return width > 31; }))
+            return false;
+        if (previous
+            && (previous->activity > row.activity
+                || (previous->activity == row.activity && previous->cell >= row.cell)))
+            return false;
+        previous = &row;
+    }
+    return true;
+}
+/** Publication swaps already-validated storage while readers hold the shared lock. */
+bool publish(Rows rows, const Fingerprint& fingerprint) noexcept {
+    if (!validate(rows)) return false;
+    AcquireSRWLockExclusive(&g_lock);
+    g_rows.swap(rows);
+    g_fingerprint = fingerprint;
+    g_confirmed = true;
+    ReleaseSRWLockExclusive(&g_lock);
+    return true;
+}
+bool ready(const Fingerprint& fingerprint) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const bool result = g_confirmed && !g_rows.empty() && g_fingerprint == fingerprint;
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+void reset() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_rows.clear();
+    g_fingerprint = {};
+    g_confirmed = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+/** Shared-cache restore keeps rows hidden until the installed manifest is available. */
+bool restore(std::span<const Row> rows, const Fingerprint& fingerprint) noexcept {
+    if (!validate(rows)) return false;
+    try {
+        Rows copy(rows.begin(), rows.end());
+        AcquireSRWLockExclusive(&g_lock);
+        g_rows.swap(copy);
+        g_fingerprint = fingerprint;
+        g_confirmed = false;
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+/** A mismatched package estate invalidates the restored domain before lookup. */
+bool confirm(const Fingerprint& fingerprint) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const bool valid = !g_rows.empty() && g_fingerprint == fingerprint;
+    if (!valid) {
+        g_rows.clear();
+        g_fingerprint = {};
+    }
+    g_confirmed = valid;
+    ReleaseSRWLockExclusive(&g_lock);
+    return valid;
+}
+bool available() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const bool value = g_confirmed && !g_rows.empty();
+    ReleaseSRWLockShared(&g_lock);
+    return value;
+}
+/** The shared writer receives one checked snapshot with its source fingerprint. */
+bool snapshot(std::span<Row> output, std::size_t& count, Fingerprint& fingerprint) noexcept {
+    count = 0;
+    fingerprint = {};
+    AcquireSRWLockShared(&g_lock);
+    bool valid = g_confirmed && !g_rows.empty() && output.size() >= g_rows.size();
+    try {
+        if (valid) {
+            std::copy(g_rows.begin(), g_rows.end(), output.begin());
+            count = g_rows.size();
+            fingerprint = g_fingerprint;
+        }
+    } catch (...) {
+        valid = false;
+        count = 0;
+        fingerprint = {};
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return valid;
+}
+/** Bubble identity uses the same validated package row as coordinate widths. */
+bool lookup_bubble(std::string_view activity, std::uint16_t cell, std::uint8_t& bubble) noexcept {
+    bubble = 255;
+    AcquireSRWLockShared(&g_lock);
+    const auto found = std::lower_bound(
+        g_rows.begin(),
+        g_rows.end(),
+        std::pair(activity, cell),
+        [](const Row& row, const auto& key) {
+            return row.activity < key.first || (row.activity == key.first && row.cell < key.second);
+        });
+    const bool result =
+        g_confirmed && found != g_rows.end() && found->activity == activity && found->cell == cell;
+    if (result) bubble = found->bubble;
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+/** Unknown activity or cell mappings never acquire guessed widths. */
+bool lookup(std::string_view activity,
+            std::uint16_t cell,
+            std::array<std::uint8_t, 3>& axisBits) noexcept {
+    axisBits = {};
+    AcquireSRWLockShared(&g_lock);
+    const auto found = std::lower_bound(
+        g_rows.begin(),
+        g_rows.end(),
+        std::pair(activity, cell),
+        [](const Row& row, const auto& key) {
+            return row.activity < key.first || (row.activity == key.first && row.cell < key.second);
+        });
+    const bool result =
+        g_confirmed && found != g_rows.end() && found->activity == activity && found->cell == cell;
+    if (result) axisBits = found->axisBits;
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+} // namespace sunrise::state::gameplay::entity_position_profiles

+ 49 - 0
Sunrise/src/state/gameplay/external/entity_position_profiles.h

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace sunrise::state::gameplay::entity_position_profiles {
+using Fingerprint = std::array<std::byte, 32>;
+struct Row final {
+    std::string activity;
+    std::uint16_t cell{};
+    std::array<std::uint8_t, 3> axisBits{};
+    std::uint8_t bubble{255};
+    bool operator==(const Row&) const = default;
+};
+using Rows = std::vector<Row>;
+/** Bounds heap-owned catalogue and shared-cache scratch storage. */
+inline constexpr std::size_t kMaximumRows = 65536;
+/** Package names reserve one final null byte on disk. */
+inline constexpr std::size_t kNameCapacity = 128;
+/** Rejects duplicates and widths outside the native 31-bit bound. */
+[[nodiscard]] bool validate(std::span<const Row> rows) noexcept;
+/** Publishes only one complete, validated extraction. */
+[[nodiscard]] bool publish(Rows rows, const Fingerprint& fingerprint) noexcept;
+/** Checks whether the installed content already owns the published rows. */
+[[nodiscard]] bool ready(const Fingerprint& fingerprint) noexcept;
+/** Clears package-derived values before a new build is loaded. */
+void reset() noexcept;
+/** Restored rows remain unavailable until their package fingerprint is confirmed. */
+[[nodiscard]] bool restore(std::span<const Row> rows, const Fingerprint& fingerprint) noexcept;
+/** Confirms a restored shared-cache domain against the installed packages. */
+[[nodiscard]] bool confirm(const Fingerprint& fingerprint) noexcept;
+/** Reports whether validated package data is available for shared-cache publication. */
+[[nodiscard]] bool available() noexcept;
+/** Copies active rows and their fingerprint into shared-cache scratch. */
+[[nodiscard]] bool
+snapshot(std::span<Row> output, std::size_t& count, Fingerprint& fingerprint) noexcept;
+/** Returns the exact map-to-scenario bubble join for one native cell. */
+[[nodiscard]] bool
+lookup_bubble(std::string_view activity, std::uint16_t cell, std::uint8_t& bubble) noexcept;
+/** Looks up only package-validated widths for this exact activity and cell. */
+[[nodiscard]] bool lookup(std::string_view activity,
+                          std::uint16_t cell,
+                          std::array<std::uint8_t, 3>& axisBits) noexcept;
+} // namespace sunrise::state::gameplay::entity_position_profiles

+ 53 - 0
Sunrise/src/state/gameplay/external/replication_common_reconciler.cpp

@@ -1,5 +1,7 @@
 #include "replication_common_reconciler.h"
 
+#include <limits>
+
 namespace sunrise::state::gameplay::external::common_reconciler {
 
 /** Clears every retained identity and generation. */
@@ -17,6 +19,7 @@ bool Reconciler::open(std::uint64_t activitySessionId,
     }
     activitySessionId_ = activitySessionId;
     ownerGeneration_ = ownerGeneration;
+    allocationDomain_ = 1;
     patchEpoch_ = {patchEpoch.first, patchEpoch.second};
     phase_ = Phase::awaitingInitial;
     return true;
@@ -68,6 +71,10 @@ Reconciler::observe(const middleware::gameplay::external::CommonState& common) n
     if (phase_ == Phase::ready && observed == requestedGeneration_) {
         return ObserveResult::ready;
     }
+    if (phase_ == Phase::ready && hasPreviousHostGeneration_
+        && observed == previousHostGeneration_) {
+        return ObserveResult::awaitingRequestedGeneration;
+    }
     return fail(ObserveResult::unexpectedGeneration);
 }
 
@@ -90,6 +97,52 @@ bool Reconciler::commit_request() noexcept {
     return true;
 }
 
+/**
+ * A host-authored epoch keeps the peer's identity and retained entity state intact.
+ * @param expected Epoch preceding the committed host operation.
+ * @param next Epoch carried by that operation.
+ * @return True only for the next epoch of a ready view.
+ */
+bool Reconciler::advance_host_epoch(std::uint8_t expected, std::uint8_t next) noexcept {
+    if (phase_ != Phase::ready || requestedGeneration_ != expected
+        || next != static_cast<std::uint8_t>(expected + 1U)
+        || allocationDomain_ == (std::numeric_limits<std::uint64_t>::max)()) {
+        return false;
+    }
+    previousHostGeneration_ = expected;
+    hasPreviousHostGeneration_ = true;
+    requestedGeneration_ = next;
+    ++allocationDomain_;
+    entityEpochConfirmed_ = false;
+    firstEntityEpochOrdinal_ = 0;
+    return true;
+}
+
+/**
+ * Untagged packets need an accepted current-epoch packet at an earlier ordinal.
+ * @param common Validated common root, or null when omitted from this packet.
+ * @param packetOrdinal Expanded packet sequence within the current peer channel.
+ * @param hasPacketOrdinal Whether the packet carries a sequence.
+ * @return True only when the entity batch belongs to the current allocation domain.
+ */
+bool Reconciler::qualify_entities(const middleware::gameplay::external::CommonState* common,
+                                  std::uint64_t packetOrdinal,
+                                  bool hasPacketOrdinal) noexcept {
+    if (phase_ != Phase::ready) return false;
+    if (common) {
+        if (validate(*common) != ObserveResult::ready
+            || common->entries[0].reconciliationGeneration != requestedGeneration_)
+            return false;
+        if (hasPacketOrdinal) {
+            if (!entityEpochConfirmed_ || packetOrdinal < firstEntityEpochOrdinal_)
+                firstEntityEpochOrdinal_ = packetOrdinal;
+            entityEpochConfirmed_ = true;
+        }
+        return true;
+    }
+    return entityEpochConfirmed_ && hasPacketOrdinal && packetOrdinal >= firstEntityEpochOrdinal_;
+}
+
 /** Copies the exact one-entry common root only after the client confirms it. */
 bool Reconciler::outbound_common(
     middleware::gameplay::external::CommonState& common) const noexcept {

+ 15 - 0
Sunrise/src/state/gameplay/external/replication_common_reconciler.h

@@ -63,6 +63,17 @@ public:
     /** Commits the message-44 request after its reliable enqueue succeeds. */
     [[nodiscard]] bool commit_request() noexcept;
 
+    /** Advances only the exact epoch transition already committed by the host. */
+    [[nodiscard]] bool advance_host_epoch(std::uint8_t expected, std::uint8_t next) noexcept;
+
+    /** A current common root establishes the packet boundary for an allocation epoch. */
+    [[nodiscard]] bool qualify_entities(const middleware::gameplay::external::CommonState* common,
+                                        std::uint64_t packetOrdinal,
+                                        bool hasPacketOrdinal) noexcept;
+    [[nodiscard]] std::uint64_t allocation_domain() const noexcept {
+        return allocationDomain_;
+    }
+
     /** Copies the exact one-entry common root only after the client confirms it. */
     [[nodiscard]] bool
     outbound_common(middleware::gameplay::external::CommonState& common) const noexcept;
@@ -88,6 +99,10 @@ private:
     std::uint64_t ownerGeneration_{};
     std::uint8_t initialGeneration_{};
     std::uint8_t requestedGeneration_{};
+    std::uint8_t previousHostGeneration_{};
+    bool hasPreviousHostGeneration_{};
+    std::uint64_t allocationDomain_{}, firstEntityEpochOrdinal_{};
+    bool entityEpochConfirmed_{};
     Phase phase_{Phase::closed};
 };
 

+ 6 - 0
Sunrise/src/state/gameplay/external/replication_view_receptor.cpp

@@ -123,6 +123,12 @@ Receptor::receive(const middleware::gameplay::group::ViewEstablishment& input) n
     return ReceiveResult::accepted;
 }
 
+/** Native publication starts at the peer's stage 3, before its stage-4 transition. */
+bool Receptor::accepts_inbound_entities() const noexcept {
+    return occupied_ && !failed_ && signatureAdopted_ && viewIndex_ >= 0
+           && localStage_ >= kSignatureStage && remoteStage_ >= 3;
+}
+
 /** Returns the current externally useful negotiation phase. */
 Phase Receptor::phase() const noexcept {
     if (!occupied_) {

Некоторые файлы не были показаны из-за большого количества измененных файлов