瀏覽代碼

fix object resuse, retire and authority stuff

stan 1 天之前
父節點
當前提交
fa6d1a4238
共有 27 個文件被更改,包括 963 次插入132 次删除
  1. 4 0
      Sunrise/Sunrise.vcxproj
  2. 4 4
      Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp
  3. 2 2
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.h
  4. 11 9
      Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp
  5. 6 4
      Sunrise/src/middleware/gameplay/external/external_entity_codec.h
  6. 146 0
      Sunrise/src/middleware/gameplay/peer/packet_fragments.cpp
  7. 64 0
      Sunrise/src/middleware/gameplay/peer/packet_fragments.h
  8. 8 0
      Sunrise/src/server/activity/mission/mission_script_lua_context_api.cpp
  9. 5 0
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  10. 5 0
      Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp
  11. 28 0
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  12. 10 2
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  13. 47 33
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp
  14. 4 4
      Sunrise/src/server/gameplay/actor_command_policy_transport.cpp
  15. 1 1
      Sunrise/src/server/gameplay/entity_identities.cpp
  16. 14 3
      Sunrise/src/server/gameplay/peer/peer_established.cpp
  17. 127 0
      Sunrise/src/server/gameplay/peer/peer_packet_fragments.cpp
  18. 34 0
      Sunrise/src/server/gameplay/peer/peer_packet_fragments.h
  19. 1 0
      Sunrise/src/server/gameplay/peer/peer_transport.cpp
  20. 2 0
      Sunrise/src/server/gameplay/peer/peer_transport_internal.h
  21. 256 8
      Sunrise/src/server/gameplay/squad_entity_retirement.cpp
  22. 21 0
      Sunrise/src/server/gameplay/squad_entity_retirement.h
  23. 2 1
      Sunrise/src/state/activity/mission/activity_mission_state.cpp
  24. 2 0
      Sunrise/src/state/activity/mission/definition.h
  25. 2 2
      Sunrise/src/state/gameplay/external/entity_identity.h
  26. 12 2
      Sunrise/src/state/gameplay/external/squad_entity_retirement.h
  27. 145 57
      Sunrise/src/state/gameplay/external/squad_entity_retirement_store.cpp

+ 4 - 0
Sunrise/Sunrise.vcxproj

@@ -1124,6 +1124,8 @@
     <ClCompile Include="src\middleware\gameplay\peer\peer_container.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\peer_container.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\connect_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\connect_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\established_packet.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\established_packet.cpp" />
+    <ClCompile Include="src\middleware\gameplay\peer\packet_fragments.cpp" />
+    <ClCompile Include="src\server\gameplay\peer\peer_packet_fragments.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\common_state.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\common_state.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\control_state_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\control_state_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\external_entity_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\external_entity_codec.cpp" />
@@ -1972,6 +1974,8 @@
     <ClInclude Include="src\middleware\gameplay\peer\peer_container.h" />
     <ClInclude Include="src\middleware\gameplay\peer\peer_container.h" />
     <ClInclude Include="src\middleware\gameplay\peer\connect_messages.h" />
     <ClInclude Include="src\middleware\gameplay\peer\connect_messages.h" />
     <ClInclude Include="src\middleware\gameplay\peer\established_packet.h" />
     <ClInclude Include="src\middleware\gameplay\peer\established_packet.h" />
+    <ClInclude Include="src\middleware\gameplay\peer\packet_fragments.h" />
+    <ClInclude Include="src\server\gameplay\peer\peer_packet_fragments.h" />
     <ClInclude Include="src\middleware\gameplay\external\common_state.h" />
     <ClInclude Include="src\middleware\gameplay\external\common_state.h" />
     <ClInclude Include="src\middleware\gameplay\external\control_state_codec.h" />
     <ClInclude Include="src\middleware\gameplay\external\control_state_codec.h" />
     <ClInclude Include="src\middleware\gameplay\external\external_entity_codec.h" />
     <ClInclude Include="src\middleware\gameplay\external\external_entity_codec.h" />

+ 4 - 4
Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp

@@ -309,7 +309,7 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
         }
         }
         if (batch.hasAllocationEpoch && !record.implicitToken && !current.occupied
         if (batch.hasAllocationEpoch && !record.implicitToken && !current.occupied
             && (record.flags & (entityCreate | entityUpdate | entityRemove)) == entityRemove) {
             && (record.flags & (entityCreate | entityUpdate | entityRemove)) == entityRemove) {
-            candidate.ignoredRecordMask |= static_cast<std::uint16_t>(1U << index);
+            candidate.ignoredRecordMask.set(index);
             continue;
             continue;
         }
         }
         if (batch.hasAllocationEpoch && !record.implicitToken && !current.occupied
         if (batch.hasAllocationEpoch && !record.implicitToken && !current.occupied
@@ -317,7 +317,7 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
             && (record.allocationSequence == 0
             && (record.allocationSequence == 0
                 || (current.known && !resetSerial
                 || (current.known && !resetSerial
                     && !serial_is_newer(record.allocationSequence, current.allocationSequence)))) {
                     && !serial_is_newer(record.allocationSequence, current.allocationSequence)))) {
-            candidate.ignoredRecordMask |= static_cast<std::uint16_t>(1U << index);
+            candidate.ignoredRecordMask.set(index);
             continue;
             continue;
         }
         }
         if (!stage_entity_record_mutation(context, record, current, next, resetSerial)) {
         if (!stage_entity_record_mutation(context, record, current, next, resetSerial)) {
@@ -387,7 +387,7 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
     };
     };
     for (std::size_t index = 0; index < count; ++index) {
     for (std::size_t index = 0; index < count; ++index) {
         const auto& record = entity_record_at(batch, index);
         const auto& record = entity_record_at(batch, index);
-        if ((candidate.ignoredRecordMask & (1U << index)) == 0 && (record.flags & entityRemove) != 0
+        if (!candidate.ignoredRecordMask.test(index) && (record.flags & entityRemove) != 0
             && !append_terminal(record.token)) {
             && !append_terminal(record.token)) {
             return false;
             return false;
         }
         }
@@ -421,7 +421,7 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
         change_at(selected).replacement.known = true;
         change_at(selected).replacement.known = true;
     }
     }
     candidate.hasChanges = changes != 0;
     candidate.hasChanges = changes != 0;
-    candidate.additionalChangeCount = static_cast<std::uint8_t>(changes == 0 ? 0 : changes - 1);
+    candidate.additionalChangeCount = static_cast<std::uint16_t>(changes == 0 ? 0 : changes - 1);
     candidate.valid = true;
     candidate.valid = true;
     output = candidate;
     output = candidate;
     return true;
     return true;

+ 2 - 2
Sunrise/src/middleware/gameplay/external/composite_entity_codec.h

@@ -93,14 +93,14 @@ struct EntityBaselineChange {
 struct EntityBaselineMutation final : EntityBaselineChange {
 struct EntityBaselineMutation final : EntityBaselineChange {
     bool valid{};
     bool valid{};
     bool hasChanges{};
     bool hasChanges{};
-    std::uint16_t ignoredRecordMask{};
+    std::bitset<kEntityBatchCapacity> ignoredRecordMask{};
     std::uint8_t expectedAllocationEpoch{}, replacementAllocationEpoch{};
     std::uint8_t expectedAllocationEpoch{}, replacementAllocationEpoch{};
     bool expectedHasAllocationEpoch{}, replacementHasAllocationEpoch{};
     bool expectedHasAllocationEpoch{}, replacementHasAllocationEpoch{};
     std::uint64_t expectedAllocationDomain{}, replacementAllocationDomain{};
     std::uint64_t expectedAllocationDomain{}, replacementAllocationDomain{};
     state::gameplay::entity_identity::Source source{};
     state::gameplay::entity_identity::Source source{};
     bool scoped{};
     bool scoped{};
     std::array<EntityBaselineChange, kEntityBatchCapacity - 1> additionalChanges{};
     std::array<EntityBaselineChange, kEntityBatchCapacity - 1> additionalChanges{};
-    std::uint8_t additionalChangeCount{};
+    std::uint16_t additionalChangeCount{};
     std::uint64_t expectedAnchorOrder{}, replacementAnchorOrder{};
     std::uint64_t expectedAnchorOrder{}, replacementAnchorOrder{};
 };
 };
 
 

+ 11 - 9
Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp

@@ -1,6 +1,8 @@
 #include "external_entity_codec.h"
 #include "external_entity_codec.h"
 
 
 #include <array>
 #include <array>
+#include <memory>
+#include <new>
 
 
 namespace sunrise::middleware::gameplay::external {
 namespace sunrise::middleware::gameplay::external {
 namespace {
 namespace {
@@ -682,6 +684,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
         }
         }
     }
     }
     bool currentCellPresent = false;
     bool currentCellPresent = false;
+    candidate.currentCell = kNoEntityCell;
     if (!read_flag(reader, currentCellPresent)) {
     if (!read_flag(reader, currentCellPresent)) {
         return false;
         return false;
     }
     }
@@ -736,7 +739,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
     }
     }
     candidate.recordPresent = recordCount != 0;
     candidate.recordPresent = recordCount != 0;
     candidate.additionalRecordCount =
     candidate.additionalRecordCount =
-        static_cast<std::uint8_t>(recordCount == 0 ? 0 : recordCount - 1);
+        static_cast<std::uint16_t>(recordCount == 0 ? 0 : recordCount - 1);
     return true;
     return true;
 }
 }
 
 
@@ -762,7 +765,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
 [[nodiscard]] bool read_frame_fields(encoding::bits::Reader& reader,
 [[nodiscard]] bool read_frame_fields(encoding::bits::Reader& reader,
                                      const TypePayloadCodec& codec,
                                      const TypePayloadCodec& codec,
                                      ExternalEntityFrame& output) noexcept {
                                      ExternalEntityFrame& output) noexcept {
-    ExternalEntityFrame candidate{};
+    auto& candidate = output;
     if (!read_flag(reader, candidate.commonPresent)
     if (!read_flag(reader, candidate.commonPresent)
         || (candidate.commonPresent && !read_common_state(reader, candidate.common))) {
         || (candidate.commonPresent && !read_common_state(reader, candidate.common))) {
         return false;
         return false;
@@ -773,7 +776,6 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
         || present) {
         || present) {
         return false;
         return false;
     }
     }
-    output = candidate;
     return true;
     return true;
 }
 }
 
 
@@ -784,12 +786,12 @@ bool read_entity_batch(encoding::bits::Reader& reader,
                        const TypePayloadCodec& codec,
                        const TypePayloadCodec& codec,
                        EntityBatch& output) noexcept {
                        EntityBatch& output) noexcept {
     encoding::bits::Reader candidateReader = reader;
     encoding::bits::Reader candidateReader = reader;
-    EntityBatch candidate{};
-    if (!read_batch_fields(candidateReader, codec, candidate)) {
+    const std::unique_ptr<EntityBatch> candidate(new (std::nothrow) EntityBatch{});
+    if (!candidate || !read_batch_fields(candidateReader, codec, *candidate)) {
         return false;
         return false;
     }
     }
     reader = candidateReader;
     reader = candidateReader;
-    output = candidate;
+    output = *candidate;
     return true;
     return true;
 }
 }
 
 
@@ -814,12 +816,12 @@ bool read_external_entity_frame(encoding::bits::Reader& reader,
                                 const TypePayloadCodec& codec,
                                 const TypePayloadCodec& codec,
                                 ExternalEntityFrame& output) noexcept {
                                 ExternalEntityFrame& output) noexcept {
     encoding::bits::Reader candidateReader = reader;
     encoding::bits::Reader candidateReader = reader;
-    ExternalEntityFrame candidate{};
-    if (!read_frame_fields(candidateReader, codec, candidate)) {
+    const std::unique_ptr<ExternalEntityFrame> candidate(new (std::nothrow) ExternalEntityFrame{});
+    if (!candidate || !read_frame_fields(candidateReader, codec, *candidate)) {
         return false;
         return false;
     }
     }
     reader = candidateReader;
     reader = candidateReader;
-    output = candidate;
+    output = *candidate;
     return true;
     return true;
 }
 }
 
 

+ 6 - 4
Sunrise/src/middleware/gameplay/external/external_entity_codec.h

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include <array>
 #include <array>
+#include <bitset>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
 
 
@@ -11,8 +12,9 @@
 
 
 namespace sunrise::middleware::gameplay::external {
 namespace sunrise::middleware::gameplay::external {
 
 
-/** Fixed server storage bounds one decoded entity lane to sixteen records. */
-inline constexpr std::size_t kEntityBatchCapacity = 16;
+/** The native packet scheduler admits at most 256 records across its external lanes. */
+inline constexpr std::size_t kEntityBatchCapacity =
+    state::gameplay::entity_identity::kObservationBatchCapacity;
 /** The native prelude carries a one-bit auxiliary count. */
 /** The native prelude carries a one-bit auxiliary count. */
 inline constexpr std::size_t kEntityAuxiliaryCapacity = 1;
 inline constexpr std::size_t kEntityAuxiliaryCapacity = 1;
 /** A token slot is the low 13 bits of the 17-bit wire token. */
 /** A token slot is the low 13 bits of the 17-bit wire token. */
@@ -86,14 +88,14 @@ struct EntityBatch {
     std::uint8_t allocationEpoch{};
     std::uint8_t allocationEpoch{};
     bool hasAllocationEpoch{};
     bool hasAllocationEpoch{};
     std::uint64_t allocationDomain{};
     std::uint64_t allocationDomain{};
-    std::uint16_t ignoredRecordMask{};
+    std::bitset<kEntityBatchCapacity> ignoredRecordMask{};
     std::array<EntityToken, kEntityAuxiliaryCapacity> auxiliaryTokens{};
     std::array<EntityToken, kEntityAuxiliaryCapacity> auxiliaryTokens{};
     EntityRecord record{};
     EntityRecord record{};
     std::array<EntityRecord, kEntityBatchCapacity - 1> additionalRecords{};
     std::array<EntityRecord, kEntityBatchCapacity - 1> additionalRecords{};
     std::uint16_t currentCell{kNoEntityCell};
     std::uint16_t currentCell{kNoEntityCell};
     std::uint8_t auxiliaryCount{};
     std::uint8_t auxiliaryCount{};
     bool recordPresent{};
     bool recordPresent{};
-    std::uint8_t additionalRecordCount{};
+    std::uint16_t additionalRecordCount{};
 };
 };
 
 
 [[nodiscard]] inline std::size_t entity_record_count(const EntityBatch& batch) noexcept {
 [[nodiscard]] inline std::size_t entity_record_count(const EntityBatch& batch) noexcept {

+ 146 - 0
Sunrise/src/middleware/gameplay/peer/packet_fragments.cpp

@@ -0,0 +1,146 @@
+#include "packet_fragments.h"
+
+#include <cstring>
+
+namespace sunrise::middleware::gameplay::peer::packet_fragments {
+namespace {
+
+/** MSB-first headers begin with the marker bit, then the fragmentation bit. */
+constexpr unsigned kMarkerMask = 0x80, kFragmentedMask = 0x40;
+/** The first fragment-header byte ends with the six-bit group sequence. */
+constexpr unsigned kSequenceMask = 0x3F;
+/** The second fragment-header byte carries guard, count-minus-one, then index. */
+constexpr unsigned kOuterGuardShift = 6, kCountShift = 3, kIndexMask = 7;
+/** Whole packet headers carry the two-bit connection guard immediately after the flag bits. */
+constexpr unsigned kInnerGuardShift = 4, kGuardMask = 3;
+
+/** The original packet header must already identify this channel and an unfragmented packet. */
+bool valid_packet(std::span<const std::byte> bytes, std::uint8_t guard) noexcept {
+    if (bytes.empty()) {
+        return false;
+    }
+    const auto first = std::to_integer<unsigned>(bytes.front());
+    return (first & (kMarkerMask | kFragmentedMask)) == 0
+           && ((first >> kInnerGuardShift) & kGuardMask) == guard;
+}
+
+} // namespace
+
+/**
+ * Buffers one bounded fragment and publishes only a complete packet with both guards intact.
+ * @param datagram One decrypted transport datagram.
+ * @param expectedGuard The admitted connection sequence modulo four.
+ * @param nowMs Unwrapped monotonic arrival time in milliseconds.
+ * @param output Receives the complete packet and may alias datagram, but not this store.
+ * @param written Cleared unless complete is returned.
+ * @param datagramLimit Maximum datagram bytes on this channel.
+ * @return Refused leaves prior fragments intact; duplicate indices never overwrite payload.
+ */
+Result Store::accept(std::span<const std::byte> datagram,
+                     std::uint8_t expectedGuard,
+                     std::uint64_t nowMs,
+                     std::span<std::byte> output,
+                     std::size_t& written,
+                     std::size_t datagramLimit) noexcept {
+    written = 0;
+    if (expectedGuard > kGuardMask || datagramLimit <= kFragmentHeaderBytes
+        || datagramLimit > kMaximumDatagramLimit || datagram.empty()
+        || datagram.size() > datagramLimit) {
+        return Result::refused;
+    }
+    const auto first = std::to_integer<unsigned>(datagram.front());
+    if ((first & kMarkerMask) != 0) {
+        return Result::refused;
+    }
+    if ((first & kFragmentedMask) == 0) {
+        if (!valid_packet(datagram, expectedGuard) || output.size() < datagram.size()) {
+            return Result::refused;
+        }
+        std::memmove(output.data(), datagram.data(), datagram.size());
+        written = datagram.size();
+        return Result::complete;
+    }
+    if (datagram.size() < kFragmentHeaderBytes) {
+        return Result::refused;
+    }
+    const auto second = std::to_integer<unsigned>(datagram[1]);
+    const auto guard = static_cast<std::uint8_t>(second >> kOuterGuardShift);
+    const auto count = static_cast<std::uint8_t>(((second >> kCountShift) & kIndexMask) + 1U);
+    const auto index = static_cast<std::uint8_t>(second & kIndexMask);
+    const auto sequence = static_cast<std::uint8_t>(first & kSequenceMask);
+    const auto body = datagram.subspan(kFragmentHeaderBytes);
+    const auto bodyLimit = datagramLimit - kFragmentHeaderBytes;
+    if (guard != expectedGuard || index >= count
+        || (index + 1U != count && body.size() != bodyLimit)
+        || (index == 0 && !valid_packet(body, expectedGuard))
+        || (count == 1 && output.size() < body.size())) {
+        return Result::refused;
+    }
+
+    Group* selected = nullptr;
+    for (auto& group : groups_) {
+        if (group.active && group.sequence == sequence) {
+            selected = &group;
+            break;
+        }
+    }
+    if (selected == nullptr) {
+        selected = &groups_[nextGroup_];
+        nextGroup_ = (nextGroup_ + 1U) % groups_.size();
+        selected->active = false;
+    } else if (nowMs >= selected->startedMs && nowMs - selected->startedMs > kExpiryMilliseconds) {
+        selected->active = false;
+    }
+    auto& group = *selected;
+    if (!group.active) {
+        group.startedMs = nowMs;
+        group.bodyLimit = bodyLimit;
+        group.finalBytes = 0;
+        group.sequence = sequence;
+        group.guard = guard;
+        group.count = count;
+        group.present = 0;
+        group.active = true;
+    } else if (group.count != count || group.guard != guard || group.bodyLimit != bodyLimit) {
+        return Result::refused;
+    }
+
+    const auto fragmentBit = static_cast<std::uint8_t>(1U << index);
+    if ((group.present & fragmentBit) != 0) {
+        return Result::duplicate;
+    }
+    const auto received = static_cast<std::uint8_t>(group.present | fragmentBit);
+    const auto complete = static_cast<std::uint8_t>((1U << count) - 1U);
+    const auto finalBytes = index + 1U == count ? body.size() : group.finalBytes;
+    const auto packetBytes = (count - 1U) * bodyLimit + finalBytes;
+    // A caller with too little space can retry the missing piece without losing the prior group.
+    if (received == complete && output.size() < packetBytes) {
+        return Result::refused;
+    }
+    if (!body.empty()) {
+        std::memcpy(group.bytes.data() + index * bodyLimit, body.data(), body.size());
+    }
+    group.finalBytes = finalBytes;
+    group.present = received;
+    if (received != complete) {
+        return Result::incomplete;
+    }
+    const auto packet = std::span<const std::byte>(group.bytes).first(packetBytes);
+    if (!valid_packet(packet, expectedGuard)) {
+        return Result::refused;
+    }
+    std::memmove(output.data(), packet.data(), packet.size());
+    written = packet.size();
+    return Result::complete;
+}
+
+/** Withdraws every sequence without materializing or clearing the payload arrays. */
+void Store::reset() noexcept {
+    for (auto& group : groups_) {
+        group.active = false;
+        group.present = 0;
+    }
+    nextGroup_ = 0;
+}
+
+} // namespace sunrise::middleware::gameplay::peer::packet_fragments

+ 64 - 0
Sunrise/src/middleware/gameplay/peer/packet_fragments.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::gameplay::peer::packet_fragments {
+
+/** Direct IPv4 transport admits 1,228 bytes per datagram. */
+inline constexpr std::size_t kDefaultDatagramLimit = 1228;
+/** The native transport's largest datagram buffer holds 1,240 bytes. */
+inline constexpr std::size_t kMaximumDatagramLimit = 1240;
+/** Fragment headers contain two bytes before their payload. */
+inline constexpr std::size_t kFragmentHeaderBytes = 2;
+/** Three-bit fragment indices and counts admit eight pieces per packet. */
+inline constexpr std::size_t kMaximumFragments = 8;
+/** The native receive ring retains eight fragment groups. */
+inline constexpr std::size_t kMaximumGroups = 8;
+/** One group stores eight maximum-sized fragment bodies. */
+inline constexpr std::size_t kMaximumPacketBytes =
+    kMaximumFragments * (kMaximumDatagramLimit - kFragmentHeaderBytes);
+/** A matching sequence starts a new group only after more than 2,000 milliseconds. */
+inline constexpr std::uint64_t kExpiryMilliseconds = 2000;
+
+enum class Result : std::uint8_t { refused, incomplete, duplicate, complete };
+
+/** Each caller-owned store belongs to one authenticated peer channel and needs about 80 KiB. */
+class Store final {
+public:
+    /**
+     * Returns a complete original packet without altering its inner header.
+     * @param datagram One decrypted transport datagram.
+     * @param expectedGuard The admitted connection sequence modulo four.
+     * @param nowMs Unwrapped monotonic arrival time in milliseconds.
+     * @param output Receives the packet only on complete; must not overlap this store.
+     * @param written Cleared, then receives the complete packet's byte count.
+     * @param datagramLimit This channel's maximum datagram size, including the fragment header.
+     * @return Complete only once for a retained group; all other results leave output unchanged.
+     */
+    [[nodiscard]] Result accept(std::span<const std::byte> datagram,
+                                std::uint8_t expectedGuard,
+                                std::uint64_t nowMs,
+                                std::span<std::byte> output,
+                                std::size_t& written,
+                                std::size_t datagramLimit = kDefaultDatagramLimit) noexcept;
+
+    /** Reset on channel replacement; payload storage needs no clearing before reuse. */
+    void reset() noexcept;
+
+private:
+    struct Group final {
+        std::array<std::byte, kMaximumPacketBytes> bytes{};
+        std::uint64_t startedMs{};
+        std::size_t bodyLimit{}, finalBytes{};
+        std::uint8_t sequence{}, guard{}, count{}, present{};
+        bool active{};
+    };
+
+    std::array<Group, kMaximumGroups> groups_{};
+    std::size_t nextGroup_{};
+};
+
+} // namespace sunrise::middleware::gameplay::peer::packet_fragments

+ 8 - 0
Sunrise/src/server/activity/mission/mission_script_lua_context_api.cpp

@@ -199,6 +199,14 @@ resolve_message_name(lua_State* state, std::string_view name, ActivityMessageDef
     if (!parse_seed_omissions(state, 3, intent)) {
     if (!parse_seed_omissions(state, 3, intent)) {
         return 0;
         return 0;
     }
     }
+    if (lua_istable(state, 3)) {
+        lua_getfield(state, 3, "retire_placed_props");
+        if (!lua_isnil(state, -1) && !lua_isboolean(state, -1)) {
+            return luaL_argerror(state, 3, "retire_placed_props must be a boolean");
+        }
+        intent.retirePlacedProps = lua_toboolean(state, -1) != 0;
+        lua_pop(state, 1);
+    }
     return queue_intent(state, frame, intent);
     return queue_intent(state, frame, intent);
 }
 }
 
 

+ 5 - 0
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -20,6 +20,7 @@
 #include "../../../core/settings/settings.h"
 #include "../../../core/settings/settings.h"
 #include "../../../state/activity/mission/runtime.h"
 #include "../../../state/activity/mission/runtime.h"
 #include "../../../state/activity/runtime.h"
 #include "../../../state/activity/runtime.h"
+#include "../../gameplay/squad_entity_retirement.h"
 #include "../host_runtime.h"
 #include "../host_runtime.h"
 #include "mission_script_event_batch.h"
 #include "mission_script_event_batch.h"
 #include "mission_script_runtime_internal.h"
 #include "mission_script_runtime_internal.h"
@@ -230,6 +231,8 @@ void note_vm_status(RuntimeInstance& instance,
 /** Frees one slot; its queued events are retired unless the caller keeps them for a reattach. */
 /** Frees one slot; its queued events are retired unless the caller keeps them for a reattach. */
 void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
 void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
     if (instance.occupied && clearPending) {
     if (instance.occupied && clearPending) {
+        server::gameplay::squad_entity_retirement::cancel_placed_transition(
+            instance.view.binding, instance.view.activityClientGeneration);
         clear_pending_events(instance.view.binding);
         clear_pending_events(instance.view.binding);
     } else if (instance.occupied) {
     } else if (instance.occupied) {
         reset_pending_events_for_reattach(instance.view.binding);
         reset_pending_events_for_reattach(instance.view.binding);
@@ -356,6 +359,8 @@ void persist_mission_fault(RuntimeInstance& instance) noexcept {
 
 
 /** Faults both the VM and the exact server-owned mission record. */
 /** Faults both the VM and the exact server-owned mission record. */
 void fault_instance(RuntimeInstance& instance, std::string_view reason) noexcept {
 void fault_instance(RuntimeInstance& instance, std::string_view reason) noexcept {
+    server::gameplay::squad_entity_retirement::cancel_placed_transition(
+        instance.view.binding, instance.view.activityClientGeneration);
     lua_vm::fault(instance.vm, reason);
     lua_vm::fault(instance.vm, reason);
     instance.programStatus = ProgramStatus::programError;
     instance.programStatus = ProgramStatus::programError;
     persist_mission_fault(instance);
     persist_mission_fault(instance);

+ 5 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp

@@ -3,6 +3,7 @@
 #include <string_view>
 #include <string_view>
 
 
 #include "../../bap/runtime.h"
 #include "../../bap/runtime.h"
+#include "../../gameplay/squad_entity_retirement.h"
 #include "mission_script_runtime_internal.h"
 #include "mission_script_runtime_internal.h"
 
 
 // The delivery state machine: the four stages and the timeout reconcilers. A
 // The delivery state machine: the four stages and the timeout reconcilers. A
@@ -332,6 +333,10 @@ void refuse_delivery(RuntimeInstance& instance,
     }
     }
     lua_vm::consume_intent(instance.vm);
     lua_vm::consume_intent(instance.vm);
     queue_effect_result(instance, intent, outcome);
     queue_effect_result(instance, intent, outcome);
+    if (intent.retirePlacedProps) {
+        server::gameplay::squad_entity_retirement::cancel_placed_transition(
+            instance.view.binding, instance.view.activityClientGeneration, intent.requestKey);
+    }
     clear_delivery(instance);
     clear_delivery(instance);
     log_line(core::log::Level::warn, &instance, "intent_refused", result, {}, reason);
     log_line(core::log::Level::warn, &instance, "intent_refused", result, {}, reason);
 }
 }

+ 28 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -11,6 +11,7 @@
 #include "../../../state/activity/membership/activity_membership_query.h"
 #include "../../../state/activity/membership/activity_membership_query.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/build_data/spawn_sets/spawn_set_catalog.h"
 #include "../../../state/build_data/spawn_sets/spawn_set_catalog.h"
+#include "../../gameplay/squad_entity_retirement.h"
 #include "../activity_sdk_device_runtime.h"
 #include "../activity_sdk_device_runtime.h"
 #include "../activity_sdk_lifetime_runtime.h"
 #include "../activity_sdk_lifetime_runtime.h"
 #include "../activity_sdk_mission_runtime.h"
 #include "../activity_sdk_mission_runtime.h"
@@ -278,6 +279,33 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
     begin_intent_attempt(instance, now);
     begin_intent_attempt(instance, now);
     switch (intent.kind) {
     switch (intent.kind) {
     case lua_vm::IntentKind::selectMissionState: {
     case lua_vm::IntentKind::selectMissionState: {
+        if (intent.retirePlacedProps) {
+            namespace retirement = server::gameplay::squad_entity_retirement;
+            const auto* world = instance.worldView.snapshot();
+            const auto placement =
+                state::activity::membership::reported_placement(instance.view.binding.sessionId);
+            const auto status =
+                world == nullptr || instance.publicTarget
+                    ? retirement::TransitionStatus::refused
+                    : retirement::begin_placed_transition(
+                          instance.view,
+                          *world,
+                          intent.requestKey,
+                          state::activity::membership::instantiated_region(placement),
+                          intent.effectiveRegion);
+            if (status == retirement::TransitionStatus::pending) {
+                report_intent_status(
+                    instance, kIntentStatusStateTransitionPending, "placed_retirement_pending");
+                return;
+            }
+            if (status != retirement::TransitionStatus::ready) {
+                refuse_delivery(instance,
+                                "state_refused",
+                                "placed_lifetimes_unavailable",
+                                host::EffectOutcome::refused);
+                return;
+            }
+        }
         scenes::Snapshot selected{};
         scenes::Snapshot selected{};
         const scenes::Status status =
         const scenes::Status status =
             scenes::select_state(instance.view,
             scenes::select_state(instance.view,

+ 10 - 2
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -15,6 +15,7 @@
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../activity/host_runtime.h"
 #include "../../../../activity/host_runtime.h"
 #include "../../../../gameplay/gameplay_advertisement.h"
 #include "../../../../gameplay/gameplay_advertisement.h"
+#include "../../../../gameplay/squad_entity_retirement.h"
 #include "../../../activity_authority_query_owner.h"
 #include "../../../activity_authority_query_owner.h"
 #include "../../../activity_authority_reset_owner.h"
 #include "../../../activity_authority_reset_owner.h"
 #include "../../activity_message/definition.h"
 #include "../../activity_message/definition.h"
@@ -287,9 +288,16 @@ bool consume_activity_keepalive(Session& session,
         active
         active
         && state::activity::membership::host_teleport_armed(session.activity.session.sessionId)
         && state::activity::membership::host_teleport_armed(session.activity.session.sessionId)
         && state::activity::membership::acknowledged(session.activity.session.sessionId);
         && state::activity::membership::acknowledged(session.activity.session.sessionId);
+    const bool placedRetirementDue =
+        active && session.activityPatchEpoch.seen
+        && session.activityPatchEpoch.bindingGeneration == session.activity.bindingGeneration
+        && activity_link_count_locked(session.activity.session) == 1
+        && server::gameplay::squad_entity_retirement::placed_transition_pending(
+            session.activity.session, session.activity.bindingGeneration);
     if (!active
     if (!active
         || (!burstDue && !keepaliveDue && !regionChanged && !hostStateDue && !scriptableDue
         || (!burstDue && !keepaliveDue && !regionChanged && !hostStateDue && !scriptableDue
-            && !incidentDue && !authorityResetDue && !authorityQueryDue && !hostTeleportDue)) {
+            && !incidentDue && !authorityResetDue && !authorityQueryDue && !hostTeleportDue
+            && !placedRetirementDue)) {
         return false;
         return false;
     }
     }
     touchesScratch = true;
     touchesScratch = true;
@@ -306,7 +314,7 @@ bool consume_activity_keepalive(Session& session,
     // republish.
     // republish.
     if (!keepaliveDue && !regionChanged && !hostTeleportDue) {
     if (!keepaliveDue && !regionChanged && !hostTeleportDue) {
         bool appendedRoster = false;
         bool appendedRoster = false;
-        if (burstDue || hostStateDue || scriptableDue) {
+        if (burstDue || hostStateDue || scriptableDue || placedRetirementDue) {
             appendedRoster = append_roster_notification(
             appendedRoster = append_roster_notification(
                 session, scratch, key, nextSendNonce, scratch.framed, framedSize);
                 session, scratch, key, nextSendNonce, scratch.framed, framedSize);
             published = appendedRoster;
             published = appendedRoster;

+ 47 - 33
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp

@@ -273,6 +273,9 @@ bool append_roster_notification(
     const std::uint8_t initialRosterState = session.activityRosterState;
     const std::uint8_t initialRosterState = session.activityRosterState;
     const std::uint8_t initialRegionEpoch = session.activityRosterRegionEpoch;
     const std::uint8_t initialRegionEpoch = session.activityRosterRegionEpoch;
     const std::int32_t initialRegionBubble = session.activityRosterRegionBubble;
     const std::int32_t initialRegionBubble = session.activityRosterRegionBubble;
+    const bool placedRetirementPending =
+        server::gameplay::squad_entity_retirement::placed_transition_pending(
+            session.activity.session, session.activity.bindingGeneration);
     server::activity::host::AuthState hostState{};
     server::activity::host::AuthState hostState{};
     const bool hasHostState =
     const bool hasHostState =
         server::activity::host::auth_state(session.activity.session, hostState);
         server::activity::host::auth_state(session.activity.session, hostState);
@@ -418,8 +421,12 @@ bool append_roster_notification(
         enteringBubble
         enteringBubble
             ? static_cast<std::int32_t>(snapshot.region)
             ? static_cast<std::int32_t>(snapshot.region)
             : (pendingRegion >= 0 ? pendingRegion : 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, enteringBubble)) {
+    if (!placedRetirementPending
+        && state::activity::bubble_authority::select_grant(
+            session.activity.session.sessionId,
+            grantRegion,
+            grant,
+            enteringBubble || client_region_ready(session, refresh))) {
         snapshot.hasGrant = true;
         snapshot.hasGrant = true;
         snapshot.grant.bubble = grant.bubble;
         snapshot.grant.bubble = grant.bubble;
         snapshot.grant.token = grant.token;
         snapshot.grant.token = grant.token;
@@ -446,12 +453,24 @@ bool append_roster_notification(
         && stagedMissionSeed.revision != stagedMissionSeed.publishedRevision
         && stagedMissionSeed.revision != stagedMissionSeed.publishedRevision
         && !stagedMissionSeed.regionArrivalPending;
         && !stagedMissionSeed.regionArrivalPending;
     bool encoded = message::encode_sensor_auth_update(snapshot, scratch.responseBody, messageSize);
     bool encoded = message::encode_sensor_auth_update(snapshot, scratch.responseBody, messageSize);
+    const bool hasRetirement =
+        encoded && allowEntityRetirement
+        && (placedRetirementPending || (snapshot.hasGrant && enteringBubble))
+        && server::gameplay::squad_entity_retirement::prepare_retirement(
+            session.activity.session,
+            session.activity.bindingGeneration,
+            static_cast<std::uint8_t>(snapshot.region
+                                      >> state::activity::bubble_authority::kSliceSetToBubbleShift),
+            entityRetirement);
+    if (placedRetirementPending && allowEntityRetirement && !hasRetirement && !solicited) {
+        encoded = false;
+    }
     // An unsolicited body identical to the last delivered one is skipped. A solicited one never
     // An unsolicited body identical to the last delivered one is skipped. A solicited one never
     // is. The repeat check knows only this host's own history, and a slice-set teardown clears
     // is. The repeat check knows only this host's own history, and a slice-set teardown clears
     // the client's mirror without telling us, which is exactly when it asks again.
     // the client's mirror without telling us, which is exactly when it asks again.
     const bool suppressible = encoded && !solicited && !snapshot.hasGrant && !hostStatePending
     const bool suppressible = encoded && !solicited && !snapshot.hasGrant && !hostStatePending
                               && !(hasScriptablePending && singleScriptableLink)
                               && !(hasScriptablePending && singleScriptableLink)
-                              && !missionSeedPending;
+                              && !missionSeedPending && !placedRetirementPending;
     // Which terms held is in the log line, because a repeat that one of them forced reaches the
     // Which terms held is in the log line, because a repeat that one of them forced reaches the
     // client as a fresh apply.
     // client as a fresh apply.
     const std::uint8_t forced = static_cast<std::uint8_t>(
     const std::uint8_t forced = static_cast<std::uint8_t>(
@@ -481,12 +500,7 @@ bool append_roster_notification(
             session, snapshot, name, 0, kNoGrant, RosterOutcome::unchanged, bodyHash, forced);
             session, snapshot, name, 0, kNoGrant, RosterOutcome::unchanged, bodyHash, forced);
         return false;
         return false;
     }
     }
-    if (encoded && snapshot.hasGrant && enteringBubble && allowEntityRetirement
-        && server::gameplay::squad_entity_retirement::prepare_retirement(
-            session.activity.session,
-            session.activity.bindingGeneration,
-            grant.bubble,
-            entityRetirement)) {
+    if (hasRetirement) {
         namespace control = middleware::bap::activity_message::host_control;
         namespace control = middleware::bap::activity_message::host_control;
         const control::PurgeAuthorityBody retirement{
         const control::PurgeAuthorityBody retirement{
             .slots = entityRetirement.entities, .epoch = retirementEpoch, .reason = 0};
             .slots = entityRetirement.entities, .epoch = retirementEpoch, .reason = 0};
@@ -651,34 +665,34 @@ void commit_staged_roster(Session& session) noexcept {
     // The BAP lock serializes publication and incoming activity messages, so replacing the whole
     // The BAP lock serializes publication and incoming activity messages, so replacing the whole
     // fixed map here exposes either the prior delivered roster or this complete delivered roster.
     // fixed map here exposes either the prior delivered roster or this complete delivered roster.
     session.activityRosterDecode = session.activityRosterStaged.decodeMap;
     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));
-            }
+    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>(
             static_cast<void>(
                 server::gameplay::peer::commit_replication_epoch(session.activity.session,
                 server::gameplay::peer::commit_replication_epoch(session.activity.session,
                                                                  session.activity.bindingGeneration,
                                                                  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;
-            }
+                                                                 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;
+        }
+    }
+    if (session.activityRosterStaged.hasGrant) {
         state::activity::bubble_authority::record_grant(session.activity.session.sessionId,
         state::activity::bubble_authority::record_grant(session.activity.session.sessionId,
                                                         session.activityRosterStaged.grant);
                                                         session.activityRosterStaged.grant);
     }
     }

+ 4 - 4
Sunrise/src/server/gameplay/actor_command_policy_transport.cpp

@@ -279,7 +279,7 @@ void observed_entity_adapter(const void* context,
                                batch.allocationDomain);
                                batch.allocationDomain);
     const external::EntityBatch* projected = &batch;
     const external::EntityBatch* projected = &batch;
     std::unique_ptr<external::EntityBatch> filtered;
     std::unique_ptr<external::EntityBatch> filtered;
-    if (batch.ignoredRecordMask != 0) {
+    if (batch.ignoredRecordMask.any()) {
         filtered.reset(new (std::nothrow) external::EntityBatch{});
         filtered.reset(new (std::nothrow) external::EntityBatch{});
         if (!filtered) {
         if (!filtered) {
             return;
             return;
@@ -287,7 +287,7 @@ void observed_entity_adapter(const void* context,
         *filtered = batch;
         *filtered = batch;
         std::size_t count = 0;
         std::size_t count = 0;
         for (std::size_t index = 0; index < external::entity_record_count(batch); ++index) {
         for (std::size_t index = 0; index < external::entity_record_count(batch); ++index) {
-            if ((batch.ignoredRecordMask & (1U << index)) == 0) {
+            if (!batch.ignoredRecordMask.test(index)) {
                 external::entity_record_at(*filtered, count++) =
                 external::entity_record_at(*filtered, count++) =
                     external::entity_record_at(batch, index);
                     external::entity_record_at(batch, index);
             }
             }
@@ -296,8 +296,8 @@ void observed_entity_adapter(const void* context,
             return;
             return;
         }
         }
         filtered->recordPresent = true;
         filtered->recordPresent = true;
-        filtered->additionalRecordCount = static_cast<std::uint8_t>(count - 1);
-        filtered->ignoredRecordMask = 0;
+        filtered->additionalRecordCount = static_cast<std::uint16_t>(count - 1);
+        filtered->ignoredRecordMask.reset();
         projected = filtered.get();
         projected = filtered.get();
     }
     }
     if (!accept_entity_batch(source.groupSessionId, *projected)) {
     if (!accept_entity_batch(source.groupSessionId, *projected)) {

+ 1 - 1
Sunrise/src/server/gameplay/entity_identities.cpp

@@ -120,7 +120,7 @@ void observe(const identities::Source& source,
     std::array<std::size_t, identities::kObservationBatchCapacity> retained{};
     std::array<std::size_t, identities::kObservationBatchCapacity> retained{};
     std::size_t admitted = 0;
     std::size_t admitted = 0;
     for (std::size_t index = 0; index < count; ++index) {
     for (std::size_t index = 0; index < count; ++index) {
-        if ((batch.ignoredRecordMask & (1U << index)) != 0) {
+        if (batch.ignoredRecordMask.test(index)) {
             continue;
             continue;
         }
         }
         retained[admitted] = index;
         retained[admitted] = index;

+ 14 - 3
Sunrise/src/server/gameplay/peer/peer_established.cpp

@@ -12,10 +12,12 @@
 #include "../../../middleware/gameplay/external/control_state_codec.h"
 #include "../../../middleware/gameplay/external/control_state_codec.h"
 #include "../../../middleware/gameplay/peer/connect_messages.h"
 #include "../../../middleware/gameplay/peer/connect_messages.h"
 #include "../../../middleware/gameplay/peer/established_packet.h"
 #include "../../../middleware/gameplay/peer/established_packet.h"
+#include "../../../middleware/gameplay/peer/packet_fragments.h"
 #include "../../../middleware/gameplay/peer/reliable_assembly.h"
 #include "../../../middleware/gameplay/peer/reliable_assembly.h"
 #include "../../bap/runtime.h"
 #include "../../bap/runtime.h"
 #include "../gameplay_log.h"
 #include "../gameplay_log.h"
 #include "../group/group_host.h"
 #include "../group/group_host.h"
+#include "peer_packet_fragments.h"
 #include "peer_transport_internal.h"
 #include "peer_transport_internal.h"
 
 
 namespace sunrise::server::gameplay::peer {
 namespace sunrise::server::gameplay::peer {
@@ -25,6 +27,7 @@ namespace {
 namespace gp = state::gameplay;
 namespace gp = state::gameplay;
 namespace wire = middleware::gameplay::peer;
 namespace wire = middleware::gameplay::peer;
 namespace bits = middleware::encoding::bits;
 namespace bits = middleware::encoding::bits;
+namespace fragments = wire::packet_fragments;
 
 
 /** Delay sentinel used until a round trip has been measured. */
 /** Delay sentinel used until a round trip has been measured. */
 constexpr std::uint16_t kDelaySentinel = 1023;
 constexpr std::uint16_t kDelaySentinel = 1023;
@@ -40,8 +43,10 @@ constexpr std::uint64_t kResendInterval = 250;
 /** Reflected root 0x80806AE6 after its lane-presence bit. */
 /** Reflected root 0x80806AE6 after its lane-presence bit. */
 constexpr std::size_t kPlayerSnapshotBits = 1373;
 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;
+/** Eight unique logical packet failures bound the replay sample. */
+constexpr std::size_t kRejectedPacketLimit = 8;
+/** A rejected logical packet can span every native transport fragment. */
+constexpr std::size_t kRejectedPacketCapacity = fragments::kMaximumPacketBytes;
 /** A 256-byte hex chunk leaves room for event fields in the 1024-byte log line. */
 /** A 256-byte hex chunk leaves room for event fields in the 1024-byte log line. */
 constexpr std::size_t kRejectedHexChunk = 256;
 constexpr std::size_t kRejectedHexChunk = 256;
 
 
@@ -443,6 +448,11 @@ void queue_common_request(const state::gameplay::Endpoint& from,
 void consume_established(const gp::Endpoint& from,
 void consume_established(const gp::Endpoint& from,
                          std::span<const std::byte> payload,
                          std::span<const std::byte> payload,
                          std::uint64_t now) noexcept {
                          std::uint64_t now) noexcept {
+    PacketInput input{};
+    if (!prepare_packet_input(from, payload, now, input)) {
+        return;
+    }
+    payload = input.payload;
     wire::EstablishedPacket packet{};
     wire::EstablishedPacket packet{};
     if (!wire::decode_established(payload, true, packet)) {
     if (!wire::decode_established(payload, true, packet)) {
         report(core::log::Level::debug, "ev=gameplay stage=packet result=drop reason=grammar");
         report(core::log::Level::debug, "ev=gameplay stage=packet result=drop reason=grammar");
@@ -486,7 +496,8 @@ void consume_established(const gp::Endpoint& from,
     if (peer != nullptr) {
     if (peer != nullptr) {
         ingress = entity_source(*peer);
         ingress = entity_source(*peer);
         expectedGuard = wire::connection_sequence_low2(peer->remoteConnectionSequence);
         expectedGuard = wire::connection_sequence_low2(peer->remoteConnectionSequence);
-        guardAccepted = packet.connectionSequenceLow2 == expectedGuard;
+        guardAccepted =
+            packet.connectionSequenceLow2 == expectedGuard && packet_channel_matches(*peer, input);
     }
     }
     if (guardAccepted) {
     if (guardAccepted) {
         if (packet.ack.outboundHeadPresent) {
         if (packet.ack.outboundHeadPresent) {

+ 127 - 0
Sunrise/src/server/gameplay/peer/peer_packet_fragments.cpp

@@ -0,0 +1,127 @@
+#include "peer_packet_fragments.h"
+
+#include <new>
+
+#include "../../../middleware/gameplay/peer/established_packet.h"
+#include "../gameplay_log.h"
+#include "peer_transport_internal.h"
+
+namespace sunrise::server::gameplay::peer {
+namespace {
+namespace gp = state::gameplay;
+namespace wire = middleware::gameplay::peer;
+namespace fragments = wire::packet_fragments;
+/** The second packet bit selects the outer fragmentation header. */
+constexpr std::byte kFragmentedPacketBit{0x40};
+
+/** Reassembly belongs to one exact peer channel, independently of its external views. */
+struct FragmentChannel final {
+    fragments::Store packets{};
+    std::uint64_t peerGeneration{}, channelGeneration{};
+    std::uint32_t remoteConnectionSequence{}, localConnectionSequence{};
+};
+std::array<FragmentChannel, gp::kAssociationCapacity> g_fragmentChannels{};
+
+} // namespace
+
+/**
+ * Reassembles only authenticated fragments from the currently bound channel.
+ * @param from Peer endpoint including the local listening port.
+ * @param payload One decrypted fragment.
+ * @param now Arrival time in milliseconds.
+ * @param output Receives the whole original packet on completion.
+ * @param source Captures the channel that supplied the completed packet.
+ * @return True only when every piece and the inner guard agree.
+ */
+bool join_packet_fragments(const gp::Endpoint& from,
+                           std::span<const std::byte> payload,
+                           std::uint64_t now,
+                           JoinedPacket& output,
+                           gp::entity_identity::Source& source) noexcept {
+    fragments::Result result = fragments::Result::refused;
+    AcquireSRWLockExclusive(&g_lock);
+    gp::PeerLink* const peer = find_locked(from);
+    if (peer != nullptr && peer->stage >= gp::PeerStage::connecting && peer->peerGeneration != 0
+        && peer->channelGeneration != 0) {
+        auto& channel = g_fragmentChannels[static_cast<std::size_t>(peer - g_peers.data())];
+        if (channel.peerGeneration != peer->peerGeneration
+            || channel.channelGeneration != peer->channelGeneration
+            || channel.remoteConnectionSequence != peer->remoteConnectionSequence
+            || channel.localConnectionSequence != peer->localConnectionSequence) {
+            channel.packets.reset();
+            channel.peerGeneration = peer->peerGeneration;
+            channel.channelGeneration = peer->channelGeneration;
+            channel.remoteConnectionSequence = peer->remoteConnectionSequence;
+            channel.localConnectionSequence = peer->localConnectionSequence;
+        }
+        result =
+            channel.packets.accept(payload,
+                                   wire::connection_sequence_low2(peer->remoteConnectionSequence),
+                                   now,
+                                   output.bytes,
+                                   output.size);
+        source = entity_source(*peer);
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    if (result == fragments::Result::complete) {
+        report(core::log::Level::debug,
+               "ev=gameplay stage=packet_reassembly result=complete bytes=%zu",
+               output.size);
+    } else if (result == fragments::Result::refused) {
+        report(core::log::Level::debug,
+               "ev=gameplay stage=packet_reassembly result=refused bytes=%zu",
+               payload.size());
+    }
+    return result == fragments::Result::complete;
+}
+
+/**
+ * Borrows ordinary input and assembles fragmented input outside the caller stack.
+ * @param from Authenticated peer endpoint.
+ * @param payload Decrypted transport datagram.
+ * @param now Arrival time in milliseconds.
+ * @param output Retains the complete packet and its channel identity.
+ * @return False for incomplete, duplicate, or invalid fragments.
+ */
+bool prepare_packet_input(const gp::Endpoint& from,
+                          std::span<const std::byte> payload,
+                          std::uint64_t now,
+                          PacketInput& output) noexcept {
+    output = {};
+    if (payload.empty()) {
+        return false;
+    }
+    if ((payload.front() & kFragmentedPacketBit) == std::byte{}) {
+        output.payload = payload;
+        return true;
+    }
+    output.storage.reset(new (std::nothrow) JoinedPacket{});
+    if (!output.storage
+        || !join_packet_fragments(from, payload, now, *output.storage, output.source)) {
+        return false;
+    }
+    output.payload = std::span(output.storage->bytes).first(output.storage->size);
+    return true;
+}
+
+/** A guard-bit wrap does not make a replaced channel the source of an old packet. */
+bool packet_channel_matches(const gp::PeerLink& peer, const PacketInput& input) noexcept {
+    return !input.storage
+           || (peer.peerGeneration == input.source.peerGeneration
+               && peer.channelGeneration == input.source.channelGeneration
+               && peer.remoteConnectionSequence == input.source.remoteConnectionSequence
+               && peer.localConnectionSequence == input.source.localConnectionSequence);
+}
+
+/** Releases every retained fragment when the peer transport resets. */
+void reset_packet_assemblies() noexcept {
+    for (auto& channel : g_fragmentChannels) {
+        channel.packets.reset();
+        channel.peerGeneration = 0;
+        channel.channelGeneration = 0;
+        channel.remoteConnectionSequence = 0;
+        channel.localConnectionSequence = 0;
+    }
+}
+
+} // namespace sunrise::server::gameplay::peer

+ 34 - 0
Sunrise/src/server/gameplay/peer/peer_packet_fragments.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <memory>
+
+#include "../../../middleware/gameplay/peer/packet_fragments.h"
+#include "../../../state/gameplay/definition.h"
+#include "../../../state/gameplay/external/entity_identity.h"
+
+namespace sunrise::server::gameplay::peer {
+
+/** One complete native packet spans at most eight transport fragments. */
+struct JoinedPacket final {
+    std::array<std::byte, middleware::gameplay::peer::packet_fragments::kMaximumPacketBytes>
+        bytes{};
+    std::size_t size{};
+};
+
+/** Whole datagrams borrow input; fragmented packets retain their assembled bytes and channel. */
+struct PacketInput final {
+    std::span<const std::byte> payload{};
+    std::unique_ptr<JoinedPacket> storage{};
+    state::gameplay::entity_identity::Source source{};
+};
+
+/** Only complete packets reach the established decoder. */
+[[nodiscard]] bool prepare_packet_input(const state::gameplay::Endpoint& from,
+                                        std::span<const std::byte> payload,
+                                        std::uint64_t now,
+                                        PacketInput& output) noexcept;
+/** A completed packet cannot cross a channel replacement after reassembly unlocks. */
+[[nodiscard]] bool packet_channel_matches(const state::gameplay::PeerLink&,
+                                          const PacketInput&) noexcept;
+
+} // namespace sunrise::server::gameplay::peer

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

@@ -689,6 +689,7 @@ void reset() noexcept {
     std::array<gp::entity_identity::Source, gp::kAssociationCapacity> sources{};
     std::array<gp::entity_identity::Source, gp::kAssociationCapacity> sources{};
     std::size_t sourceCount = 0;
     std::size_t sourceCount = 0;
     AcquireSRWLockExclusive(&g_lock);
     AcquireSRWLockExclusive(&g_lock);
+    reset_packet_assemblies();
     for (gp::PeerLink& peer : g_peers) {
     for (gp::PeerLink& peer : g_peers) {
         sources[sourceCount++] = entity_source(peer);
         sources[sourceCount++] = entity_source(peer);
         invalidate_entity_identity_locked(sources[sourceCount - 1]);
         invalidate_entity_identity_locked(sources[sourceCount - 1]);

+ 2 - 0
Sunrise/src/server/gameplay/peer/peer_transport_internal.h

@@ -30,6 +30,8 @@ extern bool (*g_entityAccepted)(const void*,
                                 const middleware::gameplay::external::EntityBatch&) noexcept;
                                 const middleware::gameplay::external::EntityBatch&) noexcept;
 extern const void* g_entityAcceptedContext;
 extern const void* g_entityAcceptedContext;
 extern EntityTransport g_entityTransport;
 extern EntityTransport g_entityTransport;
+/** Drops incomplete outer packets; callers hold the peer lock. */
+void reset_packet_assemblies() noexcept;
 /** Channel ids this host hands out. The peer refuses one that does not increase. */
 /** Channel ids this host hands out. The peer refuses one that does not increase. */
 extern std::uint32_t g_channelId;
 extern std::uint32_t g_channelId;
 
 

+ 256 - 8
Sunrise/src/server/gameplay/squad_entity_retirement.cpp

@@ -4,8 +4,12 @@
 
 
 #include <algorithm>
 #include <algorithm>
 #include <cstdio>
 #include <cstdio>
+#include <new>
+#include <utility>
 
 
 #include "../../core/logging/log.h"
 #include "../../core/logging/log.h"
+#include "../../state/activity_sdk/runtime.h"
+#include "../../state/build_data/scriptables/scriptable_catalog.h"
 #include "../../state/gameplay/external/entity_object_types.h"
 #include "../../state/gameplay/external/entity_object_types.h"
 #include "../../state/gameplay/external/entity_position_profiles.h"
 #include "../../state/gameplay/external/entity_position_profiles.h"
 #include "../activity/host_runtime.h"
 #include "../activity/host_runtime.h"
@@ -18,11 +22,43 @@ namespace policy = state::gameplay::squad_entity_retirement;
 namespace identities = state::gameplay::entity_identity;
 namespace identities = state::gameplay::entity_identity;
 SRWLOCK g_lock{SRWLOCK_INIT};
 SRWLOCK g_lock{SRWLOCK_INIT};
 policy::Store g_store;
 policy::Store g_store;
+policy::Store g_propStore;
 struct Target final {
 struct Target final {
     std::uint64_t session{}, revision{}, generation{};
     std::uint64_t session{}, revision{}, generation{};
     policy::Eligibility eligibility{};
     policy::Eligibility eligibility{};
 };
 };
 std::vector<Target> g_targets;
 std::vector<Target> g_targets;
+struct PropTransition final {
+    identities::Source source{};
+    std::uint64_t transition{};
+    std::int32_t fromRegion{}, toRegion{};
+    std::uint8_t bubble{};
+};
+std::vector<PropTransition> g_propTransitions;
+/** Eight authored states share one bubble's wire authority selector. */
+constexpr std::int32_t kStatesPerBubble = 8;
+/** One retained transition per admitted source bounds pending lifetime state. */
+constexpr std::size_t kMaximumPropTransitions = identities::kSourceCapacity;
+
+/** Resolves native cells through the installed destination map. */
+policy::CellBubbles cell_bubbles(const state::activity::SessionBinding& binding) noexcept {
+    policy::CellBubbles cells{};
+    cells.fill(-1);
+    const auto& destination = binding.destination;
+    if (destination.packageNameLength > destination.packageName.size()) {
+        return cells;
+    }
+    const std::string_view name(reinterpret_cast<const char*>(destination.packageName.data()),
+                                destination.packageNameLength);
+    for (std::size_t index = 0; index < cells.size(); ++index) {
+        std::uint8_t bubble{};
+        if (state::gameplay::entity_position_profiles::lookup_bubble(
+                name, static_cast<std::uint16_t>(index), bubble)) {
+            cells[index] = bubble;
+        }
+    }
+    return cells;
+}
 /** Exactly one admitted current view must own the ActivityClient generation. */
 /** Exactly one admitted current view must own the ActivityClient generation. */
 bool snapshot(const state::activity::SessionBinding& binding,
 bool snapshot(const state::activity::SessionBinding& binding,
               std::uint64_t generation,
               std::uint64_t generation,
@@ -190,6 +226,204 @@ void report(const char* stage,
     core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
     core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
 }
 }
 } // namespace
 } // namespace
+/**
+ * Captures outgoing map-prop allocations for an explicit same-bubble state reset.
+ * @param
+ * view Authenticated activity and source generation.
+ * @param world Pinned generated package graph
+ * for that activity.
+ * @param transition Durable mission intent identity.
+ * @param fromRegion
+ * Instantiated outgoing authored state.
+ * @param toRegion Requested authored state.
+ * @return
+ * Ready only after every captured tree's purge publication has committed.
+ */
+TransitionStatus begin_placed_transition(const state::activity_sdk::BoundView& view,
+                                         const state::build_data::scriptables::Snapshot& world,
+                                         std::uint64_t transition,
+                                         std::int32_t fromRegion,
+                                         std::int32_t toRegion) noexcept {
+    namespace data = state::build_data::scriptables;
+    const auto& destination = view.binding.destination;
+    if (!transition || !view.activityClientGeneration || world.scenarioNameLength == 0
+        || world.scenarioNameLength > world.scenarioName.size()
+        || destination.packageNameLength > destination.packageName.size()
+        || std::string_view(world.scenarioName.data(), world.scenarioNameLength)
+               != std::string_view(reinterpret_cast<const char*>(destination.packageName.data()),
+                                   destination.packageNameLength)
+        || !state::activity::binding_matches(view.binding)) {
+        return TransitionStatus::refused;
+    }
+    AcquireSRWLockShared(&g_lock);
+    for (const auto& prior : g_propTransitions) {
+        if (prior.source.activitySessionId == view.binding.sessionId
+            && prior.source.activityRevision == view.binding.createdRevision
+            && prior.source.activityClientGeneration == view.activityClientGeneration
+            && prior.transition == transition) {
+            const bool pending = g_propStore.pending(prior.source, prior.bubble);
+            const bool same = prior.toRegion == toRegion;
+            ReleaseSRWLockShared(&g_lock);
+            return !same ? TransitionStatus::refused
+                         : (pending ? TransitionStatus::pending : TransitionStatus::ready);
+        }
+    }
+    ReleaseSRWLockShared(&g_lock);
+    if (fromRegion == toRegion && fromRegion >= 0) {
+        return TransitionStatus::ready;
+    }
+    const auto& diagnostics = world.containerPlacementDiagnostics;
+    if (fromRegion < 0 || toRegion < 0
+        || fromRegion / kStatesPerBubble >= state::activity::bubble_authority::kFallbackBubble
+        || fromRegion / kStatesPerBubble != toRegion / kStatesPerBubble
+        || world.status != data::BuildStatus::ready || world.coverage != data::BuildCoverage::full
+        || !diagnostics.complete || !diagnostics.contextResolved
+        || !diagnostics.identityOwnerInventoryComplete
+        || !state::gameplay::entity_object_types::available()) {
+        return TransitionStatus::refused;
+    }
+    const auto bubble = static_cast<std::uint8_t>(fromRegion / kStatesPerBubble);
+    if (bubble >= world.bubbles.size() || world.bubbles[bubble].isPublic
+        || static_cast<std::uint32_t>(fromRegion % kStatesPerBubble)
+               >= world.bubbles[bubble].stateCount
+        || static_cast<std::uint32_t>(toRegion % kStatesPerBubble)
+               >= world.bubbles[bubble].stateCount) {
+        return TransitionStatus::refused;
+    }
+    identities::Source source{};
+    std::vector<identities::Identity> rows;
+    if (!snapshot(view.binding, view.activityClientGeneration, source, rows)) {
+        return TransitionStatus::refused;
+    }
+    try {
+        std::vector<bool> owned(world.containerPlacementLists.size());
+        for (const auto& owner : world.containerPlacementOwners) {
+            if (owner.listRow < owned.size()
+                && data::container_placement_owner_applies(owner, bubble)) {
+                owned[owner.listRow] = true;
+            }
+        }
+        std::vector<std::pair<std::uint32_t, std::uint8_t>> definitions;
+        for (const auto& placement : world.containerPlacements) {
+            if (placement.listRow < owned.size() && owned[placement.listRow] && placement.complete
+                && world.containerPlacementLists[placement.listRow].complete
+                && placement.placementIdentifierRead && placement.placementIdentifier != 0
+                && placement.placementIdentifier != UINT64_MAX
+                && std::find(policy::kRetirablePropTypes.begin(),
+                             policy::kRetirablePropTypes.end(),
+                             placement.objectType)
+                       != policy::kRetirablePropTypes.end()) {
+                definitions.emplace_back(placement.classListTag, placement.objectType);
+            }
+        }
+        std::sort(definitions.begin(), definitions.end());
+        definitions.erase(std::unique(definitions.begin(), definitions.end()), definitions.end());
+        std::vector<policy::Eligibility> eligible;
+        policy::Mask mask{};
+        for (const auto& row : rows) {
+            if (!row.known || !row.present || row.conflicted || row.type != 0
+                || !row.metadata.hasRsat) {
+                continue;
+            }
+            state::gameplay::entity_object_types::Row object{};
+            if (!state::gameplay::entity_object_types::lookup(row.metadata.rsatTag, object)
+                || !std::binary_search(definitions.begin(),
+                                       definitions.end(),
+                                       std::pair{object.definitionTag, object.objectType})) {
+                continue;
+            }
+            policy::Eligibility target{};
+            target.rsatTag = object.rsatTag;
+            target.bubble = bubble;
+            target.enabled = true;
+            target.placedProp = true;
+            target.objectType = object.objectType;
+            if (std::find(eligible.begin(), eligible.end(), target) == eligible.end()) {
+                eligible.push_back(target);
+            }
+        }
+        // The script ends these lifetimes; release reports are not needed to authorize their end.
+        for (const auto& row : rows) {
+            if (row.present && row.token.slot < identities::kSlotCapacity) {
+                mask[row.token.slot / 8] |= std::byte(1U << (row.token.slot % 8));
+            }
+        }
+        if (eligible.empty()) {
+            return TransitionStatus::refused;
+        }
+        const auto cells = cell_bubbles(view.binding);
+        AcquireSRWLockExclusive(&g_lock);
+        for (const auto& old : g_propTransitions) {
+            if (old.source.activitySessionId == source.activitySessionId
+                && old.source.activityClientGeneration == source.activityClientGeneration
+                && g_propStore.pending(old.source, old.bubble)) {
+                ReleaseSRWLockExclusive(&g_lock);
+                return TransitionStatus::refused;
+            }
+        }
+        std::erase_if(g_propTransitions, [&](const PropTransition& old) {
+            if (old.source.activitySessionId != source.activitySessionId) {
+                return false;
+            }
+            g_propStore.invalidate_source(old.source, old.bubble);
+            return true;
+        });
+        if (g_propTransitions.size() == kMaximumPropTransitions) {
+            ReleaseSRWLockExclusive(&g_lock);
+            return TransitionStatus::refused;
+        }
+        // Reserve before capturing so allocation failure cannot orphan an active retirement.
+        try {
+            g_propTransitions.reserve(g_propTransitions.size() + 1);
+        } catch (const std::bad_alloc&) {
+            ReleaseSRWLockExclusive(&g_lock);
+            return TransitionStatus::refused;
+        }
+        const bool captured =
+            g_propStore.capture(source, bubble, mask, rows, eligible, cells, true);
+        if (captured) {
+            g_propTransitions.push_back({source, transition, fromRegion, toRegion, bubble});
+        }
+        ReleaseSRWLockExclusive(&g_lock);
+        report("placed_transition", captured, bubble);
+        return captured ? TransitionStatus::pending : TransitionStatus::refused;
+    } catch (const std::bad_alloc&) {
+        return TransitionStatus::refused;
+    }
+}
+
+/** Reports a retained world change without starting a new capture. */
+bool placed_transition_pending(const state::activity::SessionBinding& binding,
+                               std::uint64_t generation) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const bool pending = std::any_of(
+        g_propTransitions.begin(), g_propTransitions.end(), [&](const PropTransition& transition) {
+            return transition.source.activitySessionId == binding.sessionId
+                   && transition.source.activityRevision == binding.createdRevision
+                   && transition.source.activityClientGeneration == generation
+                   && g_propStore.pending(transition.source, transition.bubble);
+        });
+    ReleaseSRWLockShared(&g_lock);
+    return pending;
+}
+
+/** Withdraws the exact mission's captured prop trees without sending a purge. */
+void cancel_placed_transition(const state::activity::SessionBinding& binding,
+                              std::uint64_t generation,
+                              std::uint64_t transition) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    std::erase_if(g_propTransitions, [&](const PropTransition& row) {
+        const bool matches = row.source.activitySessionId == binding.sessionId
+                             && row.source.activityRevision == binding.createdRevision
+                             && row.source.activityClientGeneration == generation
+                             && (transition == 0 || row.transition == transition);
+        if (matches) {
+            g_propStore.invalidate_source(row.source, row.bubble);
+        }
+        return matches;
+    });
+    ReleaseSRWLockExclusive(&g_lock);
+}
 /** Delivered squad choices replace eligibility for their exact authored target. */
 /** Delivered squad choices replace eligibility for their exact authored target. */
 void record_delivered_target(const state::activity::SessionBinding& binding,
 void record_delivered_target(const state::activity::SessionBinding& binding,
                              std::uint64_t generation,
                              std::uint64_t generation,
@@ -289,6 +523,7 @@ void returned_slots(const state::activity::SessionBinding& binding,
                     const state::activity::bubble_authority::EntitySlotMask& mask) noexcept {
                     const state::activity::bubble_authority::EntitySlotMask& mask) noexcept {
     AcquireSRWLockExclusive(&g_lock);
     AcquireSRWLockExclusive(&g_lock);
     g_store.returned_slots(binding.sessionId, generation, mask);
     g_store.returned_slots(binding.sessionId, generation, mask);
+    g_propStore.returned_slots(binding.sessionId, generation, mask);
     ReleaseSRWLockExclusive(&g_lock);
     ReleaseSRWLockExclusive(&g_lock);
 }
 }
 /** A fresh atomic identity snapshot must still match each captured tree. */
 /** A fresh atomic identity snapshot must still match each captured tree. */
@@ -303,7 +538,9 @@ bool prepare_retirement(const state::activity::SessionBinding& binding,
         return false;
         return false;
     }
     }
     AcquireSRWLockShared(&g_lock);
     AcquireSRWLockShared(&g_lock);
-    const bool ready = g_store.prepare(source, bubble, rows, output);
+    const bool ready = g_propStore.pending(source, bubble)
+                           ? g_propStore.prepare(source, bubble, rows, output)
+                           : g_store.prepare(source, bubble, rows, output);
     ReleaseSRWLockShared(&g_lock);
     ReleaseSRWLockShared(&g_lock);
     report("prepare", ready, bubble, &output.entities);
     report("prepare", ready, bubble, &output.entities);
     return ready;
     return ready;
@@ -333,11 +570,12 @@ bool begin_retirement_publication(const state::activity::SessionBinding& binding
     }
     }
     RetirementPlan current{};
     RetirementPlan current{};
     AcquireSRWLockShared(&g_lock);
     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;
+    const auto& store = plan.placedProps ? g_propStore : g_store;
+    const bool valid =
+        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 && current.placedProps == plan.placedProps;
     ReleaseSRWLockShared(&g_lock);
     ReleaseSRWLockShared(&g_lock);
     if (!valid) {
     if (!valid) {
         lease.release();
         lease.release();
@@ -352,15 +590,23 @@ bool validate_retirement(const state::activity::SessionBinding& binding,
     return plan.pending && prepare_retirement(binding, generation, plan.bubble, current)
     return plan.pending && prepare_retirement(binding, generation, plan.bubble, current)
            && current.source == plan.source && current.entities == plan.entities
            && current.source == plan.source && current.entities == plan.entities
            && current.lifetimes == plan.lifetimes && current.lifetimeCount == plan.lifetimeCount
            && current.lifetimes == plan.lifetimes && current.lifetimeCount == plan.lifetimeCount
-           && current.revision == plan.revision && current.bubble == plan.bubble;
+           && current.revision == plan.revision && current.bubble == plan.bubble
+           && current.placedProps == plan.placedProps;
 }
 }
 /** Retires exact lifetimes only after their carrying publication and identity lease have ended. */
 /** Retires exact lifetimes only after their carrying publication and identity lease have ended. */
 void commit_retirement(const RetirementPlan& plan) noexcept {
 void commit_retirement(const RetirementPlan& plan) noexcept {
     AcquireSRWLockExclusive(&g_lock);
     AcquireSRWLockExclusive(&g_lock);
-    const bool committed = g_store.commit(plan);
+    auto& store = plan.placedProps ? g_propStore : g_store;
+    const bool stored = store.commit(plan);
+    // A prop purge already published remains final if its script was cancelled after validation.
+    const bool committed = stored
+                           || (plan.placedProps && plan.pending && plan.lifetimeCount != 0
+                               && plan.lifetimeCount <= plan.lifetimes.size());
     if (committed) {
     if (committed) {
         g_store.returned_slots(
         g_store.returned_slots(
             plan.source.activitySessionId, plan.source.activityClientGeneration, plan.entities);
             plan.source.activitySessionId, plan.source.activityClientGeneration, plan.entities);
+        g_propStore.returned_slots(
+            plan.source.activitySessionId, plan.source.activityClientGeneration, plan.entities);
     }
     }
     ReleaseSRWLockExclusive(&g_lock);
     ReleaseSRWLockExclusive(&g_lock);
     if (committed) {
     if (committed) {
@@ -372,7 +618,9 @@ void commit_retirement(const RetirementPlan& plan) noexcept {
 void reset() noexcept {
 void reset() noexcept {
     AcquireSRWLockExclusive(&g_lock);
     AcquireSRWLockExclusive(&g_lock);
     g_store.reset();
     g_store.reset();
+    g_propStore.reset();
     g_targets.clear();
     g_targets.clear();
+    g_propTransitions.clear();
     ReleaseSRWLockExclusive(&g_lock);
     ReleaseSRWLockExclusive(&g_lock);
 }
 }
 } // namespace sunrise::server::gameplay::squad_entity_retirement
 } // namespace sunrise::server::gameplay::squad_entity_retirement

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

@@ -7,8 +7,29 @@ class PublicationLease;
 namespace sunrise::server::activity::host {
 namespace sunrise::server::activity::host {
 struct PendingScriptableOverride;
 struct PendingScriptableOverride;
 }
 }
+namespace sunrise::state::activity_sdk {
+struct BoundView;
+}
+namespace sunrise::state::build_data::scriptables {
+struct Snapshot;
+}
 namespace sunrise::server::gameplay::squad_entity_retirement {
 namespace sunrise::server::gameplay::squad_entity_retirement {
 using RetirementPlan = state::gameplay::squad_entity_retirement::RetirementPlan;
 using RetirementPlan = state::gameplay::squad_entity_retirement::RetirementPlan;
+enum class TransitionStatus : std::uint8_t { refused, pending, ready };
+/** A prop reset must finish publication before its script can move to the next state. */
+[[nodiscard]] TransitionStatus
+begin_placed_transition(const state::activity_sdk::BoundView&,
+                        const state::build_data::scriptables::Snapshot&,
+                        std::uint64_t transition,
+                        std::int32_t fromRegion,
+                        std::int32_t toRegion) noexcept;
+/** A pending host-owned prop lifetime change owes a retirement publication. */
+[[nodiscard]] bool placed_transition_pending(const state::activity::SessionBinding&,
+                                             std::uint64_t generation) noexcept;
+/** Cancelling a mission withdraws only its pending prop lifetime decision. */
+void cancel_placed_transition(const state::activity::SessionBinding&,
+                              std::uint64_t generation,
+                              std::uint64_t transition = 0) noexcept;
 /** The authenticated abdication freezes exact identities before a later renewal can retire them. */
 /** The authenticated abdication freezes exact identities before a later renewal can retire them. */
 void observe_abdication(const state::activity::SessionBinding&,
 void observe_abdication(const state::activity::SessionBinding&,
                         std::uint64_t generation,
                         std::uint64_t generation,

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

@@ -125,7 +125,8 @@ namespace {
            && left.actorCommandSelector == right.actorCommandSelector
            && left.actorCommandSelector == right.actorCommandSelector
            && left.actorCommandValue == right.actorCommandValue
            && left.actorCommandValue == right.actorCommandValue
            && left.authBitCount == right.authBitCount
            && left.authBitCount == right.authBitCount
-           && left.effectiveRegion == right.effectiveRegion && left.slotIndex == right.slotIndex
+           && left.effectiveRegion == right.effectiveRegion
+           && left.retirePlacedProps == right.retirePlacedProps && left.slotIndex == right.slotIndex
            && left.deviceValue == right.deviceValue && left.squadMode == right.squadMode
            && left.deviceValue == right.deviceValue && left.squadMode == right.squadMode
            && left.squadRetireOnReturn == right.squadRetireOnReturn
            && left.squadRetireOnReturn == right.squadRetireOnReturn
            && left.squadCount == right.squadCount && left.deviceChannel == right.deviceChannel
            && left.squadCount == right.squadCount && left.deviceChannel == right.deviceChannel

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

@@ -126,6 +126,8 @@ struct TypedIntent final {
     std::uint8_t lifetimeState{};
     std::uint8_t lifetimeState{};
     bool deviceSnap{};
     bool deviceSnap{};
     bool active{};
     bool active{};
+    /** A state transition may end its captured map-prop lifetimes before teleporting. */
+    bool retirePlacedProps{};
 };
 };
 
 
 /** One durable action and the exact Host output revision assigned to it. */
 /** One durable action and the exact Host output revision assigned to it. */

+ 2 - 2
Sunrise/src/state/gameplay/external/entity_identity.h

@@ -11,8 +11,8 @@ namespace sunrise::state::gameplay::entity_identity {
 /** Native entity and packet sequence domains. */
 /** Native entity and packet sequence domains. */
 inline constexpr std::size_t kSlotCapacity = 8192;
 inline constexpr std::size_t kSlotCapacity = 8192;
 inline constexpr std::size_t kSourceCapacity = 16;
 inline constexpr std::size_t kSourceCapacity = 16;
-/** One atomic packet, including terminal descendants, has bounded server storage. */
-inline constexpr std::size_t kObservationBatchCapacity = 16;
+/** The native external scheduler admits at most 256 records per packet. */
+inline constexpr std::size_t kObservationBatchCapacity = 256;
 inline constexpr std::uint16_t kPacketModulus = 1024;
 inline constexpr std::uint16_t kPacketModulus = 1024;
 inline constexpr std::uint16_t kNoCell = 0xFFFF;
 inline constexpr std::uint16_t kNoCell = 0xFFFF;
 
 

+ 12 - 2
Sunrise/src/state/gameplay/external/squad_entity_retirement.h

@@ -10,12 +10,16 @@ namespace sunrise::state::gameplay::squad_entity_retirement {
 namespace identities = entity_identity;
 namespace identities = entity_identity;
 using Mask = activity::bubble_authority::EntitySlotMask;
 using Mask = activity::bubble_authority::EntitySlotMask;
 using CellBubbles = std::array<std::int16_t, 256>;
 using CellBubbles = std::array<std::int16_t, 256>;
-/** Only a delivered, positive authored squad request can supply an eligible actor class. */
+/** Native map-prop types exclude actors, players, weapons and interactive objects. */
+inline constexpr std::array<std::uint8_t, 5> kRetirablePropTypes{1, 2, 3, 7, 8};
+/** Retirement eligibility comes from an explicit host policy for an exact class. */
 struct Eligibility final {
 struct Eligibility final {
     identities::SquadReference squad{};
     identities::SquadReference squad{};
     std::uint32_t rsatTag{};
     std::uint32_t rsatTag{};
     std::uint8_t bubble{};
     std::uint8_t bubble{};
     bool enabled{};
     bool enabled{};
+    bool placedProp{};
+    std::uint8_t objectType{};
     bool operator==(const Eligibility&) const = default;
     bool operator==(const Eligibility&) const = default;
 };
 };
 /** A prepared mask remains pending until its exact transport publication commits. */
 /** A prepared mask remains pending until its exact transport publication commits. */
@@ -33,6 +37,7 @@ struct RetirementPlan final {
     std::uint64_t revision{};
     std::uint64_t revision{};
     std::uint8_t bubble{};
     std::uint8_t bubble{};
     bool pending{};
     bool pending{};
+    bool placedProps{};
 };
 };
 static_assert(std::is_trivially_copyable_v<RetirementPlan>);
 static_assert(std::is_trivially_copyable_v<RetirementPlan>);
 /** Caller synchronization protects captured releases and their publication revisions. */
 /** Caller synchronization protects captured releases and their publication revisions. */
@@ -43,12 +48,15 @@ public:
                                const Mask&,
                                const Mask&,
                                std::span<const identities::Identity>,
                                std::span<const identities::Identity>,
                                std::span<const Eligibility>,
                                std::span<const Eligibility>,
-                               const CellBubbles&) noexcept;
+                               const CellBubbles&,
+                               bool requireComplete = false) noexcept;
     [[nodiscard]] bool prepare(const identities::Source&,
     [[nodiscard]] bool prepare(const identities::Source&,
                                std::uint8_t bubble,
                                std::uint8_t bubble,
                                std::span<const identities::Identity>,
                                std::span<const identities::Identity>,
                                RetirementPlan&) const noexcept;
                                RetirementPlan&) const noexcept;
     [[nodiscard]] bool commit(const RetirementPlan&) noexcept;
     [[nodiscard]] bool commit(const RetirementPlan&) noexcept;
+    [[nodiscard]] bool pending(const identities::Source&, std::uint8_t bubble) const noexcept;
+    void invalidate_source(const identities::Source&, std::uint8_t bubble) noexcept;
     void returned_slots(std::uint64_t session, std::uint64_t generation, const Mask&) noexcept;
     void returned_slots(std::uint64_t session, std::uint64_t generation, const Mask&) noexcept;
     void invalidate_target(std::uint64_t session,
     void invalidate_target(std::uint64_t session,
                            std::uint64_t generation,
                            std::uint64_t generation,
@@ -60,6 +68,8 @@ private:
         identities::Source source{};
         identities::Source source{};
         std::uint8_t bubble{};
         std::uint8_t bubble{};
         std::uint64_t revision{};
         std::uint64_t revision{};
+        bool requireComplete{};
+        bool invalidated{};
         struct Group final {
         struct Group final {
             identities::Token root{};
             identities::Token root{};
             Eligibility eligibility{};
             Eligibility eligibility{};

+ 145 - 57
Sunrise/src/state/gameplay/external/squad_entity_retirement_store.cpp

@@ -30,6 +30,24 @@ bool actor(const identities::Identity& row, const Eligibility& eligibility) {
            && row.actorSource.type == eligibility.squad.type
            && row.actorSource.type == eligibility.squad.type
            && row.actorSource.index == eligibility.squad.index;
            && row.actorSource.index == eligibility.squad.index;
 }
 }
+/** All-one bits name no installed class. */
+constexpr std::uint32_t kAbsentRsatTag = 0xFFFFFFFFU;
+/** A prop needs a positive class and type without any player or actor ownership. */
+bool prop(const identities::Identity& row) {
+    return row.known && row.present && !row.conflicted && row.type == 0 && row.metadata.hasRsat
+           && row.metadata.rsatTag != 0 && row.metadata.rsatTag != kAbsentRsatTag
+           && row.metadata.hasObjectType
+           && std::find(
+                  kRetirablePropTypes.begin(), kRetirablePropTypes.end(), row.metadata.objectType)
+                  != kRetirablePropTypes.end()
+           && !row.metadata.hasPlayerBroadcast && !row.metadata.hasSquad
+           && !row.actorSource.present;
+}
+bool root_matches(const identities::Identity& row, const Eligibility& eligibility) {
+    return eligibility.placedProp ? prop(row) && row.metadata.rsatTag == eligibility.rsatTag
+                                        && row.metadata.objectType == eligibility.objectType
+                                  : actor(row, eligibility);
+}
 /** Only an anchored weapon may use its parent tree when its own source was not reported. */
 /** Only an anchored weapon may use its parent tree when its own source was not reported. */
 bool weapon(const identities::Identity& row, const Eligibility& eligibility) {
 bool weapon(const identities::Identity& row, const Eligibility& eligibility) {
     /** Native object-type 14 is the weapon class. */
     /** Native object-type 14 is the weapon class. */
@@ -65,7 +83,7 @@ bool hierarchy(std::span<const identities::Identity> rows) {
     }
     }
     return true;
     return true;
 }
 }
-/** Closure traversal stops at unknown roles rather than retireing an unrelated descendant. */
+/** Unknown roles refuse the complete tree. */
 bool closure(identities::Token root,
 bool closure(identities::Token root,
              const Eligibility& eligibility,
              const Eligibility& eligibility,
              const Mask& released,
              const Mask& released,
@@ -73,7 +91,7 @@ bool closure(identities::Token root,
              Mask& output) {
              Mask& output) {
     output = {};
     output = {};
     if (root.slot >= rows.size() || rows[root.slot].token != root
     if (root.slot >= rows.size() || rows[root.slot].token != root
-        || !actor(rows[root.slot], eligibility) || rows[root.slot].anchorPresent
+        || !root_matches(rows[root.slot], eligibility) || rows[root.slot].anchorPresent
         || !bit(released, root.slot)) {
         || !bit(released, root.slot)) {
         return false;
         return false;
     }
     }
@@ -88,8 +106,10 @@ bool closure(identities::Token root,
             }
             }
             const bool squad =
             const bool squad =
                 row.type == 1 && row.metadata.hasSquad && row.metadata.squad == eligibility.squad;
                 row.type == 1 && row.metadata.hasSquad && row.metadata.squad == eligibility.squad;
-            if (!bit(released, row.token.slot)
-                || (!actor(row, eligibility) && !weapon(row, eligibility) && !squad)) {
+            const bool eligible = eligibility.placedProp ? prop(row)
+                                                         : actor(row, eligibility)
+                                                               || weapon(row, eligibility) || squad;
+            if (!bit(released, row.token.slot) || !eligible) {
                 return false;
                 return false;
             }
             }
             set(output, row.token.slot);
             set(output, row.token.slot);
@@ -99,64 +119,63 @@ bool closure(identities::Token root,
     return true;
     return true;
 }
 }
 } // namespace
 } // namespace
-/** Captures only complete, opted-in actor trees from the authenticated release report. */
+/** Strict captures refuse every matched prop tree that cannot be retired in full. */
 bool Store::capture(const identities::Source& source,
 bool Store::capture(const identities::Source& source,
                     std::uint8_t bubble,
                     std::uint8_t bubble,
                     const Mask& released,
                     const Mask& released,
                     std::span<const identities::Identity> rows,
                     std::span<const identities::Identity> rows,
                     std::span<const Eligibility> eligibility,
                     std::span<const Eligibility> eligibility,
-                    const CellBubbles& cells) noexcept {
+                    const CellBubbles& cells,
+                    bool requireComplete) noexcept {
     try {
     try {
         if (source.activitySessionId == 0 || source.activityClientGeneration == 0 || bubble >= 64
         if (source.activitySessionId == 0 || source.activityClientGeneration == 0 || bubble >= 64
             || !hierarchy(rows)) {
             || !hierarchy(rows)) {
             return false;
             return false;
         }
         }
-        for (auto& previous : releases_) {
-            if (previous.source != source || previous.bubble == bubble) {
-                continue;
-            }
-            const auto count = previous.groups.size();
-            std::erase_if(previous.groups, [&](const Release::Group& group) {
-                for (std::size_t i = 0; i < released.size(); ++i) {
-                    if ((released[i] & group.entities[i]) != std::byte{}) {
-                        return true;
-                    }
-                }
-                return false;
-            });
-            if (previous.groups.size() != count) {
-                previous.revision = ++revision_;
-            }
-        }
-        std::erase_if(releases_, [](const Release& previous) { return previous.groups.empty(); });
         Release next;
         Release next;
         next.source = source;
         next.source = source;
         next.bubble = bubble;
         next.bubble = bubble;
-        next.revision = ++revision_;
+        next.requireComplete = requireComplete;
         for (const auto& row : rows) {
         for (const auto& row : rows) {
-            if (!row.present || row.anchorPresent || !bit(released, row.token.slot)
-                || row.cell >= cells.size() || cells[row.cell] != bubble) {
+            if (!row.present || row.anchorPresent || !bit(released, row.token.slot)) {
                 continue;
                 continue;
             }
             }
             const Eligibility* selected = nullptr;
             const Eligibility* selected = nullptr;
             for (const auto& candidate : eligibility) {
             for (const auto& candidate : eligibility) {
-                if (candidate.enabled && candidate.bubble == bubble && actor(row, candidate)) {
+                if (candidate.enabled && candidate.bubble == bubble
+                    && root_matches(row, candidate)) {
                     if (selected) {
                     if (selected) {
+                        if (requireComplete && (selected->placedProp || candidate.placedProp)) {
+                            return false;
+                        }
                         selected = nullptr;
                         selected = nullptr;
                         break;
                         break;
                     }
                     }
                     selected = &candidate;
                     selected = &candidate;
                 }
                 }
             }
             }
-            if (!selected || std::count_if(rows.begin(), rows.end(), [&](const auto& candidate) {
-                                 return !candidate.anchorPresent && actor(candidate, *selected);
-                             }) != 1) {
+            if (!selected) {
+                continue;
+            }
+            if (row.cell >= cells.size() || cells[row.cell] != bubble) {
+                if (requireComplete && selected->placedProp) {
+                    return false;
+                }
+                continue;
+            }
+            if (!selected->placedProp
+                && std::count_if(rows.begin(), rows.end(), [&](const auto& candidate) {
+                       return !candidate.anchorPresent && actor(candidate, *selected);
+                   }) != 1) {
                 continue;
                 continue;
             }
             }
             Release::Group group;
             Release::Group group;
             group.root = row.token;
             group.root = row.token;
             group.eligibility = *selected;
             group.eligibility = *selected;
             if (!closure(row.token, *selected, released, rows, group.entities)) {
             if (!closure(row.token, *selected, released, rows, group.entities)) {
+                if (requireComplete && selected->placedProp) {
+                    return false;
+                }
                 continue;
                 continue;
             }
             }
             bool valid = true;
             bool valid = true;
@@ -170,6 +189,10 @@ bool Store::capture(const identities::Source& source,
                     group.captured.push_back(member);
                     group.captured.push_back(member);
                 }
                 }
             }
             }
+            if (requireComplete && selected->placedProp
+                && (!valid || group.captured.size() > RetirementPlan::kLifetimeCapacity)) {
+                return false;
+            }
             if (valid) {
             if (valid) {
                 next.groups.push_back(std::move(group));
                 next.groups.push_back(std::move(group));
             }
             }
@@ -202,16 +225,54 @@ bool Store::capture(const identities::Source& source,
                 }
                 }
             }
             }
         }
         }
-        std::erase_if(releases_, [&](const Release& old) {
-            return old.source.activitySessionId == source.activitySessionId
-                   && old.source.activityClientGeneration == source.activityClientGeneration
-                   && (old.source != source || old.bubble == bubble);
-        });
+        if (next.groups.empty() && requireComplete) {
+            return false;
+        }
         /** Pending releases are bounded by the native source and bubble domains. */
         /** Pending releases are bounded by the native source and bubble domains. */
         constexpr std::size_t kMaximumReleases = identities::kSourceCapacity * 64;
         constexpr std::size_t kMaximumReleases = identities::kSourceCapacity * 64;
-        if (next.groups.empty() || releases_.size() >= kMaximumReleases) {
+        const auto replacing =
+            std::count_if(releases_.begin(), releases_.end(), [&](const auto& old) {
+                return old.source.activitySessionId == source.activitySessionId
+                       && old.source.activityClientGeneration == source.activityClientGeneration
+                       && (old.source != source || old.bubble == bubble);
+            });
+        if (!next.groups.empty()
+            && releases_.size() - static_cast<std::size_t>(replacing) >= kMaximumReleases) {
             return false;
             return false;
         }
         }
+        // Reserve before changing retained releases; allocation failure leaves them intact.
+        releases_.reserve(releases_.size() + 1);
+        for (auto& previous : releases_) {
+            if (previous.source != source || previous.bubble == bubble) {
+                continue;
+            }
+            const auto count = previous.groups.size();
+            std::erase_if(previous.groups, [&](const Release::Group& group) {
+                for (std::size_t i = 0; i < released.size(); ++i) {
+                    if ((released[i] & group.entities[i]) != std::byte{}) {
+                        if (previous.requireComplete) {
+                            previous.invalidated = true;
+                            return false;
+                        }
+                        return true;
+                    }
+                }
+                return false;
+            });
+            if (previous.groups.size() != count || previous.invalidated) {
+                previous.revision = ++revision_;
+            }
+        }
+        std::erase_if(releases_, [&](const Release& old) {
+            return old.groups.empty()
+                   || (old.source.activitySessionId == source.activitySessionId
+                       && old.source.activityClientGeneration == source.activityClientGeneration
+                       && (old.source != source || old.bubble == bubble));
+        });
+        if (next.groups.empty()) {
+            return false;
+        }
+        next.revision = ++revision_;
         releases_.push_back(std::move(next));
         releases_.push_back(std::move(next));
         return true;
         return true;
     } catch (...) {
     } catch (...) {
@@ -231,18 +292,23 @@ bool Store::prepare(const identities::Source& source,
         if (release.source != source || release.bubble != bubble) {
         if (release.source != source || release.bubble != bubble) {
             continue;
             continue;
         }
         }
+        if (release.invalidated) {
+            return false;
+        }
         for (const auto& group : release.groups) {
         for (const auto& group : release.groups) {
-            if (std::count_if(rows.begin(),
-                              rows.end(),
-                              [&](const auto& row) {
-                                  return !row.anchorPresent && actor(row, group.eligibility);
-                              })
-                != 1) {
+            if (!group.eligibility.placedProp
+                && std::count_if(rows.begin(), rows.end(), [&](const auto& row) {
+                       return !row.anchorPresent && actor(row, group.eligibility);
+                   }) != 1) {
                 continue;
                 continue;
             }
             }
             Mask current{};
             Mask current{};
             if (!closure(group.root, group.eligibility, group.entities, rows, current)
             if (!closure(group.root, group.eligibility, group.entities, rows, current)
                 || current != group.entities) {
                 || current != group.entities) {
+                if (release.requireComplete && group.eligibility.placedProp) {
+                    output = {};
+                    return false;
+                }
                 continue;
                 continue;
             }
             }
             bool valid = true;
             bool valid = true;
@@ -253,30 +319,36 @@ bool Store::prepare(const identities::Source& source,
                 }
                 }
             }
             }
             if (valid) {
             if (valid) {
+                if (group.captured.size() > output.lifetimes.size()) {
+                    output = {};
+                    return false;
+                }
+                // A publication contains whole trees, even when the remaining space is too small.
+                if (group.captured.size() > output.lifetimes.size() - output.lifetimeCount) {
+                    continue;
+                }
                 for (std::size_t index = 0; index < output.entities.size(); ++index) {
                 for (std::size_t index = 0; index < output.entities.size(); ++index) {
                     output.entities[index] |= group.entities[index];
                     output.entities[index] |= group.entities[index];
                 }
                 }
+                for (const auto& member : group.captured) {
+                    output.lifetimes[output.lifetimeCount++] = {member.token,
+                                                                member.allocationSequence,
+                                                                member.allocationEpoch,
+                                                                member.allocationDomain};
+                }
+            } else if (release.requireComplete && group.eligibility.placedProp) {
+                output = {};
+                return false;
             }
             }
         }
         }
         if (!any(output.entities)) {
         if (!any(output.entities)) {
             return false;
             return false;
         }
         }
-        for (std::size_t slot = 0; slot < rows.size(); ++slot) {
-            if ((std::to_integer<unsigned>(output.entities[slot / 8]) & (1U << (slot % 8))) != 0) {
-                if (output.lifetimeCount == output.lifetimes.size()) {
-                    output = {};
-                    return false;
-                }
-                output.lifetimes[output.lifetimeCount++] = {rows[slot].token,
-                                                            rows[slot].allocationSequence,
-                                                            rows[slot].allocationEpoch,
-                                                            rows[slot].allocationDomain};
-            }
-        }
         output.source = source;
         output.source = source;
         output.bubble = bubble;
         output.bubble = bubble;
         output.revision = release.revision;
         output.revision = release.revision;
         output.pending = true;
         output.pending = true;
+        output.placedProps = release.requireComplete;
         return true;
         return true;
     }
     }
     return false;
     return false;
@@ -289,7 +361,7 @@ bool Store::commit(const RetirementPlan& plan) noexcept {
     }
     }
     for (auto& release : releases_) {
     for (auto& release : releases_) {
         if (release.source == plan.source && release.bubble == plan.bubble
         if (release.source == plan.source && release.bubble == plan.bubble
-            && release.revision == plan.revision) {
+            && release.revision == plan.revision && !release.invalidated) {
             Mask covered{};
             Mask covered{};
             for (const auto& group : release.groups) {
             for (const auto& group : release.groups) {
                 bool complete = true;
                 bool complete = true;
@@ -362,6 +434,10 @@ void Store::returned_slots(std::uint64_t session,
             std::erase_if(release.groups, [&](const Release::Group& group) {
             std::erase_if(release.groups, [&](const Release::Group& group) {
                 for (std::size_t i = 0; i < mask.size(); ++i) {
                 for (std::size_t i = 0; i < mask.size(); ++i) {
                     if ((group.entities[i] & mask[i]) != std::byte{}) {
                     if ((group.entities[i] & mask[i]) != std::byte{}) {
+                        if (release.requireComplete) {
+                            release.invalidated = true;
+                            return false;
+                        }
                         return true;
                         return true;
                     }
                     }
                 }
                 }
@@ -380,7 +456,7 @@ void Store::invalidate_target(std::uint64_t session,
             && release.source.activityClientGeneration == generation) {
             && release.source.activityClientGeneration == generation) {
             const auto old = release.groups.size();
             const auto old = release.groups.size();
             std::erase_if(release.groups, [&](const Release::Group& group) {
             std::erase_if(release.groups, [&](const Release::Group& group) {
-                return group.eligibility.squad == target;
+                return !group.eligibility.placedProp && group.eligibility.squad == target;
             });
             });
             if (release.groups.size() != old) {
             if (release.groups.size() != old) {
                 release.revision = ++revision_;
                 release.revision = ++revision_;
@@ -388,6 +464,18 @@ void Store::invalidate_target(std::uint64_t session,
         }
         }
     }
     }
 }
 }
+bool Store::pending(const identities::Source& source, std::uint8_t bubble) const noexcept {
+    return std::any_of(releases_.begin(), releases_.end(), [&](const Release& release) {
+        return release.source == source && release.bubble == bubble && !release.groups.empty();
+    });
+}
+/** Cancelling a transition invalidates every outstanding plan for its captured source. */
+void Store::invalidate_source(const identities::Source& source, std::uint8_t bubble) noexcept {
+    std::erase_if(releases_, [&](const Release& release) {
+        return release.source == source && release.bubble == bubble;
+    });
+    ++revision_;
+}
 void Store::reset() noexcept {
 void Store::reset() noexcept {
     releases_.clear();
     releases_.clear();
     ++revision_;
     ++revision_;