فهرست منبع

Add squad attachments, volume toggles and trigger state to the mission runtime

Confetti3 1 ماه پیش
والد
کامیت
85ed609d1c
63فایلهای تغییر یافته به همراه2013 افزوده شده و 260 حذف شده
  1. 9 0
      Sunrise/Sunrise.vcxproj
  2. 1 1
      Sunrise/src/core/logging/log.cpp
  3. 2 0
      Sunrise/src/core/logging/log.h
  4. 2 0
      Sunrise/src/core/settings/json_scalar.cpp
  5. 44 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_body_codec.cpp
  6. 37 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp
  7. 1 1
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  8. 63 50
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp
  9. 64 0
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_reader.h
  10. 72 0
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_root.cpp
  11. 1 1
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  12. 7 4
      Sunrise/src/middleware/bap/activity_message/sense_update.h
  13. 35 0
      Sunrise/src/middleware/bap/activity_message/squad_attachment_auth.h
  14. 29 0
      Sunrise/src/middleware/bap/activity_message/volume_toggle_auth.h
  15. 16 1
      Sunrise/src/server/activity/activity_sdk_device_prepare.cpp
  16. 78 0
      Sunrise/src/server/activity/activity_sdk_device_runtime.cpp
  17. 11 0
      Sunrise/src/server/activity/activity_sdk_device_runtime.h
  18. 12 28
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  19. 89 16
      Sunrise/src/server/activity/host_runtime.h
  20. 8 5
      Sunrise/src/server/activity/host_runtime_incidents.cpp
  21. 31 0
      Sunrise/src/server/activity/host_runtime_internal.h
  22. 18 12
      Sunrise/src/server/activity/host_runtime_mission_inputs.cpp
  23. 19 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  24. 22 3
      Sunrise/src/server/activity/host_runtime_scriptable.cpp
  25. 3 1
      Sunrise/src/server/activity/host_runtime_scriptable_requests.cpp
  26. 25 1
      Sunrise/src/server/activity/host_runtime_scriptable_staging.cpp
  27. 75 13
      Sunrise/src/server/activity/host_runtime_sense.cpp
  28. 41 0
      Sunrise/src/server/activity/mission/mission_observation_epoch.h
  29. 40 2
      Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp
  30. 4 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  31. 106 5
      Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp
  32. 1 0
      Sunrise/src/server/activity/mission/mission_script_lua_names.h
  33. 123 0
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  34. 56 0
      Sunrise/src/server/activity/mission/mission_script_lua_state_api.cpp
  35. 14 0
      Sunrise/src/server/activity/mission/mission_script_object_sense.h
  36. 2 0
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  37. 1 0
      Sunrise/src/server/activity/mission/mission_script_runtime.h
  38. 5 1
      Sunrise/src/server/activity/mission/mission_script_runtime_attach.cpp
  39. 3 0
      Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp
  40. 42 13
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  41. 211 48
      Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp
  42. 32 4
      Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp
  43. 30 5
      Sunrise/src/server/activity/mission/mission_script_runtime_internal.h
  44. 2 1
      Sunrise/src/server/activity/mission/mission_script_sdk_bridge.cpp
  45. 12 4
      Sunrise/src/server/activity/mission/mission_script_vm.cpp
  46. 3 0
      Sunrise/src/server/activity/mission/mission_script_vm.h
  47. 170 0
      Sunrise/src/server/activity/mission/mission_trigger_observation.h
  48. 165 0
      Sunrise/src/server/activity/squad_attachment_ownership_validation.h
  49. 51 0
      Sunrise/src/server/activity/squad_authored_profile.h
  50. 1 0
      Sunrise/src/server/bap/bap_activity_link_view.cpp
  51. 4 2
      Sunrise/src/server/bap/bap_scriptable_override_route.cpp
  52. 45 26
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_framing.cpp
  53. 1 0
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  54. 15 4
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  55. 3 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_report.cpp
  56. 32 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  57. 2 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp
  58. 2 0
      Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h
  59. 1 0
      Sunrise/src/server/bap/encrypted/push/snapshot/snapshot_storage.cpp
  60. 4 0
      Sunrise/src/server/bap/internal.h
  61. 2 2
      Sunrise/src/server/bap/runtime.h
  62. 6 2
      Sunrise/src/state/activity/mission/activity_mission_state.cpp
  63. 7 0
      Sunrise/src/state/activity/mission/definition.h

+ 9 - 0
Sunrise/Sunrise.vcxproj

@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -1195,6 +1196,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_start_activity_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_decoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_root.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_incident_grants.cpp" />
     <ClCompile Include="src\state\activity\receipts\activity_receipts.cpp" />
@@ -2070,8 +2072,15 @@
     <ClInclude Include="src\middleware\bap\activity_message\ghost_link_sense.h" />
     <ClInclude Include="src\middleware\bap\activity_message\interactable_object_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_auth_patch.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_sense_update_reader.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_effect_auth.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_spawn_reference.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\squad_attachment_auth.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\volume_toggle_auth.h" />
+    <ClInclude Include="src\server\activity\mission\mission_observation_epoch.h" />
+    <ClInclude Include="src\server\activity\mission\mission_trigger_observation.h" />
+    <ClInclude Include="src\server\activity\squad_attachment_ownership_validation.h" />
+    <ClInclude Include="src\server\activity\squad_authored_profile.h" />
     <ClInclude Include="src\state\activity_sdk\attachment_compatibility.h" />
     <ClInclude Include="src\middleware\bap\activity_message\music_section_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\roster_presence.h" />

+ 1 - 1
Sunrise/src/core/logging/log.cpp

@@ -22,7 +22,7 @@ namespace {
 constexpr std::array<std::string_view, static_cast<std::size_t>(Channel::count)> kChannelNames{
     "core", "client", "state", "server", "middleware"};
 /** Stable serialized severity names; Level::off is never emitted. */
-constexpr std::array<std::string_view, 4> kLevelNames{"error", "warn", "info", "debug"};
+constexpr std::array<std::string_view, 5> kLevelNames{"error", "warn", "info", "debug", "trace"};
 /** Optional logs are isolated below the shared generated-artifact directory. */
 constexpr std::wstring_view kLogDirectorySuffix = L"\\logs";
 /** The active log keeps one stable filename across process starts. */

+ 2 - 0
Sunrise/src/core/logging/log.h

@@ -26,6 +26,8 @@ enum class Level : unsigned char {
     warn,
     info,
     debug,
+    /** Per-row dumps (every roster Auth on every push). Off at the shipped `debug` threshold. */
+    trace,
     off,
 };
 

+ 2 - 0
Sunrise/src/core/settings/json_scalar.cpp

@@ -99,6 +99,8 @@ bool Parser::level_value(std::string_view name, log::Level& output) noexcept {
         output = log::Level::info;
     } else if (name == "debug") {
         output = log::Level::debug;
+    } else if (name == "trace") {
+        output = log::Level::trace;
     } else if (name == "off") {
         output = log::Level::off;
     } else {

+ 44 - 0
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_body_codec.cpp

@@ -5,6 +5,7 @@
 #include "combatant_auth.h"
 #include "mission_auth_patch.h"
 #include "scriptable_auth_internal.h"
+#include "squad_attachment_auth.h"
 
 // The two scriptable-auth bodies whose encoded width depends on the caller's values: the type-2
 // actor channel set and keyed lanes, and the type-34 filter predicate list. Both select their
@@ -842,4 +843,47 @@ bool validate_type2_body(std::span<const std::byte> input, std::size_t bitCount)
            && reader.remaining_bits() == input.size() * 8U - bitCount && finish_padding(reader);
 }
 
+bool encode_type26_squad_selection(const Type26SquadSelection& selection,
+                                   std::span<std::byte> output,
+                                   std::size_t& written,
+                                   std::size_t& writtenBits) noexcept {
+    written = 0;
+    writtenBits = 0;
+    if ((selection.active && selection.slotIndex < 0)
+        || (!selection.active
+            && (selection.registryKey != kClientRefAbsentKey || selection.slotIndex != -1))) {
+        return false;
+    }
+    const std::size_t bitCount = selection.active ? kType26SquadBitCount : kType26EmptyBitCount;
+    const std::size_t byteCount = (bitCount + 7U) / 8U;
+    if (output.size() < byteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(byteCount));
+    if (!writer.write(0, kBoolWidth) || !writer.write(0, kBoolWidth)) {
+        return false;
+    }
+    // Preserve all four authored zero scalars. Two are native control counters, not revisions
+    // that an attachment writer may freely advance. Auth dirty reconciliation owns the update.
+    for (std::size_t index = 0; index < 4; ++index) {
+        if (!writer.write(kSigned32Bias, kSigned32Width)) {
+            return false;
+        }
+    }
+    if (!write_absent_client_ref(writer) || !writer.write(selection.active ? 1U : 0U, kBoolWidth)) {
+        return false;
+    }
+    if (selection.active
+        && (!writer.write(kType26SquadSelectionSchema, kSigned32Width)
+            || !write_lane_client_ref(writer, {selection.registryKey, 1, selection.slotIndex}))) {
+        return false;
+    }
+    if (writer.bit_count() != bitCount || !writer.finish(written) || written != byteCount) {
+        written = 0;
+        return false;
+    }
+    writtenBits = bitCount;
+    return true;
+}
+
 } // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 37 - 0
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp

@@ -5,6 +5,7 @@
 #include <limits>
 
 #include "scriptable_auth_internal.h"
+#include "volume_toggle_auth.h"
 
 // The fixed-width scriptable-auth bodies that drive device state: authored objects, device
 // channels, configured-action pulses, triggers and timers. Each family has one encoder and one
@@ -454,4 +455,40 @@ bool encode_type24(const Type24Body& body,
     return true;
 }
 
+/** Exact955A descriptor: registry32, biased signed type7/index16, biased signed state2. */
+bool encode_type32_volume(const Type32VolumeBody& body,
+                          std::span<std::byte> output,
+                          std::size_t& written) noexcept {
+    written = 0;
+    if (body.registryKey == 0 || body.registryKey == 0xFFFFFFFFU || body.slotIndex < 0
+        || output.size() < kType32ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType32ByteCount));
+    return writer.write(body.registryKey, 32) && writer.write(kVolumeSlotType + 1U, 7)
+           && writer.write(static_cast<std::uint32_t>(body.slotIndex) + kSigned16Bias, 16)
+           && writer.write(body.active ? 2U : 1U, 2) && writer.bit_count() == kType32BitCount
+           && writer.finish(written) && written == kType32ByteCount;
+}
+
+/** Rejects no-op/reserved states, non-volume references and noncanonical trailing bits. */
+bool decode_type32_volume(std::span<const std::byte> input,
+                          std::size_t bitCount,
+                          Type32VolumeBody& body) noexcept {
+    if (bitCount != kType32BitCount || input.size() != kType32ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t key = 0, type = 0, index = 0, state = 0;
+    if (!reader.read(32, key) || key == 0 || key == 0xFFFFFFFFU || !reader.read(7, type)
+        || type != kVolumeSlotType + 1U || !reader.read(16, index) || index < kSigned16Bias
+        || !reader.read(2, state) || (state != 1 && state != 2) || !finish_padding(reader)) {
+        return false;
+    }
+    body = {static_cast<std::uint32_t>(key),
+            static_cast<std::int16_t>(index - kSigned16Bias),
+            state == 2};
+    return true;
+}
+
 } // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 1 - 1
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp

@@ -443,7 +443,7 @@ bool validate_type68_body(std::span<const std::byte> input, std::size_t bitCount
     if (active >= 0) {
         const std::size_t selected = static_cast<std::size_t>(active);
         if (names[selected] == 0 || names[selected] == kClientRefAbsentKey || elements[selected] < 0
-            || states[selected] < 0 || states[selected] > 2) {
+            || states[selected] < -1 || states[selected] > 2) {
             return false;
         }
     }

+ 63 - 50
Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp

@@ -3,11 +3,12 @@
 
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/byte_order.h"
+#include "activity_sense_update_reader.h"
 #include "sense_update.h"
 
 namespace sunrise::middleware::bap::activity_message::sense_update {
 namespace {
-namespace bits = middleware::encoding::bits;
+using namespace decoding;
 
 /** Field widths of the object header ClientRef, in bits. */
 constexpr std::uint8_t kKeyWidth = 32, kTypeWidth = 7, kIndexWidth = 16;
@@ -17,6 +18,7 @@ constexpr std::uint32_t kTypeBias = 1, kIndexBias = 32768;
 constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
 constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
 constexpr std::uint32_t kOccupancy = 0x80809531U;
+constexpr std::uint32_t kAttachment = 0x8080954AU;
 constexpr std::uint32_t kGhostLink = 0x80804D3EU;
 constexpr std::uint32_t kObject = 0x8080992EU;
 constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
@@ -26,6 +28,7 @@ constexpr std::uint32_t kSquadReals = 0x80807ECDU;
 constexpr std::uint32_t kObjectiveBlock = 0x80807F07U, kObjectiveTasks = 0x80807F08U;
 constexpr std::uint32_t kCombatant = 0x80807DA2U, kCombatantAtoms = 0x80807F6EU;
 constexpr std::uint32_t kCombatantKeyed = 0x80807DA3U, kCombatantLanes = 0x80807DA4U;
+constexpr std::uint32_t kEngagement = 0x808094F0U, kEngagementList = 0x808094F8U;
 /** Type-13 participation root and its nested blocks, in body order. */
 constexpr std::uint32_t kPlayer = 0x80804F2FU, kPlayerState = 0x808094E4U;
 constexpr std::uint32_t kPlayerScalars = 0x80804F39U, kPlayerTail = 0x80804F35U;
@@ -43,38 +46,6 @@ constexpr std::uint32_t kSpatialMaximumBits = 0x45000000U, kUnitMaximumBits = 0x
 
 enum class NativeStatus : std::uint8_t { complete, unsupported, unsafeCount, malformed };
 
-/** Bit reader with its own budget, so one object cannot consume the whole packet. */
-class Reader final {
-public:
-    Reader(bits::Reader& source, std::size_t budget, std::size_t total) noexcept
-        : source_(source), left_(budget), total_(total) {}
-    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
-        if (width > left_ || !source_.read(width, value)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] bool skip(std::size_t width) noexcept {
-        if (width > left_ || !source_.skip(width)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] std::size_t left() const noexcept {
-        return left_;
-    }
-    [[nodiscard]] std::size_t position() const noexcept {
-        return total_ - source_.remaining_bits();
-    }
-
-private:
-    bits::Reader& source_;
-    std::size_t left_{};
-    std::size_t total_{};
-};
-
 /** @param width Field width in bits. @return Low-bit mask of that width. */
 [[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
     return width == 64 ? (std::numeric_limits<std::uint64_t>::max)()
@@ -133,20 +104,11 @@ private:
     DecodedObject* object_{};
 };
 
-/** Required fields are present; optional fields consume one presence bit within the budget. */
-[[nodiscard]] bool present(Reader& reader, bool optional, bool& output) noexcept {
-    output = true;
-    if (!optional) {
-        return true;
-    }
-    std::uint64_t raw = 0;
-    if (!reader.read(1, raw)) {
-        return false;
-    }
-    output = raw != 0;
-    return true;
-}
-/** Records an unsigned value only when its presence bit and selected value fit. */
+/**
+ * Reads one optional or required unsigned field and records it.
+ * @param optional True when a presence bit precedes the value.
+ * @return True when the presence bit and any value were complete.
+ */
 [[nodiscard]] bool read_unsigned(Reader& reader,
                                  Values& values,
                                  std::uint32_t schema,
@@ -537,7 +499,51 @@ real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noe
     }
     return NativeStatus::complete;
 }
-/** Validates the complete participation body before its Ghost flag becomes usable. */
+/**
+ * The observed engagement list is empty. Nonempty custom35 elements remain unsupported until
+ * their native codec is established; a storage size is not a wire width.
+ */
+[[nodiscard]] NativeStatus decode_engagement(Reader& reader, Values& values) noexcept {
+    constexpr std::uint64_t capacity = 16;
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(5, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > capacity) {
+        return NativeStatus::unsafeCount;
+    }
+    if (count != 0) {
+        return NativeStatus::unsupported;
+    }
+    values.put(kEngagementList, 0, 0, at, 5, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+    return read_signed(reader, values, kEngagement, 1, 16, 16, 32768, false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+/** Build86657's attachment Sense has three required s32 counters and one bool. */
+[[nodiscard]] NativeStatus decode_attachment(Reader& reader, Values& values) noexcept {
+    for (std::uint16_t ordinal = 0; ordinal < 3; ++ordinal) {
+        if (!read_signed(reader,
+                         values,
+                         kAttachment,
+                         ordinal,
+                         32,
+                         32,
+                         (std::numeric_limits<std::int32_t>::min)(),
+                         false)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return read_bool(reader, values, kAttachment, 3, false) ? NativeStatus::complete
+                                                            : NativeStatus::malformed;
+}
+
+/**
+ * Decodes the type-13 participation body. The whole body is read before its Ghost flag counts,
+ * because a player mid-load publishes a body with the flag but without a settled actor.
+ */
 [[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
     // The participation field uses a signed 32-bit midpoint bias.
     constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
@@ -676,10 +682,14 @@ decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
         return decode_objective(reader, values);
     case kOccupancy:
         return decode_occupancy(reader, values);
+    case kAttachment:
+        return decode_attachment(reader, values);
     case kObject:
         return decode_object(reader, values);
     case kCombatant:
         return decode_combatant(reader, values);
+    case kEngagement:
+        return decode_engagement(reader, values);
     default:
         return NativeStatus::unsupported;
     }
@@ -724,8 +734,11 @@ bool decode_sense_update(std::span<const std::byte> input,
     }
     update.tailBits = static_cast<std::uint32_t>(reader.remaining_bits() + 1);
     if (root != 0) {
-        finish(update, reader, total, DecodeStatus::schemaUnavailable, consumed);
-        return true;
+        Reader rootBody(reader, reader.remaining_bits(), total);
+        if (!consume_root_sense(rootBody)) {
+            finish(update, reader, total, DecodeStatus::malformed, consumed);
+            return false;
+        }
     }
     bool partial = false;
     for (;;) {

+ 64 - 0
Sunrise/src/middleware/bap/activity_message/activity_sense_update_reader.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+
+/** Private to the msg-6 Sense decoder translation units. */
+namespace sunrise::middleware::bap::activity_message::sense_update::decoding {
+namespace bits = middleware::encoding::bits;
+
+/** Bit reader with its own budget, so one object cannot consume the whole packet. */
+class Reader final {
+public:
+    Reader(bits::Reader& source, std::size_t budget, std::size_t total) noexcept
+        : source_(source), left_(budget), total_(total) {}
+    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
+        if (width > left_ || !source_.read(width, value)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] bool skip(std::size_t width) noexcept {
+        if (width > left_ || !source_.skip(width)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] std::size_t left() const noexcept {
+        return left_;
+    }
+    [[nodiscard]] std::size_t position() const noexcept {
+        return total_ - source_.remaining_bits();
+    }
+
+private:
+    bits::Reader& source_;
+    std::size_t left_{};
+    std::size_t total_{};
+};
+
+/** Required fields are present; optional fields consume one presence bit within the budget. */
+[[nodiscard]] inline bool present(Reader& reader, bool optional, bool& output) noexcept {
+    output = true;
+    if (!optional) {
+        return true;
+    }
+    std::uint64_t raw = 0;
+    if (!reader.read(1, raw)) {
+        return false;
+    }
+    output = raw != 0;
+    return true;
+}
+
+/**
+ * Consumes the present build86657 80809445 root before the registry group stream.
+ * @return False when the root's framing does not fit the reader's budget.
+ */
+[[nodiscard]] bool consume_root_sense(Reader& reader) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::decoding

+ 72 - 0
Sunrise/src/middleware/bap/activity_message/activity_sense_update_root.cpp

@@ -0,0 +1,72 @@
+#include "activity_sense_update_reader.h"
+
+namespace sunrise::middleware::bap::activity_message::sense_update::decoding {
+namespace {
+
+/** Consumes one required count and its fixed-width elements, bounded by the native descriptor. */
+[[nodiscard]] bool consume_root_array(Reader& reader,
+                                      std::uint8_t countWidth,
+                                      std::uint32_t capacity,
+                                      std::uint8_t elementWidth) noexcept {
+    std::uint64_t count = 0;
+    return reader.read(countWidth, count) && count <= capacity
+           && reader.skip(static_cast<std::size_t>(count) * elementWidth);
+}
+
+/**
+ * Build86657 schemas 8080944C/80809452: optional word list, fixed words, and byte list.
+ * Only their framing is consumed; these root channels do not become squad observations.
+ */
+[[nodiscard]] bool consume_root_channels(Reader& reader,
+                                         std::uint8_t countWidth,
+                                         std::uint32_t capacity,
+                                         std::uint32_t fixedWords) noexcept {
+    bool exists = false;
+    if (!present(reader, true, exists)
+        || (exists && !consume_root_array(reader, countWidth, capacity, 32))) {
+        return false;
+    }
+    if (!present(reader, true, exists) || (exists && !reader.skip(fixedWords * 32))) {
+        return false;
+    }
+    return present(reader, true, exists)
+           && (!exists || consume_root_array(reader, countWidth, capacity, 8));
+}
+
+} // namespace
+
+/**
+ * Consumes the present build86657 80809445 root before the registry group stream.
+ * Its global channels hold 256 entries; up to 64 keyed rows each hold 96 local entries.
+ * Counts and nested presence come from the exact native reflection descriptors.
+ */
+bool consume_root_sense(Reader& reader) noexcept {
+    constexpr std::uint32_t globalCapacity = 256, keyedCapacity = 64, localCapacity = 96;
+    bool exists = false;
+    if (!present(reader, true, exists)
+        || (exists && !consume_root_channels(reader, 9, globalCapacity, 8))) {
+        return false;
+    }
+    if (!present(reader, true, exists)) {
+        return false;
+    }
+    if (!exists) {
+        return true;
+    }
+    std::uint64_t count = 0;
+    if (!reader.read(7, count) || count > keyedCapacity) {
+        return false;
+    }
+    for (std::uint64_t i = 0; i < count; ++i) {
+        if (!present(reader, true, exists) || (exists && !reader.skip(32))) {
+            return false;
+        }
+        if (!present(reader, true, exists)
+            || (exists && !consume_root_channels(reader, 7, localCapacity, 3))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::decoding

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

@@ -550,7 +550,7 @@ struct Type23Body final {
 struct Type68Preset final {
     std::uint32_t nameHash{};
     std::int32_t elementIndex{};
-    /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
+    /** Native directive state: -1 disables, 0 enters, 1 completes, 2 exits. */
     std::int8_t state{};
     bool visible{true};
     /** Authored type-47 destination; absent removes the explicit guidance marker. */

+ 7 - 4
Sunrise/src/middleware/bap/activity_message/sense_update.h

@@ -21,10 +21,13 @@ inline constexpr std::uint8_t kLiteralZeroWidth = 1;
 inline constexpr std::size_t kOuterByteCapacity = 514'048;
 /** The client's per-group scratch bounds one group substream. This capacity is also in bytes. */
 inline constexpr std::size_t kGroupByteCapacity = 102'400;
-/** Object rows retained for one diagnostic packet. Parsing continues after this fills. */
-inline constexpr std::size_t kDecodedObjectCapacity = 128;
-/** Scalar rows retained for one diagnostic packet. Parsing continues after this fills. */
-inline constexpr std::size_t kDecodedValueCapacity = 1024;
+/** Owned object rows per packet, shared by diagnostics and accepted mission input.
+ * Build-86657 region transitions exceed 128 rows. Overflow remains explicit and
+ * incomplete values are never delivered; this is a storage budget, not a wire limit.
+ */
+inline constexpr std::size_t kDecodedObjectCapacity = 256;
+/** Owned scalar rows, including absent fields, in the bounded packet storage budget. */
+inline constexpr std::size_t kDecodedValueCapacity = 4096;
 /** Runtime SDK row sentinel shared without depending on the State layer. */
 inline constexpr std::uint32_t kAbsentRuntimeRow = (std::numeric_limits<std::uint32_t>::max)();
 

+ 35 - 0
Sunrise/src/middleware/bap/activity_message/squad_attachment_auth.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "mission_effect_auth.h"
+
+namespace sunrise::middleware::bap::activity_message::scriptable_auth {
+
+/** Type-26 target selection with the two arena shield resources' neutral authored prefix. */
+inline constexpr std::uint32_t kType26Schema = mission_effect::kSchema;
+inline constexpr std::uint32_t kType26SquadSelectionSchema = 0x80809157U;
+inline constexpr std::size_t kType26EmptyBitCount = mission_effect::kBits;
+inline constexpr std::size_t kType26SquadBitCount = 273;
+inline constexpr std::size_t kType26MaximumByteCount = (kType26SquadBitCount + 7U) / 8U;
+
+/** No raw actor handles: the native selector enumerates this exact squad ClientRef. */
+struct Type26SquadSelection final {
+    std::uint32_t registryKey{0x811C9DC5U};
+    std::int16_t slotIndex{-1};
+    bool active{};
+};
+
+/**
+ * Encodes the neutral-prefix source selector or its exact empty removal form.
+ * Callers must establish the authored prefix and retain ownership of the attachment source.
+ * This body changes target attachment; it does not assert damage immunity.
+ */
+[[nodiscard]] bool encode_type26_squad_selection(const Type26SquadSelection& selection,
+                                                 std::span<std::byte> output,
+                                                 std::size_t& written,
+                                                 std::size_t& writtenBits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 29 - 0
Sunrise/src/middleware/bap/activity_message/volume_toggle_auth.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::scriptable_auth {
+
+/** Build-86657 toggle sensor: an EntryRef followed by signed state {-1,0,1}. */
+inline constexpr std::uint8_t kType32SlotType = 32;
+inline constexpr std::uint32_t kType32ComponentClass = 0x80809556;
+inline constexpr std::uint32_t kType32Schema = 0x8080955A;
+inline constexpr std::uint8_t kVolumeSlotType = 60;
+inline constexpr std::size_t kType32BitCount = 57;
+inline constexpr std::size_t kType32ByteCount = 8;
+/** This adapter intentionally supports only a real volume target and an explicit boolean state. */
+struct Type32VolumeBody final {
+    std::uint32_t registryKey{};
+    std::int16_t slotIndex{-1};
+    bool active{};
+};
+[[nodiscard]] bool encode_type32_volume(const Type32VolumeBody& body,
+                                        std::span<std::byte> output,
+                                        std::size_t& written) noexcept;
+[[nodiscard]] bool decode_type32_volume(std::span<const std::byte> input,
+                                        std::size_t bitCount,
+                                        Type32VolumeBody& body) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 16 - 1
Sunrise/src/server/activity/activity_sdk_device_prepare.cpp

@@ -11,6 +11,7 @@
 #include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_device_internal.h"
@@ -503,8 +504,22 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
         slotType == middleware::bap::activity_message::scriptable_auth::kType2SlotType
         && authSchema == middleware::bap::activity_message::scriptable_auth::kType2Schema
         && middleware::bap::activity_message::scriptable_auth::validate_type2_body(body, bitCount);
+    auth::Type32VolumeBody volumeBody{};
+    bool volumeToggle = slotType == auth::kType32SlotType && authSchema == auth::kType32Schema
+                        && slot.componentClass == auth::kType32ComponentClass
+                        && auth::decode_type32_volume(body, bitCount, volumeBody)
+                        && volumeBody.registryKey == registryKey;
+    if (volumeToggle) {
+        // Validate the inner reference again at dispatch under the same SDK/owner binding.
+        const auto matches = std::count_if(slots.begin(), slots.end(), [&](const auto& target) {
+            return target.objectIndex == slot.objectIndex
+                   && target.slotType == auth::kVolumeSlotType
+                   && target.slotIndex == static_cast<std::uint16_t>(volumeBody.slotIndex);
+        });
+        volumeToggle = matches == 1;
+    }
     if (!typedSdkBody && !occupancy && !directive && !engagement && !publicEvent && !performance
-        && !combatant) {
+        && !combatant && !volumeToggle) {
         return Status::invalidBody;
     }
     return Status::ready;

+ 78 - 0
Sunrise/src/server/activity/activity_sdk_device_runtime.cpp

@@ -13,6 +13,7 @@
 #include "../../middleware/bap/activity_message/music_section_auth.h"
 #include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../middleware/bap/activity_message/squad_attachment_auth.h"
 #include "../../middleware/bap/activity_message/squad_objective_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
@@ -22,6 +23,7 @@
 #include "activity_sdk_actor_sequences.h"
 #include "activity_sdk_device_internal.h"
 #include "host_runtime.h"
+#include "squad_attachment_ownership_validation.h"
 
 namespace sunrise::server::activity::activity_sdk_devices {
 namespace {
@@ -182,6 +184,80 @@ Status apply_auth_reserved(const sdk::BoundView& view,
     return Status::refused;
 }
 
+/** Selects a same-owner squad through the audited build-86657 attachment Auth schema. */
+Status
+set_squad_attachment_reserved(const sdk::BoundView& view,
+                              std::uint32_t slotRow,
+                              std::uint32_t sourceRow,
+                              std::uint64_t sourceSpawnGeneration,
+                              bool active,
+                              const host::ScriptableOutputReservation& reservation) noexcept {
+    PreparedDevice target{}, source{};
+    Status status = prepare_slot(view, slotRow, target);
+    if (status != Status::ready) {
+        return status;
+    }
+    status = prepare_slot(view, sourceRow, source);
+    if (status != Status::ready) {
+        return status;
+    }
+    if (target.activityClientGeneration != source.activityClientGeneration
+        || target.effectiveRegion != source.effectiveRegion
+        || target.scenarioRow != source.scenarioRow || target.stateRow != source.stateRow) {
+        return Status::staleActivityClient;
+    }
+    host::SquadAttachmentOwnership owned{};
+    const auto digest = view.catalog->sdk_build_sha256();
+    if (digest.size() != owned.sdkBuildSha256.size()) {
+        return Status::wrongSdkBuild;
+    }
+    std::copy(digest.begin(), digest.end(), owned.sdkBuildSha256.begin());
+    const auto payload = view.catalog->payload_sha256();
+    if (!state::activity_sdk::attachment_compatibility::supports(payload)) {
+        return Status::unsupportedAttachmentPayload;
+    }
+    std::copy(payload.begin(), payload.end(), owned.sdkPayloadSha256.begin());
+    owned.source = source.target;
+    owned.sourceSpawnGeneration = sourceSpawnGeneration;
+    owned.active = active;
+    if (!host::attachments::same_owner_pair(target.target, owned)) {
+        return Status::refused;
+    }
+    std::vector<host::PendingScriptableOverride> estate;
+    if (!host::scriptable_auth_estate(view.binding, target.activityClientGeneration, estate)) {
+        return Status::staleBinding;
+    }
+    for (const auto& previous : estate) {
+        if (host::attachments::same_ref(previous.target, target.target)) {
+            owned.previousRevision = previous.revision;
+        }
+    }
+    const auth::Type26SquadSelection selection =
+        active ? auth::Type26SquadSelection{source.target.registryKey,
+                                            static_cast<std::int16_t>(source.target.slotIndex),
+                                            true}
+               : auth::Type26SquadSelection{};
+    std::array<std::byte, auth::kType26MaximumByteCount> body{};
+    std::size_t written = 0, bits = 0;
+    if (!auth::encode_type26_squad_selection(selection, body, written, bits)
+        || !host::attachments::admits(
+            target.target, owned, estate, std::span(body).first(written), bits)) {
+        return Status::refused;
+    }
+    const bool queued = server::bap::request_activity_sdk_auth_override(
+        view.binding,
+        target.target,
+        target.target.stateLocalRoster ? &target.generatedRosterGroup : nullptr,
+        std::span(body).first(written),
+        static_cast<std::uint16_t>(bits),
+        target.effectiveRegion,
+        target.activityClientGeneration,
+        &reservation,
+        host::ScriptableOverrideKind::sdkAuth,
+        owned);
+    return queued ? Status::queued : Status::refused;
+}
+
 /** Sets the active set of one device's objects against a reserved Host output revision. */
 Status set_objects_active_reserved(const sdk::BoundView& view,
                                    std::uint32_t slotRow,
@@ -597,6 +673,8 @@ const char* status_name(Status status) noexcept {
         return "refused";
     case Status::refusedSlotType:
         return "refused_slot_type";
+    case Status::unsupportedAttachmentPayload:
+        return "unsupported_attachment_payload";
     }
     return "unknown";
 }

+ 11 - 0
Sunrise/src/server/activity/activity_sdk_device_runtime.h

@@ -37,6 +37,8 @@ enum class Status : std::uint8_t {
     refused,
     /** The slot type faults the stock client from wire-legal values, so no body reaches it. */
     refusedSlotType,
+    /** Loaded SDK data has not been audited for owned squad attachments. */
+    unsupportedAttachmentPayload,
 };
 
 /**
@@ -129,6 +131,15 @@ play_combatant_sequence_reserved(const state::activity_sdk::BoundView& view,
     const host::ScriptableOutputReservation& reservation,
     host::ScriptableOverrideKind kind = host::ScriptableOverrideKind::sdkAuth) noexcept;
 
+/** Selects or clears an audited attachment using one delivered squad spawn generation. */
+[[nodiscard]] Status
+set_squad_attachment_reserved(const state::activity_sdk::BoundView& view,
+                              std::uint32_t slotRow,
+                              std::uint32_t sourceRow,
+                              std::uint64_t sourceSpawnGeneration,
+                              bool active,
+                              const host::ScriptableOutputReservation& reservation) noexcept;
+
 /**
  * Instantiates or removes package-authored type-4 entries, one push for the whole run.
  * A row is dropped, not refused, when the client's state does not hold it, or when it names a

+ 12 - 28
Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp

@@ -6,10 +6,12 @@
 #include <string_view>
 
 #include "../../middleware/content/packages/tables/region_reader.h"
+#include "../../state/activity/membership/activity_membership_query.h"
 #include "../../state/activity/runtime.h"
 #include "../../state/build_data/runtime.h"
 #include "../bap/runtime.h"
 #include "host_runtime.h"
+#include "squad_authored_profile.h"
 
 namespace sunrise::server::activity::activity_sdk_squads {
 namespace {
@@ -95,30 +97,7 @@ struct PreparedSquad final {
                                     const format::Squad& squad,
                                     std::span<const std::int32_t> requestedCounts,
                                     std::array<std::int8_t, 4>& output) noexcept {
-    output = {};
-    const auto members = sdk::squad_members(catalog, squad);
-    const auto actors = catalog.actor_classes();
-    bool found = false;
-    // A named type-2 member needs its parent's authored profile but zero loose actors.
-    const bool loose = std::any_of(
-        requestedCounts.begin(), requestedCounts.end(), [](auto count) { return count > 0; });
-    for (std::size_t index = 0; index < members.size(); ++index) {
-        if (loose && requestedCounts[index] <= 0) {
-            continue;
-        }
-        const format::SquadMember& member = members[index];
-        if ((member.flags & format::kSquadMemberActorClassExact) == 0
-            || member.actorClassIndex >= actors.size()) {
-            return false;
-        }
-        const auto& candidate = actors[member.actorClassIndex].authoredSpawnProfile;
-        if (found && candidate != output) {
-            return false;
-        }
-        output = candidate;
-        found = true;
-    }
-    return found;
+    return select_authored_profile(sdk::squad_members(catalog, squad), requestedCounts, output);
 }
 /** Checks the generated source slot before it can select a wire roster target. */
 [[nodiscard]] bool valid_generated_slot(const sdk::Catalog& catalog,
@@ -322,7 +301,8 @@ struct PreparedSquad final {
     if (squad.scenarioIndex != view.scenarioRow) {
         return Status::wrongScenario;
     }
-    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask) {
+    const auto requiredMask = format::squad_required_mask(squad.flags);
+    if ((squad.flags & requiredMask) != requiredMask) {
         return Status::notRunnable;
     }
     const Status members = member_status(catalog, squad, squadRow, requestedCounts);
@@ -463,11 +443,15 @@ retirement_eligibility(const sdk::BoundView& view,
                                         std::optional<std::uint32_t> spawnRuleSlotRow,
                                         std::optional<squad_auth::SpawnRule>& output) noexcept {
     output.reset();
-    if (!spawnRuleSlotRow.has_value()) {
-        return Status::ready;
-    }
     const auto squads = catalog.squads();
     const auto slots = catalog.slots();
+    if (!spawnRuleSlotRow.has_value()) {
+        // A spawner with no rule of its own cannot be placed without a selected one.
+        return squadRow < squads.size()
+                       && (squads[squadRow].flags & format::kSquadRequiresSelectedRule) != 0
+                   ? Status::notRunnable
+                   : Status::ready;
+    }
     if (squadRow >= squads.size() || *spawnRuleSlotRow >= slots.size()) {
         return Status::invalidSquad;
     }

+ 89 - 16
Sunrise/src/server/activity/host_runtime.h

@@ -4,6 +4,7 @@
 #include <cstdint>
 #include <optional>
 #include <span>
+#include <type_traits>
 #include <vector>
 
 #include "../../middleware/bap/activity_message/cinematic_incident.h"
@@ -40,9 +41,11 @@ inline constexpr std::size_t kClientMessageHistoryCapacity = 512;
 /** Decoded packet details retained separately from the high-volume metadata history. */
 inline constexpr std::size_t kClientMessageDetailCapacity = 64;
 /** Latest complete msg-6 object observations retained for one activity generation. */
-inline constexpr std::size_t kSenseObservationCapacity = 128;
+inline constexpr std::size_t kSenseObservationCapacity =
+    middleware::bap::activity_message::sense_update::kDecodedObjectCapacity;
 /** Typed values owned by the retained msg-6 observations for one activity generation. */
-inline constexpr std::size_t kSenseObservationValueCapacity = 1024;
+inline constexpr std::size_t kSenseObservationValueCapacity =
+    middleware::bap::activity_message::sense_update::kDecodedValueCapacity;
 /** Per-slot counts the squad Sense body can carry. Its nested array is eight elements. */
 inline constexpr std::size_t kSquadSlotCapacity = 8;
 /** Objective task groups a squad publishes one cost for. */
@@ -145,11 +148,13 @@ enum class EventKind : std::uint8_t {
     deviceState = 39,
     /** An accepted client report moved the held region to another authored region. */
     regionChanged = 40,
+    /** Validated type-30 level or explicit continuity invalidation. */
+    triggerState = 41,
 };
 
 /** Kinds are numbered without gaps, so the last one plus one is the count. */
 inline constexpr std::size_t kEventKindCount =
-    static_cast<std::size_t>(EventKind::regionChanged) + 1U;
+    static_cast<std::size_t>(EventKind::triggerState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -206,6 +211,7 @@ struct ScriptableTarget final {
     std::uint8_t slotType{};
     /** This group is safe only in the exact selected state pinned by the requesting link. */
     bool stateLocalRoster{};
+    bool operator==(const ScriptableTarget&) const = default;
 };
 
 /** Exact unarmed Host output lane held while Mission State publishes its matching revision. */
@@ -523,6 +529,8 @@ struct Event final {
     std::int32_t triggerValue{};
     /** True when the whole watched set is inside the volume. */
     bool triggerAll{};
+    bool triggerAvailable{}, triggerOccupied{};
+    std::uint8_t triggerContinuity{};
     /** Health and shield fractions, for damageState events. Negative until published. */
     float damageHealth{-1.0F};
     float damageShield{-1.0F};
@@ -566,6 +574,12 @@ struct Event final {
     bool squadObjectiveCostQualified{};
     /** Per-slot member counts the client published, for squadState events. */
     std::array<std::int32_t, kSquadSlotCapacity> squadSlotCounts{};
+    /** Native Sense .0 echo of the squad Auth .6 spawn generation, not the wire counter. */
+    std::int32_t squadSpawnGeneration{};
+    bool squadHasSpawnGeneration{};
+    bool squadPopulationAvailable{};
+    /** A backwards report counter requires retiring any existing objective job. */
+    bool squadRegistrationReset{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
     std::int32_t squadAliveCount{};
     /** Alive count this observation replaced, for entityDied events. */
@@ -580,6 +594,16 @@ struct Event final {
     std::uint8_t squadSlotOrdinal{};
     /** Client flag written on its actor death or removal path. The meaning is unproved. */
     bool squadRemovalFlag{};
+    /** The raw flag is known for this exact observation lifetime; absence is not false. */
+    bool squadHasRemovalFlag{};
+    /** Generic msg-6 record counter plus one; not a squad/actor lifetime identity. */
+    std::uint32_t senseGenerationPlusOne{};
+    bool hasSenseGeneration{};
+    /** First/replacement observation reconciles current state instead of proving a spawn/death. */
+    bool initialObservation{};
+    /** Authored entry index and spawn-mask words, for objectState events. */
+    std::int32_t objectEntryIndex{};
+    std::array<std::uint32_t, 2> objectSpawnMask{};
     /** Activation token the completed authored scene echoed, for sceneFinished events. */
     std::int32_t sceneActivationToken{};
     /** Task counter the client published, for objectiveProgress events. Clamped to 80. */
@@ -849,6 +873,19 @@ struct PendingIncident final {
     std::uint64_t revision{};
 };
 
+/** Owned source selection for a neutral-prefix type-26 attachment, never a raw actor handle. */
+struct SquadAttachmentOwnership final {
+    std::array<std::byte, 32> sdkBuildSha256{};
+    /** Authenticated catalog data, independent of the source-derived SDK identity. */
+    std::array<std::byte, 32> sdkPayloadSha256{};
+    ScriptableTarget source{};
+    std::uint64_t sourceSpawnGeneration{};
+    /** Compare-and-swap revision of the last delivered attachment body, zero for a fresh claim. */
+    std::uint64_t previousRevision{};
+    bool active{};
+    bool operator==(const SquadAttachmentOwnership&) const = default;
+};
+
 /** Immutable typed body retained byte-for-byte until exact transport staging. */
 struct PendingScriptableOverride final {
     std::uint32_t actorSpawnGeneration{};
@@ -879,6 +916,10 @@ struct PendingScriptableOverride final {
     /** Activity lifetime state for a lifetime request; ignored by every other kind. */
     std::uint8_t lifetimeState{kDefaultLifetimeState};
     bool sdkCompiled{};
+    std::optional<SquadAttachmentOwnership> squadAttachment{};
+    /** Spawn generation of the placement a squadObjective row replaced on this ClientRef; zero
+     * otherwise. */
+    std::uint64_t squadSpawnGeneration{};
 };
 
 /**
@@ -921,11 +962,42 @@ request_squad_objective(const state::activity::SessionBinding& binding,
                         std::uint64_t expectedActivityClientGeneration,
                         const ScriptableOutputReservation& reservation) noexcept;
 
-/** Queues one owned msg-6 prefix for the Activity Host service. */
-[[nodiscard]] bool submit_sense(const SenseInput& input) noexcept;
+/** Names the gate that declined one ingress submission. */
+enum class IngressRefusal : std::uint8_t {
+    /** The submission was queued. */
+    none,
+    /** The named session no longer matches a live binding. */
+    binding,
+    /** The submission carried no source generation to bind it to. */
+    generation,
+    /** The shared codec refused a bounded outer field. */
+    payload,
+    /** The pending-input ring held no free slot. */
+    queue,
+};
+
+/**
+ * Reports which ingress gate declined, for the skip diagnostic.
+ * Every name keeps the `host_ingress_refused` prefix the earlier single reason used, so a search
+ * for the collapsed name still finds all of them.
+ * @param refusal Gate reported by one failed submission.
+ * @return Stable reason name.
+ */
+[[nodiscard]] const char* ingress_refusal_name(IngressRefusal refusal) noexcept;
+
+/**
+ * Queues one owned msg-6 prefix for the Activity Host service.
+ * @param input Owned prefix and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_sense(const SenseInput& input) noexcept;
 
-/** Queues one owned, outer-valid client msg 19 for the Activity Host service. */
-[[nodiscard]] bool submit_incident(const IncidentInput& input) noexcept;
+/**
+ * Queues one owned, outer-valid client msg 19 for the Activity Host service.
+ * @param input Parsed incident and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_incident(const IncidentInput& input) noexcept;
 
 /** Queues one committed client msg-22 numeric after-image for the Activity Host service. */
 [[nodiscard]] bool submit_client_state_change(const ClientStateChangeInput& input) noexcept;
@@ -1147,15 +1219,16 @@ request_lifetime_override(const state::activity::SessionBinding& binding,
                           const ScriptableOutputReservation* reservation = nullptr) noexcept;
 
 /** Queues one exact SDK-compiled Auth body for a generation-bound ClientRef. */
-[[nodiscard]] bool
-request_sdk_auth_override(const state::activity::SessionBinding& binding,
-                          const ScriptableTarget& target,
-                          const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
-                          std::span<const std::byte> body,
-                          std::uint16_t bitCount,
-                          std::uint64_t expectedActivityClientGeneration,
-                          const ScriptableOutputReservation* reservation = nullptr,
-                          ScriptableOverrideKind kind = ScriptableOverrideKind::sdkAuth) noexcept;
+[[nodiscard]] bool request_sdk_auth_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
+    std::span<const std::byte> body,
+    std::uint16_t bitCount,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation = nullptr,
+    ScriptableOverrideKind kind = ScriptableOverrideKind::sdkAuth,
+    std::optional<SquadAttachmentOwnership> attachment = std::nullopt) noexcept;
 
 /** @return True while any live instance holds a committed output the transport has not sent. */
 [[nodiscard]] bool any_output_pending() noexcept;

+ 8 - 5
Sunrise/src/server/activity/host_runtime_incidents.cpp

@@ -204,9 +204,12 @@ void reset_incidents() noexcept {
 } // namespace detail
 
 /** Queues one owned, outer-valid client msg 19 for the Activity Host service. */
-bool submit_incident(const IncidentInput& input) noexcept {
-    if (!state::activity::binding_matches(input.binding) || !incident_allowed(input.incident)) {
-        return false;
+IngressRefusal submit_incident(const IncidentInput& input) noexcept {
+    if (!state::activity::binding_matches(input.binding)) {
+        return IngressRefusal::binding;
+    }
+    if (!incident_allowed(input.incident)) {
+        return IngressRefusal::payload;
     }
     AcquireSRWLockExclusive(&g_lock);
     PendingInput pending{};
@@ -216,11 +219,11 @@ bool submit_incident(const IncidentInput& input) noexcept {
         ++g_droppedIngress;
         ++g_droppedIncidents;
         ReleaseSRWLockExclusive(&g_lock);
-        return false;
+        return IngressRefusal::queue;
     }
     ++g_queuedIngress;
     ReleaseSRWLockExclusive(&g_lock);
-    return true;
+    return IngressRefusal::none;
 }
 
 /** Queues one outer-valid operator msg 19 for the exact activity generation. */

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

@@ -49,6 +49,7 @@ struct ScriptableRequest final {
     std::array<std::byte,
                middleware::bap::activity_message::sensor_auth_update::kAuthOverrideByteCapacity>
         authBody{};
+    std::optional<SquadAttachmentOwnership> squadAttachment{};
     std::size_t requestedCountLength{};
     std::uint16_t authBitCount{};
     std::uint16_t authByteCount{};
@@ -312,6 +313,36 @@ void reset_client_messages() noexcept;
 
 // The mission-input unit owns these. Every reducer that accepts a client input calls into them.
 
+/** One accepted client input with the exact values its program will read. */
+struct MissionInputRecord final {
+    MissionInputEvent view{};
+    middleware::bap::activity_message::sense_update::DecodedPacket sense{};
+    ClientMessageSnapshot clientMessage{};
+    bool hasSense{};
+    bool hasClientMessage{};
+};
+
+/** What one msg-6 receipt claimed about its packet, as retained beside the decoded values. */
+struct SenseEnvelope final {
+    std::uint64_t sourceGeneration{};
+    std::uint64_t clientMessageSequence{};
+    state::activity::receipts::Verdict verdict{};
+    middleware::bap::activity_message::sense_update::DecodeStatus decodeStatus{};
+    std::uint32_t groupsSeen{};
+    std::uint32_t groupsDecoded{};
+    std::uint32_t groupsSkipped{};
+    std::uint32_t objectsSeen{};
+    std::uint32_t objectsDecoded{};
+};
+
+/** @return True when every exposed object is complete inside one safely framed packet. */
+[[nodiscard]] bool usable_sense_observation_packet(
+    const SenseEnvelope& envelope,
+    const middleware::bap::activity_message::sense_update::DecodedPacket& packet) noexcept;
+
+/** Accepted rows in feed order; the Host lock guards it. */
+extern std::vector<MissionInputRecord> g_missionInputs;
+
 /** Assigns one exact binding's ordered client mission-input sequence. */
 void stamp_mission_sequence(Event& event) noexcept;
 

+ 18 - 12
Sunrise/src/server/activity/host_runtime_mission_inputs.cpp

@@ -18,21 +18,17 @@
 #include "host_runtime_internal.h"
 
 namespace sunrise::server::activity::host {
+namespace detail {
+
+/** The feed is unbounded; it holds every accepted row until its program commits it. */
+std::vector<MissionInputRecord> g_missionInputs{};
+
+} // namespace detail
+
 namespace {
 
 using namespace detail;
 
-/** One accepted client input with the exact values its program will read. */
-struct MissionInputRecord final {
-    MissionInputEvent view{};
-    middleware::bap::activity_message::sense_update::DecodedPacket sense{};
-    ClientMessageSnapshot clientMessage{};
-    bool hasSense{};
-    bool hasClientMessage{};
-};
-
-/** The feed is unbounded; it holds every accepted row until its program commits it. */
-std::vector<MissionInputRecord> g_missionInputs{};
 std::uint64_t g_missionInputSequence{};
 
 /** @return True when this event kind enters the ordered mission-input feed. */
@@ -233,7 +229,17 @@ bool mission_input_sense_snapshot(std::uint64_t sequence,
     if (copied) {
         const Event& event = selected->view.event;
         const sense::DecodedPacket& packet = selected->sense;
-        copied = sense::observation_packet(packet);
+        copied =
+            usable_sense_observation_packet({.sourceGeneration = event.sourceGeneration,
+                                             .clientMessageSequence = event.clientMessageSequence,
+                                             .verdict = event.verdict,
+                                             .decodeStatus = event.senseDecodeStatus,
+                                             .groupsSeen = event.groupsSeen,
+                                             .groupsDecoded = event.groupsDecoded,
+                                             .groupsSkipped = event.groupsSkipped,
+                                             .objectsSeen = event.objectsSeen,
+                                             .objectsDecoded = event.objectsDecoded},
+                                            packet);
         if (copied) {
             output.revision = event.sequence;
             output.sourceGeneration = event.sourceGeneration;

+ 19 - 0
Sunrise/src/server/activity/host_runtime_names.cpp

@@ -3,6 +3,23 @@
 
 namespace sunrise::server::activity::host {
 
+/** @return Stable reason name for one declined ingress submission. */
+const char* ingress_refusal_name(IngressRefusal refusal) noexcept {
+    switch (refusal) {
+    case IngressRefusal::binding:
+        return "host_ingress_refused_binding";
+    case IngressRefusal::generation:
+        return "host_ingress_refused_generation";
+    case IngressRefusal::payload:
+        return "host_ingress_refused_payload";
+    case IngressRefusal::queue:
+        return "host_ingress_refused_queue";
+    case IngressRefusal::none:
+        break;
+    }
+    return "host_ingress_refused";
+}
+
 /** @return Stable UI name for one event kind. */
 const char* event_name(EventKind kind) noexcept {
     switch (kind) {
@@ -42,6 +59,8 @@ const char* event_name(EventKind kind) noexcept {
         return "script effect result";
     case EventKind::phaseEntered:
         return "mission phase entered";
+    case EventKind::triggerState:
+        return "triggerState";
     case EventKind::triggerEntered:
         return "trigger volume entered";
     case EventKind::triggerExited:

+ 22 - 3
Sunrise/src/server/activity/host_runtime_scriptable.cpp

@@ -13,6 +13,7 @@
 #include "host_runtime_counter_auth.h"
 #include "host_runtime_ghost_link.h"
 #include "host_runtime_internal.h"
+#include "squad_attachment_ownership_validation.h"
 
 namespace sunrise::server::activity::host {
 namespace {
@@ -185,7 +186,7 @@ namespace detail {
            && left.byteCount == right.byteCount && left.channel == right.channel
            && left.channelValue == right.channelValue && left.channelHash == right.channelHash
            && left.lifetimeState == right.lifetimeState && left.body == right.body
-           && left.sdkCompiled == right.sdkCompiled
+           && left.sdkCompiled == right.sdkCompiled && left.squadAttachment == right.squadAttachment
            && (!left.target.stateLocalRoster
                || same_group(left.stateLocalRosterGroup, right.stateLocalRosterGroup));
 }
@@ -206,6 +207,14 @@ namespace detail {
     }
     for (PendingScriptableOverride& retained : instance.scriptableAuthEstate) {
         if (same_client_ref(retained.target, pending.target)) {
+            // An objective assignment rewrites the squad's ClientRef row (kind squadObjective,
+            // generation = objective revision, patch body without the spawn generation). Carry the
+            // placement's spawn generation so attachment ownership can still name its lifetime.
+            if (owned.kind == ScriptableOverrideKind::squadObjective) {
+                owned.squadSpawnGeneration = retained.kind == ScriptableOverrideKind::squad
+                                                 ? retained.generation
+                                                 : retained.squadSpawnGeneration;
+            }
             retained = owned;
             return true;
         }
@@ -240,6 +249,8 @@ retained_sequence_combatant(const Instance& instance, const ScriptableRequest& r
     // Lifetime requests carry no ClientRef.
     const bool untargeted = request.kind == ScriptableOverrideKind::lifetime;
     if ((!untargeted && !supported_target(request.target))
+        || (request.squadAttachment
+            && (request.kind != ScriptableOverrideKind::sdkAuth || request.burstMember))
         || !state::activity::binding_matches(request.binding)) {
         return false;
     }
@@ -353,6 +364,7 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
     }
     PendingScriptableOverride pending{};
     pending.target = request.target;
+    pending.squadAttachment = request.squadAttachment;
     pending.stateLocalRosterGroup = request.stateLocalRosterGroup;
     pending.revision = request.expectedRevision == 0 ? instance->view.scriptableRevision + 1
                                                      : request.expectedRevision;
@@ -361,7 +373,13 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
     pending.expectedActivityClientGeneration = request.expectedActivityClientGeneration;
     std::size_t written = 0;
     std::size_t writtenBits = 0;
-    bool encoded = untargeted || guard != nullptr;
+    bool encoded = (untargeted || guard != nullptr)
+                   && request.authByteCount <= request.authBody.size()
+                   && attachments::admits(request.target,
+                                          request.squadAttachment,
+                                          instance->scriptableAuthEstate,
+                                          std::span(request.authBody).first(request.authByteCount),
+                                          request.authBitCount);
     if (encoded && request.kind == ScriptableOverrideKind::lifetime) {
         pending.lifetimeState = request.lifetimeState;
     } else if (encoded && request.kind == ScriptableOverrideKind::squad) {
@@ -643,7 +661,8 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
             pending.bitCount = static_cast<std::uint16_t>(composedBits);
         }
     }
-    if (!encoded || written > (std::numeric_limits<std::uint16_t>::max)()) {
+    if (!encoded || written > (std::numeric_limits<std::uint16_t>::max)()
+        || !attachments::permits_source_write(pending, instance->scriptableAuthEstate)) {
         ++g_refusedControls;
     } else {
         if (guard != nullptr && !guard->occupied) {

+ 3 - 1
Sunrise/src/server/activity/host_runtime_scriptable_requests.cpp

@@ -639,7 +639,8 @@ bool request_sdk_auth_override(
     std::uint16_t bitCount,
     std::uint64_t expectedActivityClientGeneration,
     const ScriptableOutputReservation* reservation,
-    ScriptableOverrideKind kind) noexcept {
+    ScriptableOverrideKind kind,
+    std::optional<SquadAttachmentOwnership> attachment) noexcept {
     if ((kind != ScriptableOverrideKind::sdkAuth && !counter_auth::compatible(kind, target))
         || body.empty() || body.size() > scene::kAuthOverrideByteCapacity
         || body.size() > (std::numeric_limits<std::uint16_t>::max)()
@@ -661,6 +662,7 @@ bool request_sdk_auth_override(
     request.authByteCount = static_cast<std::uint16_t>(body.size());
     request.expectedActivityClientGeneration = expectedActivityClientGeneration;
     request.kind = kind;
+    request.squadAttachment = attachment;
     return enqueue_request(request, reservation);
 }
 

+ 25 - 1
Sunrise/src/server/activity/host_runtime_scriptable_staging.cpp

@@ -6,6 +6,7 @@
 #include "../gameplay/squad_entity_retirement.h"
 #include "host_runtime_ghost_link.h"
 #include "host_runtime_internal.h"
+#include "squad_attachment_ownership_validation.h"
 
 namespace sunrise::server::activity::host {
 namespace {
@@ -83,6 +84,20 @@ bool pending_scriptable_override_for_activity_client(const state::activity::Sess
         cancel_pending(*instance, binding, revision);
         pending = false;
     }
+    if (pending
+        && (!attachments::permits_source_write(instance->pendingScriptable,
+                                               instance->scriptableAuthEstate)
+            || (instance->pendingScriptable.squadAttachment
+                && (instance->pendingScriptable.byteCount > instance->pendingScriptable.body.size()
+                    || !attachments::admits(instance->pendingScriptable.target,
+                                            instance->pendingScriptable.squadAttachment,
+                                            instance->scriptableAuthEstate,
+                                            std::span(instance->pendingScriptable.body)
+                                                .first(instance->pendingScriptable.byteCount),
+                                            instance->pendingScriptable.bitCount))))) {
+        cancel_pending(*instance, binding, instance->pendingScriptable.revision);
+        pending = false;
+    }
     if (pending) {
         output = instance->pendingScriptable;
         stamp_output_boundary(binding, output);
@@ -310,7 +325,16 @@ void note_scriptable_transport_staged(const state::activity::SessionBinding& bin
     AcquireSRWLockExclusive(&g_lock);
     Instance* const instance = find_instance(binding);
     ScriptableGuard* guard = instance != nullptr ? find_guard(*instance, pending.target) : nullptr;
-    const bool nextCounter = staged_counter_matches(guard, pending);
+    const bool nextCounter =
+        staged_counter_matches(guard, pending) && pending.byteCount <= pending.body.size()
+        && (instance == nullptr
+            || attachments::permits_source_write(pending, instance->scriptableAuthEstate))
+        && (instance == nullptr || !pending.squadAttachment
+            || attachments::admits(pending.target,
+                                   pending.squadAttachment,
+                                   instance->scriptableAuthEstate,
+                                   std::span(pending.body).first(pending.byteCount),
+                                   pending.bitCount));
     if (instance != nullptr && nextCounter && instance->view.outputPending
         && instance->view.outputKind == OutputKind::scriptableOverride
         && same_pending(instance->pendingScriptable, pending)) {

+ 75 - 13
Sunrise/src/server/activity/host_runtime_sense.cpp

@@ -21,6 +21,62 @@
 #include "host_runtime_internal.h"
 
 namespace sunrise::server::activity::host {
+namespace detail {
+
+/**
+ * Tests whether every exposed object is complete inside one safely framed packet. A partial decode
+ * must carry a partial receipt, and an unsafe or malformed sibling refuses the whole packet.
+ */
+bool usable_sense_observation_packet(
+    const SenseEnvelope& envelope,
+    const middleware::bap::activity_message::sense_update::DecodedPacket& packet) noexcept {
+    const auto& [sourceGeneration,
+                 clientMessageSequence,
+                 verdict,
+                 decodeStatus,
+                 groupsSeen,
+                 groupsDecoded,
+                 groupsSkipped,
+                 objectsSeen,
+                 objectsDecoded] = envelope;
+    namespace sense = middleware::bap::activity_message::sense_update;
+    if (sourceGeneration == 0 || clientMessageSequence == 0
+        || !((decodeStatus == sense::DecodeStatus::complete
+              && verdict == state::activity::receipts::Verdict::framed)
+             || (decodeStatus == sense::DecodeStatus::partial
+                 && verdict == state::activity::receipts::Verdict::partial))
+        || decodeStatus != packet.status || !sense::observation_packet(packet)
+        || groupsSeen != packet.groupsSeen || groupsDecoded != packet.groupsDecoded
+        || groupsSkipped != packet.groupsSkipped || objectsSeen != packet.objectsSeen
+        || objectsDecoded != packet.objectsDecoded || packet.objectsDecoded == 0
+        || packet.objectCount > packet.objectsSeen || packet.groupsDecoded > packet.groupsSeen
+        || packet.groupsSkipped > packet.groupsSeen
+        || packet.groupsDecoded != packet.groupsSeen - packet.groupsSkipped) {
+        return false;
+    }
+    for (std::size_t index = 0; index < packet.objectCount; ++index) {
+        const sense::DecodedObject& object = packet.objects[index];
+        if (object.status == sense::ObjectStatus::unsafeCount) {
+            return false;
+        }
+        if (object.status != sense::ObjectStatus::decoded) {
+            continue;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            const sense::DecodedObject& other = packet.objects[prior];
+            if (other.status == sense::ObjectStatus::decoded
+                && other.registryKey == object.registryKey && other.objectTag == object.objectTag
+                && other.senseSchema == object.senseSchema && other.schemaRow == object.schemaRow
+                && other.slotIndex == object.slotIndex && other.slotType == object.slotType) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+} // namespace detail
+
 namespace {
 
 using namespace detail;
@@ -51,14 +107,17 @@ packet_has_sense_key(const middleware::bap::activity_message::sense_update::Deco
 
 /** @return True when the envelope closed and each decoded object's storage is bounded. */
 [[nodiscard]] bool valid_sense_observation_input(const SenseInput& input) noexcept {
-    namespace sense = middleware::bap::activity_message::sense_update;
-    const auto& packet = input.decoded;
-    return input.sourceGeneration != 0 && input.clientMessageSequence != 0
-           && input.verdict == state::activity::receipts::Verdict::framed
-           && input.decodeStatus == packet.status && sense::observation_packet(packet)
-           && input.groupsSeen == packet.groupsSeen && input.groupsDecoded == packet.groupsDecoded
-           && input.groupsSkipped == packet.groupsSkipped && input.objectsSeen == packet.objectsSeen
-           && input.objectsDecoded == packet.objectsDecoded;
+    return detail::usable_sense_observation_packet(
+        {.sourceGeneration = input.sourceGeneration,
+         .clientMessageSequence = input.clientMessageSequence,
+         .verdict = input.verdict,
+         .decodeStatus = input.decodeStatus,
+         .groupsSeen = input.groupsSeen,
+         .groupsDecoded = input.groupsDecoded,
+         .groupsSkipped = input.groupsSkipped,
+         .objectsSeen = input.objectsSeen,
+         .objectsDecoded = input.objectsDecoded},
+        input.decoded);
 }
 
 /** Mixes one fixed-width value into the local scene change guard. */
@@ -588,9 +647,12 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
 } // namespace detail
 
 /** Queues one owned msg-6 prefix for the Activity Host service. */
-bool submit_sense(const SenseInput& input) noexcept {
-    if (!state::activity::binding_matches(input.binding) || input.sourceGeneration == 0) {
-        return false;
+IngressRefusal submit_sense(const SenseInput& input) noexcept {
+    if (!state::activity::binding_matches(input.binding)) {
+        return IngressRefusal::binding;
+    }
+    if (input.sourceGeneration == 0) {
+        return IngressRefusal::generation;
     }
     AcquireSRWLockExclusive(&g_lock);
     PendingInput pending{};
@@ -599,11 +661,11 @@ bool submit_sense(const SenseInput& input) noexcept {
     if (!append_pending(pending)) {
         ++g_droppedIngress;
         ReleaseSRWLockExclusive(&g_lock);
-        return false;
+        return IngressRefusal::queue;
     }
     ++g_queuedIngress;
     ReleaseSRWLockExclusive(&g_lock);
-    return true;
+    return IngressRefusal::none;
 }
 
 /** Copies initialized recovery state for one exact ActivityClient generation. */

+ 41 - 0
Sunrise/src/server/activity/mission/mission_observation_epoch.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::server::activity::mission::observation_epoch {
+
+/** Reported Sense object identity. This is not an individual actor generation or death receipt. */
+struct Cursor final {
+    std::uint64_t sourceGeneration{};
+    std::uint64_t sequence{};
+    std::uint32_t generationPlusOne{};
+    bool hasGeneration{};
+    bool used{};
+};
+
+enum class Transition : std::uint8_t { discard, baseline, continuous };
+
+/**
+ * Counts from different reported object generations cannot form a gameplay edge. Missing
+ * generations also require reconciliation, following Host's same_sense_scalar_generations rule.
+ * The caller publishes the baseline as state, without inventing a spawn or death.
+ */
+[[nodiscard]] inline Transition accept(Cursor& retained,
+                                       std::uint64_t expectedSourceGeneration,
+                                       std::uint64_t sourceGeneration,
+                                       std::uint64_t sequence,
+                                       std::uint32_t generationPlusOne,
+                                       bool hasGeneration) noexcept {
+    if (sourceGeneration == 0 || sourceGeneration != expectedSourceGeneration || sequence == 0
+        || (retained.used && retained.sourceGeneration == sourceGeneration
+            && sequence <= retained.sequence)) {
+        return Transition::discard;
+    }
+    const bool continuous = retained.used && retained.sourceGeneration == sourceGeneration
+                            && retained.hasGeneration && hasGeneration
+                            && retained.generationPlusOne == generationPlusOne;
+    retained = {sourceGeneration, sequence, generationPlusOne, hasGeneration, true};
+    return continuous ? Transition::continuous : Transition::baseline;
+}
+
+} // namespace sunrise::server::activity::mission::observation_epoch

+ 40 - 2
Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp

@@ -197,8 +197,8 @@ constexpr std::int8_t kFilterModeInside = 1;
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
     // Only these named arguments belong to this API.
-    static constexpr std::array<std::string_view, 4> kDeclared{
-        "players", "target", "inside", "inside_any"};
+    static constexpr std::array<std::string_view, 6> kDeclared{
+        "players", "target", "targets", "squad", "inside", "inside_any"};
     refuse_unknown_arguments(state, kDeclared);
     SlotDefinition slot{};
     if (!current_slot(state, *handle, slot) || slot.slotType != auth::kType34SlotType
@@ -244,6 +244,44 @@ constexpr std::int8_t kFilterModeInside = 1;
     if (target.slotIndex >= 0) {
         body.predicates[body.count++] = auth::Type34ModeSlotRefC{kFilterModeDirect, target};
     }
+    // Several authored objects at once (a beam hop-on names every crystal it tethers).
+    lua_getfield(state, 2, "targets");
+    if (!lua_isnil(state, -1)) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const std::size_t count = lua_rawlen(state, -1);
+        if (count == 0 || count + body.count > auth::kType34PredicateCapacity) {
+            return luaL_error(state, "targets count is outside the filter capacity");
+        }
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            const auto* const targetHandle =
+                static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+            SlotDefinition object{};
+            if (!current_slot(state, *targetHandle, object)
+                || object.slotType != auth::kType4SlotType
+                || object.slotIndex > auth_fields::kMaximumClientRefIndex) {
+                return luaL_error(state, "targets require authored type-4 objects");
+            }
+            body.predicates[body.count++] =
+                auth::Type34ModeSlotRefC{kFilterModeDirect,
+                                         {object.registryKey,
+                                          static_cast<std::int8_t>(auth::kType4SlotType),
+                                          static_cast<std::int16_t>(object.slotIndex)}};
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
+    // A squad reference selects the boss a shield/tether hop-on hosts on.
+    auth::Type2LaneClientRef squad{};
+    if (!optional_slot_reference(state, "squad", 1U, squad)) {
+        return luaL_error(state, "filter squad must be an authored type-1 squad");
+    }
+    if (squad.slotIndex >= 0) {
+        if (body.count >= auth::kType34PredicateCapacity) {
+            return luaL_error(state, "squad does not fit the filter capacity");
+        }
+        body.predicates[body.count++] = auth::Type34ModeSlotRefC{kFilterModeDirect, squad};
+    }
     auth::Type2LaneClientRef inside{};
     if (!optional_slot_reference(state, "inside", auth::kType60SlotType, inside)) {
         return luaL_error(state, "filter inside must be an authored type-60 volume");

+ 4 - 0
Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp

@@ -29,6 +29,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "triggerEntered";
     case host::EventKind::triggerExited:
         return "triggerExited";
+    case host::EventKind::triggerState:
+        return "triggerState";
     case host::EventKind::squadState:
         return "squadState";
     case host::EventKind::squadProvoked:
@@ -142,6 +144,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return kTriggerEnteredEventMetatable;
     case host::EventKind::triggerExited:
         return kTriggerExitedEventMetatable;
+    case host::EventKind::triggerState:
+        return kTriggerStateEventMetatable;
     case host::EventKind::squadState:
         return kSquadStateEventMetatable;
     case host::EventKind::squadProvoked:

+ 106 - 5
Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp

@@ -4,6 +4,7 @@
 
 #include "mission_script_lua_event_internal.h"
 #include "mission_script_lua_internal.h"
+#include "mission_trigger_observation.h"
 
 // The derived and internal event views. Every one carries mission_sequence. The Sense
 // edges also carry the slot identity; the phase, timer, world and session edges leave those fields
@@ -29,6 +30,28 @@ namespace {
     return 1;
 }
 
+/** The producer copies these fields from the exact Sense observation, never an actor token. */
+[[nodiscard]] bool
+push_squad_generation_member(lua_State* state, const host::Event& event, std::string_view key) {
+    if (key == "spawn_generation") {
+        if (event.squadHasSpawnGeneration) {
+            lua_pushinteger(state, event.squadSpawnGeneration);
+        } else {
+            lua_pushnil(state);
+        }
+        return true;
+    }
+    if (key != "sense_generation") {
+        return false;
+    }
+    if (event.hasSenseGeneration) {
+        lua_pushinteger(state, event.senseGenerationPlusOne);
+    } else {
+        lua_pushnil(state);
+    }
+    return true;
+}
+
 /** The type-60 target read out of one type-31 player-trigger incident. */
 [[nodiscard]] bool
 push_player_trigger_member(lua_State* state, const host::Event& event, std::string_view key) {
@@ -81,12 +104,42 @@ push_squad_state_member(lua_State* state, const host::Event& event, std::string_
         lua_pushcfunction(state, &event_task_cost);
     } else if (key == "task_group") {
         lua_pushcfunction(state, &event_task_group);
+    } else if (key == "initial_observation") {
+        lua_pushboolean(state, event.initialObservation);
+    } else if (key == "registration_reset") {
+        lua_pushboolean(state, event.squadRegistrationReset);
+    } else if (key == "population_available") {
+        lua_pushboolean(state, event.squadPopulationAvailable);
     } else if (key == "alive_count") {
-        lua_pushinteger(state, event.squadAliveCount);
+        if (event.squadPopulationAvailable) {
+            lua_pushinteger(state, event.squadAliveCount);
+        } else {
+            lua_pushnil(state);
+        }
+    } else if (key == "objective_revision") {
+        // Script-side assignment revision: one while a script assignment is retained, else zero.
+        lua_pushinteger(state, event.squadObjectiveRegistryKey != 0 ? 1 : 0);
+    } else if (key == "task_costs") {
+        lua_createtable(state, static_cast<int>(host::kSquadObjectiveGroupCount), 0);
+        for (unsigned group = 0; group < host::kSquadObjectiveGroupCount; ++group) {
+            if ((event.squadObjectiveCostMask & (1U << group)) == 0) {
+                continue;
+            }
+            lua_pushnumber(state, event.squadObjectiveCosts[group]);
+            lua_rawseti(state, -2, static_cast<lua_Integer>(group) + 1);
+        }
     } else if (key == "previous_alive_count") {
-        lua_pushinteger(state, event.squadPreviousAliveCount);
+        if (event.squadPopulationAvailable) {
+            lua_pushinteger(state, event.squadPreviousAliveCount);
+        } else {
+            lua_pushnil(state);
+        }
     } else if (key == "removal_flag") {
-        lua_pushboolean(state, event.squadRemovalFlag ? 1 : 0);
+        if (event.squadHasRemovalFlag) {
+            lua_pushboolean(state, event.squadRemovalFlag);
+        } else {
+            lua_pushnil(state);
+        }
     } else if (key == "slot_counts") {
         const std::size_t count = event.squadSlotCountLength;
         lua_createtable(state, static_cast<int>(count), 0);
@@ -118,7 +171,13 @@ push_entity_spawned_member(lua_State* state, const host::Event& event, std::stri
 /** The alive count after the death, and the count it replaced. */
 [[nodiscard]] bool
 push_entity_died_member(lua_State* state, const host::Event& event, std::string_view key) {
-    if (key == "alive_count") {
+    if (key == "removal_flag") {
+        if (event.squadHasRemovalFlag) {
+            lua_pushboolean(state, event.squadRemovalFlag);
+        } else {
+            lua_pushnil(state);
+        }
+    } else if (key == "alive_count") {
         lua_pushinteger(state, event.squadAliveCount);
     } else if (key == "previous_alive_count") {
         lua_pushinteger(state, event.squadPreviousAliveCount);
@@ -277,6 +336,33 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return cinematic_started_index(state);
 }
 
+/** Reads a complete occupancy report or an explicit invalidation without inventing an edge. */
+[[nodiscard]] int trigger_state_index(lua_State* state) {
+    const host::Event& event = check_event(state, 1);
+    const std::string_view key = lua_string_view(state, 2);
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)
+        || push_slot_identity_member(state, event, key)
+        || push_squad_generation_member(state, event, key)) {
+        return 1;
+    }
+    if (key == "available") {
+        lua_pushboolean(state, event.triggerAvailable);
+    } else if (key == "continuity") {
+        lua_pushstring(state,
+                       trigger_observation::name(
+                           static_cast<trigger_observation::Continuity>(event.triggerContinuity)));
+    } else if (!event.triggerAvailable) {
+        lua_pushnil(state);
+    } else if (key == "occupied") {
+        lua_pushboolean(state, event.triggerOccupied);
+    } else if (push_trigger_member(state, event, key)) {
+        return 1;
+    } else {
+        lua_pushnil(state);
+    }
+    return 1;
+}
+
 /** Reports the accepted member generation and native movement-path state. */
 [[nodiscard]] int actor_path_index(lua_State* state) {
     const auto& event = check_event(state, 1);
@@ -401,7 +487,18 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
         }
         return 1;
     }
-    if (key == "generation") {
+    if (push_squad_generation_member(state, event, key)) {
+        return 1;
+    }
+    if (key == "initial_observation") {
+        lua_pushboolean(state, event.initialObservation);
+    } else if (key == "entry_index") {
+        lua_pushinteger(state, event.objectEntryIndex);
+    } else if (key == "spawn_mask_low") {
+        lua_pushinteger(state, event.objectSpawnMask[0]);
+    } else if (key == "spawn_mask_high") {
+        lua_pushinteger(state, event.objectSpawnMask[1]);
+    } else if (key == "generation") {
         lua_pushinteger(state, event.objectGeneration);
     } else if (key == "present" || key == "alive") {
         lua_pushboolean(state, event.objectPresent);
@@ -457,6 +554,7 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     }
     if (event_surface_visible(state, event.kind)
         && (push_slot_identity_member(state, event, key)
+            || push_squad_generation_member(state, event, key)
             || push_squad_state_member(state, event, key))) {
         return 1;
     }
@@ -492,6 +590,7 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     }
     if (event_surface_visible(state, event.kind)
         && (push_slot_identity_member(state, event, key)
+            || push_squad_generation_member(state, event, key)
             || push_entity_spawned_member(state, event, key))) {
         return 1;
     }
@@ -508,6 +607,7 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     }
     if (event_surface_visible(state, event.kind)
         && (push_slot_identity_member(state, event, key)
+            || push_squad_generation_member(state, event, key)
             || push_entity_died_member(state, event, key))) {
         return 1;
     }
@@ -646,6 +746,7 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
 /** Installs the derived and internal view metatables. */
 void register_derived_event_metatables(lua_State* state) {
     register_metatable(state, kRegionChangedEventMetatable, &region_changed_index);
+    register_metatable(state, kTriggerStateEventMetatable, &trigger_state_index);
     register_metatable(state, kFireteamStateEventMetatable, &fireteam_state_index);
     register_metatable(state, kTriggerEnteredEventMetatable, &trigger_entered_index);
     register_metatable(state, kTriggerExitedEventMetatable, &trigger_exited_index);

+ 1 - 0
Sunrise/src/server/activity/mission/mission_script_lua_names.h

@@ -59,6 +59,7 @@ inline constexpr char kScriptableOverrideCanceledEventMetatable[] =
 // Operator refusal, then the world changes a program subscribes to.
 inline constexpr char kOperatorRefusedEventMetatable[] = "sunrise.mission.event.operator_refused";
 inline constexpr char kEffectResultEventMetatable[] = "sunrise.mission.event.effect_result";
+inline constexpr char kTriggerStateEventMetatable[] = "sunrise.mission.event.trigger_state";
 inline constexpr char kTriggerEnteredEventMetatable[] = "sunrise.mission.event.trigger_entered";
 inline constexpr char kTriggerExitedEventMetatable[] = "sunrise.mission.event.trigger_exited";
 inline constexpr char kSquadStateEventMetatable[] = "sunrise.mission.event.squad_state";

+ 123 - 0
Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp

@@ -7,6 +7,9 @@
 
 #include "../../../middleware/bap/activity_message/auth_fields.h"
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../../middleware/bap/activity_message/squad_attachment_auth.h"
+#include "../../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "mission_script_lua_internal.h"
 
 namespace sunrise::server::activity::mission::lua_vm::detail {
@@ -100,6 +103,120 @@ namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
     return value;
 }
 
+/** Sends a native type-32 toggle for one current same-owner authored volume. */
+[[nodiscard]] int slot_set_volume_active(lua_State* state) {
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> declared{"volume", "active"};
+    refuse_unknown_arguments(state, declared);
+    const SlotHandle target = checked_argument<SlotHandle>(state, "volume", kSlotMetatable);
+    lua_getfield(state, 2, "active");
+    if (!lua_isboolean(state, -1)) {
+        return luaL_error(state, "active must be a boolean");
+    }
+    const bool active = lua_toboolean(state, -1) != 0;
+    lua_pop(state, 1);
+    SlotDefinition source{}, volume{};
+    if (!current_slot(state, *handle, source) || !current_slot(state, target, volume)) {
+        return luaL_error(state, "toggle or volume is stale or invalid");
+    }
+    if (source.slotType != scriptable_auth::kType32SlotType
+        || source.componentClass != scriptable_auth::kType32ComponentClass
+        || source.authSchema != scriptable_auth::kType32Schema
+        || (source.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "source must be an exact type-32 toggle sensor");
+    }
+    if (volume.slotType != scriptable_auth::kVolumeSlotType || volume.objectTag != source.objectTag
+        || volume.registryKey != source.registryKey
+        || volume.slotIndex
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return luaL_error(state, "volume must be a same-owner type-60 slot");
+    }
+    std::array<std::byte, scriptable_auth::kType32ByteCount> body{};
+    std::size_t written = 0;
+    if (!scriptable_auth::encode_type32_volume(
+            {volume.registryKey, static_cast<std::int16_t>(volume.slotIndex), active},
+            body,
+            written)) {
+        return luaL_error(state, "native toggle encoder rejected the volume");
+    }
+    return queue_slot_auth(
+        state, source, scriptable_auth::kType32Schema, scriptable_auth::kType32BitCount, body);
+}
+
+/** Selects an audited same-owner squad lifetime for a type-26 attachment. */
+[[nodiscard]] int slot_set_squad_attachment(lua_State* state) {
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> declared{
+        "source", "spawn_generation", "active"};
+    refuse_unknown_arguments(state, declared);
+    const SlotHandle sourceHandle = checked_argument<SlotHandle>(state, "source", kSlotMetatable);
+    const lua_Integer generation = checked_integer_argument(state, "spawn_generation");
+    lua_getfield(state, 2, "active");
+    if (!lua_isboolean(state, -1)) {
+        return luaL_error(state, "active must be a boolean");
+    }
+    const bool active = lua_toboolean(state, -1) != 0;
+    lua_pop(state, 1);
+    SlotDefinition target{}, source{};
+    if (!current_slot(state, *handle, target) || !current_slot(state, sourceHandle, source)) {
+        return luaL_error(state, "attachment or squad source is stale or invalid");
+    }
+    if (target.slotType != 26 || target.authSchema != scriptable_auth::kType26Schema
+        || (target.flags & format::kSlotSchemaJoinExact) == 0
+        || source.slotType != format::kSquadSlotType
+        || source.authSchema != format::kSquadAuthSchema
+        || (source.flags & format::kSlotSchemaJoinExact) == 0
+        || target.objectTag != source.objectTag || target.registryKey != source.registryKey) {
+        return luaL_error(state, "attachment requires an exact same-owner type-1 source");
+    }
+    if (generation <= 0 || generation > (std::numeric_limits<std::int32_t>::max)()) {
+        return luaL_error(state, "spawn_generation must be a positive signed 32-bit integer");
+    }
+    CallFrame& frame = active_frame(state);
+    Intent intent{};
+    intent.kind = IntentKind::setSquadAttachment;
+    intent.firstRow = target.nativeRow;
+    intent.secondRow = source.nativeRow;
+    intent.sourceSpawnGeneration = static_cast<std::uint64_t>(generation);
+    intent.active = active;
+    return queue_intent(state, frame, intent);
+}
+
+/** Attaches the authored hop-on effect to the entities a type-34 filter selects. */
+[[nodiscard]] int slot_set_mission_effect(lua_State* state) {
+    namespace effect = middleware::bap::activity_message::mission_effect;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"filter", "enabled", "revision"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != effect::kSlotType
+        || slot.authSchema != effect::kSchema) {
+        return luaL_error(state, "mission effect requires an authored type-26 hop-on");
+    }
+    const bool enabled = optional_boolean_argument(state, "enabled", true);
+    scriptable_auth::Type2LaneClientRef filter{};
+    if (enabled) {
+        lua_getfield(state, 2, "filter");
+        const bool present = !lua_isnil(state, -1);
+        lua_pop(state, 1);
+        if (!present
+            || !optional_slot_reference(
+                state, "filter", scriptable_auth::kType34SlotType, filter)) {
+            return luaL_error(state, "mission effect requires a type-34 filter");
+        }
+    }
+    const lua_Integer revision = optional_integer_argument(state, "revision", 1);
+    if (!valid_counter(revision)) {
+        return luaL_error(state, "effect revision must be positive");
+    }
+    std::array<std::byte, effect::kBytes> body{};
+    std::size_t written = 0;
+    if (!effect::encode(filter, enabled, static_cast<std::int32_t>(revision), body, written)) {
+        return luaL_error(state, "mission effect encoder failed");
+    }
+    return queue_slot_auth(state, slot, effect::kSchema, effect::kBits, body);
+}
 /** Reads one Slot row member, its syntax methods, and its authorized actions. */
 [[nodiscard]] int slot_index(lua_State* state) {
     const auto* const handle =
@@ -165,6 +282,12 @@ namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
         lua_pushinteger(state, definition.flags);
     } else if (key == "set_object_filter") {
         lua_pushcfunction(state, &slot_set_object_filter);
+    } else if (key == "set_volume_active") {
+        lua_pushcfunction(state, &slot_set_volume_active);
+    } else if (key == "set_squad_attachment") {
+        lua_pushcfunction(state, &slot_set_squad_attachment);
+    } else if (key == "set_mission_effect") {
+        lua_pushcfunction(state, &slot_set_mission_effect);
     } else if (key == "watch_damage") {
         lua_pushcfunction(state, &slot_watch_damage);
     } else if (key == "set_object_active") {

+ 56 - 0
Sunrise/src/server/activity/mission/mission_script_lua_state_api.cpp

@@ -1,10 +1,13 @@
 #include <algorithm>
+#include <array>
 #include <cmath>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <limits>
 #include <string_view>
 
+#include "../../../core/logging/log.h"
 #include "mission_script_lua_internal.h"
 
 namespace sunrise::server::activity::mission::lua_vm::detail {
@@ -160,6 +163,58 @@ void push_variable_value(lua_State* state, const VariableValue& value) {
     frame.candidate.phaseChanged = true;
     return 0;
 }
+/**
+ * Emits one script variable write. Controllers park their gate state in named variables
+ * (`later.<name>.status`, `later.<name>.reason`, `route.invalidated`, ...); without this row a
+ * stalled encounter can only be described as "no further requests", never explained.
+ */
+void log_variable_write(const StateKey& key, const VariableValue& value) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const std::string_view name = state_key_view(key);
+    int written = 0;
+    switch (value.kind) {
+    case VariableValueKind::boolean:
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=mission_script stage=variable result=set name=%.*s value=%s",
+                                static_cast<int>(name.size()),
+                                name.data(),
+                                value.booleanValue ? "true" : "false");
+        break;
+    case VariableValueKind::integer:
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=mission_script stage=variable result=set name=%.*s value=%lld",
+                                static_cast<int>(name.size()),
+                                name.data(),
+                                static_cast<long long>(value.integerValue));
+        break;
+    case VariableValueKind::real:
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=mission_script stage=variable result=set name=%.*s value=%.6g",
+                                static_cast<int>(name.size()),
+                                name.data(),
+                                value.realValue);
+        break;
+    case VariableValueKind::string:
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=mission_script stage=variable result=set name=%.*s value=%.*s",
+                                static_cast<int>(name.size()),
+                                name.data(),
+                                static_cast<int>(value.stringLength),
+                                value.stringValue.data());
+        break;
+    }
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::server,
+            core::log::Level::debug,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
 /** Stages one variable write that commits with the enclosing callback. */
 [[nodiscard]] int context_set_variable(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
@@ -185,6 +240,7 @@ void push_variable_value(lua_State* state, const VariableValue& value) {
         ++frame.candidate.variableCount;
     }
     frame.candidate.variables[row] = {key, value};
+    log_variable_write(key, value);
     return 0;
 }
 /** Stages one variable removal that commits with the enclosing callback. */

+ 14 - 0
Sunrise/src/server/activity/mission/mission_script_object_sense.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstdint>
 #include <span>
 
@@ -13,6 +14,12 @@ inline constexpr std::uint16_t kObjectGenerationOrdinal = 0;
 /** Ordinal 1 is the property the mode-2 interaction effect clears. The meaning is assumed. */
 inline constexpr std::uint16_t kObjectInteractionOpenOrdinal = 1;
 inline constexpr std::uint16_t kObjectExistsOrdinal = 2;
+/** Local aliases: ordinal 1 is read as the alive level, ordinal 2 as presence. */
+inline constexpr std::uint16_t kObjectAliveOrdinal = kObjectInteractionOpenOrdinal;
+inline constexpr std::uint16_t kObjectPresentOrdinal = kObjectExistsOrdinal;
+/** Authored entry the object instantiated, and its two 32-bit spawn-mask words. */
+inline constexpr std::uint16_t kObjectEntryIndexOrdinal = 3;
+inline constexpr std::uint32_t kObjectSpawnMaskSchema = 0x80809E1BU;
 /** Ownership reply: field 0 held, field 1 the owner key. */
 inline constexpr std::uint16_t kOwnerHeldOrdinal = 0;
 inline constexpr std::uint16_t kOwnerKeyOrdinal = 1;
@@ -31,6 +38,8 @@ struct ObjectInteractionLevel final {
     bool ownerKnown{};
     bool hasOwner{};
     std::uint64_t ownerKey{};
+    std::int32_t entryIndex{};
+    std::array<std::uint32_t, 2> spawnMask{};
 };
 
 /**
@@ -72,6 +81,11 @@ struct ObjectInteractionLevel final {
         } else if (value.schemaRow == root && value.fieldOrdinal == kObjectExistsOrdinal) {
             level.present = value.unsignedValue != 0;
             level.stateKnown = true;
+        } else if (value.schemaRow == root && value.fieldOrdinal == kObjectEntryIndexOrdinal) {
+            level.entryIndex = static_cast<std::int32_t>(value.signedValue);
+        } else if (value.schemaRow == kObjectSpawnMaskSchema && value.fieldOrdinal == 0
+                   && value.occurrence < level.spawnMask.size()) {
+            level.spawnMask[value.occurrence] = static_cast<std::uint32_t>(value.unsignedValue);
         } else if (value.schemaRow == object::kOwnershipReply
                    && value.fieldOrdinal == kOwnerHeldOrdinal) {
             level.ownerKnown = true;

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

@@ -337,6 +337,8 @@ void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
     instance.ghostObservations = {};
     instance.actorPathObservations = {};
     instance.squadObservations = {};
+    instance.objectInteractionObservations = {};
+    instance.objectObservationHeldRegion = -1;
     instance.combatantDamageObservations = {};
     instance.deviceObservations = {};
     instance.sceneObservations = {};

+ 1 - 0
Sunrise/src/server/activity/mission/mission_script_runtime.h

@@ -12,6 +12,7 @@ namespace sunrise::server::activity::mission {
 /** One bounded, value-owned diagnostic row for an attached mission program. */
 struct InstanceDiagnostics final {
     state::activity::SessionBinding binding{};
+    std::array<std::byte, 32> scriptSourceSha256{}, sdkBuildSha256{};
     std::array<char, 64> activityId{};
     std::array<char, 24> programStatus{};
     std::array<char, 32> deliveryStage{};

+ 5 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_attach.cpp

@@ -86,8 +86,10 @@ reload_authorization(const state::activity::SessionBinding& binding) noexcept {
     return module;
 }
 
+} // namespace
+
 /** True when the link, the SDK view and the world view still match what the instance bound. */
-[[nodiscard]] bool still_exact(RuntimeInstance& instance) noexcept {
+bool still_exact(RuntimeInstance& instance) noexcept {
     server::bap::ActivityLinkView link{};
     lua_vm::WorldGenerationIdentity worldGeneration{};
     return server::bap::activity_link_view(instance.view.binding, link)
@@ -104,6 +106,8 @@ reload_authorization(const state::activity::SessionBinding& binding) noexcept {
            && sdk_bridge::world_generation_identity(instance.worldView, worldGeneration);
 }
 
+namespace {
+
 /**
  * Re-points one open program at the current ActivityClient generation.
  * @return True when the instance holds an exact view of the same program.

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

@@ -422,6 +422,9 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::signalAuthoredScene:
         result = "scene_event_staged";
         break;
+    case lua_vm::IntentKind::setSquadAttachment:
+        result = "attachment_staged";
+        break;
     case lua_vm::IntentKind::activateAuthoredScene:
         result = "scene_staged";
         break;

+ 42 - 13
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -313,19 +313,18 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
             }
             return;
         }
-        const squads::Status status =
-            squads::place_reserved(instance.view,
-                                   intent.firstRow,
-                                   counts,
-                                   mode,
-                                   reservation,
-                                   std::nullopt,
-                                   intent.squadRetireOnReturn,
-                                   std::nullopt,
-                                   intent.squadSpawnRuleRow < 0
-                                       ? std::nullopt
-                                       : std::optional<std::uint32_t>{
-                                             static_cast<std::uint32_t>(intent.squadSpawnRuleRow)});
+        const squads::Status status = squads::place_reserved(
+            instance.view,
+            intent.firstRow,
+            counts,
+            mode,
+            reservation,
+            std::nullopt,
+            intent.squadRetireOnReturn,
+            std::nullopt,
+            intent.squadSpawnRuleRow < 0 ? std::nullopt
+                                         : std::optional<std::uint32_t>{static_cast<std::uint32_t>(
+                                               intent.squadSpawnRuleRow)});
         if (status == squads::Status::queued) {
             await_host_commit(instance, now, "squad_enqueued");
         } else if (!abandon_reserved_delivery(instance, reservation)) {
@@ -352,6 +351,36 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         }
         return;
     }
+    case lua_vm::IntentKind::setSquadAttachment: {
+        host::ScriptableOutputReservation reservation{};
+        if (!reserve_delivery(instance, reservation)) {
+            if (instance.programStatus == ProgramStatus::loaded) {
+                refuse_delivery(instance,
+                                "attachment_refused",
+                                "host_reservation_unavailable",
+                                host::EffectOutcome::refused);
+            }
+            return;
+        }
+        const devices::Status status =
+            devices::set_squad_attachment_reserved(instance.view,
+                                                   intent.firstRow,
+                                                   intent.secondRow,
+                                                   intent.sourceSpawnGeneration,
+                                                   intent.active,
+                                                   reservation);
+        if (status == devices::Status::queued) {
+            await_host_commit(instance, now, "attachment_enqueued");
+        } else if (!abandon_reserved_delivery(instance, reservation)) {
+            return;
+        } else {
+            refuse_delivery(instance,
+                            "attachment_refused",
+                            devices::status_name(status),
+                            host::EffectOutcome::refused);
+        }
+        return;
+    }
     case lua_vm::IntentKind::bindCombatantToSquad: {
         host::ScriptableOutputReservation reservation{};
         if (!reserve_delivery(instance, reservation)) {

+ 211 - 48
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -18,16 +18,10 @@
 namespace sunrise::server::activity::mission {
 namespace {
 
-/** Trigger volume occupancy publishes on this Sense slot type. */
-constexpr std::uint8_t kTriggerOccupancySlotType = 30;
-/** Values in one occupancy body: any, all, intersection count, and copied Auth value. */
-constexpr std::size_t kTriggerOccupancyValueCount = 4;
-constexpr std::uint16_t kTriggerAnyOrdinal = 0;
-constexpr std::uint16_t kTriggerAllOrdinal = 1;
-constexpr std::uint16_t kTriggerCountOrdinal = 2;
-constexpr std::uint16_t kTriggerThresholdOrdinal = 3;
 /** Root ordinal of the squad removal flag. The rest of the root is inert on this build. */
 constexpr std::uint16_t kSquadRemovalOrdinal = 8;
+/** Root ordinal 0 echoes the squad Auth .6 spawn generation on build 86657. */
+constexpr std::uint16_t kSquadSpawnGenerationOrdinal = 0;
 /** Root ordinals of the type-20 damage Sense body: health, shield, then the echoed revision. */
 constexpr std::uint16_t kDamageHealthOrdinal = 0;
 constexpr std::uint16_t kDamageShieldOrdinal = 1;
@@ -92,6 +86,8 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     event.firstSlotType = observation.key.slotType;
     event.slotObjectTag = observation.key.objectTag;
     event.slotSenseSchema = observation.key.senseSchema;
+    event.senseGenerationPlusOne = observation.generationPlusOne;
+    event.hasSenseGeneration = observation.hasGeneration;
     return event;
 }
 
@@ -101,7 +97,8 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     SquadObservation* spare = nullptr;
     for (SquadObservation& retained : instance.squadObservations) {
         if (retained.used && retained.registryKey == key.registryKey
-            && retained.objectTag == key.objectTag && retained.slotIndex == key.slotIndex) {
+            && retained.objectTag == key.objectTag && retained.slotIndex == key.slotIndex
+            && retained.slotType == key.slotType && retained.schemaRow == key.schemaRow) {
             return &retained;
         }
         if (!retained.used && spare == nullptr) {
@@ -128,6 +125,8 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     spare->registryKey = key.registryKey;
     spare->objectTag = key.objectTag;
     spare->slotIndex = key.slotIndex;
+    spare->slotType = key.slotType;
+    spare->schemaRow = key.schemaRow;
     return spare;
 }
 
@@ -212,23 +211,22 @@ void objective_task_counters(std::span<const sense_values::DecodedValue> body,
 } // namespace
 
 /**
- * Raises one event per watched volume whose occupancy changed.
- * The client publishes occupancy as a level, so the edge is ours to derive. A volume seen for the
- * first time only records its level, because a first observation is not an entry.
+ * Publishes qualified trigger levels and derives ordinary enter/exit edges from consecutive levels.
+ * A first observation publishes a baseline state, because a first observation is not an entry.
  */
 void push_trigger_edges(RuntimeInstance& instance,
-                        const host::SenseObservationSnapshot& sense) noexcept {
+                        const host::SenseObservationSnapshot& sense,
+                        std::uint64_t missionSequence) noexcept {
     for (std::size_t index = 0; index < sense.observationCount; ++index) {
         const host::SenseObservation& observation = sense.observations[index];
-        if (observation.key.slotType != kTriggerOccupancySlotType
-            || observation.valueCount < kTriggerOccupancyValueCount
-            || observation.firstValue + kTriggerOccupancyValueCount > sense.valueCount) {
+        if (observation.key.slotType != trigger_observation::kSlotType
+            || observation.key.senseSchema != trigger_observation::kSchema
+            || observation.key.schemaRow != trigger_observation::kSchema
+            || observation.sourceGeneration == 0
+            || observation.sourceGeneration != instance.view.activityClientGeneration
+            || observation.sequence == 0) {
             continue;
         }
-        const std::span<const sense_values::DecodedValue> body(
-            &sense.values[observation.firstValue], kTriggerOccupancyValueCount);
-        const std::uint32_t root = observation.key.schemaRow;
-        const bool occupied = sense_flag(body, root, kTriggerAnyOrdinal);
         TriggerOccupancy* slot = nullptr;
         TriggerOccupancy* spare = nullptr;
         for (TriggerOccupancy& retained : instance.triggerOccupancy) {
@@ -247,23 +245,46 @@ void push_trigger_edges(RuntimeInstance& instance,
                 log_line(core::log::Level::warn, &instance, "trigger", "watch_capacity");
                 continue;
             }
-            spare->registryKey = observation.key.registryKey;
-            spare->objectTag = observation.key.objectTag;
-            spare->slotIndex = observation.key.slotIndex;
-            spare->occupied = occupied;
-            spare->used = true;
+            slot = spare;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+            slot->used = true;
+        }
+        trigger_observation::Snapshot current{};
+        // An exact source with a malformed body must invalidate the prior baseline as well.
+        const bool bounded = observation.firstValue <= sense.valueCount
+                             && observation.valueCount <= sense.valueCount - observation.firstValue;
+        const bool valid = bounded
+                           && trigger_observation::read(
+                               std::span(sense.values.data(), sense.valueCount)
+                                   .subspan(observation.firstValue, observation.valueCount),
+                               current);
+        const auto result = slot->tracker.observe(valid ? &current : nullptr,
+                                                  instance.view.activityClientGeneration,
+                                                  observation.sourceGeneration,
+                                                  observation.sequence,
+                                                  observation.generationPlusOne,
+                                                  observation.hasGeneration);
+        if (!result.notify) {
             continue;
         }
-        if (slot->occupied == occupied) {
-            continue;
-        }
-        slot->occupied = occupied;
         host::Event event = sense_edge_event(instance, observation);
-        event.triggerAll = sense_flag(body, root, kTriggerAllOrdinal);
-        event.triggerCount = sense_number(body, root, kTriggerCountOrdinal);
-        event.triggerValue = sense_number(body, root, kTriggerThresholdOrdinal);
-        event.kind = occupied ? host::EventKind::triggerEntered : host::EventKind::triggerExited;
+        event.missionSequence = missionSequence;
+        event.kind = host::EventKind::triggerState;
+        event.triggerAvailable = result.available;
+        event.triggerOccupied = current.occupied;
+        event.triggerContinuity = static_cast<std::uint8_t>(result.continuity);
+        event.triggerCount = current.count;
+        event.triggerValue = current.threshold;
+        event.triggerAll = current.all;
         push_script_event(instance, event);
+        if (result.edge != trigger_observation::Edge::none) {
+            event.kind = result.edge == trigger_observation::Edge::entered
+                             ? host::EventKind::triggerEntered
+                             : host::EventKind::triggerExited;
+            push_script_event(instance, event);
+        }
     }
 }
 
@@ -423,6 +444,45 @@ void push_damage_edges(RuntimeInstance& instance,
     }
 }
 
+/**
+ * Finds the retained row for one interactable object, allocating a free row on a first
+ * observation. At capacity the least recently observed row is recycled: a mission streams more
+ * objects than the table holds, and a later-authored object such as a door crystal must still
+ * publish its levels rather than silently vanish. Recycling drops that row's level history, so the
+ * next report republishes as a baseline.
+ */
+[[nodiscard]] ObjectInteractionObservation*
+find_object_row(RuntimeInstance& instance, const host::SenseObservation& observation) noexcept {
+    ObjectInteractionObservation* spare = nullptr;
+    ObjectInteractionObservation* oldest = nullptr;
+    for (ObjectInteractionObservation& retained : instance.objectInteractionObservations) {
+        if (retained.used && retained.registryKey == observation.key.registryKey
+            && retained.objectTag == observation.key.objectTag
+            && retained.slotIndex == observation.key.slotIndex) {
+            retained.sequence = observation.sequence;
+            return &retained;
+        }
+        if (!retained.used) {
+            if (spare == nullptr) {
+                spare = &retained;
+            }
+        } else if (oldest == nullptr || retained.sequence < oldest->sequence) {
+            oldest = &retained;
+        }
+    }
+    ObjectInteractionObservation* const row = spare != nullptr ? spare : oldest;
+    if (row == nullptr) {
+        return nullptr;
+    }
+    *row = {};
+    row->used = true;
+    row->sequence = observation.sequence;
+    row->registryKey = observation.key.registryKey;
+    row->objectTag = observation.key.objectTag;
+    row->slotIndex = observation.key.slotIndex;
+    return row;
+}
+
 /** Raises object state and accepted interaction events per interactable object. */
 void push_object_interaction_edges(RuntimeInstance& instance,
                                    const host::SenseObservationSnapshot& sense) noexcept {
@@ -432,8 +492,7 @@ void push_object_interaction_edges(RuntimeInstance& instance,
                 observation, sense, format::kObjectSlotType, format::kObjectSenseSchema)) {
             continue;
         }
-        ObjectInteractionObservation* const slot =
-            find_slot_row(instance.objectInteractionObservations, observation.key);
+        ObjectInteractionObservation* const slot = find_object_row(instance, observation);
         if (slot == nullptr) {
             continue;
         }
@@ -442,6 +501,9 @@ void push_object_interaction_edges(RuntimeInstance& instance,
             slot->level, observation_values(observation, sense), observation.key.schemaRow);
         const ObjectInteractionLevel& level = slot->level;
         host::Event event = sense_edge_event(instance, observation);
+        event.initialObservation = !before.stateKnown || before.generation != level.generation;
+        event.objectEntryIndex = level.entryIndex;
+        event.objectSpawnMask = level.spawnMask;
         event.objectGeneration = level.generation;
         event.objectPresent = level.present;
         // The alive lane carries Sense ordinal 1, which the script reads as interaction_open.
@@ -455,7 +517,8 @@ void push_object_interaction_edges(RuntimeInstance& instance,
                 || before.present != level.present
                 || before.interactionOpen != level.interactionOpen
                 || before.ownerKnown != level.ownerKnown || before.hasOwner != level.hasOwner
-                || before.ownerKey != level.ownerKey);
+                || before.ownerKey != level.ownerKey || before.entryIndex != level.entryIndex
+                || before.spawnMask != level.spawnMask);
         if (stateChanged) {
             event.kind = host::EventKind::objectState;
             std::array<char, 128> details{};
@@ -547,31 +610,115 @@ void push_squad_edges(RuntimeInstance& instance,
         if (!observe_population(instance, observation, body)) {
             return;
         }
+        // A root-absent delta carries no squad state and cannot replace the previous levels.
+        if (std::none_of(body.begin(), body.end(), [root](const sense_values::DecodedValue& value) {
+                return value.schemaRow == root;
+            })) {
+            continue;
+        }
         SquadObservation* const squad = find_squad(instance, observation.key);
         if (squad == nullptr) {
             continue;
         }
+        if (observation.sourceGeneration == 0
+            || observation.sourceGeneration != instance.view.activityClientGeneration) {
+            continue;
+        }
+        const bool hadPrevious = squad->epoch.used;
+        const bool sameSource =
+            hadPrevious && squad->epoch.sourceGeneration == observation.sourceGeneration;
+        if (observation.sequence == 0
+            || (sameSource && observation.sequence <= squad->epoch.sequence)) {
+            continue;
+        }
+        if (sameSource && squad->hasReportCounter && observation.hasGeneration
+            && observation.generationPlusOne == squad->reportCounter) {
+            continue;
+        }
+        // After a Sense rebase the client re-sends already delivered counters, often with a fuller
+        // delta against an older baseline. A replay repeats known state; treating it as a counter
+        // reset tells the scripts the squad lost its registration and spawn echo. A native
+        // re-registration restarts at one and still reconciles below.
+        if (sameSource && squad->hasReportCounter && observation.hasGeneration
+            && observation.generationPlusOne > 1
+            && observation.generationPlusOne < squad->reportCounter) {
+            continue;
+        }
+        // Retained levels only bridge a proven consecutive report. A gap, wrap or re-registration
+        // reconciles from this body alone.
+        const bool consecutive = sameSource && squad->hasReportCounter && observation.hasGeneration
+                                 && squad->reportCounter != UINT32_MAX
+                                 && observation.generationPlusOne == squad->reportCounter + 1;
+
+        const sense_values::DecodedValue* const generationValue =
+            sense_value(body, root, kSquadSpawnGenerationOrdinal);
+        const bool bodyHasGeneration = generationValue != nullptr && generationValue->present
+                                       && generationValue->signedValue >= 0
+                                       && generationValue->signedValue <= kMaximumCounter;
+        std::int32_t spawnGeneration =
+            bodyHasGeneration ? static_cast<std::int32_t>(generationValue->signedValue) : 0;
+        bool hasSpawnGeneration = bodyHasGeneration;
+        if (!bodyHasGeneration && consecutive) {
+            spawnGeneration = squad->spawnGeneration;
+            hasSpawnGeneration = squad->hasSpawnGeneration;
+        }
+        const bool lifetimeChanged =
+            bodyHasGeneration
+            && (!squad->hasSpawnGeneration || squad->spawnGeneration != spawnGeneration);
+        const bool sameLifetime = consecutive && !lifetimeChanged;
+        if (!sameLifetime) {
+            squad->objectiveCosts = {};
+        }
         const bool costsChanged = update_squad_objective_costs(squad->objectiveCosts, body, root);
-        // Cost-only deltas retain combat counts; they must never manufacture a death.
-        std::int32_t alive = squad->aliveCount;
-        const bool hasAlive = read_squad_alive(body, root, alive);
-        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
-        auto counts = squad->slotCounts;
-        auto countLength = squad->slotCountLength;
+
+        std::int32_t alive = sameLifetime ? squad->aliveCount : 0;
+        bool hasAlive = read_squad_alive(body, root, alive);
+        if (!hasAlive && sameLifetime) {
+            hasAlive = squad->hasAlive;
+        }
+        const sense_values::DecodedValue* const removalValue =
+            sense_value(body, root, kSquadRemovalOrdinal);
+        bool hasRemoval = removalValue != nullptr && removalValue->present;
+        bool removal = hasRemoval && removalValue->unsignedValue != 0;
+        if (!hasRemoval && sameLifetime) {
+            removal = squad->removalFlag;
+            hasRemoval = squad->hasRemoval;
+        }
+        auto counts = sameLifetime ? squad->slotCounts : decltype(squad->slotCounts){};
+        auto countLength = sameLifetime ? squad->slotCountLength : std::uint8_t{};
         std::array<std::int32_t, host::kSquadSlotCapacity> incomingCounts{};
         const auto incomingLength = read_squad_created_counts(body, incomingCounts);
         if (incomingLength != 0) {
             counts = incomingCounts;
             countLength = incomingLength;
         }
-        if (!hasAlive && !costsChanged && incomingLength == 0) {
+
+        auto transition = observation_epoch::accept(squad->epoch,
+                                                    instance.view.activityClientGeneration,
+                                                    observation.sourceGeneration,
+                                                    observation.sequence,
+                                                    static_cast<std::uint32_t>(spawnGeneration),
+                                                    hasSpawnGeneration && spawnGeneration > 0);
+        const bool counterReset = hadPrevious && !consecutive;
+        if (transition != observation_epoch::Transition::discard
+            && (counterReset || !squad->hasAlive || !hasAlive)) {
+            transition = observation_epoch::Transition::baseline;
+        }
+        if (transition == observation_epoch::Transition::discard) {
             continue;
         }
-        const bool first = !squad->used;
+        squad->reportCounter = observation.generationPlusOne;
+        squad->hasReportCounter = observation.hasGeneration;
+
+        const bool first = transition == observation_epoch::Transition::baseline;
+        const bool wasUsed = squad->used;
         squad->used = true;
-        const std::int32_t previousAlive = first ? 0 : squad->aliveCount;
+        const std::int32_t previousAlive = first ? alive : squad->aliveCount;
         const bool changed =
-            costsChanged || first || squad->aliveCount != alive || squad->removalFlag != removal
+            costsChanged || first || squad->aliveCount != alive || squad->hasAlive != hasAlive
+            || squad->removalFlag != removal || squad->hasRemoval != hasRemoval
+            || squad->spawnGeneration != spawnGeneration
+            || squad->hasSpawnGeneration != hasSpawnGeneration
             || squad->slotCountLength != countLength || squad->slotCounts != counts;
         if (!changed) {
             continue;
@@ -582,10 +729,16 @@ void push_squad_edges(RuntimeInstance& instance,
         state.squadObjectiveRevision = squad->objectiveCosts.revision;
         qualify_objective_costs(instance, state);
         state.squadAliveCount = alive;
+        state.squadPopulationAvailable = hasAlive;
+        state.squadSpawnGeneration = spawnGeneration;
+        state.squadHasSpawnGeneration = hasSpawnGeneration;
+        state.squadRegistrationReset = counterReset || (hadPrevious && lifetimeChanged);
         state.squadPreviousAliveCount = previousAlive;
         state.squadRemovalFlag = removal;
+        state.squadHasRemovalFlag = hasRemoval;
         state.squadSlotCounts = counts;
         state.squadSlotCountLength = countLength;
+        state.initialObservation = first;
         state.kind = host::EventKind::squadState;
         if (instance.programStatus == ProgramStatus::loaded) {
             push_script_event(instance, state);
@@ -593,7 +746,7 @@ void push_squad_edges(RuntimeInstance& instance,
 
         for (std::uint8_t slot = 0; slot < countLength; ++slot) {
             const std::int32_t previous =
-                !first && slot < squad->slotCountLength ? squad->slotCounts[slot] : 0;
+                wasUsed && !first && slot < squad->slotCountLength ? squad->slotCounts[slot] : 0;
             if (counts[slot] <= previous) {
                 continue;
             }
@@ -606,17 +759,27 @@ void push_squad_edges(RuntimeInstance& instance,
                 push_script_event(instance, spawned);
             }
         }
-        if (!first && alive < squad->aliveCount) {
+        // Only consecutive reports of one spawn lifetime may prove a population decrease.
+        if (!first && hasAlive && squad->hasAlive && alive < squad->aliveCount) {
             host::Event died = sense_edge_event(instance, observation);
             died.squadAliveCount = alive;
+            died.squadPopulationAvailable = true;
+            died.squadSpawnGeneration = spawnGeneration;
+            died.squadHasSpawnGeneration = hasSpawnGeneration;
             died.squadPreviousAliveCount = squad->aliveCount;
+            died.squadRemovalFlag = removal;
+            died.squadHasRemovalFlag = hasRemoval;
             died.kind = host::EventKind::entityDied;
             if (instance.programStatus == ProgramStatus::loaded) {
                 push_script_event(instance, died);
             }
         }
         squad->aliveCount = alive;
+        squad->hasAlive = hasAlive;
         squad->removalFlag = removal;
+        squad->hasRemoval = hasRemoval;
+        squad->spawnGeneration = spawnGeneration;
+        squad->hasSpawnGeneration = hasSpawnGeneration;
         squad->slotCounts = counts;
         squad->slotCountLength = countLength;
     }

+ 32 - 4
Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp

@@ -81,6 +81,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
     case host::EventKind::phaseEntered:
     case host::EventKind::triggerEntered:
     case host::EventKind::triggerExited:
+    case host::EventKind::triggerState:
     case host::EventKind::squadState:
     case host::EventKind::squadProvoked:
     case host::EventKind::entitySpawned:
@@ -125,7 +126,9 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
            || event.kind == host::EventKind::effectResult
            || event.kind == host::EventKind::phaseEntered
            || event.kind == host::EventKind::triggerEntered
+           || event.kind == host::EventKind::triggerState
            || event.kind == host::EventKind::triggerExited
+           || event.kind == host::EventKind::objectState
            || event.kind == host::EventKind::squadState
            || event.kind == host::EventKind::squadProvoked
            || event.kind == host::EventKind::entitySpawned
@@ -564,6 +567,28 @@ void clear_feed_cursors() noexcept {
     g_missionInputCursor = {};
 }
 
+/** A committed held-region change requires fresh native object levels, preserving replay cursors.
+ */
+void reconcile_object_region(RuntimeInstance& instance, const host::Event& event) noexcept {
+    if (event.kind != host::EventKind::clientStateChanged
+        || instance.objectObservationHeldRegion == event.heldRegionIndex) {
+        return;
+    }
+    // Clearing only the state flag republishes the next level as a baseline without replaying an
+    // already-reported interaction latch.
+    for (auto& observation : instance.objectInteractionObservations) {
+        if (observation.used) {
+            observation.level.stateKnown = false;
+        }
+    }
+    for (auto& observation : instance.triggerOccupancy) {
+        if (observation.used) {
+            observation.tracker.invalidate_levels();
+        }
+    }
+    instance.objectObservationHeldRegion = event.heldRegionIndex;
+}
+
 /**
  * Runs one event through the VM, commits what it changed, and faults on a script failure.
  * @param sense Values owned by a Sense row, or null.
@@ -634,7 +659,7 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
             observe_player_life(instance, *sense);
             publish_fireteam_life(now);
         }
-        push_trigger_edges(instance, *sense);
+        push_trigger_edges(instance, *sense, event.missionSequence);
         push_ghost_edges(instance, *sense);
         push_object_interaction_edges(instance, *sense);
         push_damage_edges(instance, *sense);
@@ -668,6 +693,7 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         if (!commit_mission_state(instance, true, nextInputSequence)) {
             return lua_vm::CallStatus::scriptError;
         }
+        reconcile_object_region(instance, event);
         ++instance.eventsCommitted;
         lua_vm::Snapshot diagnostics{};
         lua_vm::snapshot(instance.vm, diagnostics);
@@ -690,9 +716,11 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         return status;
     }
     if (status == lua_vm::CallStatus::noHandler) {
-        return commit_mission_state(instance, true, nextInputSequence)
-                   ? status
-                   : lua_vm::CallStatus::scriptError;
+        if (!commit_mission_state(instance, true, nextInputSequence)) {
+            return lua_vm::CallStatus::scriptError;
+        }
+        reconcile_object_region(instance, event);
+        return status;
     }
     if (status == lua_vm::CallStatus::inactive) {
         return status;

+ 30 - 5
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -15,6 +15,7 @@
 #include "../../../state/activity_sdk/generated_world/runtime.h"
 #include "../../../state/activity_sdk/runtime.h"
 #include "../host_runtime.h"
+#include "mission_observation_epoch.h"
 #include "mission_script_actor_path_sense.h"
 #include "mission_script_combatant_damage_sense.h"
 #include "mission_script_device_sense.h"
@@ -24,6 +25,7 @@
 #include "mission_script_runtime.h"
 #include "mission_script_squad_sense.h"
 #include "mission_script_vm.h"
+#include "mission_trigger_observation.h"
 
 // What the mission-runtime translation units share: the instance table and service slice,
 // the attach pipeline, the two Host feeds and the VM callback, the panel rows, the delivery state
@@ -89,13 +91,17 @@ constexpr std::size_t kTriggerOccupancyCapacity = 32;
 constexpr std::size_t kSquadObservationCapacity = 160;
 /** Watched authored scenes retained per instance. */
 constexpr std::size_t kSceneObservationCapacity = 32;
-/** Watched objective sensors retained per instance. */
+/**
+ * Watched objective sensors retained per instance. Strange Terrain reports eleven distinct
+ * sensors before the relic phase; keep route-wide headroom aligned with the other authored-watch
+ * tables so later crystals cannot lose their first baseline.
+ */
 constexpr std::size_t kObjectiveObservationCapacity = 32;
 static_assert(kSquadObjectiveGroupCount == host::kSquadObjectiveGroupCount);
 /** Watched Ghost links, damage monitors, interactable objects and named actors per instance. */
 constexpr std::size_t kGhostObservationCapacity = kGhostLinkCapacity;
 constexpr std::size_t kDamageObservationCapacity = 8;
-constexpr std::size_t kObjectInteractionObservationCapacity = 64;
+constexpr std::size_t kObjectInteractionObservationCapacity = 160;
 constexpr std::size_t kActorPathObservationCapacity = 64;
 /** Watched device levels retained per instance. */
 constexpr std::size_t kDeviceObservationCapacity = 128;
@@ -110,10 +116,10 @@ constexpr std::uint64_t kHostCommitTimeoutMs = 2'000;
 
 /** Last occupancy seen for one watched volume, so only a change raises an event. */
 struct TriggerOccupancy final {
+    trigger_observation::Tracker tracker{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::uint16_t slotIndex{};
-    bool occupied{};
     bool used{};
 };
 
@@ -158,6 +164,8 @@ struct DeviceObservation final {
 /** Last object and interaction level seen for one interactable object. */
 struct ObjectInteractionObservation final {
     ObjectInteractionLevel level{};
+    /** Sense sequence of the last accepted observation; the oldest row yields at capacity. */
+    std::uint64_t sequence{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::uint16_t slotIndex{};
@@ -177,12 +185,23 @@ struct GhostObservation final {
 struct SquadObservation final {
     SquadObjectiveCosts objectiveCosts{};
     std::array<std::int32_t, host::kSquadSlotCapacity> slotCounts{};
+    /** Source, sequence and spawn-echo continuity; only consecutive reports bridge absent levels.
+     */
+    observation_epoch::Cursor epoch{};
+    std::uint32_t reportCounter{};
+    bool hasReportCounter{};
+    std::int32_t spawnGeneration{};
+    bool hasSpawnGeneration{};
+    bool hasAlive{};
+    bool hasRemoval{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::int32_t aliveCount{};
     std::uint16_t slotIndex{};
     std::uint8_t slotCountLength{};
     bool removalFlag{};
+    std::uint32_t schemaRow{};
+    std::uint8_t slotType{};
     bool used{};
 };
 /** Last completion latch seen for one watched authored scene, so only the edge raises an event. */
@@ -252,6 +271,8 @@ struct RuntimeInstance final {
     std::array<DeviceObservation, kDeviceObservationCapacity> deviceObservations{};
     std::array<ObjectInteractionObservation, kObjectInteractionObservationCapacity>
         objectInteractionObservations{};
+    /** Held region whose object levels are current; a region change re-baselines them. */
+    std::int32_t objectObservationHeldRegion{-1};
     std::array<ActorPathObservation, kActorPathObservationCapacity> actorPathObservations{};
     std::array<SceneObservation, kSceneObservationCapacity> sceneObservations{};
     std::array<ObjectiveObservation, kObjectiveObservationCapacity> objectiveObservations{};
@@ -279,6 +300,7 @@ struct RuntimeInstance final {
     std::int32_t activeRegion{-1};
     ProgramStatus programStatus{ProgramStatus::none};
     DeliveryStage deliveryStage{DeliveryStage::idle};
+    /** Last logged native reaction drain class; 0 is ready/empty, otherwise status+1. */
     /** The player key the bound link's message 5 binds, read at attach. */
     std::uint64_t playerKey{};
     bool publicTarget{};
@@ -313,7 +335,8 @@ void observe_player_life(RuntimeInstance& instance,
 
 /** Raises one event per watched trigger volume whose occupancy changed. */
 void push_trigger_edges(RuntimeInstance& instance,
-                        const host::SenseObservationSnapshot& sense) noexcept;
+                        const host::SenseObservationSnapshot& sense,
+                        std::uint64_t missionSequence) noexcept;
 /** Raises one dedicated type-31 edge from a decoded schema-0x8080879F msg-19 payload. */
 void push_player_trigger(RuntimeInstance& instance, const host::Event& incident) noexcept;
 /** Raises one exact Type-6 start/finish edge from a decoded schema-0x808087BF msg-19 payload. */
@@ -461,10 +484,12 @@ void note_vm_status(RuntimeInstance& instance,
 
 /** Resolves the script root and the SDK Lua search path. Logs its own refusal. */
 [[nodiscard]] bool resolve_script_paths() noexcept;
-/** Clears the script buffer, both paths and every reload authorization. */
+/** Clears both script buffers and both paths. */
 void clear_script_paths() noexcept;
 /** @return False when no authorization slot is free, so the reload cannot replace this program. */
 [[nodiscard]] bool authorize_reload(const RuntimeInstance& instance) noexcept;
+/** @return True while the binding, link, SDK view and generated world still match the instance. */
+[[nodiscard]] bool still_exact(RuntimeInstance& instance) noexcept;
 /** Drops slots that no longer match, publishes the roster, and attaches active host instances. */
 void synchronize_instances(std::uint64_t now) noexcept;
 /** Advances fresh programs only when their declared state roster has reached transport output. */

+ 2 - 1
Sunrise/src/server/activity/mission/mission_script_sdk_bridge.cpp

@@ -122,7 +122,8 @@ resolve_activity_binding_locator(const void* context,
     const auto allSquads = catalog.squads();
     const auto members = sdk::squad_members(catalog, squad);
     if (members.empty() || members.size() > output.defaultCounts.size()
-        || (squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask
+        || (squad.flags & format::squad_required_mask(squad.flags))
+               != format::squad_required_mask(squad.flags)
         || &squad < allSquads.data() || &squad >= allSquads.data() + allSquads.size()) {
         return false;
     }

+ 12 - 4
Sunrise/src/server/activity/mission/mission_script_vm.cpp

@@ -198,6 +198,7 @@ inline constexpr std::array<const char*, host::kEventKindCount> kEventHandlerNam
     "on_event_squad_provoked",
     "on_event_device_state",
     "on_event_region_changed",
+    "on_event_trigger_state",
 }};
 
 static_assert([] {
@@ -546,15 +547,14 @@ OpenStatus open(Vm& vm,
         preserve_failure_and_close(impl);
         return status;
     }
-
     CallFrame loadFrame{};
     loadFrame.remainingInstructions = kInitializationInstructionBudget;
     impl.frame = &loadFrame;
     lua_sethook(impl.state, &instruction_hook, LUA_MASKCOUNT, kHookInterval);
     const int executed = lua_pcall(impl.state, 0, 1, 0);
-    lua_sethook(impl.state, nullptr, 0, 0);
-    impl.frame = nullptr;
     if (executed != LUA_OK) {
+        lua_sethook(impl.state, nullptr, 0, 0);
+        impl.frame = nullptr;
         detail::set_error(
             impl,
             existing_error(impl.state, "mission initialization failed with a non-string error"));
@@ -564,10 +564,14 @@ OpenStatus open(Vm& vm,
         preserve_failure_and_close(impl);
         return status;
     }
+    // The returned program table stays above the capture/seal frames so a declared preload is
+    // charged to the same initialization budget as the opening chunk.
     lua_pushcfunction(impl.state, &capture_program);
-    lua_insert(impl.state, -2);
+    lua_pushvalue(impl.state, -2);
     const int captured = lua_pcall(impl.state, 1, 0, 0);
     if (captured != LUA_OK) {
+        lua_sethook(impl.state, nullptr, 0, 0);
+        impl.frame = nullptr;
         detail::set_error(
             impl, existing_error(impl.state, "mission declaration failed with a non-string error"));
         const OpenStatus status =
@@ -575,6 +579,8 @@ OpenStatus open(Vm& vm,
         preserve_failure_and_close(impl);
         return status;
     }
+    lua_sethook(impl.state, nullptr, 0, 0);
+    impl.frame = nullptr;
     lua_settop(impl.state, 0);
     impl.active = true;
     impl.faulted = false;
@@ -902,6 +908,8 @@ const char* status_name(OpenStatus status) noexcept {
         return "runtime_error";
     case OpenStatus::invalidProgram:
         return "invalid_program";
+    case OpenStatus::dependencyRefused:
+        return "dependency_refused";
     }
     return "unknown";
 }

+ 3 - 0
Sunrise/src/server/activity/mission/mission_script_vm.h

@@ -74,6 +74,7 @@ struct SceneDefinition final {
 
 /** One scenario-local reusable object slot resolved against the pinned SDK view. */
 struct SlotDefinition final {
+    std::uint32_t objectiveCount{};
     std::string_view id{};
     std::string_view name{};
     std::string_view objectId{};
@@ -444,6 +445,8 @@ enum class OpenStatus : std::uint8_t {
     compileError,
     runtimeError,
     invalidProgram,
+    /** The returned sealed dependency declaration was missing, malformed or incomplete. */
+    dependencyRefused,
 };
 
 /** Stable protected-callback outcome. */

+ 170 - 0
Sunrise/src/server/activity/mission/mission_trigger_observation.h

@@ -0,0 +1,170 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission::trigger_observation {
+
+namespace sense = middleware::bap::activity_message::sense_update;
+/** Build-86657 type30 Sense: two required bits followed by two required signed words. */
+constexpr std::uint32_t kSchema = 0x80809531U;
+constexpr std::uint8_t kSlotType = 30;
+
+struct Snapshot final {
+    bool occupied{};
+    bool all{};
+    std::int32_t count{};
+    std::int32_t threshold{};
+};
+
+/** Rejects incomplete/ambiguous bodies; required fields never inherit invented defaults. */
+[[nodiscard]] inline bool read(std::span<const sense::DecodedValue> body,
+                               Snapshot& output) noexcept {
+    if (body.size() != 4) {
+        return false;
+    }
+    std::array<const sense::DecodedValue*, 4> fields{};
+    for (const auto& value : body) {
+        if (value.schemaRow != kSchema || value.fieldOrdinal >= fields.size()
+            || value.occurrence != 0 || !value.present || fields[value.fieldOrdinal]) {
+            return false;
+        }
+        if (value.fieldOrdinal < 2) {
+            if (value.kind != sense::ValueKind::boolean || value.width != 1
+                || value.unsignedValue > 1) {
+                return false;
+            }
+        } else if (value.kind != sense::ValueKind::signedInteger || value.width != 32
+                   || value.signedValue < (std::numeric_limits<std::int32_t>::min)()
+                   || value.signedValue > (std::numeric_limits<std::int32_t>::max)()) {
+            return false;
+        }
+        fields[value.fieldOrdinal] = &value;
+    }
+    output = {fields[0]->unsignedValue != 0,
+              fields[1]->unsignedValue != 0,
+              static_cast<std::int32_t>(fields[2]->signedValue),
+              static_cast<std::int32_t>(fields[3]->signedValue)};
+    return true;
+}
+
+enum class Edge : std::uint8_t { none, entered, exited };
+enum class Continuity : std::uint8_t {
+    invalid,
+    baseline,
+    consecutive,
+    duplicate,
+    conflict,
+    gap,
+    reset,
+    missingCounter,
+    wrap
+};
+[[nodiscard]] inline const char* name(Continuity value) noexcept {
+    switch (value) {
+    case Continuity::baseline:
+        return "baseline";
+    case Continuity::consecutive:
+        return "consecutive";
+    case Continuity::duplicate:
+        return "duplicate";
+    case Continuity::conflict:
+        return "conflict";
+    case Continuity::gap:
+        return "gap";
+    case Continuity::reset:
+        return "reset";
+    case Continuity::missingCounter:
+        return "missing_counter";
+    case Continuity::wrap:
+        return "wrap";
+    default:
+        return "invalid";
+    }
+}
+struct Observation final {
+    Edge edge{Edge::none};
+    Continuity continuity{Continuity::invalid};
+    bool notify{}, available{};
+};
+
+/** Adjacent native reports establish continuity; repeated envelopes never confirm a level. */
+struct Tracker final {
+    std::uint64_t source{}, sequence{};
+    std::uint32_t counter{};
+    bool hasCounter{}, known{}, occupied{};
+    Snapshot snapshot{};
+    void invalidate_levels() noexcept {
+        known = false;
+    }
+
+    [[nodiscard]] Observation observe(const Snapshot* current,
+                                      std::uint64_t expectedSource,
+                                      std::uint64_t nextSource,
+                                      std::uint64_t nextSequence,
+                                      std::uint32_t nextCounter,
+                                      bool nextHasCounter) noexcept {
+        if (nextSource == 0 || nextSource != expectedSource || nextSequence == 0
+            || (source == nextSource && nextSequence <= sequence)) {
+            return {};
+        }
+        const bool sameSource = source == nextSource;
+        if (!current) {
+            source = nextSource;
+            sequence = nextSequence;
+            known = false;
+            hasCounter = false;
+            return {Edge::none, Continuity::invalid, true, false};
+        }
+        if (sameSource && hasCounter && nextHasCounter && counter == nextCounter) {
+            const bool same = snapshot.occupied == current->occupied && snapshot.all == current->all
+                              && snapshot.count == current->count
+                              && snapshot.threshold == current->threshold;
+            if (!same) {
+                invalidate_levels();
+            }
+            sequence = nextSequence;
+            return {Edge::none, same ? Continuity::duplicate : Continuity::conflict, !same, false};
+        }
+        Continuity continuity = Continuity::baseline;
+        const bool consecutive = known && sameSource && hasCounter && nextHasCounter
+                                 && counter != UINT32_MAX && nextCounter == counter + 1;
+        if (!nextHasCounter) {
+            continuity = Continuity::missingCounter;
+        } else if (consecutive) {
+            continuity = Continuity::consecutive;
+        } else if (sameSource && hasCounter && known) {
+            continuity = counter == UINT32_MAX   ? Continuity::wrap
+                         : nextCounter < counter ? Continuity::reset
+                                                 : Continuity::gap;
+        }
+        const Edge edge = !consecutive || occupied == current->occupied ? Edge::none
+                          : current->occupied                           ? Edge::entered
+                                                                        : Edge::exited;
+        source = nextSource;
+        sequence = nextSequence;
+        counter = nextCounter;
+        hasCounter = nextHasCounter;
+        known = nextHasCounter;
+        occupied = current->occupied;
+        snapshot = *current;
+        return {edge, continuity, true, nextHasCounter};
+    }
+    /** Compatibility entry point for edge-only consumers. */
+    [[nodiscard]] Edge accept(const Snapshot& current,
+                              std::uint64_t expectedSource,
+                              std::uint64_t nextSource,
+                              std::uint64_t nextSequence,
+                              std::uint32_t nextCounter,
+                              bool nextHasCounter) noexcept {
+        return observe(
+                   &current, expectedSource, nextSource, nextSequence, nextCounter, nextHasCounter)
+            .edge;
+    }
+};
+
+} // namespace sunrise::server::activity::mission::trigger_observation

+ 165 - 0
Sunrise/src/server/activity/squad_attachment_ownership_validation.h

@@ -0,0 +1,165 @@
+#pragma once
+
+#include <algorithm>
+
+#include "../../middleware/bap/activity_message/squad_attachment_auth.h"
+#include "../../state/activity_sdk/attachment_compatibility.h"
+#include "host_runtime.h"
+
+namespace sunrise::server::activity::host::attachments {
+
+/**
+ * Requires a type-26 attachment and the squad source that shares its object, registry, roster group
+ * and state-local placement. Any authored pair with that shape is admitted; no content is named.
+ */
+[[nodiscard]] inline bool same_owner_pair(const ScriptableTarget& target,
+                                          const SquadAttachmentOwnership& owned) noexcept {
+    const auto& source = owned.source;
+    return owned.sdkBuildSha256 != std::array<std::byte, 32>{}
+           && state::activity_sdk::attachment_compatibility::supports(owned.sdkPayloadSha256)
+           && owned.sourceSpawnGeneration > 0 && owned.sourceSpawnGeneration <= 0x7FFFFFFFU
+           && target.slotType == 26
+           && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType26Schema
+           && source.objectTag == target.objectTag && source.registryKey == target.registryKey
+           && source.slotType == state::activity_sdk::format::kSquadSlotType
+           && source.authSchema == state::activity_sdk::format::kSquadAuthSchema
+           && source.sdkObjectIndex == target.sdkObjectIndex
+           && source.stateLocalRoster == target.stateLocalRoster
+           && source.stateLocalRegion == target.stateLocalRegion
+           && source.rosterGroupIndex == target.rosterGroupIndex;
+}
+
+/** Full wire identity, independent of the occurrence metadata required by owned attachments. */
+[[nodiscard]] inline bool same_ref(const ScriptableTarget& left,
+                                   const ScriptableTarget& right) noexcept {
+    return left.objectTag == right.objectTag && left.registryKey == right.registryKey
+           && left.slotType == right.slotType && left.slotIndex == right.slotIndex;
+}
+
+/**
+ * @return The placement spawn generation a retained squad row stands for. An objective assignment
+ * replaces the placement row with a squadObjective patch that carries the generation forward.
+ */
+[[nodiscard]] inline bool source_spawn_generation(const PendingScriptableOverride& row,
+                                                  std::uint64_t& value) noexcept {
+    if (row.byteCount == 0 || row.byteCount > row.body.size()) {
+        return false;
+    }
+    if (row.kind == ScriptableOverrideKind::squad) {
+        value = row.generation;
+        return true;
+    }
+    if (row.kind == ScriptableOverrideKind::squadObjective && row.squadSpawnGeneration != 0) {
+        value = row.squadSpawnGeneration;
+        return true;
+    }
+    return false;
+}
+
+/** @return True when the body exactly represents the owned selection and authored prefix. */
+[[nodiscard]] inline bool matches_body(const SquadAttachmentOwnership& owned,
+                                       std::span<const std::byte> body,
+                                       std::size_t bitCount) noexcept {
+    namespace auth = middleware::bap::activity_message::scriptable_auth;
+    std::array<std::byte, auth::kType26MaximumByteCount> expected{};
+    std::size_t written = 0, bits = 0;
+    const auth::Type26SquadSelection selection =
+        owned.active ? auth::Type26SquadSelection{owned.source.registryKey,
+                                                  static_cast<std::int16_t>(owned.source.slotIndex),
+                                                  true}
+                     : auth::Type26SquadSelection{};
+    return auth::encode_type26_squad_selection(selection, expected, written, bits)
+           && bits == bitCount && body.size() == written
+           && std::equal(body.begin(), body.end(), expected.begin());
+}
+
+/**
+ * Checks fresh claim/update/clear against delivered source and attachment lifetimes.
+ * The caller owns binding/held-region locks and must repeat this before transport publication.
+ */
+[[nodiscard]] inline bool admits(const ScriptableTarget& target,
+                                 const std::optional<SquadAttachmentOwnership>& requested,
+                                 std::span<const PendingScriptableOverride> estate,
+                                 std::span<const std::byte> body,
+                                 std::size_t bitCount) noexcept {
+    if (!requested) {
+        // Occurrence metadata can change for shared unowned scriptables. Only retained
+        // attachment ownership requires an owned update, including after an owned clear.
+        for (const auto& row : estate) {
+            if (same_ref(row.target, target) && row.squadAttachment) {
+                return false;
+            }
+        }
+        return true;
+    }
+    const PendingScriptableOverride* previous = nullptr;
+    for (const auto& row : estate) {
+        if (same_ref(row.target, target)) {
+            if (previous != nullptr || row.target != target) {
+                return false;
+            }
+            previous = &row;
+        }
+    }
+    const auto& owned = *requested;
+    if (!same_owner_pair(target, owned)) {
+        return false;
+    }
+    const PendingScriptableOverride* source = nullptr;
+    for (const auto& row : estate) {
+        if (same_ref(row.target, owned.source)) {
+            if (source != nullptr || row.target != owned.source) {
+                return false;
+            }
+            source = &row;
+        }
+    }
+    std::uint64_t sourceSpawn = 0;
+    if (source == nullptr || !source_spawn_generation(*source, sourceSpawn)
+        || sourceSpawn != owned.sourceSpawnGeneration) {
+        return false;
+    }
+    if (previous == nullptr) {
+        if (!owned.active || owned.previousRevision != 0) {
+            return false;
+        }
+    } else {
+        if (!previous->squadAttachment || previous->revision != owned.previousRevision
+            || previous->squadAttachment->sdkBuildSha256 != owned.sdkBuildSha256
+            || previous->squadAttachment->sdkPayloadSha256 != owned.sdkPayloadSha256
+            || previous->squadAttachment->source != owned.source
+            || ((previous->squadAttachment->active || !owned.active)
+                && previous->squadAttachment->sourceSpawnGeneration
+                       != owned.sourceSpawnGeneration)) {
+            return false;
+        }
+        if (previous->kind != ScriptableOverrideKind::sdkAuth
+            || previous->byteCount > previous->body.size()
+            || !matches_body(*previous->squadAttachment,
+                             std::span(previous->body).first(previous->byteCount),
+                             previous->bitCount)) {
+            return false;
+        }
+    }
+    return matches_body(owned, body, bitCount);
+}
+
+/** Keeps an active attachment on its selected lifetime until an owned clear is delivered. */
+[[nodiscard]] inline bool
+permits_source_write(const PendingScriptableOverride& pending,
+                     std::span<const PendingScriptableOverride> estate) noexcept {
+    for (const auto& row : estate) {
+        // An objective assignment never changes the selected lifetime; a re-placement must keep it.
+        if (row.squadAttachment && row.squadAttachment->active
+            && same_ref(row.squadAttachment->source, pending.target)
+            && pending.kind != ScriptableOverrideKind::squadObjective
+            && (pending.target != row.squadAttachment->source
+                || pending.kind != ScriptableOverrideKind::squad
+                || pending.generation != row.squadAttachment->sourceSpawnGeneration)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::server::activity::host::attachments

+ 51 - 0
Sunrise/src/server/activity/squad_authored_profile.h

@@ -0,0 +1,51 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+#include <span>
+
+#include "../../state/activity_sdk/format.h"
+
+namespace sunrise::server::activity::activity_sdk_squads {
+
+/** Selects the exact common profile of requested members without choosing an actor identity. */
+[[nodiscard]] inline bool
+select_authored_profile(std::span<const state::activity_sdk::format::SquadMember> members,
+                        std::span<const std::int32_t> counts,
+                        std::array<std::int8_t, 4>& output) noexcept {
+    namespace format = state::activity_sdk::format;
+    output = {};
+    if (members.size() != counts.size()) {
+        return false;
+    }
+    bool found = false;
+    std::array<std::int8_t, 4> selected{};
+    // A named type-2 member needs its parent's authored profile but zero loose actors, so an
+    // all-zero request evaluates every member.
+    bool loose = false;
+    for (const std::int32_t count : counts) {
+        if (count < 0) {
+            return false;
+        }
+        loose = loose || count > 0;
+    }
+    for (std::size_t index = 0; index < members.size(); ++index) {
+        if (loose && counts[index] == 0) {
+            continue;
+        }
+        const auto& member = members[index];
+        if ((member.flags & format::kSquadMemberAuthoredProfileExact) == 0
+            || !format::valid_authored_spawn_profile(member.authoredSpawnProfile)
+            || (found && selected != member.authoredSpawnProfile)) {
+            return false;
+        }
+        selected = member.authoredSpawnProfile;
+        found = true;
+    }
+    if (found) {
+        output = selected;
+    }
+    return found;
+}
+
+} // namespace sunrise::server::activity::activity_sdk_squads

+ 1 - 0
Sunrise/src/server/bap/bap_activity_link_view.cpp

@@ -2,6 +2,7 @@
 
 #include <cstddef>
 #include <cstdint>
+#include <optional>
 #include <shared_mutex>
 
 #include "../../state/activity/runtime.h"

+ 4 - 2
Sunrise/src/server/bap/bap_scriptable_override_route.cpp

@@ -302,7 +302,8 @@ bool request_activity_sdk_auth_override(
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation,
-    activity::host::ScriptableOverrideKind kind) noexcept {
+    activity::host::ScriptableOverrideKind kind,
+    std::optional<activity::host::SquadAttachmentOwnership> attachment) noexcept {
     const std::lock_guard lock(session_lock());
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
@@ -326,7 +327,8 @@ bool request_activity_sdk_auth_override(
                                                                      bitCount,
                                                                      expectedGeneration,
                                                                      reservation,
-                                                                     kind);
+                                                                     kind,
+                                                                     attachment);
     return queued;
 }
 

+ 45 - 26
Sunrise/src/server/bap/encrypted/activity_message/activity_message_framing.cpp

@@ -2,6 +2,8 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <memory>
+#include <new>
 #include <span>
 
 #include "../../../../middleware/bap/activity_message/cinematic_incident.h"
@@ -162,9 +164,20 @@ bool frame_only(const ActivityClientBinding& binding,
         kFramingRoutes.end(),
         [adapter](const FramingRoute& candidate) noexcept { return candidate.adapter == adapter; });
     service::incident::Incident parsedIncident{};
-    sense_update::SenseUpdate parsedSense{};
     const bool isIncident = adapter == IngressAdapter::incidentHostIncident;
     const bool isSense = adapter == IngressAdapter::senseUpdateHostSense;
+    // Packet storage is shared by diagnostics and mission delivery; keep its bounded
+    // arrays off the networking thread's stack, including the submission copy.
+    std::unique_ptr<sense_update::SenseUpdate> parsedSense;
+    std::unique_ptr<server::activity::host::SenseInput> senseInput;
+    if (isSense) {
+        parsedSense.reset(new (std::nothrow) sense_update::SenseUpdate{});
+        senseInput.reset(new (std::nothrow) server::activity::host::SenseInput{});
+        if (!parsedSense || !senseInput) {
+            report_message(request.messageType, request.sessionId, "sense_storage_unavailable");
+            return false;
+        }
+    }
     receipts::Framed framed{};
     if (isSense) {
         const activity_sdk::Snapshot catalog = activity_sdk::snapshot();
@@ -172,8 +185,8 @@ bool frame_only(const ActivityClientBinding& binding,
         const sense_update::Resolver resolver{&context, resolve_sense_group, resolve_sense_slot};
         std::size_t consumedBits = 0;
         static_cast<void>(sense_update::decode_sense_update(
-            request.payload, resolver, parsedSense, consumedBits));
-        framed = receipts::frame_sense_update(request, parsedSense);
+            request.payload, resolver, *parsedSense, consumedBits));
+        framed = receipts::frame_sense_update(request, *parsedSense);
     } else {
         framed = isIncident ? receipts::frame_incident_copy(request, parsedIncident)
                  : row != kFramingRoutes.end() ? row->frame(request)
@@ -182,8 +195,8 @@ bool frame_only(const ActivityClientBinding& binding,
     DiagnosticBody diagnostic{};
     diagnostic.consumedBits = framed.consumedBits;
     diagnostic.status = diagnostic_status(framed, isIncident);
-    diagnostic.sense = isSense ? &parsedSense.decoded : nullptr;
-    if (isSense && parsedSense.decoded.status == sense_update::DecodeStatus::partial) {
+    diagnostic.sense = isSense ? &parsedSense->decoded : nullptr;
+    if (isSense && parsedSense->decoded.status == sense_update::DecodeStatus::partial) {
         diagnostic.status = server::activity::host::ClientMessageStatus::decodedPartial;
     }
     const std::uint64_t clientMessageSequence = record(request,
@@ -192,32 +205,35 @@ bool frame_only(const ActivityClientBinding& binding,
                                                        &binding.session,
                                                        binding.bindingGeneration,
                                                        diagnostic);
-    if (isSense && parsedSense.decoded.status != sense_update::DecodeStatus::malformed) {
-        server::activity::host::SenseInput input{};
+    if (isSense && parsedSense->decoded.status != sense_update::DecodeStatus::malformed) {
+        server::activity::host::SenseInput& input = *senseInput;
         input.binding = binding.session;
         input.sourceGeneration = binding.bindingGeneration;
         input.clientMessageSequence = clientMessageSequence;
-        input.epochFirst = parsedSense.epoch.first;
-        input.epochSecond = parsedSense.epoch.second;
+        input.epochFirst = parsedSense->epoch.first;
+        input.epochSecond = parsedSense->epoch.second;
         input.payloadBytes = static_cast<std::uint32_t>(request.payload.size());
         input.peerHeardMask = request.peerHeardMask;
-        input.tailBits = parsedSense.tailBits;
+        input.tailBits = parsedSense->tailBits;
         input.consumedBits = static_cast<std::uint32_t>(framed.consumedBits);
-        input.firstGroupBits = parsedSense.firstGroupBits;
-        input.firstRegistryKey = parsedSense.firstRegistryKey;
-        input.groupsSeen = parsedSense.decoded.groupsSeen;
-        input.groupsDecoded = parsedSense.decoded.groupsDecoded;
-        input.groupsSkipped = parsedSense.decoded.groupsSkipped;
-        input.objectsSeen = parsedSense.decoded.objectsSeen;
-        input.objectsDecoded = parsedSense.decoded.objectsDecoded;
-        input.firstSlotIndex = parsedSense.firstSlotIndex;
-        input.firstSlotType = parsedSense.firstSlotType;
-        input.decodeStatus = parsedSense.decoded.status;
+        input.firstGroupBits = parsedSense->firstGroupBits;
+        input.firstRegistryKey = parsedSense->firstRegistryKey;
+        input.groupsSeen = parsedSense->decoded.groupsSeen;
+        input.groupsDecoded = parsedSense->decoded.groupsDecoded;
+        input.groupsSkipped = parsedSense->decoded.groupsSkipped;
+        input.objectsSeen = parsedSense->decoded.objectsSeen;
+        input.objectsDecoded = parsedSense->decoded.objectsDecoded;
+        input.firstSlotIndex = parsedSense->firstSlotIndex;
+        input.firstSlotType = parsedSense->firstSlotType;
+        input.decodeStatus = parsedSense->decoded.status;
         input.verdict = framed.verdict;
-        input.decoded = parsedSense.decoded;
-        input.hasFirstObject = parsedSense.hasFirstObject;
-        if (!server::activity::host::submit_sense(input)) {
-            report_message(request.messageType, request.sessionId, "host_ingress_refused");
+        input.decoded = parsedSense->decoded;
+        input.hasFirstObject = parsedSense->hasFirstObject;
+        const auto refusal = server::activity::host::submit_sense(input);
+        if (refusal != server::activity::host::IngressRefusal::none) {
+            report_message(request.messageType,
+                           request.sessionId,
+                           server::activity::host::ingress_refusal_name(refusal));
         }
     } else if (isIncident && framed.verdict == store::Verdict::framed) {
         server::activity::host::IncidentInput input{};
@@ -239,8 +255,11 @@ bool frame_only(const ActivityClientBinding& binding,
                 std::span(parsedIncident.payload).first(parsedIncident.payloadLength),
                 input.cinematic);
         }
-        if (!server::activity::host::submit_incident(input)) {
-            report_message(request.messageType, request.sessionId, "host_ingress_refused");
+        const auto refusal = server::activity::host::submit_incident(input);
+        if (refusal != server::activity::host::IngressRefusal::none) {
+            report_message(request.messageType,
+                           request.sessionId,
+                           server::activity::host::ingress_refusal_name(refusal));
         }
     }
     return true;

+ 1 - 0
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -211,6 +211,7 @@ bool consume(Session& session,
              const middleware::bap::OuterFrame& outer,
              std::span<std::byte> response,
              std::size_t& written) noexcept {
+    const state::activity_sdk::CatalogPublicationGuard catalogPublication;
     written = 0;
     session.accountMutationPublished = false;
     if (!session.authenticated) {

+ 15 - 4
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -192,6 +192,7 @@ bool consume_activity_keepalive(Session& session,
                                 std::span<std::byte> response,
                                 std::size_t& written,
                                 bool& touchesScratch) noexcept {
+    const state::activity_sdk::CatalogPublicationGuard catalogPublication;
     written = 0;
     const std::uint64_t now = GetTickCount64();
     const bool keepaliveDue = now >= session.activityKeepaliveDueTick;
@@ -239,6 +240,14 @@ bool consume_activity_keepalive(Session& session,
     // The arrival report (ws 702 world state 8) is answered by one roster built after it, on each
     // link whose delivered body still holds the wait bit. That answer raises `entered`.
     const bool arrivalDue = active && session.activityRosterAwaitClientSync && incidentClientReady;
+    // A script-selected state revision that has not reached the client's roster. Nothing else
+    // makes a roster due for it, so it would otherwise wait for the next solicited report.
+    const MissionSeedLease& missionSeed = session.activityMissionSeed;
+    const bool missionSeedDue =
+        active && missionSeed.configured
+        && missionSeed.bindingGeneration == session.activity.bindingGeneration
+        && missionSeed.revision != missionSeed.publishedRevision
+        && !missionSeed.regionArrivalPending && client_region_ready(session, nullptr);
     // The region the client holds, which is the one its advertisement must describe. The pending
     // leg alone names the region behind the player after a z-leg switch, and advertising that
     // hands the client an ambassadorship for a region it has left.
@@ -342,7 +351,7 @@ bool consume_activity_keepalive(Session& session,
     if (!active
         || (!keepaliveDue && !regionChanged && !hostStateDue && !scriptableDue && !incidentDue
             && !authorityResetDue && !authorityQueryDue && !hostTeleportDue && !retirementDue
-            && !arrivalDue)) {
+            && !arrivalDue && !missionSeedDue)) {
         return false;
     }
     touchesScratch = true;
@@ -357,7 +366,7 @@ bool consume_activity_keepalive(Session& session,
     // Only a changed Host value, retirement or the arrival answer can publish a standalone roster.
     if (!keepaliveDue && !regionChanged && !hostTeleportDue) {
         bool appendedRoster = false;
-        if (hostStateDue || scriptableDue || retirementDue || arrivalDue) {
+        if (hostStateDue || scriptableDue || retirementDue || arrivalDue || missionSeedDue) {
             appendedRoster = append_roster_notification(
                 session, scratch, key, nextSendNonce, scratch.framed, framedSize);
             published = appendedRoster;
@@ -442,7 +451,8 @@ bool consume_activity_keepalive(Session& session,
                 SecureZeroMemory(&plan, sizeof plan);
             }
         }
-        if (appended || hostStateDue || scriptableDue || retirementDue || arrivalDue) {
+        if (appended || hostStateDue || scriptableDue || retirementDue || arrivalDue
+            || missionSeedDue) {
             published = append_roster_notification(
                             session, scratch, key, nextSendNonce, scratch.framed, framedSize)
                         || published;
@@ -601,7 +611,8 @@ bool consume_activity_keepalive(Session& session,
     // push could have been read at all. Without it a correct body and a deduped one look the same.
     const std::uint32_t reportedRevision = refresh.snapshot.revision;
     SecureZeroMemory(&refresh, sizeof refresh);
-    if (appendedMembership || hostStateDue || scriptableDue || retirementDue || arrivalDue) {
+    if (appendedMembership || hostStateDue || scriptableDue || retirementDue || arrivalDue
+        || missionSeedDue) {
         published = append_roster_notification(
                         session, scratch, key, nextSendNonce, scratch.framed, framedSize)
                     || published;

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

@@ -212,7 +212,7 @@ void report_roster_push(Session& session,
                           group.hasStateSequence ? 1U : 0U);
         if (groupWritten > 0) {
             core::log::write(core::log::Channel::server,
-                             core::log::Level::debug,
+                             core::log::Level::trace,
                              {line.data(), static_cast<std::size_t>(groupWritten)});
         }
     }
@@ -226,7 +226,7 @@ void report_roster_push(Session& session,
                               key);
             if (keyWritten > 0) {
                 core::log::write(core::log::Channel::server,
-                                 core::log::Level::debug,
+                                 core::log::Level::trace,
                                  {line.data(), static_cast<std::size_t>(keyWritten)});
             }
         }
@@ -248,7 +248,7 @@ void report_roster_push(Session& session,
                           static_cast<unsigned long long>(body_hash(body)));
         if (authWritten > 0) {
             core::log::write(core::log::Channel::server,
-                             core::log::Level::debug,
+                             core::log::Level::trace,
                              {line.data(), static_cast<std::size_t>(authWritten)});
         }
     }

+ 32 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -597,9 +597,29 @@ build_roster_snapshot(Session& session,
     if (pendingStateLocal && pendingGroupPosition >= snapshot.roster.groupCount) {
         return refuse_override("pending_group_position");
     }
+    // Echoed Sense becomes the client's acknowledged baseline and counter. Only a rebuilt group has
+    // lost that baseline. Echoing into a live group rewinds it to the host's last merged report,
+    // which lags reports still in flight: the client then replays old counters and omits changed
+    // fields, so a respawned carrier's population is never reported.
+    const auto rebuilt = [&session, &snapshot](std::uint32_t key) {
+        for (std::size_t index = 0; index < snapshot.roster.groupCount; ++index) {
+            const message::Group& group = snapshot.roster.groups[index];
+            if (group.key != key) {
+                continue;
+            }
+            for (const RosterGroupLease& groupLease : session.activityRosterGroupLeases) {
+                if (groupLease.used && groupLease.key == key) {
+                    return !groupLease.published
+                           || groupLease.publishedSequence != group.stateSequence;
+                }
+            }
+            return true;
+        }
+        return false;
+    };
     std::size_t senseCount = 0;
     for (const message::AuthOverride& auth : snapshot.authOverrides) {
-        if (auth.slotType != 1) {
+        if (auth.slotType != 1 || !rebuilt(auth.key)) {
             continue;
         }
         server::activity::host::SenseObservationKey key{};
@@ -665,6 +685,17 @@ build_roster_snapshot(Session& session,
         ++senseCount;
     }
     snapshot.senseOverrides = std::span(scratch.rosterSenseOverrides).first(senseCount);
+    // A discarded body restores the prior leases, so these marks commit only with the frame.
+    for (std::size_t index = 0; index < snapshot.roster.groupCount; ++index) {
+        const message::Group& group = snapshot.roster.groups[index];
+        for (RosterGroupLease& groupLease : session.activityRosterGroupLeases) {
+            if (groupLease.used && groupLease.key == group.key) {
+                groupLease.publishedSequence = group.stateSequence;
+                groupLease.published = true;
+                break;
+            }
+        }
+    }
     return RosterOutcome::published;
 }
 

+ 2 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -83,6 +83,8 @@ bool prepare(Scratch& scratch,
     }
 
     Prepared staged{};
+    staged.accountIdentity = {account.primarySoid,
+                              state::account::selected_character_soid(account)};
     staged.rawClearSize =
         (std::max)(reservation.rawClearSize,
                    reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);

+ 2 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h

@@ -28,6 +28,8 @@ struct Prepared {
     middleware::queuez::Family family{};
     std::size_t rawClearSize{};
     std::size_t compressedClearSize{};
+    /** Exact account/character used to encode a Family-4 image, zero for other families. */
+    std::array<std::uint64_t, 2> accountIdentity{};
 
     // Default copying would leave family.objects pointing into the source descriptor array.
     Prepared() noexcept = default;

+ 1 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/snapshot_storage.cpp

@@ -105,6 +105,7 @@ bool commit(const Prepared& staged, Prepared& output) noexcept {
     output.objects = staged.objects;
     output.rawClearSize = staged.rawClearSize;
     output.compressedClearSize = staged.compressedClearSize;
+    output.accountIdentity = staged.accountIdentity;
     output.family = middleware::queuez::Family{
         staged.family.type,
         staged.family.rootSoid,

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

@@ -114,6 +114,10 @@ struct RosterGroupLease {
     /** Bubbles this key was ever registered under; a later push keeps it in every one of them. */
     std::uint64_t bubbles{};
     std::uint8_t sequence{};
+    /** Group revision the client last received; it rebuilds the group, and loses Sense, on a
+     * change. */
+    std::uint8_t publishedSequence{};
+    bool published{};
     bool used{};
 };
 

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

@@ -256,8 +256,8 @@ activity_authority_reset_snapshot(const state::activity::SessionBinding& binding
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation = nullptr,
-    activity::host::ScriptableOverrideKind kind =
-        activity::host::ScriptableOverrideKind::sdkAuth) noexcept;
+    activity::host::ScriptableOverrideKind kind = activity::host::ScriptableOverrideKind::sdkAuth,
+    std::optional<activity::host::SquadAttachmentOwnership> attachment = std::nullopt) noexcept;
 
 /** Queues a type-31 arm or disarm only while exactly one authenticated link owns the binding. */
 [[nodiscard]] bool request_activity_type31_override(

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

@@ -132,8 +132,12 @@ namespace {
            && left.authBody == right.authBody && left.sequenceOwner == right.sequenceOwner
            && left.sdkBuildSha256 == right.sdkBuildSha256 && left.kind == right.kind
            && left.firstRow == right.firstRow && left.secondRow == right.secondRow
-           && left.objectTag == right.objectTag && left.registryKey == right.registryKey
-           && left.sceneEventKey == right.sceneEventKey && left.authSchema == right.authSchema
+           && left.spawnRuleSlotRow == right.spawnRuleSlotRow
+           && left.expectedObjectiveRevision == right.expectedObjectiveRevision
+           && left.sourceSpawnGeneration == right.sourceSpawnGeneration
+           && left.objectTag == right.objectTag
+           && left.registryKey == right.registryKey && left.sceneEventKey == right.sceneEventKey
+           && left.authSchema == right.authSchema
            && left.actorCommandSelector == right.actorCommandSelector
            && left.actorCommandValue == right.actorCommandValue
            && left.authBitCount == right.authBitCount

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

@@ -197,6 +197,7 @@ enum class IntentKind : std::uint8_t {
     watchDamage,
     setGhostLink,
     holdSpawn,
+    setSquadAttachment,
 };
 
 /** Actor sequence values belong to one combatant and one SDK/client generation. */
@@ -243,7 +244,13 @@ struct TypedIntent final {
     std::uint64_t requestKey{kAbsentIntentKey};
     IntentKind kind{IntentKind::placeSquad};
     std::uint32_t firstRow{};
+    /** placeSquad with active=true: director SDK row; slotIndex is its authored objective ordinal.
+     */
     std::uint32_t secondRow{};
+    /** Placement-only exact SDK slot row; all other intent kinds leave this absent. */
+    std::uint32_t spawnRuleSlotRow{0xFFFFFFFFU};
+    /** Attachment source lifetime observed from an exact type-1 Sense baseline. */
+    std::uint64_t sourceSpawnGeneration{};
     std::uint32_t sceneEventKey{};
     std::uint32_t objectTag{};
     std::uint32_t registryKey{};