Selaa lähdekoodia

leviathan combat

stan 1 kuukausi sitten
vanhempi
commit
241b76fd55
100 muutettua tiedostoa jossa 3984 lisäystä ja 335 poistoa
  1. 14 0
      Sunrise/Sunrise.vcxproj
  2. 28 4
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.cpp
  3. 28 7
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.h
  4. 10 0
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory_internal.h
  5. 2 0
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_validation.cpp
  6. 422 0
      Sunrise/src/client/content/activity/activity_sdk_actor_sequences.h
  7. 18 1
      Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp
  8. 87 0
      Sunrise/src/client/content/activity/activity_sdk_lua_actor_sequences.cpp
  9. 3 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h
  10. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts_internal.h
  11. 26 2
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  12. 3 0
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  13. 3 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  14. 9 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp
  15. 3 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.h
  16. 12 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  17. 3 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_validate.cpp
  18. 5 0
      Sunrise/src/client/content/activity/scriptable_catalog_object_analysis.cpp
  19. 46 0
      Sunrise/src/client/content/activity/scriptable_catalog_reference_scan.cpp
  20. 15 1
      Sunrise/src/client/content/activity/scriptable_catalog_reference_scan.h
  21. 5 3
      Sunrise/src/client/content/activity/scriptable_catalog_worker.cpp
  22. 134 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_body_codec.cpp
  23. 183 1
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp
  24. 4 28
      Sunrise/src/middleware/bap/activity_message/scene_events_auth.h
  25. 31 1
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  26. 30 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  27. 23 12
      Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h
  28. 15 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h
  29. 15 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h
  30. 2 0
      Sunrise/src/middleware/content/packages/tables/slot_descriptor_reader.h
  31. 45 17
      Sunrise/src/middleware/gameplay/external/actor_command_runtime_codec.cpp
  32. 66 21
      Sunrise/src/middleware/gameplay/external/actor_entity_registry.cpp
  33. 14 2
      Sunrise/src/middleware/gameplay/external/actor_entity_registry.h
  34. 92 0
      Sunrise/src/middleware/gameplay/external/actor_entity_squad_source.cpp
  35. 14 3
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp
  36. 105 0
      Sunrise/src/server/activity/activity_sdk_actor_sequences.cpp
  37. 18 0
      Sunrise/src/server/activity/activity_sdk_actor_sequences.h
  38. 90 1
      Sunrise/src/server/activity/activity_sdk_device_runtime.cpp
  39. 18 2
      Sunrise/src/server/activity/activity_sdk_device_runtime.h
  40. 2 0
      Sunrise/src/server/activity/activity_sdk_mission_internal.h
  41. 7 0
      Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp
  42. 19 9
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  43. 3 1
      Sunrise/src/server/activity/activity_sdk_mission_runtime.h
  44. 69 0
      Sunrise/src/server/activity/activity_sdk_scene_dependencies.h
  45. 34 2
      Sunrise/src/server/activity/host_runtime.h
  46. 5 0
      Sunrise/src/server/activity/host_runtime_internal.h
  47. 4 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  48. 67 22
      Sunrise/src/server/activity/host_runtime_scriptable.cpp
  49. 36 2
      Sunrise/src/server/activity/host_runtime_scriptable_requests.cpp
  50. 11 0
      Sunrise/src/server/activity/host_runtime_scriptable_staging.cpp
  51. 66 0
      Sunrise/src/server/activity/mission/mission_script_actor_sequence_bridge.cpp
  52. 10 0
      Sunrise/src/server/activity/mission/mission_script_actor_sequence_bridge.h
  53. 113 0
      Sunrise/src/server/activity/mission/mission_script_combatant_damage_sense.h
  54. 102 0
      Sunrise/src/server/activity/mission/mission_script_device_sense.h
  55. 200 0
      Sunrise/src/server/activity/mission/mission_script_lua_actor_sequence_api.cpp
  56. 8 1
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  57. 5 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_delivery.cpp
  58. 97 21
      Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp
  59. 5 0
      Sunrise/src/server/activity/mission/mission_script_lua_internal.h
  60. 2 0
      Sunrise/src/server/activity/mission/mission_script_lua_names.h
  61. 1 0
      Sunrise/src/server/activity/mission/mission_script_lua_sandbox.cpp
  62. 45 0
      Sunrise/src/server/activity/mission/mission_script_lua_scene_api.cpp
  63. 24 12
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  64. 35 0
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  65. 6 0
      Sunrise/src/server/activity/mission/mission_script_runtime.h
  66. 107 0
      Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp
  67. 9 0
      Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp
  68. 119 0
      Sunrise/src/server/activity/mission/mission_script_runtime_device.cpp
  69. 34 1
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  70. 6 0
      Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp
  71. 30 0
      Sunrise/src/server/activity/mission/mission_script_runtime_internal.h
  72. 18 4
      Sunrise/src/server/activity/mission/mission_script_squad_sense.h
  73. 7 2
      Sunrise/src/server/activity/mission/mission_script_vm.cpp
  74. 32 2
      Sunrise/src/server/activity/mission/mission_script_vm.h
  75. 2 0
      Sunrise/src/server/activity/mission/mission_script_world_sdk_bridge.cpp
  76. 12 3
      Sunrise/src/server/bap/bap_scriptable_override_route.cpp
  77. 1 2
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  78. 4 1
      Sunrise/src/server/bap/runtime.h
  79. 42 67
      Sunrise/src/server/gameplay/actor_command_policy.cpp
  80. 168 26
      Sunrise/src/server/gameplay/actor_command_policy_lane.cpp
  81. 165 12
      Sunrise/src/server/gameplay/actor_command_policy_session.h
  82. 125 0
      Sunrise/src/server/ui/activity_host/activity_host_sdk_actor_sequences.cpp
  83. 8 0
      Sunrise/src/server/ui/activity_host/activity_host_sdk_actor_sequences.h
  84. 2 1
      Sunrise/src/server/ui/activity_host/activity_host_sdk_state_pages.cpp
  85. 1 1
      Sunrise/src/server/ui/activity_host/activity_host_sdk_state_pages.h
  86. 1 1
      Sunrise/src/server/ui/activity_host/activity_host_sdk_view.cpp
  87. 2 1
      Sunrise/src/state/activity/mission/activity_mission_state.cpp
  88. 19 0
      Sunrise/src/state/activity/mission/definition.h
  89. 13 0
      Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp
  90. 3 0
      Sunrise/src/state/activity_sdk/activity_sdk_loader.cpp
  91. 5 0
      Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp
  92. 249 0
      Sunrise/src/state/activity_sdk/actor_sequences.h
  93. 61 4
      Sunrise/src/state/activity_sdk/format.h
  94. 6 0
      Sunrise/src/state/activity_sdk/generated_world/codec.h
  95. 1 1
      Sunrise/src/state/activity_sdk/generated_world/format.h
  96. 61 14
      Sunrise/src/state/activity_sdk/generated_world/generated_world_codec.cpp
  97. 9 8
      Sunrise/src/state/activity_sdk/generated_world/generated_world_scalar_codec.cpp
  98. 32 0
      Sunrise/src/state/activity_sdk/generated_world/generated_world_store.cpp
  99. 6 0
      Sunrise/src/state/activity_sdk/generated_world/store.h
  100. 6 0
      Sunrise/src/state/activity_sdk/generation/activity_sdk_pack_writer.cpp

+ 14 - 0
Sunrise/Sunrise.vcxproj

@@ -728,6 +728,7 @@
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_activities.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_artifacts.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_contract.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_lua_actor_sequences.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_catalog_contract.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_manifest_contract.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_lua_world_contract.cpp" />
@@ -796,6 +797,7 @@
     <ClCompile Include="src\server\runtime\server_runtime.cpp" />
     <ClCompile Include="src\server\activity\activity_sdk_scriptable_route.cpp" />
     <ClCompile Include="src\server\activity\activity_sdk_device_runtime.cpp" />
+    <ClCompile Include="src\server\activity\activity_sdk_actor_sequences.cpp" />
     <ClCompile Include="src\server\activity\activity_sdk_lifetime_runtime.cpp" />
     <ClCompile Include="src\server\activity\activity_sdk_squad_runtime.cpp" />
     <ClCompile Include="src\server\activity\activity_sdk_mission_runtime.cpp" />
@@ -815,6 +817,7 @@
     <ClCompile Include="src\server\activity\mission\mission_script_lua_message_api.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_lua_peer_api.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_lua_slot_api.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_lua_actor_sequence_api.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_lua_value_api.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_lua_enum_api.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_lua_key_api.cpp" />
@@ -828,11 +831,14 @@
     <ClCompile Include="src\server\activity\mission\mission_script_message_catalog.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_runtime.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_runtime_edges.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_runtime_combatant_damage.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_runtime_device.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_cinematic.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_player_trigger.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_runtime_delivery.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_runtime_dispatch.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_sdk_bridge.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_actor_sequence_bridge.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_catalog_sdk_bridge.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_manifest_sdk_bridge.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_world_sdk_bridge.cpp" />
@@ -861,6 +867,7 @@
     <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_view.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_mission_view.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_state_pages.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_actor_sequences.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_squad_view.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_authored_anchors.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_anchor_render_controls.cpp" />
@@ -1134,6 +1141,7 @@
     <ClCompile Include="src\middleware\gameplay\external\simulation_event_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\actor_command_runtime_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\actor_entity_registry.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\actor_entity_squad_source.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\composite_entity_codec.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\simulation_event_runtime_codec.cpp" />
     <ClCompile Include="src\server\gameplay\gameplay_log.cpp" />
@@ -2069,6 +2077,8 @@
     <ClInclude Include="src\server\activity\mission\mission_script_object_sense.h" />
     <ClInclude Include="src\server\activity\mission\mission_script_player_sense.h" />
     <ClInclude Include="src\server\activity\mission\mission_script_squad_sense.h" />
+    <ClInclude Include="src\server\activity\mission\mission_script_combatant_damage_sense.h" />
+    <ClInclude Include="src\server\activity\mission\mission_script_device_sense.h" />
     <ClInclude Include="src\server\activity\activity_sdk_behavior_scope.h" />
     <ClInclude Include="src\server\bap\encrypted\push\activity\mission_seed_world_change.h" />
     <ClInclude Include="src\server\bap\encrypted\queuez\queuez_reward_staging.h" />
@@ -2155,5 +2165,9 @@
     <None Include="resources\database\account_settings_schema.sql" />
     <None Include="resources\database\account_settings_defaults.sql" />
   </ItemGroup>
+  <ItemGroup>
+    <ClInclude Include="src\state\activity_sdk\actor_sequences.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_actor_sequences.h" />
+  </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 28 - 4
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.cpp

@@ -8,6 +8,7 @@
 
 #include "../../../middleware/content/packages/reader/parallel.h"
 #include "activity_sdk_actor_rsat_inventory_internal.h"
+#include "activity_sdk_actor_sequences.h"
 
 namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
 namespace {
@@ -55,7 +56,8 @@ bool build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
                                void* readContext,
                                CancelProbe cancel,
                                void* cancelContext,
-                               Snapshot& output) noexcept {
+                               Snapshot& output,
+                               std::span<const SequenceTableSource> sequenceTables) noexcept {
     output = {};
     if (actorTags.empty() || readTag == nullptr || is_cancelled(cancel, cancelContext)) {
         return false;
@@ -87,6 +89,14 @@ bool build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
             return false;
         }
 
+        for (const auto& source : sequenceTables) {
+            std::uint32_t table = format::kAbsentIndex;
+            if (is_absent_tag(source.definitionTag)
+                || !sequence_inventory::table(state, source, table)) {
+                return false;
+            }
+        }
+
         for (std::size_t actorIndex = 0; actorIndex < actorTags.size(); ++actorIndex) {
             if (is_cancelled(cancel, cancelContext)
                 || actorIndex > (std::numeric_limits<std::uint32_t>::max)()
@@ -142,7 +152,8 @@ bool build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
                 }
             }
             state.snapshot.behaviorProfiles.push_back(profile);
-            if (!collect_state_names(state, static_cast<std::uint32_t>(actorIndex), actorBlob)) {
+            if (!sequence_inventory::actor(state, static_cast<std::uint32_t>(actorIndex), actorBlob)
+                || !collect_state_names(state, static_cast<std::uint32_t>(actorIndex), actorBlob)) {
                 return false;
             }
             if (is_absent_tag(actor.rsatTag)) {
@@ -304,6 +315,9 @@ bool build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
             descriptor.schemaIndex = static_cast<std::uint32_t>(found->second);
         }
 
+        if (!sequence_inventory::finish_names(state.snapshot)) {
+            return false;
+        }
         state.snapshot.complete = true;
         if (!validate(state.snapshot)) {
             return false;
@@ -408,8 +422,18 @@ bool build_with_rsats(const reader::Source& source,
                 return false;
             }
         }
-        return build_from_tags_and_rsats(
-            actorTags, rsatTags, &package_read, &context, cancel, cancelContext, output);
+        std::vector<SequenceTableSource> sequenceTables;
+        if (!sequence_inventory::scan_tables(source, sequenceTables)) {
+            return false;
+        }
+        return build_from_tags_and_rsats(actorTags,
+                                         rsatTags,
+                                         &package_read,
+                                         &context,
+                                         cancel,
+                                         cancelContext,
+                                         output,
+                                         sequenceTables);
     } catch (...) {
         output = {};
         return false;

+ 28 - 7
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.h

@@ -4,6 +4,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <span>
+#include <string>
 #include <vector>
 
 #include "../../../middleware/content/packages/reader/reader.h"
@@ -195,6 +196,21 @@ struct EntityTypeDefinition final {
     std::uint32_t flags{};
 };
 
+/** One complete installed definition selected by the sequence-table class scan. */
+struct SequenceTableSource final {
+    std::uint32_t definitionTag{};
+    std::uint32_t definitionClass{};
+};
+
+/** Strings remain owned until the immutable pack string table is linked. */
+struct SequenceEntry final {
+    state::activity_sdk::format::ActorSequenceEntry row{};
+    std::string id{};
+    std::string name{};
+    std::string symbol{};
+    std::string sourcePath{};
+};
+
 /** Complete native actor and RSAT inventory with unresolved string references. */
 struct Snapshot final {
     std::vector<ActorClass> actorClasses{};
@@ -214,6 +230,9 @@ struct Snapshot final {
     std::vector<SobjectRsatFieldBinding> sobjectRsatFieldBindings{};
     /** Sorted by actor class then authored ordinal; the group is always `state_machine`. */
     std::vector<ActorStateName> actorStateNames{};
+    std::vector<state::activity_sdk::format::ActorSequenceTable> sequenceTables{};
+    std::vector<SequenceEntry> sequenceEntries{};
+    std::vector<state::activity_sdk::format::ActorSequenceBinding> sequenceBindings{};
     bool complete{};
 };
 
@@ -238,13 +257,15 @@ using ReadTag = bool (*)(void* context,
                                    Snapshot& output) noexcept;
 
 /** Builds actor joins plus a complete caller-supplied installed RSAT tag set. */
-[[nodiscard]] bool build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
-                                             std::span<const std::uint32_t> rsatTags,
-                                             ReadTag readTag,
-                                             void* readContext,
-                                             CancelProbe cancel,
-                                             void* cancelContext,
-                                             Snapshot& output) noexcept;
+[[nodiscard]] bool
+build_from_tags_and_rsats(std::span<const std::uint32_t> actorTags,
+                          std::span<const std::uint32_t> rsatTags,
+                          ReadTag readTag,
+                          void* readContext,
+                          CancelProbe cancel,
+                          void* cancelContext,
+                          Snapshot& output,
+                          std::span<const SequenceTableSource> sequenceTables = {}) noexcept;
 
 /** Builds a structurally complete installed inventory for an upstream exact actor tag set. */
 [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,

+ 10 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory_internal.h

@@ -39,6 +39,14 @@ struct SchemaSource final {
     std::vector<RsatField> fields{};
 };
 
+/** One decoded ActorGroup source, or an unrelated component remembered as absent. */
+struct SequenceOwner final {
+    std::uint32_t sourceOffset{};
+    std::uint32_t globalTag{};
+    std::uint32_t localTag{};
+    bool present{};
+};
+
 /** State owned by one transactional build. */
 struct BuildState final {
     ReadTag readTag{};
@@ -49,6 +57,8 @@ struct BuildState final {
     std::vector<SchemaSource> schemaSources{};
     std::unordered_map<std::uint32_t, std::size_t> schemaIndexes{};
     std::unordered_set<std::uint32_t> rsatTags{};
+    std::unordered_map<std::uint32_t, std::uint32_t> sequenceTableIndexes{};
+    std::unordered_map<std::uint32_t, SequenceOwner> sequenceOwners{};
 };
 
 /** Actual reader callback context. */

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_validation.cpp

@@ -8,6 +8,7 @@
 #include <vector>
 
 #include "activity_sdk_actor_rsat_inventory_internal.h"
+#include "activity_sdk_actor_sequences.h"
 
 namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
 namespace {
@@ -203,6 +204,7 @@ bool add_engine_semantics(Snapshot& snapshot) {
 
 /** Validates every id, scalar, raw row, owner, range, order, join, and flag. */
 bool validate(const Snapshot& snapshot) noexcept {
+    if (!sequence_inventory::validate(snapshot)) return false;
     if (!snapshot.complete || snapshot.actorClasses.empty()) {
         return false;
     }

+ 422 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_sequences.h

@@ -0,0 +1,422 @@
+#pragma once
+
+#include <string>
+#include <unordered_map>
+
+#include "../../../state/build_data/scriptables/inline_name_evidence.h"
+#include "activity_sdk_actor_rsat_inventory_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
+namespace sequence_inventory {
+
+/** Declared component references and action entries retain their native package classes. */
+inline constexpr std::uint32_t kComponentClass = 0x80809C04U;
+inline constexpr std::uint32_t kConfigClass = 0x80809C36U;
+inline constexpr std::uint32_t kActorGroupClass = 0x80807EE1U;
+inline constexpr std::uint32_t kActionEntryClass = 0x808081AEU;
+inline constexpr std::size_t kComponentArrayOffset = 16, kComponentStride = 12;
+inline constexpr std::size_t kNativeRootRelativeOffset = 16;
+inline constexpr std::size_t kLocalTableOffset = 760, kGlobalTableOffset = 768;
+inline constexpr std::size_t kActionStride = 24, kPathOffset = 8, kResourceOffset = 16;
+/** Diagnostic paths are bounded while searching for their terminator. */
+inline constexpr std::size_t kMaximumPathBytes = 4096;
+
+/** Hashes exact candidate bytes using the same FNV-1 rule as native authored keys. */
+inline std::uint32_t name_hash(std::string_view value) noexcept {
+    return state::build_data::scriptables::inline_name_evidence::hash(
+        std::as_bytes(std::span(value)));
+}
+
+/** Retains the source path without treating its basename as the authored lookup key. */
+inline bool source_path(std::span<const std::byte> blob, std::size_t field, std::string& output) {
+    std::int64_t relative = 0;
+    if (!read_value(blob, field, relative)) {
+        return false;
+    }
+    output.clear();
+    if (relative == 0) {
+        return true;
+    }
+    std::size_t start = 0;
+    if (!relative_offset(field, relative, start) || start >= blob.size()) {
+        return false;
+    }
+    std::size_t length = 0;
+    while (start + length < blob.size() && length <= kMaximumPathBytes
+           && blob[start + length] != std::byte{0}) {
+        ++length;
+    }
+    if (length > kMaximumPathBytes || start + length == blob.size()) {
+        return false;
+    }
+    output.assign(reinterpret_cast<const char*>(blob.data() + start), length);
+    return output.empty()
+           || state::build_data::scriptables::inline_name_evidence::valid_utf8(
+               std::as_bytes(std::span(output)));
+}
+
+/** Only the filename stem supplies a generated presentation symbol. */
+inline std::string_view basename(std::string_view path) noexcept {
+    const auto slash = path.find_last_of("/\\");
+    if (slash != std::string_view::npos) {
+        path.remove_prefix(slash + 1);
+    }
+    const auto dot = path.find('.');
+    return path.substr(0, dot);
+}
+
+/** Symbols are identifiers; their bytes are never substituted for the extracted native key. */
+inline std::string symbol(std::string_view value) {
+    std::string result;
+    for (const unsigned char c : value) {
+        if (c >= 'a' && c <= 'z') {
+            result.push_back(static_cast<char>(c - 'a' + 'A'));
+        } else if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
+            result.push_back(static_cast<char>(c));
+        } else if (result.empty() || result.back() != '_') {
+            result.push_back('_');
+        }
+    }
+    while (!result.empty() && result.back() == '_') {
+        result.pop_back();
+    }
+    if (!result.empty() && result.front() >= '0' && result.front() <= '9') {
+        result.insert(0, "VALUE_");
+    }
+    return result;
+}
+
+/** One physical definition is read once even when many actors share it. */
+inline bool table(BuildState& state, SequenceTableSource source, std::uint32_t& output) {
+    output = format::kAbsentIndex;
+    if (is_absent_tag(source.definitionTag)) {
+        return true;
+    }
+    const auto found = state.sequenceTableIndexes.find(source.definitionTag);
+    if (found != state.sequenceTableIndexes.end()) {
+        output = found->second;
+        return state.snapshot.sequenceTables[output].definitionClass == source.definitionClass;
+    }
+    const bool global = source.definitionClass == format::kActorSequenceGlobalDefinitionClass;
+    if (!global && source.definitionClass != format::kActorSequenceLocalDefinitionClass) {
+        return false;
+    }
+    std::vector<std::byte> blob;
+    TypedArray array{};
+    const auto arrayOffset =
+        global ? format::kActorSequenceGlobalArrayOffset : format::kActorSequenceLocalArrayOffset;
+    if (!state.readTag(state.readContext, source.definitionTag, source.definitionClass, blob)
+        || !typed_array(blob, arrayOffset, kActionEntryClass, kActionStride, array)
+        || state.snapshot.sequenceEntries.size() > (std::numeric_limits<std::uint32_t>::max)()
+        || state.snapshot.sequenceTables.size() > (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    output = static_cast<std::uint32_t>(state.snapshot.sequenceTables.size());
+    format::ActorSequenceTable row{
+        source.definitionTag,
+        source.definitionClass,
+        arrayOffset,
+        {static_cast<std::uint32_t>(state.snapshot.sequenceEntries.size()), array.count},
+        format::kActorSequenceExact};
+    std::uint32_t previousKey = 0;
+    for (std::uint32_t ordinal = 0; ordinal < array.count; ++ordinal) {
+        if (is_cancelled(state.cancel, state.cancelContext)) {
+            return false;
+        }
+        const auto offset = static_cast<std::size_t>(array.dataOffset) + ordinal * kActionStride;
+        SequenceEntry entry{};
+        std::uint8_t kind = 0;
+        if (!read_value(blob, offset, entry.row.keyHash) || !read_value(blob, offset + 4, kind)
+            || !read_value(blob, offset + kResourceOffset, entry.row.resourceTag)
+            || !source_path(blob, offset + kPathOffset, entry.sourcePath)
+            || !to_u32(offset, entry.row.sourceOffset)
+            || (ordinal != 0 && entry.row.keyHash < previousKey)) {
+            return false;
+        }
+        previousKey = entry.row.keyHash;
+        entry.row.tableIndex = output;
+        entry.row.ordinal = ordinal;
+        entry.row.kind = kind;
+        entry.row.flags = format::kActorSequenceExact;
+        std::array<char, 64> id{};
+        const auto length = std::snprintf(id.data(),
+                                          id.size(),
+                                          "actor-sequence/%08x/%u/%u",
+                                          source.definitionTag,
+                                          arrayOffset,
+                                          ordinal);
+        if (length <= 0 || static_cast<std::size_t>(length) >= id.size()) {
+            return false;
+        }
+        entry.id.assign(id.data(), static_cast<std::size_t>(length));
+        state.snapshot.sequenceEntries.push_back(std::move(entry));
+    }
+    state.snapshot.sequenceTables.push_back(row);
+    state.sequenceTableIndexes.emplace(source.definitionTag, output);
+    return true;
+}
+
+/** The native ActorGroup source names its local definition, program and global definition. */
+inline bool owner(BuildState& state, std::uint32_t configTag, SequenceOwner& output) {
+    const auto cached = state.sequenceOwners.find(configTag);
+    if (cached != state.sequenceOwners.end()) {
+        output = cached->second;
+        return true;
+    }
+    output = {};
+    std::vector<std::byte> config;
+    if (state.readTag(state.readContext, configTag, kConfigClass, config)) {
+        std::int64_t relative = 0;
+        std::size_t native = 0;
+        std::uint32_t nativeClass = 0;
+        if (!read_value(config, kNativeRootRelativeOffset, relative)
+            || !relative_offset(kNativeRootRelativeOffset, relative, native) || native < 4
+            || !read_value(config, native - 4, nativeClass)) {
+            return false;
+        }
+        if (nativeClass == kActorGroupClass) {
+            std::uint32_t ownTag = 0, sourceClass = 0;
+            std::uint64_t source = 0;
+            if (!read_value(config, native, ownTag) || ownTag != configTag
+                || !read_value(config, native + 4, sourceClass)
+                || sourceClass != format::kActorSequenceOwnerSourceClass
+                || !read_value(config, native + 8, source)
+                || source > (std::numeric_limits<std::uint32_t>::max)()
+                || !contains(config, static_cast<std::size_t>(source), kGlobalTableOffset + 4)) {
+                return false;
+            }
+            std::uint32_t sourceOwn = 0, sourceNativeClass = 0;
+            std::uint64_t sourceNative = 0;
+            if (!read_value(config, static_cast<std::size_t>(source), sourceOwn)
+                || sourceOwn != configTag
+                || !read_value(config, static_cast<std::size_t>(source) + 4U, sourceNativeClass)
+                || sourceNativeClass != kActorGroupClass
+                || !read_value(config, static_cast<std::size_t>(source) + 8U, sourceNative)
+                || sourceNative != native) {
+                return false;
+            }
+            output.sourceOffset = static_cast<std::uint32_t>(source);
+            if (!read_value(config, output.sourceOffset + kLocalTableOffset, output.localTag)
+                || !read_value(
+                    config, output.sourceOffset + kGlobalTableOffset, output.globalTag)) {
+                return false;
+            }
+            output.present = true;
+        }
+    }
+    state.sequenceOwners.emplace(configTag, output);
+    return true;
+}
+
+/** Traverses only declared components; tag-shaped incidental words cannot create ownership. */
+inline bool
+actor(BuildState& state, std::uint32_t actorIndex, std::span<const std::byte> actorBlob) {
+    TypedArray components{};
+    if (!typed_array(
+            actorBlob, kComponentArrayOffset, kComponentClass, kComponentStride, components)) {
+        return false;
+    }
+    for (std::uint32_t ordinal = 0; ordinal < components.count; ++ordinal) {
+        if (is_cancelled(state.cancel, state.cancelContext)) {
+            return false;
+        }
+        const auto offset =
+            static_cast<std::size_t>(components.dataOffset) + ordinal * kComponentStride;
+        std::uint32_t configTag = 0;
+        SequenceOwner source{};
+        if (!read_value(actorBlob, offset, configTag) || !owner(state, configTag, source)) {
+            return false;
+        }
+        if (!source.present) {
+            continue;
+        }
+        format::ActorSequenceBinding binding{};
+        binding.actorClassIndex = actorIndex;
+        binding.componentOrdinal = ordinal;
+        binding.configTag = configTag;
+        binding.sourceOffset = source.sourceOffset;
+        binding.sourceClass = format::kActorSequenceOwnerSourceClass;
+        binding.flags = format::kActorSequenceExact;
+        if (!table(state,
+                   {source.globalTag, format::kActorSequenceGlobalDefinitionClass},
+                   binding.globalTableIndex)
+            || !table(state,
+                      {source.localTag, format::kActorSequenceLocalDefinitionClass},
+                      binding.localTableIndex)) {
+            return false;
+        }
+        state.snapshot.sequenceBindings.push_back(binding);
+    }
+    return true;
+}
+
+/** FNV-verified names and path-derived symbols have distinct provenance. */
+inline bool finish_names(Snapshot& snapshot) {
+    std::unordered_map<std::uint32_t, std::string> names;
+    for (const auto& entry : snapshot.sequenceEntries) {
+        const auto candidate = basename(entry.sourcePath);
+        if (candidate.empty()) {
+            continue;
+        }
+        const auto key = name_hash(candidate);
+        const auto [found, inserted] = names.emplace(key, candidate);
+        if (!inserted && found->second != candidate) {
+            found->second.clear();
+        }
+    }
+    std::unordered_map<std::string, std::uint32_t> symbols;
+    std::unordered_set<std::string> collisions;
+    for (auto& entry : snapshot.sequenceEntries) {
+        const auto named = names.find(entry.row.keyHash);
+        if (named != names.end() && !named->second.empty()) {
+            entry.name = named->second;
+            entry.row.flags |= format::kActorSequenceNameVerified;
+        }
+        entry.symbol = symbol(basename(entry.sourcePath));
+        if (!entry.symbol.empty()) {
+            entry.row.flags |= format::kActorSequenceSymbolFromPath;
+        } else {
+            std::array<char, 32> fallback{};
+            std::snprintf(fallback.data(), fallback.size(), "KEY_%08X", entry.row.keyHash);
+            entry.symbol = fallback.data();
+        }
+        const auto [found, inserted] = symbols.emplace(entry.symbol, entry.row.keyHash);
+        if (!inserted && found->second != entry.row.keyHash) {
+            collisions.emplace(entry.symbol);
+        }
+    }
+    for (auto& entry : snapshot.sequenceEntries) {
+        if (collisions.contains(entry.symbol)) {
+            std::array<char, 16> suffix{};
+            std::snprintf(suffix.data(), suffix.size(), "_%08X", entry.row.keyHash);
+            entry.symbol += suffix.data();
+        }
+    }
+    symbols.clear();
+    collisions.clear();
+    for (const auto& entry : snapshot.sequenceEntries) {
+        const auto [found, inserted] = symbols.emplace(entry.symbol, entry.row.keyHash);
+        if (!inserted && found->second != entry.row.keyHash) {
+            collisions.emplace(entry.symbol);
+        }
+    }
+    for (auto& entry : snapshot.sequenceEntries) {
+        if (collisions.contains(entry.symbol) || entry.symbol.starts_with("KEY_")) {
+            std::array<char, 32> fallback{};
+            std::snprintf(fallback.data(), fallback.size(), "KEY_%08X", entry.row.keyHash);
+            entry.symbol = fallback.data();
+            entry.row.flags &= ~format::kActorSequenceSymbolFromPath;
+        }
+    }
+    return true;
+}
+
+/** Validates retained ownership and names before any strings are linked into the pack. */
+inline bool validate(const Snapshot& snapshot) noexcept {
+    std::size_t cursor = 0;
+    for (std::size_t index = 0; index < snapshot.sequenceTables.size(); ++index) {
+        const auto& table = snapshot.sequenceTables[index];
+        const bool global = table.definitionClass == format::kActorSequenceGlobalDefinitionClass;
+        if (table.flags != format::kActorSequenceExact || is_absent_tag(table.definitionTag)
+            || (!global && table.definitionClass != format::kActorSequenceLocalDefinitionClass)
+            || table.arrayOffset
+                   != (global ? format::kActorSequenceGlobalArrayOffset
+                              : format::kActorSequenceLocalArrayOffset)
+            || table.entries.first != cursor || cursor > snapshot.sequenceEntries.size()
+            || table.entries.count > snapshot.sequenceEntries.size() - cursor) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (snapshot.sequenceTables[prior].definitionTag == table.definitionTag) {
+                return false;
+            }
+        }
+        for (std::uint32_t ordinal = 0; ordinal < table.entries.count; ++ordinal) {
+            const auto& entry = snapshot.sequenceEntries[cursor + ordinal];
+            const auto& row = entry.row;
+            if (row.tableIndex != index || row.ordinal != ordinal || row.kind > 255
+                || row.reserved != 0 || (row.flags & format::kActorSequenceExact) == 0
+                || (row.flags & ~format::kActorSequenceEntryFlagMask) != 0 || entry.id.empty()
+                || entry.symbol.empty() || entry.id.find('\0') != std::string::npos
+                || entry.name.find('\0') != std::string::npos
+                || entry.sourcePath.find('\0') != std::string::npos
+                || (((row.flags & format::kActorSequenceNameVerified) != 0) != !entry.name.empty())
+                || (!entry.name.empty() && name_hash(entry.name) != row.keyHash)
+                || ((row.flags & format::kActorSequenceSymbolFromPath) != 0
+                    && entry.sourcePath.empty())
+                || (ordinal != 0
+                    && snapshot.sequenceEntries[cursor + ordinal - 1].row.keyHash > row.keyHash)) {
+                return false;
+            }
+            for (const char c : entry.symbol) {
+                if (!(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '_') {
+                    return false;
+                }
+            }
+        }
+        cursor += table.entries.count;
+    }
+    if (cursor != snapshot.sequenceEntries.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.sequenceBindings.size(); ++index) {
+        const auto& row = snapshot.sequenceBindings[index];
+        if (row.actorClassIndex >= snapshot.actorClasses.size() || is_absent_tag(row.configTag)
+            || row.flags != format::kActorSequenceExact
+            || row.sourceClass != format::kActorSequenceOwnerSourceClass
+            || (index != 0
+                && (snapshot.sequenceBindings[index - 1].actorClassIndex > row.actorClassIndex
+                    || (snapshot.sequenceBindings[index - 1].actorClassIndex == row.actorClassIndex
+                        && snapshot.sequenceBindings[index - 1].componentOrdinal
+                               >= row.componentOrdinal)))) {
+            return false;
+        }
+        for (const bool global : {true, false}) {
+            const auto table = global ? row.globalTableIndex : row.localTableIndex;
+            if (table != format::kAbsentIndex
+                && (table >= snapshot.sequenceTables.size()
+                    || snapshot.sequenceTables[table].definitionClass
+                           != (global ? format::kActorSequenceGlobalDefinitionClass
+                                      : format::kActorSequenceLocalDefinitionClass))) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+struct ScanContext final {
+    std::vector<SequenceTableSource>* rows{};
+    std::uint32_t definitionClass{};
+};
+
+/** The class scan records all tables, including definitions no current actor selects. */
+inline bool visit_table(void* raw, std::uint32_t tag) noexcept {
+    auto& context = *static_cast<ScanContext*>(raw);
+    try {
+        context.rows->push_back({tag, context.definitionClass});
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Scans both physical definition classes without limiting them to currently bound actors. */
+inline bool scan_tables(const reader::Source& source, std::vector<SequenceTableSource>& output) {
+    output.clear();
+    for (const auto cls : {format::kActorSequenceGlobalDefinitionClass,
+                           format::kActorSequenceLocalDefinitionClass}) {
+        ScanContext context{&output, cls};
+        reader::ScanResult result{};
+        if (!reader::scan_class(source.directory, cls, &visit_table, &context, result)) {
+            return false;
+        }
+    }
+    std::sort(output.begin(), output.end(), [](const auto& a, const auto& b) {
+        return a.definitionTag < b.definitionTag;
+    });
+    return true;
+}
+
+} // namespace sequence_inventory
+} // namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory

+ 18 - 1
Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp

@@ -622,8 +622,25 @@ void service() noexcept {
     }
     if (state::activity_sdk::status() == state::activity_sdk::Status::ready) {
         const state::activity_sdk::Snapshot loaded = state::activity_sdk::snapshot();
+        generated::Digest fingerprint{};
+        manifest::SdkIdentity identity{};
+        const bool hasIdentity =
+            loaded && loaded->sdk_build_sha256().size() == identity.buildSha256.size()
+            && loaded->payload_sha256().size() == identity.payloadSha256.size()
+            && state::content_manifest::visit_snapshot(&copy_fingerprint, &fingerprint);
+        if (hasIdentity) {
+            std::copy(loaded->sdk_build_sha256().begin(),
+                      loaded->sdk_build_sha256().end(),
+                      identity.buildSha256.begin());
+            std::copy(loaded->payload_sha256().begin(),
+                      loaded->payload_sha256().end(),
+                      identity.payloadSha256.begin());
+        }
         const bool current =
-            loaded && (!g_luaDeclarations || lua::is_current(g_sdkDirectory.c_str(), *loaded));
+            hasIdentity
+            && shard_store::compatible_catalog(
+                g_catalogPath.c_str(), g_scenarioDirectory, fingerprint, identity)
+            && (!g_luaDeclarations || lua::is_current(g_sdkDirectory.c_str(), *loaded));
         if (current) {
             g_started = true;
             publish_progress(state::activity_sdk::generation::Status::ready,

+ 87 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_actor_sequences.cpp

@@ -0,0 +1,87 @@
+#include <array>
+#include <cstdio>
+#include <set>
+
+#include "activity_sdk_lua_artifacts_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal {
+
+/** Every extracted row remains inspectable; only slot-owned runtime values can be played. */
+void append_actor_sequence_contract(const Source& source, std::string& output) {
+    output.append(R"lua(
+---@class SunriseActorSequence
+---@field id string
+---@field name string|nil
+---@field symbol string
+---@field source_path string|nil
+---@field key integer
+---@field kind integer
+---@field resource_tag integer
+---@field table_index integer
+---@field ordinal integer
+---@field source_offset integer
+---@field playable boolean
+
+---@class SunriseActorSequences
+---@field count integer
+---@field at fun(self: SunriseActorSequences, ordinal: integer): SunriseActorSequence|nil
+)lua");
+    std::set<std::string> symbols{};
+    for (const auto& row : source.actorSequenceEntries) {
+        const auto symbol = text(source, row.symbol);
+        if (!symbol.empty()) {
+            symbols.emplace(symbol);
+        }
+        std::array<char, 13> alias{};
+        std::snprintf(alias.data(), alias.size(), "KEY_%08X", row.keyHash);
+        symbols.emplace(alias.data());
+    }
+    for (const auto& symbol : symbols) {
+        output.append("---@field ").append(symbol).append(" SunriseActorSequence|nil\n");
+    }
+    Value::Array tables{};
+    Value::Array entries{};
+    Value::Array bindings{};
+    for (const auto& row : source.actorSequenceTables) {
+        tables.push_back(object({{"definition_tag", number(row.definitionTag)},
+                                 {"definition_class", number(row.definitionClass)},
+                                 {"array_offset", number(row.arrayOffset)},
+                                 {"first_entry", number(row.entries.first)},
+                                 {"entry_count", number(row.entries.count)},
+                                 {"flags", number(row.flags)}}));
+    }
+    for (const auto& row : source.actorSequenceEntries) {
+        entries.push_back(object({{"id", string(text(source, row.id))},
+                                  {"name", string(text(source, row.name))},
+                                  {"symbol", string(text(source, row.symbol))},
+                                  {"source_path", string(text(source, row.sourcePath))},
+                                  {"table_index", number(row.tableIndex)},
+                                  {"ordinal", number(row.ordinal)},
+                                  {"source_offset", number(row.sourceOffset)},
+                                  {"key", number(row.keyHash)},
+                                  {"kind", number(row.kind)},
+                                  {"resource_tag", number(row.resourceTag)},
+                                  {"flags", number(row.flags)}}));
+    }
+    for (const auto& row : source.actorSequenceBindings) {
+        bindings.push_back(object({{"actor_class_index", number(row.actorClassIndex)},
+                                   {"component_ordinal", number(row.componentOrdinal)},
+                                   {"config_tag", number(row.configTag)},
+                                   {"source_offset", number(row.sourceOffset)},
+                                   {"global_table_index", number(row.globalTableIndex)},
+                                   {"local_table_index", number(row.localTableIndex)},
+                                   {"source_class", number(row.sourceClass)},
+                                   {"flags", number(row.flags)}}));
+    }
+    std::string catalog{};
+    if (!render_lua(object({{"tables", array(std::move(tables))},
+                            {"entries", array(std::move(entries))},
+                            {"bindings", array(std::move(bindings))}}),
+                    0,
+                    catalog)) {
+        throw std::bad_alloc{};
+    }
+    output.append("\nsdk.ActorSequenceCatalog = ").append(catalog).append("\n\n");
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h

@@ -73,6 +73,9 @@ struct Source final {
     std::span<const format::EntityTypeDefinition> entityTypeDefinitions{};
     std::span<const format::SobjectRsatFieldBinding> sobjectRsatFieldBindings{};
     std::span<const format::ActorStateName> actorStateNames{};
+    std::span<const format::ActorSequenceTable> actorSequenceTables{};
+    std::span<const format::ActorSequenceEntry> actorSequenceEntries{};
+    std::span<const format::ActorSequenceBinding> actorSequenceBindings{};
     std::span<const ScenarioWorldSource> scenarioWorldSources{};
 };
 

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts_internal.h

@@ -45,6 +45,7 @@ void append_world_spatial_views(Value::Object& output);
 [[nodiscard]] bool build_world_sdk_contract_value(Value& output) noexcept;
 [[nodiscard]] bool render_activity_files(const Source& source, Bundle& output) noexcept;
 [[nodiscard]] bool render_contract_files(const Source& source, Bundle& output) noexcept;
+void append_actor_sequence_contract(const Source& source, std::string& output);
 
 [[nodiscard]] std::string_view text(const Source& source, format::StringRef reference) noexcept;
 [[nodiscard]] std::string digest_hex(const std::array<std::byte, 32>& digest, bool prefix);

+ 26 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp

@@ -46,6 +46,9 @@ bool render_contract_files(const Source& source, Bundle& output) noexcept {
                  {"entity_type_definitions", number(source.entityTypeDefinitions.size())},
                  {"sobject_rsat_field_bindings", number(source.sobjectRsatFieldBindings.size())},
                  {"actor_state_names", number(source.actorStateNames.size())},
+                 {"actor_sequence_tables", number(source.actorSequenceTables.size())},
+                 {"actor_sequence_entries", number(source.actorSequenceEntries.size())},
+                 {"actor_sequence_bindings", number(source.actorSequenceBindings.size())},
              })},
         });
         if (!render_json(manifest, 0, output.manifestJson)) {
@@ -94,6 +97,8 @@ local EventKind = {
     FIRETEAM_STATE = 35,
     OBJECT_STATE = 36,
     DAMAGE_STATE = 37,
+    SQUAD_PROVOKED = 38,
+    DEVICE_STATE = 39,
 }
 
 ---@class SunriseEvent
@@ -124,6 +129,20 @@ local EventKind = {
 ---@class SunriseEntitySlotsRequestedEvent: SunriseEvent
 ---@field requested_count integer
 
+---@class SunriseDeviceStateEvent: SunriseEvent
+---@field registry_key integer
+---@field object_tag integer
+---@field slot_type integer
+---@field slot_index integer
+---@field position number?
+---@field power number?
+---@field lock number?
+---@field position_sequence integer?
+---@field power_sequence integer?
+---@field lock_sequence integer?
+---@field first_report boolean
+---@field reset boolean
+
 ---@class SunriseProgram
 ---@field on_start? fun(context: any, state: SunriseState)
 ---@field on_load? fun(context: any, state: SunriseState)
@@ -148,6 +167,7 @@ local EventKind = {
 ---@field on_event_trigger_entered? SunriseEventHandler
 ---@field on_event_trigger_exited? SunriseEventHandler
 ---@field on_event_squad_state? SunriseEventHandler
+---@field on_event_device_state? fun(ctx: any, state: SunriseState, event: SunriseDeviceStateEvent)
 ---@field on_event_entity_spawned? fun(context: any, state: SunriseState, event: SunriseEvent)
 ---@field on_event_entity_died? fun(context: any, state: SunriseState, event: SunriseEvent)
 ---@field on_event_scene_finished? fun(context: any, state: SunriseState, event: SunriseEvent)
@@ -192,7 +212,8 @@ local EventKind = {
 ---@field auth_writable boolean|nil
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
 ---@field assign_combat_objective fun(self: SunriseSlot, args: {objective: SunriseSlot, )lua"
-            R"lua(revision: integer, task_group: integer, reserved: boolean?}): SunriseRequestKey
+            R"lua(revision: integer, task_group: integer, reserved: boolean?, )lua"
+            R"lua(refresh_player_awareness: boolean?}): SunriseRequestKey
 ---@field play_actor_path fun(self: SunriseSlot, args: {generation: integer, )lua"
             R"lua(revision: integer, path: SunriseSlot}): SunriseRequestKey
 ---@field deliver_squads fun(self: SunriseSlot, args: {generation: integer, )lua"
@@ -220,7 +241,9 @@ local EventKind = {
 ---@field bind_combatant_to_squad fun(self: SunriseSlot): SunriseRequestKey
 ---@field transition fun(SunriseSlot, SunriseDeviceTransitionArguments): SunriseRequestKey
 ---@field fire_trigger fun(self: SunriseSlot): SunriseRequestKey
----@field play_sequence fun(self: SunriseSlot): SunriseRequestKey
+---@field sequences fun(self: SunriseSlot): SunriseActorSequences
+---@field play_sequence fun(self: SunriseSlot, args: {sequence: SunriseActorSequence}): )lua"
+            R"lua(SunriseRequestKey
 ---@field set_cinematic_active fun(SunriseSlot, SunriseCinematicArguments?): SunriseRequestKey
 ---@field reset_objectives fun(self: SunriseSlot): SunriseRequestKey
 ---@field advance_task fun(self: SunriseSlot): SunriseRequestKey
@@ -499,6 +522,7 @@ sdk.ObjectFilterPredicateSchema = {
 ---@field flag boolean
 
 )lua");
+        append_actor_sequence_contract(source, authTypes);
         output.activitySdkModule.insert(returnOffset, authTypes);
 
         output.behaviorModule =

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp

@@ -424,6 +424,9 @@ bool is_current(const wchar_t* sdkDirectory, const state::activity_sdk::Catalog&
         source.entityTypeDefinitions = catalog.entity_type_definitions();
         source.sobjectRsatFieldBindings = catalog.sobject_rsat_field_bindings();
         source.actorStateNames = catalog.actor_state_names();
+        source.actorSequenceTables = catalog.actor_sequence_tables();
+        source.actorSequenceEntries = catalog.actor_sequence_entries();
+        source.actorSequenceBindings = catalog.actor_sequence_bindings();
         Bundle expected{};
         if (!internal::render_contract_files(source, expected)) {
             return false;

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -206,6 +206,9 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             storage.entityTypeDefinitions,
             storage.sobjectRsatFieldBindings,
             storage.actorStateNames,
+            storage.actorSequenceTables,
+            storage.actorSequenceEntries,
+            storage.actorSequenceBindings,
             worldSources};
 }
 

+ 9 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp

@@ -628,6 +628,12 @@ link_text(const StringLinker& linker, const Text& input, format::StringRef& outp
         }
     }
 
+    for (const auto& entry : actorRsat.sequenceEntries) {
+        if (!linker.keep_extension(entry.id) || !linker.keep_extension(entry.name)
+            || !linker.keep_extension(entry.symbol) || !linker.keep_extension(entry.sourcePath)) {
+            return false;
+        }
+    }
     for (const actor_rsat_inventory::ActorClass& row : actorRsat.actorClasses) {
         if (!keep_legacy(linker, row.id)) {
             return false;
@@ -762,6 +768,9 @@ pack::Tables Storage::tables() const noexcept {
         sobjectRsatFieldBindings,
         runtimeTypeDefinitions,
         actorStateNames,
+        actorSequenceTables,
+        actorSequenceEntries,
+        actorSequenceBindings,
     };
 }
 

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer.h

@@ -106,6 +106,9 @@ struct Storage final {
     std::vector<format::EntityTypeDefinition> entityTypeDefinitions{};
     std::vector<format::SobjectRsatFieldBinding> sobjectRsatFieldBindings{};
     std::vector<format::ActorStateName> actorStateNames{};
+    std::vector<format::ActorSequenceTable> actorSequenceTables{};
+    std::vector<format::ActorSequenceEntry> actorSequenceEntries{};
+    std::vector<format::ActorSequenceBinding> actorSequenceBindings{};
 
     /** @return Borrowed sections in exact current format order. */
     [[nodiscard]] pack::Tables tables() const noexcept;

+ 12 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp

@@ -485,6 +485,18 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
     output.sobjectRsatDescriptors = actorRsat.sobjectRsatDescriptors;
     output.sobjectRsatFieldBindings = actorRsat.sobjectRsatFieldBindings;
     output.actorStateNames = actorRsat.actorStateNames;
+    output.actorSequenceTables = actorRsat.sequenceTables;
+    output.actorSequenceBindings = actorRsat.sequenceBindings;
+    output.actorSequenceEntries.reserve(actorRsat.sequenceEntries.size());
+    for (const auto& input : actorRsat.sequenceEntries) {
+        auto row = input.row;
+        if (!link_string(linker, input.id, row.id) || !link_string(linker, input.name, row.name)
+            || !link_string(linker, input.symbol, row.symbol)
+            || !link_string(linker, input.sourcePath, row.sourcePath)) {
+            return false;
+        }
+        output.actorSequenceEntries.push_back(row);
+    }
     output.entityTypeDefinitions.resize(actorRsat.entityTypes.size());
     for (std::size_t index = 0; index < actorRsat.entityTypes.size(); ++index) {
         const actor_rsat_inventory::EntityTypeDefinition& input = actorRsat.entityTypes[index];

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer_validate.cpp

@@ -54,6 +54,9 @@ bool validate_storage(const Inputs&,
         value.sobjectRsatFieldBindings.size(),
         value.runtimeTypeDefinitions.size(),
         value.actorStateNames.size(),
+        value.actorSequenceTables.size(),
+        value.actorSequenceEntries.size(),
+        value.actorSequenceBindings.size(),
     };
     const bool bounded = std::all_of(counts.begin(), counts.end(), [](std::size_t count) {
         return count <= (std::numeric_limits<std::uint32_t>::max)();

+ 5 - 0
Sunrise/src/client/content/activity/scriptable_catalog_object_analysis.cpp

@@ -52,6 +52,10 @@ struct PlacedChainContext final {
         row.placementIdentifierRead = tables::type23_placement_identifier(
             context.config, descriptor, row.placementIdentifier);
         context.analysis->descriptors.push_back(row);
+        internal::RawReference reference{};
+        if (internal::read_type2_squad_reference(context.config, descriptor, reference)) {
+            context.analysis->references.push_back(reference);
+        }
     } catch (...) {
         return false;
     }
@@ -104,6 +108,7 @@ collect_placed_config(void* opaque, std::uint32_t tag, std::span<const std::byte
     if (!tables::visit_slot_descriptors(
             blob, tag, context.registryKey, &collect_descriptor, &descriptorContext)) {
         analysis.descriptors.resize(firstDescriptor);
+        analysis.references.resize(firstReference);
         return false;
     }
     try {

+ 46 - 0
Sunrise/src/client/content/activity/scriptable_catalog_reference_scan.cpp

@@ -2,6 +2,8 @@
 
 #include <cstring>
 
+#include "../../../middleware/bap/activity_message/scriptable_auth_body.h"
+#include "../../../state/activity_sdk/format.h"
 #include "../../../state/build_data/scenarios/definition.h"
 
 namespace sunrise::client::content::activity::scriptables::internal {
@@ -10,6 +12,8 @@ namespace {
 // Client references: the package class id and the fixed row stride.
 constexpr std::uint32_t kClientReferenceClass = 0x80809C42U;
 constexpr std::size_t kClientReferenceSize = 16;
+/** Native ClientRef uses the empty-name hash for an absent key. */
+constexpr std::uint32_t kAbsentClientReferenceKey = 0x811C9DC5U;
 
 /** Reads one bounded scalar without imposing alignment on package bytes. */
 template <typename T>
@@ -25,6 +29,48 @@ read_value(std::span<const std::byte> blob, std::size_t offset, T& value) noexce
 
 } // namespace
 
+/**
+ * Reads the squad ClientRef owned by one exact Type 2 descriptor.
+ * @param blob Package config containing the descriptor.
+ * @param descriptor Validated slot descriptor.
+ * @param output Receives the exact source slot and squad target; cleared on failure.
+ * @return True when the descriptor and reference have the native shapes.
+ */
+bool read_type2_squad_reference(
+    std::span<const std::byte> blob,
+    const middleware::content::packages::tables::SlotDescriptor& descriptor,
+    RawReference& output) noexcept {
+    namespace auth = middleware::bap::activity_message::scriptable_auth;
+    namespace tables = middleware::content::packages::tables;
+    namespace format = state::activity_sdk::format;
+    output = {};
+    if (descriptor.slotType != auth::kType2SlotType
+        || descriptor.componentClass != auth::kType2ComponentClass
+        || descriptor.senseSchema != auth::kType2SenseSchema
+        || descriptor.authSchema != auth::kType2Schema
+        || descriptor.slotIndex > (std::numeric_limits<std::int16_t>::max)()) {
+        return false;
+    }
+    const std::size_t offset =
+        static_cast<std::size_t>(descriptor.descriptorOffset) + tables::kType2SquadReferenceOffset;
+    RawReference row{};
+    if (offset > (std::numeric_limits<std::uint32_t>::max)()
+        || !read_value(blob, offset, row.targetKey)
+        || !read_value(blob, offset + sizeof(row.targetKey), row.targetType)
+        || !read_value(
+            blob, offset + sizeof(row.targetKey) + sizeof(row.targetType), row.targetIndex)
+        || row.targetKey == 0 || row.targetKey == format::kAbsentIndex
+        || row.targetKey == kAbsentClientReferenceKey || row.targetType != format::kSquadSlotType
+        || row.targetIndex > (std::numeric_limits<std::int16_t>::max)()) {
+        return false;
+    }
+    row.configTag = descriptor.configTag;
+    row.offset = static_cast<std::uint32_t>(offset);
+    row.sourceIndex = descriptor.slotIndex;
+    output = row;
+    return true;
+}
+
 /** Retains aligned ClientRef records from one reached config blob. */
 void collect_typed_references(std::span<const std::byte> blob,
                               std::uint32_t configTag,

+ 15 - 1
Sunrise/src/client/content/activity/scriptable_catalog_reference_scan.h

@@ -2,20 +2,34 @@
 
 #include <cstddef>
 #include <cstdint>
+#include <limits>
 #include <span>
 #include <vector>
 
+#include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
+
 namespace sunrise::client::content::activity::scriptables::internal {
 
-/** One aligned package ClientRef record retained without behavior semantics. */
+/** Authored slot indices fit a signed 16-bit value. */
+inline constexpr std::uint16_t kUnresolvedReferenceSlot =
+    (std::numeric_limits<std::uint16_t>::max)();
+
+/** One package ClientRef and its exact source slot when the descriptor owns it. */
 struct RawReference final {
     std::uint32_t configTag{};
     std::uint32_t offset{};
     std::uint32_t targetKey{};
     std::uint16_t targetType{};
     std::uint16_t targetIndex{};
+    std::uint16_t sourceIndex{kUnresolvedReferenceSlot};
 };
 
+/** Reads only the authored Type 2 descriptor's squad reference. */
+[[nodiscard]] bool
+read_type2_squad_reference(std::span<const std::byte> blob,
+                           const middleware::content::packages::tables::SlotDescriptor& descriptor,
+                           RawReference& output) noexcept;
+
 /** Retains aligned ClientRef records from one reached config blob. */
 void collect_typed_references(std::span<const std::byte> blob,
                               std::uint32_t configTag,

+ 5 - 3
Sunrise/src/client/content/activity/scriptable_catalog_worker.cpp

@@ -430,9 +430,11 @@ rebase_row(std::uint32_t local, std::uint32_t first, std::uint32_t& output) noex
         row.targetKey = source.targetKey;
         row.targetSlotType = source.targetType;
         row.targetSlotIndex = source.targetIndex;
-        std::uint16_t sourceIndex = 0;
-        if (descriptors_for_config(analysis, source.configTag, sourceIndex) == 1
-            && sourceIndex < analysis.slots.size()) {
+        std::uint16_t sourceIndex = source.sourceIndex;
+        const bool sourceKnown =
+            sourceIndex != internal::kUnresolvedReferenceSlot
+            || descriptors_for_config(analysis, source.configTag, sourceIndex) == 1;
+        if (sourceKnown && sourceIndex < analysis.slots.size()) {
             row.sourceSlotRow = firstSlot + sourceIndex;
         }
         output.references.push_back(row);

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

@@ -2,6 +2,7 @@
 #include <bit>
 #include <cmath>
 
+#include "mission_auth_patch.h"
 #include "scriptable_auth_internal.h"
 
 // The two scriptable-auth bodies whose encoded width depends on the caller's values: the type-2
@@ -552,6 +553,139 @@ bool encode_type2_body(const Type2Body& body,
     return true;
 }
 
+/** Reads every optional root block before reporting the atom program span. */
+bool inspect_type2_program(std::span<const std::byte> input,
+                           std::size_t bitCount,
+                           Type2ProgramLayout& output) noexcept {
+    output = {};
+    if (bitCount < 11U || bitCount > kType2FullMaximumBitCount
+        || input.size() != (bitCount + 7U) / 8U) {
+        return false;
+    }
+    bits::Reader reader(input);
+    const auto optional = [&](std::size_t width) noexcept {
+        std::uint64_t present = 0;
+        return reader.read(1, present) && (present == 0 || reader.skip(width));
+    };
+    std::uint64_t value = 0, present = 0, count = 0;
+    Type2ProgramLayout candidate{};
+    if (!optional(31) || !reader.skip(2) || !reader.read(3, value)) {
+        return false;
+    }
+    candidate.bindingWire = static_cast<std::uint8_t>(value);
+    if (!reader.read(1, value)) {
+        return false;
+    }
+    candidate.enabled = value != 0;
+    if (!reader.read(1, present)) {
+        return false;
+    }
+    if (present != 0) {
+        if (!reader.read(1, present)) {
+            return false;
+        }
+        if (present != 0) {
+            for (std::size_t index = 0; index < 8; ++index) {
+                if (!optional(32) || !optional(31)) {
+                    return false;
+                }
+            }
+        }
+        if (!optional(32)) {
+            return false;
+        }
+    }
+    if (!reader.read(1, present)) {
+        return false;
+    }
+    if (present != 0) {
+        if (!reader.skip(31 + 6 + 6) || !reader.read(3, count) || count > kType2TemperamentCapacity
+            || !reader.skip(count * 32) || !reader.skip(55 + 32) || !reader.read(5, count)
+            || count > kType2ChannelCapacity || !reader.skip(count * 64)) {
+            return false;
+        }
+    }
+    candidate.programOffset = input.size() * 8U - reader.remaining_bits();
+    if (!reader.read(1, present)) {
+        return false;
+    }
+    if (present != 0) {
+        std::uint64_t progress = 0;
+        if (!reader.read(31, value) || !reader.read(6, progress) || !reader.read(6, count)
+            || count > kType2AtomCapacity || progress > count) {
+            return false;
+        }
+        candidate.generation = static_cast<std::uint32_t>(value);
+        for (std::size_t index = 0; index < count; ++index) {
+            if (!reader.read(1, present) || (present != 0 && !read_type2_lane(reader))) {
+                return false;
+            }
+        }
+    }
+    candidate.programBits = input.size() * 8U - reader.remaining_bits() - candidate.programOffset;
+    if (!reader.read(1, present)) {
+        return false;
+    }
+    if (present != 0 && (!reader.read(4, count) || count > 8 || !reader.skip(count * 55 + 31))) {
+        return false;
+    }
+    if (reader.remaining_bits() != input.size() * 8U - bitCount || !finish_padding(reader)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Preserves all root fields except the new sequence program and its generation. */
+bool replace_type2_sequence(std::span<const std::byte> previous,
+                            std::size_t previousBits,
+                            std::uint32_t sequenceHash,
+                            std::uint32_t committedGeneration,
+                            std::span<std::byte> output,
+                            std::size_t& written,
+                            std::size_t& writtenBits,
+                            std::uint32_t& generation) noexcept {
+    written = 0;
+    writtenBits = 0;
+    generation = 0;
+    Type2ProgramLayout layout{};
+    if (sequenceHash == kClientRefAbsentKey
+        || sequenceHash == (std::numeric_limits<std::uint32_t>::max)()
+        || !inspect_type2_program(previous, previousBits, layout) || !layout.enabled
+        || (layout.bindingWire != 2 && layout.bindingWire != 4)) {
+        return false;
+    }
+    const auto last = (std::max)(committedGeneration, layout.generation);
+    if (last >= kMaximumRevision) {
+        return false;
+    }
+    const auto next = last + 1;
+    const bool play = sequenceHash != 0;
+    Type2KeyedLane lane{};
+    lane.primary = Type2LaneU32{sequenceHash};
+    lane.secondary = Type2LaneSecondaryEmpty::first;
+    std::array<std::byte, kType2FullMaximumByteCount> staged{};
+    bits::Writer writer(staged);
+    namespace patch = mission_auth_patch;
+    const auto tailOffset = layout.programOffset + layout.programBits;
+    if (!patch::copy_field(writer, previous, {0, layout.programOffset, true}) || !writer.write(1, 1)
+        || !writer.write(next, 31) || !writer.write(0, 6) || !writer.write(play ? 1 : 0, 6)
+        || (play && (!writer.write(1, 1) || !write_type2_lane(writer, lane)))
+        || !patch::copy_field(writer, previous, {tailOffset, previousBits - tailOffset, true})) {
+        return false;
+    }
+    const auto bitCount = writer.bit_count();
+    std::size_t byteCount = 0;
+    if (!writer.finish(byteCount) || byteCount > output.size()) {
+        return false;
+    }
+    std::copy_n(staged.begin(), byteCount, output.begin());
+    written = byteCount;
+    writtenBits = bitCount;
+    generation = next;
+    return true;
+}
+
 /** Accepts only a canonical type-2 root: the actor-control block and an optional atom program. */
 bool validate_type2_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
     if (bitCount < kType2ChannelBaseBitCount || bitCount > kType2MaximumBodyBitCount

+ 183 - 1
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

@@ -1,6 +1,9 @@
 #include <algorithm>
+#include <limits>
 
+#include "../../encoding/bit_reader.h"
 #include "roster_presence.h"
+#include "scene_events_auth.h"
 #include "sensor_auth_update.h"
 
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
@@ -49,6 +52,184 @@ template <typename PackedBody>
 
 } // namespace
 
+/**
+ * Checks the bounded scene dependency set before it enters the host queue.
+ * @param value Dependency set.
+ * @return True for bounded, unique references.
+ */
+bool valid_authored_scene_dependencies(const AuthoredSceneDependencies& value) noexcept {
+    if (value.count > value.references.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < value.count; ++index) {
+        const auto& ref = value.references[index];
+        if (ref.rosterKey == 0 || ref.rosterKey == kEmptyNameHash
+            || ref.rosterKey == (std::numeric_limits<std::uint32_t>::max)() || ref.slotType < 0
+            || ref.slotType > kMaximumSlotType || ref.slotIndex < 0) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            const auto& other = value.references[prior];
+            if (ref.rosterKey == other.rosterKey && ref.slotType == other.slotType
+                && ref.slotIndex == other.slotIndex) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+/**
+ * Encodes the Type 43 generation and authored spawn dependencies.
+ * @param generation Positive activation generation.
+ * @param dependencies Exact spawn dependencies.
+ * @param output Receives the packed Auth body.
+ * @param written Receives the byte count, or zero on failure.
+ * @param writtenBits Receives the bit count, or zero on failure.
+ * @return True when every field fits its native width.
+ */
+bool encode_authored_scene_auth(std::uint32_t generation,
+                                const AuthoredSceneDependencies& dependencies,
+                                std::span<std::byte> output,
+                                std::size_t& written,
+                                std::size_t& writtenBits,
+                                std::span<const std::uint32_t> events,
+                                std::uint32_t scalar,
+                                bool stop) noexcept {
+    /** Native signed generation uses a 32-bit bias. */
+    constexpr std::uint32_t generationBias = 0x80000000U;
+    /** Native dependency count, scalar and event count widths. */
+    constexpr std::uint8_t dependencyCountWidth = 4, scalarWidth = 31, eventCountWidth = 6;
+    written = 0;
+    writtenBits = 0;
+    if (generation == 0 || generation >= generationBias
+        || !valid_authored_scene_dependencies(dependencies)
+        || events.size() > kAuthoredSceneMaximumEventCount || scalar >= generationBias) {
+        return false;
+    }
+    for (std::size_t index = 0; index < events.size(); ++index) {
+        const auto prior = events.first(index);
+        if (events[index] == 0 || events[index] == scene_events::kInvalidEventKey
+            || std::find(prior.begin(), prior.end(), events[index]) != prior.end()) {
+            return false;
+        }
+    }
+    bits::Writer writer(output);
+    bool encoded = writer.write(generation + generationBias, kKeyWidth)
+                   && writer.write(stop, kPresenceWidth)
+                   && writer.write(dependencies.count, dependencyCountWidth);
+    for (std::size_t index = 0; encoded && index < dependencies.count; ++index) {
+        const auto& ref = dependencies.references[index];
+        encoded = writer.write(ref.rosterKey, kKeyWidth)
+                  && writer.write(static_cast<std::uint32_t>(ref.slotType) + kSlotTypeBias,
+                                  kSlotTypeWidth)
+                  && writer.write(static_cast<std::uint32_t>(ref.slotIndex) + kSlotIndexBias,
+                                  kSlotIndexWidth);
+    }
+    encoded = encoded && writer.write(scalar, scalarWidth)
+              && writer.write(events.size(), eventCountWidth);
+    for (const std::uint32_t key : events) {
+        encoded = encoded && writer.write(key, kAuthoredSceneEventBitCount);
+    }
+    const auto expected = kAuthoredSceneBaseAuthBitCount
+                          + kAuthoredSceneDependencyBitCount * dependencies.count
+                          + kAuthoredSceneEventBitCount * events.size();
+    if (!encoded || writer.bit_count() != expected || !writer.finish(written)) {
+        written = 0;
+        return false;
+    }
+    writtenBits = expected;
+    return true;
+}
+
+/** Adds an event or stops a complete active scene body without changing its generation. */
+bool update_authored_scene_auth(std::span<const std::byte> previous,
+                                std::size_t previousBits,
+                                std::uint32_t eventKey,
+                                bool stop,
+                                std::span<std::byte> output,
+                                std::size_t& written,
+                                std::size_t& writtenBits,
+                                std::uint32_t& generation) noexcept {
+    written = 0;
+    writtenBits = 0;
+    generation = 0;
+    if (previousBits < kAuthoredSceneBaseAuthBitCount
+        || previousBits > kAuthoredSceneMaximumAuthBitCount
+        || previous.size() != (previousBits + 7U) / 8U || (stop ? eventKey != 0 : eventKey == 0)
+        || eventKey == scene_events::kInvalidEventKey) {
+        return false;
+    }
+    bits::Reader reader(previous);
+    std::uint64_t encodedGeneration = 0, clear = 0, count = 0, scalar = 0;
+    if (!reader.read(kKeyWidth, encodedGeneration) || !reader.read(kPresenceWidth, clear)
+        || clear != 0 || encodedGeneration <= auth_fields::kSigned32Bias
+        || !reader.read(scene_events::kDependencyCountWidth, count)
+        || count > kAuthoredSceneMaximumDependencyCount) {
+        return false;
+    }
+    AuthoredSceneDependencies dependencies{};
+    dependencies.count = static_cast<std::uint8_t>(count);
+    for (std::size_t index = 0; index < dependencies.count; ++index) {
+        std::uint64_t key = 0, type = 0, slot = 0;
+        if (!reader.read(kKeyWidth, key) || !reader.read(kSlotTypeWidth, type)
+            || !reader.read(kSlotIndexWidth, slot) || type < kSlotTypeBias
+            || slot < kSlotIndexBias) {
+            return false;
+        }
+        dependencies.references[index] = {static_cast<std::uint32_t>(key),
+                                          static_cast<std::int8_t>(type - kSlotTypeBias),
+                                          static_cast<std::int16_t>(slot - kSlotIndexBias)};
+    }
+    if (!valid_authored_scene_dependencies(dependencies)
+        || !reader.read(scene_events::kScalarWidth, scalar)
+        || !reader.read(scene_events::kEventCountWidth, count)
+        || count > kAuthoredSceneMaximumEventCount
+        || previousBits
+               != kAuthoredSceneBaseAuthBitCount
+                      + kAuthoredSceneDependencyBitCount * dependencies.count
+                      + kAuthoredSceneEventBitCount * count) {
+        return false;
+    }
+    std::array<std::uint32_t, kAuthoredSceneMaximumEventCount> events{};
+    bool found = stop;
+    for (std::size_t index = 0; index < count; ++index) {
+        std::uint64_t key = 0;
+        if (!reader.read(kAuthoredSceneEventBitCount, key) || key == 0
+            || key == scene_events::kInvalidEventKey) {
+            return false;
+        }
+        events[index] = static_cast<std::uint32_t>(key);
+        const auto prior = std::span(events).first(index);
+        if (std::find(prior.begin(), prior.end(), events[index]) != prior.end()) {
+            return false;
+        }
+        found = found || events[index] == eventKey;
+    }
+    std::uint64_t padding = 0;
+    if (!reader.read(static_cast<std::uint8_t>(previous.size() * 8U - previousBits), padding)
+        || padding != 0 || (!found && count == events.size())) {
+        return false;
+    }
+    if (!found) {
+        events[count++] = eventKey;
+    }
+    const auto retainedGeneration =
+        static_cast<std::uint32_t>(encodedGeneration) - auth_fields::kSigned32Bias;
+    if (!encode_authored_scene_auth(retainedGeneration,
+                                    dependencies,
+                                    output,
+                                    written,
+                                    writtenBits,
+                                    std::span(events).first(static_cast<std::size_t>(count)),
+                                    static_cast<std::uint32_t>(scalar),
+                                    stop)) {
+        return false;
+    }
+    generation = retainedGeneration;
+    return true;
+}
+
 /** Writes zero bits in chunks the writer accepts. */
 bool pad_bits(bits::Writer& writer, std::size_t count) noexcept {
     bool encoded = true;
@@ -143,8 +324,9 @@ group_state_sequence(const Roster& roster, std::uint32_t key, std::uint8_t fallb
         encoded = writer.write(block.keys[index], kKeyWidth);
     }
     encoded = encoded && writer.write(1, kPresenceWidth);
-    for (std::size_t word = 0; encoded && word < kBubbleMaskWords; ++word)
+    for (std::size_t word = 0; encoded && word < kBubbleMaskWords; ++word) {
         encoded = writer.write(presence_word(roster, block.keys, word), kChunkWidth);
+    }
     encoded = encoded && writer.write(1, kPresenceWidth) && writer.write(count, kBubbleCountWidth);
     for (std::size_t index = 0; encoded && index < keyCount; ++index) {
         encoded = writer.write(

+ 4 - 28
Sunrise/src/middleware/bap/activity_message/scene_events_auth.h

@@ -6,6 +6,7 @@
 
 #include "../../encoding/bit_writer.h"
 #include "auth_fields.h"
+#include "sensor_auth_update.h"
 
 // Type-43 authored scene Auth in its event-only form: generation, no clear, no dependencies,
 // a zero scalar, then the cumulative event keys. The generation stays while events are added,
@@ -46,34 +47,9 @@ inline constexpr std::uint32_t kInvalidEventKey = 0xFFFFFFFFU;
                                  std::size_t& bits) noexcept {
     bytes = 0;
     bits = 0;
-    const std::size_t expectedBits = kHeaderBits + kEventKeyWidth * events.size();
-    if (generation <= 0 || events.size() > kMaximumEvents
-        || output.size() < (expectedBits + 7) / 8) {
-        return false;
-    }
-    for (std::size_t index = 0; index < events.size(); ++index) {
-        if (events[index] == 0 || events[index] == kInvalidEventKey) {
-            return false;
-        }
-        for (std::size_t prior = 0; prior < index; ++prior) {
-            if (events[index] == events[prior]) {
-                return false;
-            }
-        }
-    }
-    encoding::bits::Writer writer(output);
-    if (!writer.write(static_cast<std::uint32_t>(generation) + fields::kSigned32Bias, 32)
-        || !writer.write(0, fields::kBoolWidth) || !writer.write(0, kDependencyCountWidth)
-        || !writer.write(0, kScalarWidth) || !writer.write(events.size(), kEventCountWidth)) {
-        return false;
-    }
-    for (const std::uint32_t event : events) {
-        if (!writer.write(event, kEventKeyWidth)) {
-            return false;
-        }
-    }
-    bits = writer.bit_count();
-    return bits == expectedBits && writer.finish(bytes);
+    return generation > 0
+           && sensor_auth_update::encode_authored_scene_auth(
+               static_cast<std::uint32_t>(generation), {}, output, bytes, bits, events);
 }
 
 } // namespace sunrise::middleware::bap::activity_message::scene_events

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

@@ -38,7 +38,7 @@ struct TemperamentId final {
     [[nodiscard]] friend constexpr bool operator==(TemperamentId, TemperamentId) noexcept = default;
 };
 
-/** Selects whether Type 2 creates its own actor or binds one authored squad member. */
+/** Selects automatic actor creation or creation by an authored scene. */
 enum class Type2ActorBinding : std::int8_t {
     selfOwned = -1,
     squadMember = 1,
@@ -161,6 +161,36 @@ inline constexpr std::size_t kType2MaximumBodyBitCount =
 /** Same size padded up to whole bytes. */
 inline constexpr std::size_t kType2MaximumBodyByteCount = (kType2MaximumBodyBitCount + 7) / 8;
 
+/** Full Auth also permits spawn generation, eight keyed rows and eight manifest references. */
+inline constexpr std::size_t kType2FullMaximumBitCount =
+    kType2MaximumBodyBitCount + 31U + (1U + 8U * (1U + 32U + 1U + 31U) + 1U + 32U)
+    + (4U + 8U * 55U + 31U);
+inline constexpr std::size_t kType2FullMaximumByteCount = (kType2FullMaximumBitCount + 7U) / 8U;
+
+/** Exact retained spans around `.6`; all other root fields remain byte-for-byte unchanged. */
+struct Type2ProgramLayout final {
+    std::size_t programOffset{};
+    std::size_t programBits{};
+    std::uint32_t generation{};
+    std::uint8_t bindingWire{};
+    bool enabled{};
+};
+
+/** Reads every full-root field using native bounded arrays and finite atom-lane tags. */
+[[nodiscard]] bool inspect_type2_program(std::span<const std::byte> input,
+                                         std::size_t bitCount,
+                                         Type2ProgramLayout& output) noexcept;
+
+/** Replaces only `.6`; a zero hash cancels the program on the enabled squad-bound actor. */
+[[nodiscard]] bool replace_type2_sequence(std::span<const std::byte> previous,
+                                          std::size_t previousBits,
+                                          std::uint32_t sequenceHash,
+                                          std::uint32_t committedGeneration,
+                                          std::span<std::byte> output,
+                                          std::size_t& written,
+                                          std::size_t& writtenBits,
+                                          std::uint32_t& generation) noexcept;
+
 /** Encodes both keyed-lane tags and their selected native child schemas. */
 [[nodiscard]] bool encode_type2_keyed_lane(const Type2KeyedLane& lane,
                                            std::span<std::byte> output,

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

@@ -8,6 +8,7 @@
 #include "../../encoding/bit_writer.h"
 #include "activity_patch_epoch_parser.h"
 #include "definition.h"
+#include "sensor_message.h"
 #include "squad_auth_body.h"
 #include "squad_sense_state.h"
 
@@ -53,6 +54,35 @@ inline constexpr std::size_t kAuthoredSceneMaximumAuthByteCount =
 /** Complete baseline body: signed activation generation, clear bool, and empty collections. */
 inline constexpr std::uint16_t kAuthoredSceneAuthBitCount = kAuthoredSceneBaseAuthBitCount;
 inline constexpr std::uint8_t kAuthoredSceneAuthByteCount = (kAuthoredSceneAuthBitCount + 7U) / 8U;
+/** Authored scene dependencies are full ClientRefs, retained before the activation is queued. */
+struct AuthoredSceneDependencies final {
+    std::array<sensor_message::ClientReference, kAuthoredSceneMaximumDependencyCount> references{};
+    std::uint8_t count{};
+};
+
+/** Checks the native count and ClientRef ranges without changing the dependency set. */
+[[nodiscard]] bool
+valid_authored_scene_dependencies(const AuthoredSceneDependencies& value) noexcept;
+
+/** Encodes one activation with its exact dependencies, scalar and cumulative event keys. */
+[[nodiscard]] bool encode_authored_scene_auth(std::uint32_t generation,
+                                              const AuthoredSceneDependencies& dependencies,
+                                              std::span<std::byte> output,
+                                              std::size_t& written,
+                                              std::size_t& writtenBits,
+                                              std::span<const std::uint32_t> events = {},
+                                              std::uint32_t scalar = 0,
+                                              bool stop = false) noexcept;
+
+/** Updates event keys or stops a scene while retaining its complete transported body. */
+[[nodiscard]] bool update_authored_scene_auth(std::span<const std::byte> previous,
+                                              std::size_t previousBits,
+                                              std::uint32_t eventKey,
+                                              bool stop,
+                                              std::span<std::byte> output,
+                                              std::size_t& written,
+                                              std::size_t& writtenBits,
+                                              std::uint32_t& generation) noexcept;
 /** The widest statically bounded Auth body is type 19, at 53,150 bits. */
 inline constexpr std::size_t kAuthOverrideByteCapacity = (53'150U + 7U) / 8U;
 /** Lifetime states that pass the player-spawn gate. */

+ 23 - 12
Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h

@@ -10,9 +10,7 @@
 #include "scriptable_auth_body.h"
 #include "squad_auth_body.h"
 
-// Type-1 squad Auth that assigns the squad to a native combat objective. Root .0 names the
-// objective, .13 carries the revision that invalidates its cost pass, .16 links a task group.
-// Counts, profile and spawn generation stay absent so the placement is untouched.
+// Objective updates preserve placement fields and may refresh player awareness once per revision.
 
 namespace sunrise::middleware::bap::activity_message::squad_objective {
 
@@ -21,9 +19,12 @@ namespace fields = auth_fields;
 inline constexpr std::uint32_t kSchema = squad_auth::kSchema;
 inline constexpr std::size_t kBits = 153;
 inline constexpr std::size_t kBytes = 20;
+/** Optional root .14 adds one 31-bit awareness revision. */
+inline constexpr std::size_t kMaximumBits = kBits + fields::kCounterWidth;
+inline constexpr std::size_t kMaximumBytes = (kMaximumBits + 7U) / 8U;
 /** Absent presence bits for fields .1 to .12. */
 inline constexpr std::uint8_t kAbsentMiddleFieldCount = 12;
-/** Root .15 is present and zero. Meaning unverified. */
+/** Root .15 carries the biased -1 sentinel; its role is unverified. */
 inline constexpr std::uint8_t kField15Width = 6;
 /** Root .16 task group: 5 bits with bias one, so -1 requests costs without a link. */
 inline constexpr std::uint8_t kTaskGroupWidth = 5;
@@ -44,15 +45,24 @@ struct Request final {
     std::uint16_t objectiveIndex{};
     std::int32_t taskGroup{kNoTaskGroup};
     bool reserved{};
+    bool refreshPlayerAwareness{};
 };
 
+[[nodiscard]] constexpr std::size_t bit_count(const Request& request) noexcept {
+    return request.refreshPlayerAwareness ? kMaximumBits : kBits;
+}
+
+[[nodiscard]] constexpr std::size_t byte_count(const Request& request) noexcept {
+    return (bit_count(request) + 7U) / 8U;
+}
+
 /**
  * Encodes the objective assignment.
- * @param output Exactly kBytes.
+ * @param output Exactly byte_count(request) bytes.
  * @return False on an out-of-range revision, index or task group.
  */
 [[nodiscard]] inline bool encode(const Request& request, std::span<std::byte> output) noexcept {
-    if (output.size() != kBytes || request.registryKey == 0 || request.revision == 0
+    if (output.size() != byte_count(request) || request.registryKey == 0 || request.revision == 0
         || request.revision > fields::kMaximumCounter
         || request.objectiveIndex > fields::kMaximumClientRefIndex
         || request.taskGroup < kNoTaskGroup || request.taskGroup >= kTaskGroupCount) {
@@ -62,11 +72,7 @@ struct Request final {
                                                                   : squad_auth::Mode::mode2);
     encoding::bits::Writer writer(output);
     std::size_t written = 0;
-    const std::array<fields::Field, 13> tail{{
-        {0, kAbsentMiddleFieldCount},
-        {1, fields::kPresenceWidth}, // .13 present
-        {request.revision, fields::kCounterWidth},
-        {0, fields::kPresenceWidth}, // .14 absent
+    const std::array<fields::Field, 9> tail{{
         {1, fields::kPresenceWidth}, // .15 present
         {0, kField15Width},
         {1, fields::kPresenceWidth}, // .16 present
@@ -80,8 +86,13 @@ struct Request final {
     return writer.write(1, fields::kPresenceWidth)
            && fields::write_client_ref(
                writer, request.registryKey, scriptable_auth::kType3SlotType, request.objectiveIndex)
+           && writer.write(0, kAbsentMiddleFieldCount) && writer.write(1, fields::kPresenceWidth)
+           && writer.write(request.revision, fields::kCounterWidth)
+           && writer.write(request.refreshPlayerAwareness, fields::kPresenceWidth)
+           && (!request.refreshPlayerAwareness
+               || writer.write(request.revision, fields::kCounterWidth))
            && fields::write_fields(writer, tail)
-           && fields::finish_exact(writer, kBits, kBytes, written);
+           && fields::finish_exact(writer, bit_count(request), byte_count(request), written);
 }
 
 } // namespace sunrise::middleware::bap::activity_message::squad_objective

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

@@ -345,7 +345,7 @@ private:
         return RuntimeWalkStatus::complete;
     }
 
-    /** Type 13 preserves raw floats or cell-profile quantized codes without a float round trip. */
+    /** Types 13 and 39 share raw floats and cell-profile quantized codes. */
     [[nodiscard]] RuntimeWalkStatus walk_vector3(const runtime::SchemaView& owner,
                                                  const runtime::FieldView& field,
                                                  std::uint32_t occurrence) noexcept {
@@ -581,6 +581,13 @@ private:
         if (primary != RuntimeWalkStatus::complete) {
             return primary;
         }
+        return walk_reference_value(owner, field, occurrence);
+    }
+
+    /** Types 27 and 43 share a nullable pair of tags followed by a sized index. */
+    [[nodiscard]] RuntimeWalkStatus walk_reference_value(const runtime::SchemaView& owner,
+                                                         const runtime::FieldView& field,
+                                                         std::uint32_t occurrence) noexcept {
         const std::uint32_t at = static_cast<std::uint32_t>(reader_.position());
         std::uint64_t valuePresent = 0;
         if (!reader_.read(1, valuePresent)) {
@@ -631,7 +638,7 @@ private:
         if (resolver_.canonicalType != nullptr) {
             field.typeCode = resolver_.canonicalType(resolver_.context, field.typeCode);
         }
-        if (field.typeCode == 13) {
+        if (field.typeCode == 13 || field.typeCode == 39) {
             return walk_vector3(owner, field, occurrence);
         }
         if (field.typeCode == 16) {
@@ -758,6 +765,9 @@ private:
         if (field.typeCode == 26) {
             return walk_nullable160(owner, field, occurrence);
         }
+        if (field.typeCode == 27) {
+            return walk_reference_value(owner, field, occurrence);
+        }
         if (field.typeCode == 43) {
             return walk_reference_value_variant(owner, field, occurrence);
         }
@@ -848,8 +858,9 @@ private:
         const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 15
                             || field.typeCode == 16 || field.typeCode == 22 || field.typeCode == 19
                             || field.typeCode == 24 || field.typeCode == 25 || field.typeCode == 26
-                            || field.typeCode == 28 || field.typeCode == 40 || field.typeCode == 42
-                            || field.typeCode == 43 || field.typeCode == 44 || field.typeCode == 45;
+                            || field.typeCode == 27 || field.typeCode == 28 || field.typeCode == 39
+                            || field.typeCode == 40 || field.typeCode == 42 || field.typeCode == 43
+                            || field.typeCode == 44 || field.typeCode == 45;
         const bool raw64 = field.typeCode == 35;
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);

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

@@ -373,7 +373,7 @@ private:
                    : RuntimeWalkStatus::outputTooSmall;
     }
 
-    /** Type 13 replays the selected cell's quantized codes or raw float components. */
+    /** Types 13 and 39 share quantized codes and raw float components. */
     [[nodiscard]] RuntimeWalkStatus write_vector3(const runtime::SchemaView& owner,
                                                   const runtime::FieldView& field,
                                                   std::uint32_t occurrence) noexcept {
@@ -599,6 +599,13 @@ private:
         if (primary != RuntimeWalkStatus::complete) {
             return primary;
         }
+        return write_reference_value(owner, field, occurrence);
+    }
+
+    /** Types 27 and 43 share a nullable pair of tags followed by a sized index. */
+    [[nodiscard]] RuntimeWalkStatus write_reference_value(const runtime::SchemaView& owner,
+                                                          const runtime::FieldView& field,
+                                                          std::uint32_t occurrence) noexcept {
         RuntimeAuthoredValue valuePresent{};
         if (!source_.take(
                 owner, field, occurrence, ValueRole::referenceValuePresent, valuePresent)) {
@@ -651,7 +658,7 @@ private:
         if (resolver_.canonicalType != nullptr) {
             field.typeCode = resolver_.canonicalType(resolver_.context, field.typeCode);
         }
-        if (field.typeCode == 13) {
+        if (field.typeCode == 13 || field.typeCode == 39) {
             return write_vector3(owner, field, occurrence);
         }
         if (field.typeCode == 16) {
@@ -756,6 +763,9 @@ private:
         if (field.typeCode == 26) {
             return write_nullable160(owner, field, occurrence);
         }
+        if (field.typeCode == 27) {
+            return write_reference_value(owner, field, occurrence);
+        }
         if (field.typeCode == 43) {
             return write_reference_value_variant(owner, field, occurrence);
         }
@@ -822,8 +832,9 @@ private:
         const bool custom = field.typeCode == 13 || field.typeCode == 14 || field.typeCode == 15
                             || field.typeCode == 16 || field.typeCode == 22 || field.typeCode == 19
                             || field.typeCode == 24 || field.typeCode == 25 || field.typeCode == 26
-                            || field.typeCode == 28 || field.typeCode == 40 || field.typeCode == 42
-                            || field.typeCode == 43 || field.typeCode == 44 || field.typeCode == 45;
+                            || field.typeCode == 27 || field.typeCode == 28 || field.typeCode == 39
+                            || field.typeCode == 40 || field.typeCode == 42 || field.typeCode == 43
+                            || field.typeCode == 44 || field.typeCode == 45;
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);
         // A selected field carries its schema handle on the wire, so it is structural but must

+ 2 - 0
Sunrise/src/middleware/content/packages/tables/slot_descriptor_reader.h

@@ -48,6 +48,8 @@ inline constexpr std::size_t kDescriptorSlotIndexOffset = 54;
 inline constexpr std::size_t kDescriptorBubbleIndexOffset = 56;
 inline constexpr std::size_t kDescriptorSenseSchemaOffset = 68;
 inline constexpr std::size_t kDescriptorAuthSchemaOffset = 72;
+/** Type 2 descriptors bind their authored squad through this ClientRef. */
+inline constexpr std::size_t kType2SquadReferenceOffset = 104;
 
 /** What one slot type resolves to. */
 struct SlotDescriptor {

+ 45 - 17
Sunrise/src/middleware/gameplay/external/actor_command_runtime_codec.cpp

@@ -21,6 +21,14 @@ constexpr std::size_t kCommandHeaderValueCount = 7;
 constexpr std::uint8_t kMaximumCommandMode = 7;
 /** The default and target-reference fields are one bit each. */
 constexpr std::uint8_t kMaximumCommandBit = 1;
+/** Activity types 29 and 43 share the primary-reference and tag/index wire body. */
+constexpr std::uint8_t kReferenceValueType = 29;
+constexpr std::uint8_t kReferenceValuePairType = 43;
+
+/** Activity aliases must not change the raw reference forms used by other families. */
+[[nodiscard]] std::uint8_t sdk_canonical_type(const void*, std::uint8_t type) noexcept {
+    return type == kReferenceValueType ? kReferenceValuePairType : type;
+}
 
 /** Carries one selected message while delegating ordinary reflection. */
 struct ResolverContext final {
@@ -126,7 +134,7 @@ sdk_read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView
               : dynamicArray ? 1
                              : bias,
               bits,
-              static_cast<std::uint8_t>(row.typeCode),
+              sdk_canonical_type(raw, static_cast<std::uint8_t>(row.typeCode)),
               static_cast<std::uint8_t>((row.flags & format::kRuntimeFieldPresenceBit) != 0),
               static_cast<std::uint8_t>(
                   row.codecParameters[2] <= kMaximumByteField ? row.codecParameters[2] : 0)};
@@ -283,15 +291,29 @@ resolve_payload(const void* raw, std::uint8_t selector, std::uint32_t& output) n
            && context->reflection.isZeroBitType(context->reflection.context, typeCode);
 }
 
-/** Overrides command selection while preserving ordinary reflection. */
+/** Delegates family-specific aliases through the borrowed reflection context. */
+[[nodiscard]] std::uint8_t canonical_type(const void* raw, std::uint8_t typeCode) noexcept {
+    const auto& context = *static_cast<const ResolverContext*>(raw);
+    return context.reflection.canonicalType != nullptr
+               ? context.reflection.canonicalType(context.reflection.context, typeCode)
+               : typeCode;
+}
+
+/**
+ * Overrides command selection while preserving ordinary reflection.
+ * @param context Borrowed message and reflection state, which must outlive the adapter.
+ * @return An adapter retaining the source codec's type aliases.
+ */
 [[nodiscard]] wire::RuntimeSchemaResolver resolver(ResolverContext& context) noexcept {
-    return {&context,
-            find_schema,
-            read_schema,
-            read_field,
-            resolve_payload,
-            validate_type,
-            zero_bit_type};
+    wire::RuntimeSchemaResolver output{&context,
+                                       find_schema,
+                                       read_schema,
+                                       read_field,
+                                       resolve_payload,
+                                       validate_type,
+                                       zero_bit_type};
+    output.canonicalType = canonical_type;
+    return output;
 }
 
 /** Finds the message schema's single dynamic actor-command field. */
@@ -325,16 +347,22 @@ identity(std::uint32_t messageIndex,
 
 } // namespace
 
-/** Builds the generic activity-family reflection adapter. */
+/**
+ * Builds the generic activity-family reflection adapter.
+ * @param catalog Borrowed SDK rows, which must outlive the adapter.
+ * @return An adapter using the activity family's field and union aliases.
+ */
 wire::RuntimeSchemaResolver
 actor_runtime_schema_resolver(const ActorCommandCatalog& catalog) noexcept {
-    return {&catalog,
-            sdk_find_schema,
-            sdk_read_schema,
-            sdk_read_field,
-            sdk_resolve_payload,
-            sdk_validate_type,
-            sdk_zero_bit_type};
+    wire::RuntimeSchemaResolver output{&catalog,
+                                       sdk_find_schema,
+                                       sdk_read_schema,
+                                       sdk_read_field,
+                                       sdk_resolve_payload,
+                                       sdk_validate_type,
+                                       sdk_zero_bit_type};
+    output.canonicalType = sdk_canonical_type;
+    return output;
 }
 
 /** Encodes one type-36 command and its selector-owned payload. */

+ 66 - 21
Sunrise/src/middleware/gameplay/external/actor_entity_registry.cpp

@@ -1,5 +1,10 @@
 #include "actor_entity_registry.h"
 
+#include "../../bap/activity_message/scriptable_auth_body.h"
+#include "../../content/packages/tables/slot_descriptor_reader.h"
+#include "composite_entity_codec.h"
+#include "entity_identity_metadata.h"
+
 namespace sunrise::middleware::gameplay::external {
 namespace {
 
@@ -57,7 +62,7 @@ void bind_catalog(ActorEntityRegistry& registry, const ActorEntityCatalog& catal
 } // namespace
 
 /**
- * Applies one decoded channel-2 record without assigning authored-member identity.
+ * Applies one accepted allocation and its exact decoded actor-source relation.
  * @param registry Slot table to update.
  * @param catalog Pinned actor-class rows.
  * @param record Decoded record.
@@ -70,39 +75,79 @@ ActorEntityApplyResult apply_actor_entity_record(ActorEntityRegistry& registry,
         || record.token.incarnation > kMaximumEntityIncarnation) {
         return ActorEntityApplyResult::invalid;
     }
-    bind_catalog(registry, catalog);
-    ActorEntitySlot& slot = registry.slots[record.token.slot];
-
-    if (record.flags == entityRemove) {
-        if (!same_token(slot, record.token)) {
+    const bool catalogChanged = registry.catalog != catalog.owner
+                                || registry.classData != catalog.classes.data()
+                                || registry.classCount != catalog.classes.size();
+    const ActorEntitySlot previous =
+        catalogChanged ? ActorEntitySlot{} : registry.slots[record.token.slot];
+    ActorEntitySlot candidate = previous;
+    const bool creating = (record.flags & entityCreate) != 0;
+    const bool removing = (record.flags & entityRemove) != 0;
+    if (removing && !creating) {
+        if (!same_token(previous, record.token)) {
             return ActorEntityApplyResult::staleToken;
         }
-        slot = {};
+        bind_catalog(registry, catalog);
+        registry.slots[record.token.slot] = {};
         return ActorEntityApplyResult::actorRemoved;
     }
-    if ((record.flags & entityCreate) != 0) {
+    bool fresh = false;
+    if (creating) {
         if (record.type != EntityType::sobject) {
-            slot = {};
+            bind_catalog(registry, catalog);
+            registry.slots[record.token.slot] = {};
             return ActorEntityApplyResult::nonActor;
         }
-        SobjectBaseline baseline{};
-        if (!load_sobject_baseline(record.baseline, baseline)) {
-            slot = {};
+        std::uint32_t rsatTag{};
+        if (!composite_sobject_rsat(record.baseline, rsatTag)) {
             return ActorEntityApplyResult::invalid;
         }
         std::uint32_t actorClassIndex = format::kAbsentIndex;
-        if (!resolve_actor_class(catalog, baseline.rsatTag, actorClassIndex)) {
-            slot = {};
+        if (!resolve_actor_class(catalog, rsatTag, actorClassIndex)) {
+            bind_catalog(registry, catalog);
+            registry.slots[record.token.slot] = {};
             return ActorEntityApplyResult::nonActor;
         }
-        slot.actorClassIndex = actorClassIndex;
-        slot.rsatTag = baseline.rsatTag;
-        slot.incarnation = record.token.incarnation;
-        slot.occupied = true;
-        return ActorEntityApplyResult::actorCreated;
+        fresh = !same_token(previous, record.token) || previous.rsatTag != rsatTag
+                || previous.actorClassIndex != actorClassIndex
+                || previous.allocationSequence != record.allocationSequence;
+        if (fresh) {
+            candidate = {};
+        }
+        candidate.actorClassIndex = actorClassIndex;
+        candidate.rsatTag = rsatTag;
+        candidate.incarnation = record.token.incarnation;
+        candidate.allocationSequence = record.allocationSequence;
+        candidate.occupied = true;
+    } else if (!same_token(previous, record.token)) {
+        return ActorEntityApplyResult::staleToken;
     }
-    return same_token(slot, record.token) ? ActorEntityApplyResult::unchanged
-                                          : ActorEntityApplyResult::staleToken;
+    if (removing) {
+        bind_catalog(registry, catalog);
+        registry.slots[record.token.slot] = {};
+        return ActorEntityApplyResult::actorRemoved;
+    }
+    state::gameplay::entity_identity::ActorSourceReference source{};
+    if ((record.flags & entityUpdate) != 0 && record.update.actorSource.known) {
+        if (!extract_actor_source_reference(record, source)) {
+            return ActorEntityApplyResult::invalid;
+        }
+        candidate.authoredSource = source;
+    }
+    const bool membershipChanged = candidate.authoredSource != previous.authoredSource;
+    bind_catalog(registry, catalog);
+    registry.slots[record.token.slot] = candidate;
+    return fresh ? ActorEntityApplyResult::actorCreated
+                 : (membershipChanged ? ActorEntityApplyResult::membershipChanged
+                                      : ActorEntityApplyResult::unchanged);
+}
+
+/** Catalog and source relations end with their exact peer view. */
+void reset_actor_entity_registry(ActorEntityRegistry& registry) noexcept {
+    registry.catalog.reset();
+    registry.classData = nullptr;
+    registry.classCount = 0;
+    registry.slots.fill({});
 }
 
 /**

+ 14 - 2
Sunrise/src/middleware/gameplay/external/actor_entity_registry.h

@@ -7,16 +7,24 @@
 #include <utility>
 
 #include "../../../state/activity_sdk/runtime.h"
+#include "../../../state/build_data/scriptables/definition.h"
 #include "external_entity_codec.h"
-#include "sobject_payload_codec.h"
 
 namespace sunrise::middleware::gameplay::external {
 
+/** Resolves only a Type 2 descriptor's exact authored squad reference. */
+[[nodiscard]] bool resolve_combatant_squad_source(
+    const state::build_data::scriptables::Snapshot& world,
+    const state::gameplay::entity_identity::ActorSourceReference& source,
+    state::gameplay::entity_identity::ActorSourceReference& output) noexcept;
+
 /** One registry slot mirrors the 13-bit simulation entity slot space. */
 struct ActorEntitySlot final {
     std::uint32_t actorClassIndex{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t rsatTag{};
+    state::gameplay::entity_identity::ActorSourceReference authoredSource{};
     std::uint8_t incarnation{};
+    std::uint8_t allocationSequence{};
     bool occupied{};
 };
 
@@ -53,17 +61,21 @@ struct ActorEntityRegistry final {
 enum class ActorEntityApplyResult : std::uint8_t {
     actorCreated,
     actorRemoved,
+    membershipChanged,
     nonActor,
     unchanged,
     staleToken,
     invalid,
 };
 
-/** Applies one decoded channel-2 record without assigning authored-member identity. */
+/** Applies only membership carried by the decoded actor-source component. */
 [[nodiscard]] ActorEntityApplyResult apply_actor_entity_record(ActorEntityRegistry& registry,
                                                                const ActorEntityCatalog& catalog,
                                                                const EntityRecord& record) noexcept;
 
+/** Clears the registry in place without a full-table stack temporary. */
+void reset_actor_entity_registry(ActorEntityRegistry& registry) noexcept;
+
 /** Copies current entity tokens for one SDK actor class into caller-owned storage. */
 [[nodiscard]] bool actor_entity_targets(const ActorEntityRegistry& registry,
                                         std::uint32_t actorClassIndex,

+ 92 - 0
Sunrise/src/middleware/gameplay/external/actor_entity_squad_source.cpp

@@ -0,0 +1,92 @@
+#include "../../bap/activity_message/scriptable_auth_body.h"
+#include "../../content/packages/tables/slot_descriptor_reader.h"
+#include "actor_entity_registry.h"
+
+namespace sunrise::middleware::gameplay::external {
+
+namespace format = state::activity_sdk::format;
+
+/**
+ * Resolves a bound combatant source through its descriptor-owned squad edge.
+ * @param world Authenticated generated-world rows.
+ * @param source Raw source from an accepted actor update.
+ * @param output Receives the unique squad source; cleared on failure.
+ * @return True when every matching edge agrees on one exact Type 1 target.
+ */
+bool resolve_combatant_squad_source(
+    const state::build_data::scriptables::Snapshot& world,
+    const state::gameplay::entity_identity::ActorSourceReference& source,
+    state::gameplay::entity_identity::ActorSourceReference& output) noexcept {
+    namespace catalog = state::build_data::scriptables;
+    namespace auth = bap::activity_message::scriptable_auth;
+    namespace tables = content::packages::tables;
+    output = {};
+    if (!source.known || !source.present || source.type != auth::kType2SlotType) {
+        return false;
+    }
+    state::gameplay::entity_identity::ActorSourceReference found{};
+    for (const catalog::TypedReference& edge : world.references) {
+        if (edge.sourceObjectRow >= world.objects.size()
+            || edge.sourceSlotRow >= world.slots.size()) {
+            continue;
+        }
+        const auto& object = world.objects[edge.sourceObjectRow];
+        const auto& slot = world.slots[edge.sourceSlotRow];
+        if (!object.complete || object.registryKey != source.key
+            || slot.objectRow != edge.sourceObjectRow || slot.slotType != source.type
+            || slot.slotIndex != source.index || slot.firstDescriptor > world.descriptors.size()
+            || slot.descriptorCount > world.descriptors.size() - slot.firstDescriptor) {
+            continue;
+        }
+        std::size_t descriptors = 0;
+        for (std::size_t index = slot.firstDescriptor;
+             index < static_cast<std::size_t>(slot.firstDescriptor) + slot.descriptorCount;
+             ++index) {
+            const auto& descriptor = world.descriptors[index];
+            descriptors += descriptor.slotRow == edge.sourceSlotRow
+                                   && descriptor.configTag == edge.sourceConfigTag
+                                   && descriptor.componentClass == auth::kType2ComponentClass
+                                   && descriptor.senseSchema == auth::kType2SenseSchema
+                                   && descriptor.authSchema == auth::kType2Schema
+                                   && static_cast<std::uint64_t>(descriptor.descriptorOffset)
+                                              + tables::kType2SquadReferenceOffset
+                                          == edge.sourceOffset
+                               ? 1U
+                               : 0U;
+        }
+        if (descriptors == 0) {
+            continue;
+        }
+        if (descriptors != 1 || edge.join != catalog::ReferenceJoin::exact
+            || edge.targetSlotType != format::kSquadSlotType
+            || edge.targetObjectRow >= world.objects.size()) {
+            return false;
+        }
+        const auto& target = world.objects[edge.targetObjectRow];
+        const std::size_t targetSlot =
+            static_cast<std::size_t>(target.firstSlot) + edge.targetSlotIndex;
+        if (!target.complete || target.registryKey != edge.targetKey
+            || target.registryTag != object.registryTag || target.stateRow != object.stateRow
+            || edge.targetSlotIndex >= target.slotCount || targetSlot >= world.slots.size()
+            || world.slots[targetSlot].objectRow != edge.targetObjectRow
+            || world.slots[targetSlot].slotType != format::kSquadSlotType
+            || world.slots[targetSlot].slotIndex != edge.targetSlotIndex
+            || world.slots[targetSlot].descriptorCount == 0) {
+            return false;
+        }
+        const state::gameplay::entity_identity::ActorSourceReference candidate{
+            edge.targetKey,
+            edge.targetSlotIndex,
+            static_cast<std::uint8_t>(edge.targetSlotType),
+            true,
+            true};
+        if (found.present && found != candidate) {
+            return false;
+        }
+        found = candidate;
+    }
+    output = found;
+    return found.present;
+}
+
+} // namespace sunrise::middleware::gameplay::external

+ 14 - 3
Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp

@@ -101,8 +101,10 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
             }
             output.nestedSchemaRow = target.row;
         }
-        if (context->family == format::RuntimeCodecFamily::sobjectModeOne
-            && output.typeCode == 20) {
+        if ((context->family != format::RuntimeCodecFamily::sobjectModeZero
+             && output.typeCode == 30)
+            || (context->family == format::RuntimeCodecFamily::sobjectModeOne
+                && output.typeCode == 20)) {
             output.typeCode = 18;
         }
         if (context->family == format::RuntimeCodecFamily::sobjectModeOne
@@ -159,7 +161,9 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
                                     ? 0
                                     : static_cast<std::int32_t>(row.bits);
     output.typeCode = static_cast<std::uint8_t>(row.typeCode);
-    if (context->family == format::RuntimeCodecFamily::sobjectModeOne && output.typeCode == 20) {
+    if ((context->family != format::RuntimeCodecFamily::sobjectModeZero && output.typeCode == 30)
+        || (context->family == format::RuntimeCodecFamily::sobjectModeOne
+            && output.typeCode == 20)) {
         output.typeCode = 18;
     }
     if (context->family == format::RuntimeCodecFamily::sobjectModeOne && output.typeCode == 29) {
@@ -221,6 +225,13 @@ static bool record_presence(const void* raw, std::uint32_t bit, bool present) no
 /** T11 reference aliases share wire forms without changing other codec families. */
 static std::uint8_t canonical_type(const void* raw, std::uint8_t type) noexcept {
     const auto& context = *static_cast<const detail::ResolverContext*>(raw);
+    // Mode zero uses a different raw reference body.
+    if (context.family == format::RuntimeCodecFamily::sobjectModeZero && type == 27) {
+        return 0;
+    }
+    if (context.family != format::RuntimeCodecFamily::sobjectModeZero && type == 30) {
+        return 18;
+    }
     if (context.family == format::RuntimeCodecFamily::sobjectModeOne) {
         if (type == 20) {
             return 18;

+ 105 - 0
Sunrise/src/server/activity/activity_sdk_actor_sequences.cpp

@@ -0,0 +1,105 @@
+#include "activity_sdk_actor_sequences.h"
+
+#include <algorithm>
+#include <limits>
+
+#include "../../middleware/bap/activity_message/scriptable_auth_body.h"
+#include "../../middleware/gameplay/external/actor_entity_registry.h"
+#include "../../state/activity_sdk/generated_world/runtime.h"
+
+namespace sunrise::server::activity::actor_sequences {
+
+/** Authored references must agree before a shared actor catalog becomes slot-owned. */
+bool owner(const state::activity_sdk::generated_world::GeneratedWorldView& world,
+           std::uint32_t slotRow,
+           Owner& output) noexcept {
+    namespace sdk = state::activity_sdk;
+    namespace format = sdk::format;
+    namespace auth = middleware::bap::activity_message::scriptable_auth;
+    output = {};
+    const auto& view = world.activity_sdk_view();
+    const auto* const scenario = sdk::bound_scenario(view);
+    if (view.catalog == nullptr || scenario == nullptr || view.activityClientGeneration == 0
+        || slotRow >= view.catalog->slots().size()) {
+        return false;
+    }
+    const auto& catalog = *view.catalog;
+    const auto& slot = catalog.slots()[slotRow];
+    if (slot.slotType != auth::kType2SlotType || slot.componentClass != auth::kType2ComponentClass
+        || slot.authSchema != auth::kType2Schema || slot.senseSchema != auth::kType2SenseSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0
+        || slot.objectIndex >= catalog.objects().size()
+        || slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    const auto& object = catalog.objects()[slot.objectIndex];
+    bool present = false;
+    for (const auto& occurrence : sdk::scenario_occurrences(catalog, *scenario)) {
+        present = present || occurrence.objectIndex == slot.objectIndex;
+    }
+    if (!present || world.snapshot() == nullptr) {
+        return false;
+    }
+    const state::gameplay::entity_identity::ActorSourceReference source{
+        object.objectKey,
+        static_cast<std::uint16_t>(slot.slotIndex),
+        static_cast<std::uint8_t>(slot.slotType),
+        true,
+        true};
+    state::gameplay::entity_identity::ActorSourceReference squadSource{};
+    if (!middleware::gameplay::external::resolve_combatant_squad_source(
+            *world.snapshot(), source, squadSource)) {
+        return false;
+    }
+    std::uint32_t actor = format::kAbsentIndex;
+    for (const auto& squad : sdk::scenario_squads(catalog, *scenario)) {
+        if (squad.slotIndex >= catalog.slots().size()
+            || squad.objectIndex >= catalog.objects().size()) {
+            return false;
+        }
+        const auto& squadSlot = catalog.slots()[squad.slotIndex];
+        if (catalog.objects()[squad.objectIndex].objectKey != squadSource.key
+            || squadSlot.slotIndex != squadSource.index || squadSlot.slotType != squadSource.type) {
+            continue;
+        }
+        for (const auto& member : sdk::squad_members(catalog, squad)) {
+            if ((member.flags & format::kSquadMemberActorClassExact) == 0
+                || member.actorClassIndex >= catalog.actor_classes().size()
+                || (actor != format::kAbsentIndex && actor != member.actorClassIndex)) {
+                return false;
+            }
+            actor = member.actorClassIndex;
+        }
+    }
+    if (actor == format::kAbsentIndex
+        || catalog.sdk_build_sha256().size() != output.sdkBuildSha256.size()
+        || catalog.payload_sha256().size() != output.sdkPayloadSha256.size()) {
+        return false;
+    }
+    std::copy(catalog.sdk_build_sha256().begin(),
+              catalog.sdk_build_sha256().end(),
+              output.sdkBuildSha256.begin());
+    std::copy(catalog.payload_sha256().begin(),
+              catalog.payload_sha256().end(),
+              output.sdkPayloadSha256.begin());
+    output.activityClientGeneration = view.activityClientGeneration;
+    output.sessionId = view.binding.sessionId;
+    output.sessionCreatedRevision = view.binding.createdRevision;
+    output.activityRow = view.activityRow;
+    output.scenarioRow = view.scenarioRow;
+    output.slotRow = slotRow;
+    output.actorClassRow = actor;
+    return true;
+}
+
+bool owner(const state::activity_sdk::BoundView& view,
+           std::uint32_t slotRow,
+           Owner& output) noexcept {
+    state::activity_sdk::generated_world::GeneratedWorldView world{};
+    output = {};
+    return state::activity_sdk::generated_world::resolve(view, world)
+               == state::activity_sdk::generated_world::BindStatus::ready
+           && owner(world, slotRow, output);
+}
+
+} // namespace sunrise::server::activity::actor_sequences

+ 18 - 0
Sunrise/src/server/activity/activity_sdk_actor_sequences.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#include "../../state/activity/mission/definition.h"
+#include "../../state/activity_sdk/generated_world/runtime.h"
+#include "../../state/activity_sdk/runtime.h"
+
+namespace sunrise::server::activity::actor_sequences {
+
+using Owner = state::activity::mission::ActorSequenceOwner;
+
+/** Resolves a combatant's exact authored squad and its single consistent actor class. */
+[[nodiscard]] bool
+owner(const state::activity_sdk::BoundView& view, std::uint32_t slotRow, Owner& output) noexcept;
+[[nodiscard]] bool owner(const state::activity_sdk::generated_world::GeneratedWorldView& world,
+                         std::uint32_t slotRow,
+                         Owner& output) noexcept;
+
+} // namespace sunrise::server::activity::actor_sequences

+ 90 - 1
Sunrise/src/server/activity/activity_sdk_device_runtime.cpp

@@ -16,8 +16,10 @@
 #include "../../middleware/bap/activity_message/squad_objective_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
+#include "../../state/activity_sdk/actor_sequences.h"
 #include "../../state/build_data/runtime.h"
 #include "../bap/runtime.h"
+#include "activity_sdk_actor_sequences.h"
 #include "activity_sdk_device_internal.h"
 #include "host_runtime.h"
 
@@ -289,7 +291,94 @@ Status set_combatant_channel(const sdk::BoundView& view,
                : Status::refused;
 }
 
-/** Binds one type-2 combatant to its authored squad member for an operator action. */
+/** Plays a sequence only through the current client's exact generated combatant route. */
+Status play_combatant_sequence(const sdk::BoundView& view,
+                               std::uint32_t slotRow,
+                               std::uint32_t sequenceRow) noexcept {
+    actor_sequences::Owner owner{};
+    if (!actor_sequences::owner(view, slotRow, owner)) {
+        return Status::invalidValue;
+    }
+    const auto* const sequence =
+        sdk::resolve_actor_sequence(*view.catalog, owner.actorClassRow, sequenceRow);
+    if (sequence == nullptr || !sdk::actor_sequence_playable(*sequence)) {
+        return Status::invalidValue;
+    }
+    PreparedDevice prepared{};
+    const Status status = prepare_combatant(view, slotRow, prepared);
+    if (status != Status::ready) {
+        return status;
+    }
+    if (!prepared.target.stateLocalRoster) {
+        return Status::targetUnavailable;
+    }
+    return host::request_state_local_type2_sequence(view.binding,
+                                                    prepared.target,
+                                                    prepared.generatedRosterGroup,
+                                                    sequence->keyHash,
+                                                    prepared.activityClientGeneration)
+               ? Status::queued
+               : Status::refused;
+}
+
+/** Resolves the captured owner again before reserving a native atom program. */
+Status
+play_combatant_sequence_reserved(const sdk::BoundView& view,
+                                 const state::activity::mission::ActorSequenceOwner& expected,
+                                 std::uint32_t sequenceRow,
+                                 const host::ScriptableOutputReservation& reservation) noexcept {
+    actor_sequences::Owner owner{};
+    if (!actor_sequences::owner(view, expected.slotRow, owner) || owner != expected) {
+        return Status::staleActivityClient;
+    }
+    const auto* const sequence =
+        sdk::resolve_actor_sequence(*view.catalog, owner.actorClassRow, sequenceRow);
+    if (sequence == nullptr || !sdk::actor_sequence_playable(*sequence)) {
+        return Status::invalidValue;
+    }
+    PreparedDevice prepared{};
+    const Status status = prepare_combatant(view, expected.slotRow, prepared);
+    if (status != Status::ready) {
+        return status;
+    }
+    if (!prepared.target.stateLocalRoster) {
+        return Status::targetUnavailable;
+    }
+    return host::request_state_local_type2_sequence(view.binding,
+                                                    prepared.target,
+                                                    prepared.generatedRosterGroup,
+                                                    sequence->keyHash,
+                                                    prepared.activityClientGeneration,
+                                                    &reservation)
+               ? Status::queued
+               : Status::refused;
+}
+
+/** An empty new-generation atom program cancels the current sequence. */
+Status stop_combatant_sequence(const sdk::BoundView& view, std::uint32_t slotRow) noexcept {
+    PreparedDevice prepared{};
+    const Status status = prepare_combatant(view, slotRow, prepared);
+    if (status != Status::ready) {
+        return status;
+    }
+    if (!prepared.target.stateLocalRoster) {
+        return Status::targetUnavailable;
+    }
+    return host::request_state_local_type2_sequence(view.binding,
+                                                    prepared.target,
+                                                    prepared.generatedRosterGroup,
+                                                    0,
+                                                    prepared.activityClientGeneration)
+               ? Status::queued
+               : Status::refused;
+}
+
+/**
+ * Arms one combatant for its scene's squad member spawn.
+ * @param view Current SDK and activity binding.
+ * @param slotRow Exact combatant slot in that SDK.
+ * @return Queued only when the binding request reaches the host.
+ */
 Status bind_combatant_to_squad(const sdk::BoundView& view, std::uint32_t slotRow) noexcept {
     PreparedDevice prepared{};
     const Status status = prepare_combatant(view, slotRow, prepared);

+ 18 - 2
Sunrise/src/server/activity/activity_sdk_device_runtime.h

@@ -64,7 +64,23 @@ enum class Status : std::uint8_t {
                                            std::uint32_t channelHash,
                                            float value) noexcept;
 
-/** Binds one type-2 combatant to its authored squad member for an operator action. */
+/** Plays an extracted actor-owned sequence while preserving the combatant's Auth state. */
+[[nodiscard]] Status play_combatant_sequence(const state::activity_sdk::BoundView& view,
+                                             std::uint32_t slotRow,
+                                             std::uint32_t sequenceRow) noexcept;
+
+/** Queues an extracted sequence only on its captured owner and reserved Host revision. */
+[[nodiscard]] Status
+play_combatant_sequence_reserved(const state::activity_sdk::BoundView& view,
+                                 const state::activity::mission::ActorSequenceOwner& expected,
+                                 std::uint32_t sequenceRow,
+                                 const host::ScriptableOutputReservation& reservation) noexcept;
+
+/** Cancels the combatant's atom program without changing actor binding or control fields. */
+[[nodiscard]] Status stop_combatant_sequence(const state::activity_sdk::BoundView& view,
+                                             std::uint32_t slotRow) noexcept;
+
+/** Arms one combatant for its scene's squad member spawn. */
 [[nodiscard]] Status bind_combatant_to_squad(const state::activity_sdk::BoundView& view,
                                              std::uint32_t slotRow) noexcept;
 
@@ -111,7 +127,7 @@ set_combatant_channel_reserved(const state::activity_sdk::BoundView& view,
                                float value,
                                const host::ScriptableOutputReservation& reservation) noexcept;
 
-/** Binds one generated type-2 combatant to its authored squad member. */
+/** Arms one generated combatant for its scene's squad member spawn. */
 [[nodiscard]] Status
 bind_combatant_to_squad_reserved(const state::activity_sdk::BoundView& view,
                                  std::uint32_t slotRow,

+ 2 - 0
Sunrise/src/server/activity/activity_sdk_mission_internal.h

@@ -14,6 +14,8 @@ namespace sunrise::server::activity::activity_sdk_mission::detail {
 struct PreparedScene final {
     host::ScriptableTarget target{};
     state::build_data::scenarios::RosterGroup rosterGroup{};
+    middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies
+        sceneDependencies{};
     std::uint64_t activityClientGeneration{};
     std::uint32_t scenarioRow{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t stateRow{state::activity_sdk::format::kAbsentIndex};

+ 7 - 0
Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp

@@ -7,6 +7,7 @@
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
+#include "activity_sdk_scene_dependencies.h"
 #include "activity_sdk_scriptable_route.h"
 
 namespace sunrise::server::activity::activity_sdk_mission::detail {
@@ -352,6 +353,12 @@ namespace {
         return SceneStatus::missingResource;
     }
 
+    const SceneStatus dependencies =
+        scene_dependencies(catalog, slot, resource, output.sceneDependencies);
+    if (dependencies != SceneStatus::ready) {
+        return dependencies;
+    }
+
     const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
     if (lease != SceneStatus::ready) {
         return lease;

+ 19 - 9
Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp

@@ -129,7 +129,9 @@ SceneStatus activate_authored_scene(const sdk::BoundView& view,
             prepared.target,
             prepared.rosterGroup,
             prepared.effectiveRegion,
-            prepared.activityClientGeneration)) {
+            prepared.activityClientGeneration,
+            nullptr,
+            prepared.sceneDependencies)) {
         return SceneStatus::queued;
     }
     return SceneStatus::refused;
@@ -512,7 +514,9 @@ set_cinematic_active_reserved(const sdk::BoundView& view,
     }
     server::bap::ActivityLinkView link{};
     const SceneStatus live = scene_binding_status(view, link);
-    if (live != SceneStatus::ready) return live;
+    if (live != SceneStatus::ready) {
+        return live;
+    }
     const auto selected = behavior_scope::select(occurrences,
                                                  view.catalog->states(),
                                                  view.catalog->bubbles(),
@@ -520,7 +524,9 @@ set_cinematic_active_reserved(const sdk::BoundView& view,
                                                  slots[slotRow].objectIndex,
                                                  snapshot.plan.stateRow,
                                                  link.effectiveRegion);
-    if (selected.ambiguous) return SceneStatus::ambiguousTarget;
+    if (selected.ambiguous) {
+        return SceneStatus::ambiguousTarget;
+    }
     occurrenceRow = selected.row;
     return occurrenceRow == sdk::format::kAbsentIndex ? SceneStatus::targetUnavailable
                                                       : SceneStatus::ready;
@@ -709,11 +715,12 @@ play_dialogue_cue_slot_reserved(const sdk::BoundView& view,
 }
 
 /** Queues one preflighted authored scene only through an exact unarmed Host revision. */
-SceneStatus
-activate_authored_scene_reserved(const sdk::BoundView& view,
-                                 std::uint32_t occurrenceRow,
-                                 std::uint32_t slotRow,
-                                 const host::ScriptableOutputReservation& reservation) noexcept {
+SceneStatus activate_authored_scene_reserved(const sdk::BoundView& view,
+                                             std::uint32_t occurrenceRow,
+                                             std::uint32_t slotRow,
+                                             const host::ScriptableOutputReservation& reservation,
+                                             std::uint32_t eventKey,
+                                             bool stop) noexcept {
     PreparedScene prepared{};
     const SceneStatus status = prepare_scene(view, occurrenceRow, slotRow, prepared);
     if (status != SceneStatus::ready) {
@@ -725,7 +732,10 @@ activate_authored_scene_reserved(const sdk::BoundView& view,
             prepared.rosterGroup,
             prepared.effectiveRegion,
             prepared.activityClientGeneration,
-            &reservation)) {
+            &reservation,
+            prepared.sceneDependencies,
+            eventKey,
+            stop)) {
         return SceneStatus::queued;
     }
     return SceneStatus::refused;

+ 3 - 1
Sunrise/src/server/activity/activity_sdk_mission_runtime.h

@@ -228,7 +228,9 @@ play_dialogue_cue_slot_reserved(const state::activity_sdk::BoundView& view,
 activate_authored_scene_reserved(const state::activity_sdk::BoundView& view,
                                  std::uint32_t occurrenceRow,
                                  std::uint32_t slotRow,
-                                 const host::ScriptableOutputReservation& reservation) noexcept;
+                                 const host::ScriptableOutputReservation& reservation,
+                                 std::uint32_t eventKey = 0,
+                                 bool stop = false) noexcept;
 
 /** @return Stable concise text for one mission runtime result. */
 [[nodiscard]] const char* status_name(Status status) noexcept;

+ 69 - 0
Sunrise/src/server/activity/activity_sdk_scene_dependencies.h

@@ -0,0 +1,69 @@
+#pragma once
+
+#include <limits>
+
+#include "../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "activity_sdk_mission_runtime.h"
+
+namespace sunrise::server::activity::activity_sdk_mission {
+
+/**
+ * Collects only the selected scene descriptor's exact squad edges.
+ * @param catalog Authenticated SDK catalog.
+ * @param sceneSlot Owned scene slot.
+ * @param resource Selected resource descriptor for that slot.
+ * @param output Receives the complete dependency set; cleared on failure.
+ * @return Ready for an exact bounded set, including an empty set.
+ */
+[[nodiscard]] inline SceneStatus
+scene_dependencies(const state::activity_sdk::Catalog& catalog,
+                   const state::activity_sdk::format::Slot& sceneSlot,
+                   const state::activity_sdk::format::AuthoredSceneResource& resource,
+                   middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies&
+                       output) noexcept {
+    namespace sdk = state::activity_sdk;
+    namespace message = middleware::bap::activity_message::sensor_auth_update;
+    output = {};
+    message::AuthoredSceneDependencies candidate{};
+    const auto slots = catalog.slots();
+    if (resource.slotIndex >= slots.size() || &slots[resource.slotIndex] != &sceneSlot
+        || sceneSlot.slotType != sdk::format::kAuthoredSceneSlotType
+        || sceneSlot.componentClass != sdk::format::kAuthoredSceneComponentClass
+        || sceneSlot.authSchema != sdk::format::kAuthoredSceneAuthSchema
+        || sceneSlot.senseSchema != sdk::format::kAuthoredSceneSenseSchema
+        || resource.flags != sdk::format::kAuthoredSceneResourceExact) {
+        return SceneStatus::invalidSlot;
+    }
+    const auto edges = sdk::slot_authored_scene_squad_edges(catalog, sceneSlot);
+    if (edges.size() > candidate.references.size()) {
+        return SceneStatus::refused;
+    }
+    for (const auto& edge : edges) {
+        const auto* target = sdk::authored_scene_linked_squad_slot(catalog, edge);
+        if (edge.flags != sdk::format::kAuthoredSceneSquadSameObjectExact
+            || edge.configTag != resource.configTag
+            || edge.descriptorOffset != resource.descriptorOffset || target == nullptr
+            || target->objectIndex != sceneSlot.objectIndex
+            || target->objectIndex >= catalog.objects().size()
+            || edge.targetObjectKey != catalog.objects()[target->objectIndex].objectKey
+            || target->slotType != sdk::format::kSquadSlotType
+            || target->componentClass != sdk::format::kSquadComponentClass
+            || target->authSchema != sdk::format::kSquadAuthSchema
+            || target->senseSchema != sdk::format::kSquadSenseSchema
+            || (target->flags & sdk::format::kSlotSchemaJoinExact) == 0
+            || target->slotIndex
+                   > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
+            return SceneStatus::ambiguousTarget;
+        }
+        candidate.references[candidate.count++] = {edge.targetObjectKey,
+                                                   static_cast<std::int8_t>(target->slotType),
+                                                   static_cast<std::int16_t>(target->slotIndex)};
+    }
+    if (!message::valid_authored_scene_dependencies(candidate)) {
+        return SceneStatus::ambiguousTarget;
+    }
+    output = candidate;
+    return SceneStatus::ready;
+}
+
+} // namespace sunrise::server::activity::activity_sdk_mission

+ 34 - 2
Sunrise/src/server/activity/host_runtime.h

@@ -21,6 +21,9 @@
 
 namespace sunrise::server::activity::host {
 
+using AuthoredSceneDependencies =
+    middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies;
+
 /** Activity instances tracked by the diagnostic host. */
 inline constexpr std::size_t kInstanceCapacity = state::activity::kSessionCapacity;
 /** Recent normalized events retained for the panel and HUD. */
@@ -136,11 +139,15 @@ enum class EventKind : std::uint8_t {
     objectState = 36,
     /** A damage monitor published new health, shield or revision values. */
     damageState = 37,
+    /** A gameplay policy accepted the first damage to an exact selected squad. */
+    squadProvoked = 38,
+    /** A device reported changed current channel values or accepted sequences. */
+    deviceState = 39,
 };
 
 /** 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::damageState) + 1U;
+    static_cast<std::size_t>(EventKind::deviceState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -246,6 +253,9 @@ enum class ScriptableOverrideKind : std::uint8_t {
     lifetime,
     /** Type-42 performance start: one state name behind a rising per-slot generation. */
     performance,
+    authoredSceneEvent,
+    authoredSceneStop,
+    combatantSequence,
 };
 
 /** Current diagnostic state of one retained incident. */
@@ -481,6 +491,16 @@ struct Event final {
     float damageHealth{-1.0F};
     float damageShield{-1.0F};
     std::int32_t damageRevision{};
+    /** Current position, power and lock levels; masks distinguish unknown fields. */
+    std::array<float, middleware::bap::activity_message::scriptable_auth::kType23ChannelCount>
+        deviceValues{};
+    std::array<std::int32_t,
+               middleware::bap::activity_message::scriptable_auth::kType23ChannelCount>
+        deviceSequences{};
+    std::uint8_t deviceValueMask{};
+    std::uint8_t deviceSequenceMask{};
+    bool deviceFirstReport{};
+    bool deviceReset{};
     /** Object level, for objectState and objectInteracted events. */
     std::int32_t objectGeneration{};
     bool objectPresent{};
@@ -897,6 +917,15 @@ request_type23_override(const state::activity::SessionBinding& binding,
     std::uint64_t expectedActivityClientGeneration,
     const ScriptableOutputReservation* reservation = nullptr) noexcept;
 
+/** Queues a sequence on an enabled combatant without changing its actor binding. */
+[[nodiscard]] bool request_state_local_type2_sequence(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint32_t sequenceHash,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation = nullptr) noexcept;
+
 /** Queues squad-member binding for one exact generated type-2 combatant slot. */
 [[nodiscard]] bool request_state_local_type2_squad_binding(
     const state::activity::SessionBinding& binding,
@@ -978,7 +1007,10 @@ request_type31_override(const state::activity::SessionBinding& binding,
     const ScriptableTarget& target,
     const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
     std::uint64_t expectedActivityClientGeneration,
-    const ScriptableOutputReservation* reservation = nullptr) noexcept;
+    const ScriptableOutputReservation* reservation = nullptr,
+    const AuthoredSceneDependencies& dependencies = {},
+    std::uint32_t eventKey = 0,
+    bool stop = false) noexcept;
 
 /** Queues one authored dialogue line from the exact generated group in the mission seed. */
 [[nodiscard]] bool request_state_local_dialogue_override(

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

@@ -25,6 +25,10 @@ struct IncidentRequest final {
 
 /** One queued typed ClientRef request; its counter is assigned by the reducer. */
 struct ScriptableRequest final {
+    middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies
+        sceneDependencies{};
+    std::uint32_t sceneEventKey{};
+    std::uint32_t sequenceHash{};
     state::gameplay::squad_entity_retirement::Eligibility squadRetirement{};
     state::activity::SessionBinding binding{};
     ScriptableTarget target{};
@@ -115,6 +119,7 @@ struct ScriptableGuard final {
     middleware::bap::activity_message::scriptable_auth::Type53SequenceGuard type53{};
     middleware::bap::activity_message::scriptable_auth::Type42GenerationGuard type42{};
     std::uint32_t authoredSceneGeneration{};
+    std::uint32_t type2AtomGeneration{};
     bool occupied{};
 };
 

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

@@ -48,6 +48,8 @@ const char* event_name(EventKind kind) noexcept {
         return "trigger volume exited";
     case EventKind::squadState:
         return "squad state changed";
+    case EventKind::squadProvoked:
+        return "squad provoked";
     case EventKind::entitySpawned:
         return "squad slot count rose";
     case EventKind::entityDied:
@@ -72,6 +74,8 @@ const char* event_name(EventKind kind) noexcept {
         return "fireteam state";
     case EventKind::damageState:
         return "damage state";
+    case EventKind::deviceState:
+        return "device state";
     case EventKind::objectState:
         return "object state";
     case EventKind::objectInteracted:

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

@@ -21,25 +21,6 @@ using namespace detail;
 /** Authored-scene activation generations are positive signed 32-bit values. */
 constexpr std::uint32_t kMaximumAuthoredSceneGeneration = 0x7FFFFFFFU;
 
-/** Encodes the fixed type-43 body with no dependencies, scalar input, or events. */
-[[nodiscard]] bool encode_authored_scene(std::uint32_t generation,
-                                         std::span<std::byte> output,
-                                         std::size_t& written) noexcept {
-    written = 0;
-    if (generation == 0 || generation > kMaximumAuthoredSceneGeneration
-        || output.size() < scene::kAuthoredSceneAuthByteCount) {
-        return false;
-    }
-    const std::uint32_t wireGeneration = generation + 0x80000000U;
-    output[0] = static_cast<std::byte>(wireGeneration >> 24U);
-    output[1] = static_cast<std::byte>(wireGeneration >> 16U);
-    output[2] = static_cast<std::byte>(wireGeneration >> 8U);
-    output[3] = static_cast<std::byte>(wireGeneration);
-    std::fill(output.begin() + 4, output.begin() + scene::kAuthoredSceneAuthByteCount, std::byte{});
-    written = scene::kAuthoredSceneAuthByteCount;
-    return true;
-}
-
 /** @return True when both values name the same full ClientRef slot. */
 [[nodiscard]] bool same_target(const ScriptableTarget& left,
                                const ScriptableTarget& right) noexcept {
@@ -245,6 +226,26 @@ valid_state_local_group(const ScriptableTarget& target,
     return true;
 }
 
+/** A sequence needs the current client's delivered, enabled squad binding. */
+[[nodiscard]] const PendingScriptableOverride*
+retained_sequence_combatant(const Instance& instance, const ScriptableRequest& request) noexcept {
+    for (const auto& retained : instance.scriptableAuthEstate) {
+        if (!same_client_ref(retained.target, request.target)
+            || retained.expectedActivityClientGeneration != request.expectedActivityClientGeneration
+            || retained.byteCount > retained.body.size()) {
+            continue;
+        }
+        auth::Type2ProgramLayout layout{};
+        return auth::inspect_type2_program(
+                   std::span(retained.body).first(retained.byteCount), retained.bitCount, layout)
+                       && layout.enabled && (layout.bindingWire == 2 || layout.bindingWire == 4)
+                       && layout.generation < squad::kMaximumGeneration
+                   ? &retained
+                   : nullptr;
+    }
+    return nullptr;
+}
+
 /** Queues one validated scriptable request in the shared ordered control lane. */
 [[nodiscard]] bool enqueue_request(ScriptableRequest request,
                                    const ScriptableOutputReservation* reservation) noexcept {
@@ -267,7 +268,9 @@ valid_state_local_group(const ScriptableTarget& target,
         && instance->view.scriptableRevision + 1 == reservation->revision;
     const bool burst = request.burstMember && request.expectedRevision != 0
                        && tail_eligible(request.kind) && instance != nullptr;
-    if ((!burst && has_queued_control(request.binding))
+    if ((request.kind == ScriptableOverrideKind::combatantSequence
+         && (instance == nullptr || retained_sequence_combatant(*instance, request) == nullptr))
+        || (!burst && has_queued_control(request.binding))
         || (!burst && instance != nullptr && instance->view.outputPending)
         || (!burst && reservation != nullptr && !ownsReservation)
         || (!burst && reservation == nullptr && instance != nullptr
@@ -425,6 +428,22 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
             encoded = false;
         }
         pending.generation = revision;
+    } else if (encoded && request.kind == ScriptableOverrideKind::combatantSequence) {
+        const auto* const retained = retained_sequence_combatant(*instance, request);
+        std::size_t bits = 0;
+        std::uint32_t generation = 0;
+        encoded =
+            retained != nullptr
+            && auth::replace_type2_sequence(std::span(retained->body).first(retained->byteCount),
+                                            retained->bitCount,
+                                            request.sequenceHash,
+                                            candidate.type2AtomGeneration,
+                                            pending.body,
+                                            written,
+                                            bits,
+                                            generation);
+        pending.bitCount = static_cast<std::uint16_t>(bits);
+        pending.generation = generation;
     } else if (encoded && request.kind == ScriptableOverrideKind::object) {
         pending.bitCount = static_cast<std::uint16_t>(auth::kType4BitCount);
         std::int32_t generation = 0;
@@ -504,11 +523,37 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
                   && auth::encode_type38({generation}, candidate.type38, pending.body, written);
         pending.generation = static_cast<std::uint64_t>(generation);
     } else if (encoded && request.kind == ScriptableOverrideKind::authoredScene) {
-        pending.bitCount = scene::kAuthoredSceneAuthBitCount;
+        std::size_t bits = 0;
         std::uint32_t generation = 0;
         encoded = next_authored_scene_generation(candidate.authoredSceneGeneration, generation)
-                  && encode_authored_scene(generation, pending.body, written);
+                  && scene::encode_authored_scene_auth(
+                      generation, request.sceneDependencies, pending.body, written, bits);
+        pending.bitCount = static_cast<std::uint16_t>(bits);
         pending.generation = generation;
+    } else if (encoded
+               && (request.kind == ScriptableOverrideKind::authoredSceneEvent
+                   || request.kind == ScriptableOverrideKind::authoredSceneStop)) {
+        encoded = false;
+        for (const auto& retained : instance->scriptableAuthEstate) {
+            if (!same_client_ref(retained.target, request.target)) {
+                continue;
+            }
+            std::size_t bits = 0;
+            std::uint32_t generation = 0;
+            encoded = scene::update_authored_scene_auth(
+                          std::span(retained.body).first(retained.byteCount),
+                          retained.bitCount,
+                          request.sceneEventKey,
+                          request.kind == ScriptableOverrideKind::authoredSceneStop,
+                          pending.body,
+                          written,
+                          bits,
+                          generation)
+                      && generation == candidate.authoredSceneGeneration;
+            pending.bitCount = static_cast<std::uint16_t>(bits);
+            pending.generation = generation;
+            break;
+        }
     } else if (encoded && request.kind == ScriptableOverrideKind::dialogue) {
         pending.bitCount = static_cast<std::uint16_t>(auth::kType53BitCount);
         pending.dialogueCue = request.dialogueCue;

+ 36 - 2
Sunrise/src/server/activity/host_runtime_scriptable_requests.cpp

@@ -96,6 +96,31 @@ bool request_state_local_type2_channel_override(
     return enqueue_request(request, reservation);
 }
 
+/** Queues only a sequence hash; the reducer owns the atom generation and retained body. */
+bool request_state_local_type2_sequence(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint32_t sequenceHash,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType2SlotType || target.authSchema != auth::kType2Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || sequenceHash == middleware::bap::activity_message::kEmptyNameHash
+        || sequenceHash == (std::numeric_limits<std::uint32_t>::max)()
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.sequenceHash = sequenceHash;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::combatantSequence;
+    return enqueue_request(request, reservation);
+}
+
 /** Queues squad-member binding for one exact generated type-2 combatant. */
 bool request_state_local_type2_squad_binding(
     const state::activity::SessionBinding& binding,
@@ -295,10 +320,15 @@ bool request_state_local_authored_scene_override(
     const ScriptableTarget& target,
     const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
     std::uint64_t expectedActivityClientGeneration,
-    const ScriptableOutputReservation* reservation) noexcept {
+    const ScriptableOutputReservation* reservation,
+    const AuthoredSceneDependencies& dependencies,
+    std::uint32_t eventKey,
+    bool stop) noexcept {
     if (target.slotType != scene::kAuthoredSceneSlotType
         || target.authSchema != scene::kAuthoredSceneAuthSchema
         || !valid_state_local_group(target, stateLocalRosterGroup)
+        || !scene::valid_authored_scene_dependencies(dependencies)
+        || eventKey == (std::numeric_limits<std::uint32_t>::max)() || (stop && eventKey != 0)
         || expectedActivityClientGeneration == 0) {
         return false;
     }
@@ -307,7 +337,11 @@ bool request_state_local_authored_scene_override(
     request.target = target;
     request.stateLocalRosterGroup = stateLocalRosterGroup;
     request.expectedActivityClientGeneration = expectedActivityClientGeneration;
-    request.kind = ScriptableOverrideKind::authoredScene;
+    request.kind = stop            ? ScriptableOverrideKind::authoredSceneStop
+                   : eventKey == 0 ? ScriptableOverrideKind::authoredScene
+                                   : ScriptableOverrideKind::authoredSceneEvent;
+    request.sceneEventKey = eventKey;
+    request.sceneDependencies = dependencies;
     return enqueue_request(request, reservation);
 }
 

+ 11 - 0
Sunrise/src/server/activity/host_runtime_scriptable_staging.cpp

@@ -134,6 +134,10 @@ void note_scriptable_attempt(const state::activity::SessionBinding& binding,
             auth::next_type2_revision(candidate, revision) && revision == pending.generation;
         candidate.revision = revision;
         candidate.actorBinding = auth::Type2ActorBinding::squadMember;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::combatantSequence) {
+        // A retained generic Auth program may be ahead of this typed guard.
+        nextCounter = pending.generation > guard->type2AtomGeneration
+                      && pending.generation <= squad::kMaximumGeneration;
     } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::object) {
         std::int32_t next = 0;
         nextCounter = auth::next_type4_generation(guard->type4, next)
@@ -164,6 +168,11 @@ void note_scriptable_attempt(const state::activity::SessionBinding& binding,
         std::int32_t next = 0;
         nextCounter = auth::next_type38_generation(guard->type38, next)
                       && static_cast<std::uint64_t>(next) == pending.generation;
+    } else if (guard != nullptr
+               && (pending.kind == ScriptableOverrideKind::authoredSceneEvent
+                   || pending.kind == ScriptableOverrideKind::authoredSceneStop)) {
+        nextCounter =
+            pending.generation != 0 && pending.generation == guard->authoredSceneGeneration;
     } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::authoredScene) {
         std::uint32_t next = 0;
         nextCounter = next_authored_scene_generation(guard->authoredSceneGeneration, next)
@@ -192,6 +201,8 @@ void advance_staged_guard(ScriptableGuard* guard,
     } else if (pending.kind == ScriptableOverrideKind::combatantBinding) {
         guard->type2.revision = static_cast<std::uint32_t>(pending.generation);
         guard->type2.actorBinding = auth::Type2ActorBinding::squadMember;
+    } else if (pending.kind == ScriptableOverrideKind::combatantSequence) {
+        guard->type2AtomGeneration = static_cast<std::uint32_t>(pending.generation);
     } else if (pending.kind == ScriptableOverrideKind::object) {
         guard->type4.last = static_cast<std::int32_t>(pending.generation);
         guard->type4.hasLast = true;

+ 66 - 0
Sunrise/src/server/activity/mission/mission_script_actor_sequence_bridge.cpp

@@ -0,0 +1,66 @@
+#include "mission_script_actor_sequence_bridge.h"
+
+#include "../../../state/activity_sdk/actor_sequences.h"
+#include "../activity_sdk_actor_sequences.h"
+
+namespace sunrise::server::activity::mission::sdk_bridge {
+namespace {
+namespace sdk = state::activity_sdk;
+using World = sdk::generated_world::GeneratedWorldView;
+
+[[nodiscard]] bool
+owner(const void* context, std::uint32_t slotRow, lua_vm::ActorSequenceOwner& output) noexcept {
+    return context != nullptr
+           && actor_sequences::owner(*static_cast<const World*>(context), slotRow, output);
+}
+
+[[nodiscard]] bool current(const void* context,
+                           const lua_vm::ActorSequenceOwner& expected) noexcept {
+    lua_vm::ActorSequenceOwner actual{};
+    return owner(context, expected.slotRow, actual) && actual == expected;
+}
+
+[[nodiscard]] std::size_t count(const void* context,
+                                const lua_vm::ActorSequenceOwner& expected) noexcept {
+    return current(context, expected)
+               ? sdk::actor_sequence_count(
+                     *static_cast<const World*>(context)->activity_sdk_view().catalog,
+                     expected.actorClassRow)
+               : 0;
+}
+
+/** Metadata keeps the exact table row while playback uses a validated actor-owned handle. */
+[[nodiscard]] bool resolve(const void* context,
+                           const lua_vm::ActorSequenceOwner& expected,
+                           std::uint32_t ordinal,
+                           lua_vm::ActorSequenceDefinition& output) noexcept {
+    output = {};
+    if (!current(context, expected) || ordinal == 0) {
+        return false;
+    }
+    const auto& catalog = *static_cast<const World*>(context)->activity_sdk_view().catalog;
+    const auto* const row = sdk::actor_sequence_at(catalog, expected.actorClassRow, ordinal - 1U);
+    if (row == nullptr) {
+        return false;
+    }
+    output.id = catalog.string(row->id);
+    output.name = catalog.string(row->name);
+    output.symbol = catalog.string(row->symbol);
+    output.sourcePath = catalog.string(row->sourcePath);
+    output.catalogRow = static_cast<std::uint32_t>(row - catalog.actor_sequence_entries().data());
+    output.keyHash = row->keyHash;
+    output.kind = row->kind;
+    output.resourceTag = row->resourceTag;
+    output.tableIndex = row->tableIndex;
+    output.ordinal = row->ordinal;
+    output.sourceOffset = row->sourceOffset;
+    output.playable = sdk::actor_sequence_playable(*row);
+    return true;
+}
+} // namespace
+
+void attach_actor_sequences(lua_vm::DefinitionApi& output, const World& world) noexcept {
+    output.actorSequences = {&world, &owner, &count, &resolve};
+}
+
+} // namespace sunrise::server::activity::mission::sdk_bridge

+ 10 - 0
Sunrise/src/server/activity/mission/mission_script_actor_sequence_bridge.h

@@ -0,0 +1,10 @@
+#pragma once
+
+#include "../../../state/activity_sdk/generated_world/runtime.h"
+#include "mission_script_vm.h"
+
+namespace sunrise::server::activity::mission::sdk_bridge {
+void attach_actor_sequences(
+    lua_vm::DefinitionApi& output,
+    const state::activity_sdk::generated_world::GeneratedWorldView& world) noexcept;
+}

+ 113 - 0
Sunrise/src/server/activity/mission/mission_script_combatant_damage_sense.h

@@ -0,0 +1,113 @@
+#pragma once
+
+#include <cmath>
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-2 Sense carries two normalized runtime damage pools. */
+inline constexpr std::uint32_t kCombatantDamageSenseSchema = 0x80807DA2U;
+inline constexpr std::uint8_t kCombatantDamageSlotType = 2;
+/** Root ordinals of the accepted spawn revision, pools and lifecycle flags. */
+inline constexpr std::uint16_t kCombatantDamageRevisionOrdinal = 0;
+inline constexpr std::uint16_t kCombatantPrimaryDamageOrdinal = 8;
+inline constexpr std::uint16_t kCombatantSecondaryDamageOrdinal = 9;
+inline constexpr std::uint16_t kCombatantSuppressedOrdinal = 10;
+inline constexpr std::uint16_t kCombatantFirstAuthOrdinal = 11;
+
+/** Observed fractions do not prove that the combatant has an actor. */
+struct CombatantDamageLevel final {
+    std::int32_t revision{};
+    float primary{-1.0F};
+    float secondary{-1.0F};
+    bool suppressed{};
+    bool firstAuthApplied{};
+    bool observed{};
+    bool operator==(const CombatantDamageLevel&) const = default;
+};
+
+/**
+ * Merges optional pool fields while clearing fractions across combatant lifetimes.
+ * @param retained Last accepted level for this exact combatant and client generation.
+ * @param values Accepted Type-2 Sense fields.
+ * @param root Native root schema identity.
+ * @return True when the observed level changed; invalid input leaves it unchanged.
+ */
+[[nodiscard]] inline bool update_combatant_damage(
+    CombatantDamageLevel& retained,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    CombatantDamageLevel next = retained;
+    float primary = -1.0F;
+    float secondary = -1.0F;
+    bool relevant = false;
+    for (const auto& value : values) {
+        if (!value.present || value.schemaRow != root || value.occurrence != 0) {
+            continue;
+        }
+        switch (value.fieldOrdinal) {
+        case kCombatantDamageRevisionOrdinal:
+            if (value.kind != sense::ValueKind::signedInteger
+                || value.signedValue < retained.revision
+                || value.signedValue > (std::numeric_limits<std::int32_t>::max)()) {
+                return false;
+            }
+            next.revision = static_cast<std::int32_t>(value.signedValue);
+            relevant = true;
+            break;
+        case kCombatantPrimaryDamageOrdinal:
+        case kCombatantSecondaryDamageOrdinal:
+            if (value.kind != sense::ValueKind::real32 || !std::isfinite(value.realValue)
+                || value.realValue < 0.0F || value.realValue > 1.0F) {
+                return false;
+            }
+            if (value.fieldOrdinal == kCombatantPrimaryDamageOrdinal) {
+                primary = value.realValue;
+            } else {
+                secondary = value.realValue;
+            }
+            relevant = true;
+            break;
+        case kCombatantSuppressedOrdinal:
+        case kCombatantFirstAuthOrdinal:
+            if (value.kind != sense::ValueKind::boolean || value.unsignedValue > 1) {
+                return false;
+            }
+            if (value.fieldOrdinal == kCombatantSuppressedOrdinal) {
+                next.suppressed = value.unsignedValue != 0;
+            } else {
+                next.firstAuthApplied = value.unsignedValue != 0;
+            }
+            relevant = true;
+            break;
+        default:
+            break;
+        }
+    }
+    if (!relevant) {
+        return false;
+    }
+    if (next.revision != retained.revision || next.suppressed || !next.firstAuthApplied) {
+        next.primary = -1.0F;
+        next.secondary = -1.0F;
+    }
+    if (!next.suppressed && next.firstAuthApplied) {
+        if (primary >= 0.0F) {
+            next.primary = primary;
+        }
+        if (secondary >= 0.0F) {
+            next.secondary = secondary;
+        }
+    }
+    next.observed = true;
+    const bool changed = next != retained;
+    retained = next;
+    return changed;
+}
+
+} // namespace sunrise::server::activity::mission

+ 102 - 0
Sunrise/src/server/activity/mission/mission_script_device_sense.h

@@ -0,0 +1,102 @@
+#pragma once
+
+#include <array>
+#include <cmath>
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/scriptable_auth_body.h"
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-23 Sense reports current position, power and lock, each followed by its sequence. */
+inline constexpr std::uint32_t kDeviceSenseSchema = 0x80804F47U;
+inline constexpr std::uint8_t kDeviceSenseSlotType = 23;
+inline constexpr std::size_t kDeviceSenseChannelCount =
+    middleware::bap::activity_message::scriptable_auth::kType23ChannelCount;
+
+/** A sequence of -1 is the native device's initial value. */
+struct DeviceChannelLevel final {
+    float value{};
+    std::int32_t sequence{-1};
+    bool valueKnown{};
+    bool sequenceKnown{};
+    bool operator==(const DeviceChannelLevel&) const = default;
+};
+
+/** Only reported fields are known; an absent optional field retains its previous value. */
+struct DeviceLevel final {
+    std::array<DeviceChannelLevel, kDeviceSenseChannelCount> channels{};
+    bool observed{};
+    bool operator==(const DeviceLevel&) const = default;
+};
+
+/**
+ * Merges accepted current values without substituting a command's target value.
+ * @param retained Last reported levels for this exact device and client generation.
+ * @param values Accepted Type-23 Sense fields.
+ * @param root Native schema identity.
+ * @param reset True when a reported sequence regressed and cleared prior observations.
+ * @return True when a level changed; invalid input leaves retained state unchanged.
+ */
+[[nodiscard]] inline bool update_device_level(
+    DeviceLevel& retained,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root,
+    bool& reset) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    DeviceLevel incoming{};
+    reset = false;
+    bool sequenceRegressed = false;
+    for (const auto& value : values) {
+        if (!value.present || value.schemaRow != root || value.occurrence != 0
+            || value.fieldOrdinal >= 2 * kDeviceSenseChannelCount) {
+            continue;
+        }
+        const auto index = value.fieldOrdinal / 2;
+        auto& channel = incoming.channels[index];
+        if (value.fieldOrdinal % 2 == 0) {
+            if (value.kind != sense::ValueKind::real32 || !std::isfinite(value.realValue)
+                || value.realValue < 0.0F || value.realValue > 1.0F || channel.valueKnown) {
+                return false;
+            }
+            channel.value = value.realValue;
+            channel.valueKnown = true;
+        } else {
+            if (value.kind != sense::ValueKind::signedInteger || value.signedValue < -1
+                || value.signedValue > (std::numeric_limits<std::int32_t>::max)()
+                || channel.sequenceKnown) {
+                return false;
+            }
+            channel.sequence = static_cast<std::int32_t>(value.signedValue);
+            channel.sequenceKnown = true;
+            sequenceRegressed = sequenceRegressed
+                                || (retained.channels[index].sequenceKnown
+                                    && channel.sequence < retained.channels[index].sequence);
+        }
+        incoming.observed = true;
+    }
+    if (!incoming.observed) {
+        return false;
+    }
+    DeviceLevel next = sequenceRegressed ? DeviceLevel{} : retained;
+    for (std::size_t index = 0; index < next.channels.size(); ++index) {
+        if (incoming.channels[index].valueKnown) {
+            next.channels[index].value = incoming.channels[index].value;
+            next.channels[index].valueKnown = true;
+        }
+        if (incoming.channels[index].sequenceKnown) {
+            next.channels[index].sequence = incoming.channels[index].sequence;
+            next.channels[index].sequenceKnown = true;
+        }
+    }
+    next.observed = true;
+    const bool changed = next != retained;
+    retained = next;
+    reset = sequenceRegressed;
+    return changed;
+}
+
+} // namespace sunrise::server::activity::mission

+ 200 - 0
Sunrise/src/server/activity/mission/mission_script_lua_actor_sequence_api.cpp

@@ -0,0 +1,200 @@
+#include <array>
+#include <charconv>
+#include <limits>
+
+#include "mission_script_lua_internal.h"
+
+namespace sunrise::server::activity::mission::lua_vm::detail {
+namespace {
+
+constexpr const char* kSequencesMetatable = "SunriseActorSequences";
+constexpr const char* kSequenceMetatable = "SunriseActorSequence";
+
+struct SequenceHandle final {
+    ActorSequenceOwner owner{};
+    std::uint32_t ordinal{};
+};
+
+/** An actor value cannot cross a slot or SDK/client generation boundary. */
+[[nodiscard]] bool current_owner(lua_State* state, const ActorSequenceOwner& owner) {
+    const Impl* const impl = impl_from_state(state);
+    ActorSequenceOwner current{};
+    return impl != nullptr && impl->definitions.actorSequences.owner != nullptr
+           && impl->definitions.actorSequences.owner(
+               impl->definitions.actorSequences.context, owner.slotRow, current)
+           && current == owner;
+}
+
+/** Rechecks ownership before resolving the same authored ordinal. */
+[[nodiscard]] bool
+current_sequence(lua_State* state, const SequenceHandle& handle, ActorSequenceDefinition& output) {
+    const Impl* const impl = impl_from_state(state);
+    return current_owner(state, handle.owner) && impl->definitions.actorSequences.resolve != nullptr
+           && impl->definitions.actorSequences.resolve(
+               impl->definitions.actorSequences.context, handle.owner, handle.ordinal, output);
+}
+
+/** Exposes all extracted kinds without making unsupported entries executable. */
+[[nodiscard]] int sequence_index(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SequenceHandle*>(luaL_checkudata(state, 1, kSequenceMetatable));
+    ActorSequenceDefinition row{};
+    if (!current_sequence(state, *handle, row)) {
+        return luaL_error(state, "actor sequence owner is stale");
+    }
+    const std::string_view key = luaL_checkstring(state, 2);
+    std::string_view text{};
+    if (key == "id") {
+        text = row.id;
+    } else if (key == "name") {
+        text = row.name;
+    } else if (key == "symbol") {
+        text = row.symbol;
+    } else if (key == "source_path") {
+        text = row.sourcePath;
+    } else if (key == "key") {
+        lua_pushinteger(state, row.keyHash);
+        return 1;
+    } else if (key == "kind") {
+        lua_pushinteger(state, row.kind);
+        return 1;
+    } else if (key == "resource_tag") {
+        lua_pushinteger(state, row.resourceTag);
+        return 1;
+    } else if (key == "table_index") {
+        lua_pushinteger(state, row.tableIndex);
+        return 1;
+    } else if (key == "ordinal") {
+        lua_pushinteger(state, row.ordinal);
+        return 1;
+    } else if (key == "source_offset") {
+        lua_pushinteger(state, row.sourceOffset);
+        return 1;
+    } else if (key == "playable") {
+        lua_pushboolean(state, row.playable);
+        return 1;
+    } else {
+        lua_pushnil(state);
+        return 1;
+    }
+    if (text.empty()) {
+        lua_pushnil(state);
+    } else {
+        lua_pushlstring(state, text.data(), text.size());
+    }
+    return 1;
+}
+
+/** Collection rows are one-based; an absent ordinal returns nil. */
+[[nodiscard]] int sequences_at(lua_State* state) {
+    const auto* const owner =
+        static_cast<const ActorSequenceOwner*>(luaL_checkudata(state, 1, kSequencesMetatable));
+    if (!current_owner(state, *owner)) {
+        return luaL_error(state, "actor sequence owner is stale");
+    }
+    const lua_Integer ordinal = luaL_checkinteger(state, 2);
+    ActorSequenceDefinition row{};
+    const SequenceHandle handle{*owner, static_cast<std::uint32_t>(ordinal)};
+    if (ordinal <= 0 || ordinal > (std::numeric_limits<std::uint32_t>::max)()
+        || !current_sequence(state, handle, row)) {
+        lua_pushnil(state);
+    } else {
+        push_handle(state, kSequenceMetatable, handle);
+    }
+    return 1;
+}
+
+/** Named members are the extractor's collision-safe symbols. */
+[[nodiscard]] int sequences_index(lua_State* state) {
+    const auto* const owner =
+        static_cast<const ActorSequenceOwner*>(luaL_checkudata(state, 1, kSequencesMetatable));
+    if (!current_owner(state, *owner)) {
+        return luaL_error(state, "actor sequence owner is stale");
+    }
+    const auto& api = impl_from_state(state)->definitions.actorSequences;
+    const std::size_t count = api.count != nullptr ? api.count(api.context, *owner) : 0;
+    const std::string_view key = luaL_checkstring(state, 2);
+    if (key == "count") {
+        lua_pushinteger(state, static_cast<lua_Integer>(count));
+    } else if (key == "at") {
+        lua_pushcfunction(state, &sequences_at);
+    } else {
+        std::uint32_t keyHash = 0;
+        bool alias = false;
+        if (key.size() == 12 && key.starts_with("KEY_")) {
+            const auto parsed =
+                std::from_chars(key.data() + 4, key.data() + key.size(), keyHash, 16);
+            alias = parsed.ec == std::errc{} && parsed.ptr == key.data() + key.size();
+        }
+        for (std::size_t ordinal = 1; ordinal <= count; ++ordinal) {
+            ActorSequenceDefinition row{};
+            if (!api.resolve(api.context, *owner, static_cast<std::uint32_t>(ordinal), row)) {
+                continue;
+            }
+            if (alias ? row.keyHash == keyHash : row.symbol == key) {
+                push_handle(state,
+                            kSequenceMetatable,
+                            SequenceHandle{*owner, static_cast<std::uint32_t>(ordinal)});
+                return 1;
+            }
+        }
+        lua_pushnil(state);
+    }
+    return 1;
+}
+} // namespace
+
+/** Binds the catalog to the selected combatant before any sequence handle is minted. */
+int slot_actor_sequences(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    SlotDefinition slot{};
+    ActorSequenceOwner owner{};
+    const Impl* const impl = impl_from_state(state);
+    if (!current_slot(state, *handle, slot) || !exact_combatant_slot(slot)
+        || impl->definitions.actorSequences.owner == nullptr
+        || impl->definitions.actorSequences.count == nullptr
+        || impl->definitions.actorSequences.resolve == nullptr
+        || !impl->definitions.actorSequences.owner(
+            impl->definitions.actorSequences.context, slot.nativeRow, owner)) {
+        return luaL_error(state, "combatant has no exact authored actor owner");
+    }
+    push_handle(state, kSequencesMetatable, owner);
+    return 1;
+}
+
+/** Only an extracted value owned by this exact combatant can become a playback intent. */
+int slot_play_actor_sequence(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || !exact_combatant_slot(slot)) {
+        return luaL_error(state, "activity slot is not an exact combatant");
+    }
+    static constexpr std::array<std::string_view, 1> kArguments{"sequence"};
+    refuse_unknown_arguments(state, kArguments);
+    static_cast<void>(push_argument(state, "sequence"));
+    const SequenceHandle sequence =
+        *static_cast<const SequenceHandle*>(luaL_checkudata(state, -1, kSequenceMetatable));
+    lua_pop(state, 1);
+    ActorSequenceDefinition row{};
+    if (sequence.owner.slotRow != slot.nativeRow || !current_sequence(state, sequence, row)) {
+        return luaL_error(state, "sequence belongs to another combatant or generation");
+    }
+    if (!row.playable) {
+        return luaL_error(state, "extracted sequence kind is not playable");
+    }
+    Intent intent{};
+    intent.kind = IntentKind::playActorSequence;
+    intent.firstRow = slot.nativeRow;
+    intent.secondRow = row.catalogRow;
+    intent.sequenceOwner = sequence.owner;
+    return queue_intent(state, active_frame(state), intent);
+}
+
+void register_actor_sequence_metatables(lua_State* state) {
+    register_metatable(state, kSequencesMetatable, &sequences_index);
+    register_metatable(state, kSequenceMetatable, &sequence_index);
+}
+
+} // namespace sunrise::server::activity::mission::lua_vm::detail

+ 8 - 1
Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp

@@ -29,6 +29,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "triggerExited";
     case host::EventKind::squadState:
         return "squadState";
+    case host::EventKind::squadProvoked:
+        return "squadProvoked";
     case host::EventKind::entitySpawned:
         return "entitySpawned";
     case host::EventKind::entityDied:
@@ -57,6 +59,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "objectInteracted";
     case host::EventKind::damageState:
         return "damageState";
+    case host::EventKind::deviceState:
+        return "deviceState";
     case host::EventKind::objectState:
         return "objectState";
     case host::EventKind::ghostLinkState:
@@ -136,6 +140,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return kTriggerExitedEventMetatable;
     case host::EventKind::squadState:
         return kSquadStateEventMetatable;
+    case host::EventKind::squadProvoked:
+        return kSquadProvokedEventMetatable;
     case host::EventKind::entitySpawned:
         return kEntitySpawnedEventMetatable;
     case host::EventKind::entityDied:
@@ -164,10 +170,11 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
     case host::EventKind::actorPathState:
         return kActorPathEventMetatable;
     case host::EventKind::objectInteracted:
-        return kObjectInteractionEventMetatable;
     case host::EventKind::damageState:
     case host::EventKind::objectState:
         return kObjectInteractionEventMetatable;
+    case host::EventKind::deviceState:
+        return kDeviceStateEventMetatable;
     case host::EventKind::ghostLinkState:
         return kGhostLinkEventMetatable;
     }

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

@@ -38,6 +38,10 @@ push_incident_revision_member(lua_State* state, const host::Event& event, std::s
         return "squad.place";
     case ActionKind::activateAuthoredScene:
         return "scene.activate";
+    case ActionKind::stopAuthoredScene:
+        return "scene.stop";
+    case ActionKind::signalAuthoredScene:
+        return "scene.send_event";
     case ActionKind::setObjectActive:
         return "slot.set_object_active";
     case ActionKind::setDeviceChannel:
@@ -51,6 +55,7 @@ push_incident_revision_member(lua_State* state, const host::Event& event, std::s
     case ActionKind::fireTrigger:
         return "slot.fire_trigger";
     case ActionKind::playSequence:
+    case ActionKind::playActorSequence:
         return "slot.play_sequence";
     case ActionKind::setCinematicActive:
         return "slot.set_cinematic_active";

+ 97 - 21
Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp

@@ -1,3 +1,4 @@
+#include <array>
 #include <cstddef>
 #include <string_view>
 
@@ -270,10 +271,13 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
 [[nodiscard]] int actor_path_index(lua_State* state) {
     const auto& event = check_event(state, 1);
     const auto key = lua_string_view(state, 2);
-    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key))
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) {
         return 1;
+    }
     if (event_surface_visible(state, event.kind)) {
-        if (push_slot_identity_member(state, event, key)) return 1;
+        if (push_slot_identity_member(state, event, key)) {
+            return 1;
+        }
         if (key == "generation") {
             lua_pushinteger(state, event.actorGeneration);
             return 1;
@@ -303,39 +307,85 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return 1;
 }
 
+/**
+ * Exposes reported device values without giving unknown fields a numeric default.
+ * @param state Lua call holding the device event and requested member name.
+ * @return One reported value, flag or nil.
+ */
+[[nodiscard]] int device_state_index(lua_State* state) {
+    const auto& event = check_event(state, 1);
+    const auto 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)) {
+        return 1;
+    }
+    static constexpr std::array<std::string_view, 3> values{"position", "power", "lock"};
+    static constexpr std::array<std::string_view, 3> sequences{
+        "position_sequence", "power_sequence", "lock_sequence"};
+    for (std::size_t channel = 0; channel < values.size(); ++channel) {
+        if (key == values[channel]) {
+            if ((event.deviceValueMask & (1U << channel)) != 0) {
+                lua_pushnumber(state, event.deviceValues[channel]);
+            } else {
+                lua_pushnil(state);
+            }
+            return 1;
+        }
+        if (key == sequences[channel]) {
+            if ((event.deviceSequenceMask & (1U << channel)) != 0) {
+                lua_pushinteger(state, event.deviceSequences[channel]);
+            } else {
+                lua_pushnil(state);
+            }
+            return 1;
+        }
+    }
+    if (key == "first_report") {
+        lua_pushboolean(state, event.deviceFirstReport);
+    } else if (key == "reset") {
+        lua_pushboolean(state, event.deviceReset);
+    } else {
+        lua_pushnil(state);
+    }
+    return 1;
+}
+
 /** Lua index for one object interaction event: the common, slot and interaction members. */
 [[nodiscard]] int object_interaction_index(lua_State* state) {
     const host::Event& event = check_event(state, 1);
     const auto key = lua_string_view(state, 2);
     if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)
         || (event_surface_visible(state, event.kind)
-            && push_slot_identity_member(state, event, key)))
+            && push_slot_identity_member(state, event, key))) {
         return 1;
+    }
     if (event.kind == host::EventKind::damageState) {
-        if (key == "health")
+        if (key == "health") {
             lua_pushnumber(state, event.damageHealth);
-        else if (key == "shield")
+        } else if (key == "shield") {
             lua_pushnumber(state, event.damageShield);
-        else if (key == "revision")
+        } else if (key == "revision") {
             lua_pushinteger(state, event.damageRevision);
-        else
+        } else {
             lua_pushnil(state);
+        }
         return 1;
     }
-    if (key == "generation")
+    if (key == "generation") {
         lua_pushinteger(state, event.objectGeneration);
-    else if (key == "present")
+    } else if (key == "present") {
         lua_pushboolean(state, event.objectPresent);
-    else if (key == "alive")
+    } else if (key == "alive") {
         lua_pushboolean(state, event.objectAlive);
-    else if (key == "owner_known")
+    } else if (key == "owner_known") {
         lua_pushboolean(state, event.objectOwnerKnown);
-    else if (key == "has_owner")
+    } else if (key == "has_owner") {
         lua_pushboolean(state, event.objectHasOwner);
-    else if (key == "owner_key" && event.objectOwnerKey != 0)
+    } else if (key == "owner_key" && event.objectOwnerKey != 0) {
         push_u64_string(state, event.objectOwnerKey);
-    else
+    } else {
         lua_pushnil(state);
+    }
     return 1;
 }
 
@@ -343,10 +393,13 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
 [[nodiscard]] int ghost_link_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))
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) {
         return 1;
+    }
     if (event_surface_visible(state, event.kind)) {
-        if (push_slot_identity_member(state, event, key)) return 1;
+        if (push_slot_identity_member(state, event, key)) {
+            return 1;
+        }
         if (key == "generation") {
             lua_pushinteger(state, event.ghostGeneration);
             return 1;
@@ -380,6 +433,25 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return 1;
 }
 
+/** A gameplay provocation carries the exact squad reference, not a native Sense body. */
+[[nodiscard]] int squad_provoked_index(lua_State* state) {
+    const auto& event = check_event(state, 1);
+    const auto key = lua_string_view(state, 2);
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) {
+        return 1;
+    }
+    if (key == "registry_key") {
+        lua_pushinteger(state, event.firstRegistryKey);
+    } else if (key == "slot_type") {
+        lua_pushinteger(state, event.firstSlotType);
+    } else if (key == "slot_index") {
+        lua_pushinteger(state, event.firstSlotIndex);
+    } else {
+        lua_pushnil(state);
+    }
+    return 1;
+}
+
 /** Reads one member of a squad member spawn edge. */
 [[nodiscard]] int entity_spawned_index(lua_State* state) {
     const host::Event& event = check_event(state, 1);
@@ -523,16 +595,18 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
 [[nodiscard]] int fireteam_state_index(lua_State* state) {
     const auto& event = check_event(state, 1);
     const auto key = lua_string_view(state, 2);
-    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key))
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) {
         return 1;
-    if (key == "alive_count")
+    }
+    if (key == "alive_count") {
         lua_pushinteger(state, event.fireteamAlive);
-    else if (key == "dead_count")
+    } else if (key == "dead_count") {
         lua_pushinteger(state, event.fireteamDead);
-    else if (key == "unknown_count")
+    } else if (key == "unknown_count") {
         lua_pushinteger(state, event.fireteamUnknown);
-    else
+    } else {
         lua_pushnil(state);
+    }
     return 1;
 }
 
@@ -544,6 +618,7 @@ void register_derived_event_metatables(lua_State* state) {
     register_metatable(state, kTriggerEnteredEventMetatable, &trigger_entered_index);
     register_metatable(state, kTriggerExitedEventMetatable, &trigger_exited_index);
     register_metatable(state, kSquadStateEventMetatable, &squad_state_index);
+    register_metatable(state, kSquadProvokedEventMetatable, &squad_provoked_index);
     register_metatable(state, kEntitySpawnedEventMetatable, &entity_spawned_index);
     register_metatable(state, kEntityDiedEventMetatable, &entity_died_index);
     register_metatable(state, kSceneFinishedEventMetatable, &scene_finished_index);
@@ -558,6 +633,7 @@ void register_derived_event_metatables(lua_State* state) {
     register_metatable(state, kCinematicTerminatedEventMetatable, &cinematic_terminated_index);
     register_metatable(state, kGhostLinkEventMetatable, &ghost_link_index);
     register_metatable(state, kObjectInteractionEventMetatable, &object_interaction_index);
+    register_metatable(state, kDeviceStateEventMetatable, &device_state_index);
     register_metatable(state, kActorPathEventMetatable, &actor_path_index);
 }
 

+ 5 - 0
Sunrise/src/server/activity/mission/mission_script_lua_internal.h

@@ -95,6 +95,9 @@ void register_event_metatables(lua_State* state);
 void register_squad_metatables(lua_State* state);
 void register_scene_metatables(lua_State* state);
 void register_slot_metatables(lua_State* state);
+void register_actor_sequence_metatables(lua_State* state);
+[[nodiscard]] int slot_actor_sequences(lua_State* state);
+[[nodiscard]] int slot_play_actor_sequence(lua_State* state);
 
 // SDK definition readers, defined in mission_script_lua_definition_api.cpp.
 
@@ -128,6 +131,8 @@ void register_slot_metatables(lua_State* state);
 [[nodiscard]] int squad_actor_command(lua_State* state);
 [[nodiscard]] int scene_index(lua_State* state);
 [[nodiscard]] int scene_activate(lua_State* state);
+[[nodiscard]] int scene_send_event(lua_State* state);
+[[nodiscard]] int scene_stop(lua_State* state);
 [[nodiscard]] int lifetime_index(lua_State* state);
 [[nodiscard]] int lifetime_set(lua_State* state);
 

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

@@ -61,6 +61,7 @@ inline constexpr char kEffectResultEventMetatable[] = "sunrise.mission.event.eff
 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";
+inline constexpr char kSquadProvokedEventMetatable[] = "sunrise.mission.event.squad_provoked";
 inline constexpr char kEntitySpawnedEventMetatable[] = "sunrise.mission.event.entity_spawned";
 inline constexpr char kEntityDiedEventMetatable[] = "sunrise.mission.event.entity_died";
 inline constexpr char kSceneFinishedEventMetatable[] = "sunrise.mission.event.scene_finished";
@@ -75,6 +76,7 @@ inline constexpr char kEntitySlotsRequestedEventMetatable[] =
 inline constexpr char kSessionJoinedEventMetatable[] = "sunrise.mission.event.session_joined";
 inline constexpr char kSessionLeftEventMetatable[] = "sunrise.mission.event.session_left";
 inline constexpr char kActorPathEventMetatable[] = "sunrise.mission.event.actor_path";
+inline constexpr char kDeviceStateEventMetatable[] = "sunrise.mission.event.device_state";
 inline constexpr char kObjectInteractionEventMetatable[] =
     "sunrise.mission.event.object_interaction";
 inline constexpr char kGhostLinkEventMetatable[] = "sunrise.mission.event.ghost_link";

+ 1 - 0
Sunrise/src/server/activity/mission/mission_script_lua_sandbox.cpp

@@ -198,6 +198,7 @@ void configure_generated_require(lua_State* state, const ProgramIdentity& identi
     register_squad_metatables(state);
     register_scene_metatables(state);
     register_slot_metatables(state);
+    register_actor_sequence_metatables(state);
     register_peer_metatables(state);
     register_bap_metatables(state);
     register_value_metatables(state);

+ 45 - 0
Sunrise/src/server/activity/mission/mission_script_lua_scene_api.cpp

@@ -1,5 +1,6 @@
 #include <string_view>
 
+#include "../../../middleware/bap/activity_message/scene_events_auth.h"
 #include "mission_script_lua_internal.h"
 #include "mission_script_lua_names.h"
 #include "mission_script_lua_resolve.h"
@@ -23,6 +24,10 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         lua_pushlstring(state, definition.id.data(), definition.idLength);
     } else if (key == "activate") {
         lua_pushcfunction(state, &scene_activate);
+    } else if (key == "stop") {
+        lua_pushcfunction(state, &scene_stop);
+    } else if (key == "send_event") {
+        lua_pushcfunction(state, &scene_send_event);
     } else {
         lua_pushnil(state);
     }
@@ -46,6 +51,46 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
     return queue_intent(state, frame, intent);
 }
 
+/** Adds one event to the current scene generation after its activation reaches transport. */
+[[nodiscard]] int scene_send_event(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SceneHandle*>(luaL_checkudata(state, 1, kSceneMetatable));
+    SceneDefinition definition{};
+    if (!current_scene(state, *handle, definition)) {
+        return luaL_error(state, "authored scene is stale or invalid");
+    }
+    static constexpr std::array<std::string_view, 1> kDeclared{"key"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer key = checked_integer_argument(state, "key");
+    if (key <= 0
+        || key >= static_cast<lua_Integer>(
+               middleware::bap::activity_message::scene_events::kInvalidEventKey)) {
+        return luaL_error(state, "invalid scene event key");
+    }
+    Intent intent{};
+    intent.kind = IntentKind::signalAuthoredScene;
+    intent.firstRow = definition.occurrenceRow;
+    intent.secondRow = definition.slotRow;
+    intent.sceneEventKey = static_cast<std::uint32_t>(key);
+    return queue_intent(state, active_frame(state), intent);
+}
+
+/** Stops the current scene generation without creating another activation. */
+[[nodiscard]] int scene_stop(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SceneHandle*>(luaL_checkudata(state, 1, kSceneMetatable));
+    SceneDefinition definition{};
+    if (!current_scene(state, *handle, definition)) {
+        return luaL_error(state, "authored scene is stale or invalid");
+    }
+    refuse_unknown_arguments(state, {});
+    Intent intent{};
+    intent.kind = IntentKind::stopAuthoredScene;
+    intent.firstRow = definition.occurrenceRow;
+    intent.secondRow = definition.slotRow;
+    return queue_intent(state, active_frame(state), intent);
+}
+
 void register_scene_metatables(lua_State* state) {
     register_metatable(state, kSceneMetatable, &scene_index);
 }

+ 24 - 12
Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp

@@ -702,13 +702,14 @@ constexpr std::int8_t kFilterModeInside = 1;
     namespace objective = middleware::bap::activity_message::squad_objective;
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
-    static constexpr std::array<std::string_view, 4> kDeclared{
-        "objective", "revision", "task_group", "reserved"};
+    static constexpr std::array<std::string_view, 5> kDeclared{
+        "objective", "revision", "task_group", "reserved", "refresh_player_awareness"};
     refuse_unknown_arguments(state, kDeclared);
     const auto reference = checked_argument<SlotHandle>(state, "objective", kSlotMetatable);
     const lua_Integer revision = checked_integer_argument(state, "revision");
     const lua_Integer group = checked_integer_argument(state, "task_group");
     const bool reserved = optional_boolean_argument(state, "reserved", false);
+    const bool refresh = optional_boolean_argument(state, "refresh_player_awareness", false);
     SlotDefinition squad{};
     SlotDefinition target{};
     if (!current_slot(state, *handle, squad) || squad.slotType != format::kSquadSlotType
@@ -727,16 +728,19 @@ constexpr std::int8_t kFilterModeInside = 1;
         return luaL_error(state,
                           "combat objective revision, group or index is outside its native range");
     }
-    std::array<std::byte, objective::kBytes> body{};
-    if (!objective::encode({target.registryKey,
-                            static_cast<std::uint32_t>(revision),
-                            static_cast<std::uint16_t>(target.slotIndex),
-                            static_cast<std::int32_t>(group),
-                            reserved},
-                           body)) {
+    const objective::Request request{target.registryKey,
+                                     static_cast<std::uint32_t>(revision),
+                                     static_cast<std::uint16_t>(target.slotIndex),
+                                     static_cast<std::int32_t>(group),
+                                     reserved,
+                                     refresh};
+    std::array<std::byte, objective::kMaximumBytes> body{};
+    const auto encoded = std::span(body).first(objective::byte_count(request));
+    if (!objective::encode(request, encoded)) {
         return luaL_error(state, "combat objective encoder failed");
     }
-    return queue_slot_auth(state, squad, objective::kSchema, objective::kBits, body);
+    return queue_slot_auth(
+        state, squad, objective::kSchema, objective::bit_count(request), encoded);
 }
 
 /** Creates a named actor and starts one package-authored movement path. */
@@ -913,7 +917,11 @@ constexpr std::int8_t kFilterModeInside = 1;
     return queue_slot_auth(state, actor, combatant::kSchema, combatant::kRetireBits, body);
 }
 
-/** Binds one exact combatant to its package-authored squad member. */
+/**
+ * Arms one exact combatant for its scene's squad member spawn.
+ * @param state Lua call holding the slot and empty argument table.
+ * @return One request handle after the intent is retained.
+ */
 [[nodiscard]] int slot_bind_combatant_to_squad(lua_State* state) {
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
@@ -1377,7 +1385,11 @@ constexpr std::int8_t kFilterModeInside = 1;
     } else if (key == "fire_trigger") {
         lua_pushcfunction(state, &slot_fire_trigger);
     } else if (key == "play_sequence") {
-        lua_pushcfunction(state, &slot_play_sequence);
+        lua_pushcfunction(state,
+                          exact_combatant_slot(definition) ? &slot_play_actor_sequence
+                                                           : &slot_play_sequence);
+    } else if (key == "sequences") {
+        lua_pushcfunction(state, &slot_actor_sequences);
     } else if (key == "set_scene_events") {
         lua_pushcfunction(state, &slot_set_scene_events);
     } else if (key == "set_cinematic_active") {

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

@@ -220,6 +220,39 @@ void push_script_event(RuntimeInstance& instance, const host::Event& event) noex
     }
 }
 
+/**
+ * Queues an exact host-policy transition without consuming a client mission-input sequence.
+ * @param binding Activity generation that accepted the damage.
+ * @param sourceGeneration ActivityClient generation that owns the policy.
+ * @param registryKey Authored squad registry key.
+ * @param slotIndex Native squad slot index.
+ */
+void report_squad_provoked(const state::activity::SessionBinding& binding,
+                           std::uint64_t sourceGeneration,
+                           std::uint32_t registryKey,
+                           std::uint16_t slotIndex) noexcept {
+    if (sourceGeneration == 0 || registryKey == 0
+        || slotIndex > static_cast<std::uint16_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    auto* instance = find_instance(binding);
+    if (instance != nullptr && instance->view.activityClientGeneration == sourceGeneration) {
+        host::Event event{};
+        event.kind = host::EventKind::squadProvoked;
+        event.binding = binding;
+        event.sequence = instance->missionStateRevision;
+        event.missionSequence = instance->lastMissionSequence;
+        event.sourceGeneration = sourceGeneration;
+        event.tick = GetTickCount64();
+        event.firstRegistryKey = registryKey;
+        event.firstSlotType = sdk::format::kSquadSlotType;
+        event.firstSlotIndex = slotIndex;
+        push_script_event(*instance, event);
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
 /** Retains the last VM stage and status shown on the panel. */
 void note_vm_status(RuntimeInstance& instance,
                     std::string_view stage,
@@ -287,6 +320,8 @@ void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
     instance.ghostObservations = {};
     instance.actorPathObservations = {};
     instance.squadObservations = {};
+    instance.combatantDamageObservations = {};
+    instance.deviceObservations = {};
     instance.sceneObservations = {};
     instance.objectiveObservations = {};
     instance.sessionRoster = {};

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

@@ -89,6 +89,12 @@ using ActorCommandPolicy = ActorCommandPolicyStatus (*)(
 /** Installs the gameplay-owned policy callback before mission service starts. */
 void install_actor_command_policy(const void* context, ActorCommandPolicy policy) noexcept;
 
+/** Publishes accepted gameplay damage only to the mission's matching source generation. */
+void report_squad_provoked(const state::activity::SessionBinding& binding,
+                           std::uint64_t sourceGeneration,
+                           std::uint32_t registryKey,
+                           std::uint16_t slotIndex) noexcept;
+
 /** Starts the optional, off-by-default server mission-script manager. */
 void initialize() noexcept;
 /** Writes the readable authored controller filename for one 1-based SDK activity row. */

+ 107 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp

@@ -0,0 +1,107 @@
+#include <array>
+#include <cstdio>
+#include <span>
+
+#include "mission_script_runtime_internal.h"
+
+namespace sunrise::server::activity::mission {
+namespace {
+
+/**
+ * Retains each combatant under its complete authored slot identity.
+ * @param instance Bound mission instance.
+ * @param key Exact accepted Sense source.
+ * @return The matching or newly allocated observation, or null at capacity.
+ */
+[[nodiscard]] CombatantDamageObservation*
+find_combatant_damage(RuntimeInstance& instance, const host::SenseObservationKey& key) noexcept {
+    CombatantDamageObservation* spare = nullptr;
+    for (auto& row : instance.combatantDamageObservations) {
+        if (row.used && row.registryKey == key.registryKey && row.objectTag == key.objectTag
+            && row.slotIndex == key.slotIndex) {
+            return &row;
+        }
+        if (!row.used && spare == nullptr) {
+            spare = &row;
+        }
+    }
+    if (spare != nullptr) {
+        spare->registryKey = key.registryKey;
+        spare->objectTag = key.objectTag;
+        spare->slotIndex = key.slotIndex;
+        spare->used = true;
+    }
+    return spare;
+}
+
+} // namespace
+
+/**
+ * Raises damage-state observations without inferring actor attachment or provocation.
+ * @param instance Bound mission instance retaining prior combatant levels.
+ * @param sense Accepted observations for the current client generation.
+ */
+void push_combatant_damage_edges(RuntimeInstance& instance,
+                                 const host::SenseObservationSnapshot& sense) noexcept {
+    if (sense.sourceGeneration != instance.view.activityClientGeneration
+        || sense.observationCount > sense.observations.size()
+        || sense.valueCount > sense.values.size()) {
+        return;
+    }
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const auto& observation = sense.observations[index];
+        if (observation.sourceGeneration != sense.sourceGeneration
+            || observation.key.slotType != kCombatantDamageSlotType
+            || observation.key.senseSchema != kCombatantDamageSenseSchema
+            || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) {
+            continue;
+        }
+        auto* const row = find_combatant_damage(instance, observation.key);
+        if (row == nullptr) {
+            log_line(core::log::Level::warn, &instance, "combatant_damage", "watch_capacity");
+            continue;
+        }
+        const auto body =
+            std::span(sense.values).subspan(observation.firstValue, observation.valueCount);
+        if (!update_combatant_damage(row->level, body, observation.key.schemaRow)) {
+            continue;
+        }
+        const auto& level = row->level;
+        host::Event event{};
+        event.kind = host::EventKind::damageState;
+        event.binding = instance.view.binding;
+        event.sequence = observation.sequence;
+        event.tick = observation.tick;
+        event.sourceGeneration = instance.view.activityClientGeneration;
+        event.missionSequence = instance.lastMissionSequence;
+        event.firstRegistryKey = observation.key.registryKey;
+        event.firstSlotIndex = observation.key.slotIndex;
+        event.firstSlotType = observation.key.slotType;
+        event.slotObjectTag = observation.key.objectTag;
+        event.slotSenseSchema = observation.key.senseSchema;
+        event.damageHealth = level.primary;
+        event.damageShield = level.secondary;
+        event.damageRevision = level.revision;
+        std::array<char, 128> details{};
+        const int written = std::snprintf(details.data(),
+                                          details.size(),
+                                          "registry=%08X slot=%u revision=%d primary=%.4f "
+                                          "secondary=%.4f",
+                                          observation.key.registryKey,
+                                          static_cast<unsigned>(observation.key.slotIndex),
+                                          level.revision,
+                                          static_cast<double>(level.primary),
+                                          static_cast<double>(level.secondary));
+        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "combatant_damage",
+                     "changed",
+                     {details.data(), static_cast<std::size_t>(written)});
+        }
+        push_script_event(instance, event);
+    }
+}
+
+} // namespace sunrise::server::activity::mission

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

@@ -221,6 +221,12 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::bindCombatantToSquad:
         result = "combatant_binding_staged";
         break;
+    case lua_vm::IntentKind::stopAuthoredScene:
+        result = "scene_stop_staged";
+        break;
+    case lua_vm::IntentKind::signalAuthoredScene:
+        result = "scene_event_staged";
+        break;
     case lua_vm::IntentKind::activateAuthoredScene:
         result = "scene_staged";
         break;
@@ -251,6 +257,9 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::playPerformance:
         result = "performance_staged";
         break;
+    case lua_vm::IntentKind::playActorSequence:
+        result = "actor_sequence_staged";
+        break;
     case lua_vm::IntentKind::resetObjectives:
         result = "objective_reset_staged";
         break;

+ 119 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_device.cpp

@@ -0,0 +1,119 @@
+#include <array>
+#include <cstdio>
+#include <span>
+
+#include "mission_script_runtime_internal.h"
+
+namespace sunrise::server::activity::mission {
+namespace {
+
+/**
+ * Retains a device under its complete authored slot identity.
+ * @param instance Bound mission instance.
+ * @param key Exact accepted Sense source.
+ * @return The matching or newly allocated row, or null at capacity.
+ */
+[[nodiscard]] DeviceObservation*
+find_device_observation(RuntimeInstance& instance, const host::SenseObservationKey& key) noexcept {
+    DeviceObservation* spare = nullptr;
+    for (auto& row : instance.deviceObservations) {
+        if (row.used && row.registryKey == key.registryKey && row.objectTag == key.objectTag
+            && row.slotIndex == key.slotIndex) {
+            return &row;
+        }
+        if (!row.used && spare == nullptr) {
+            spare = &row;
+        }
+    }
+    if (spare != nullptr) {
+        spare->registryKey = key.registryKey;
+        spare->objectTag = key.objectTag;
+        spare->slotIndex = key.slotIndex;
+    }
+    return spare;
+}
+
+} // namespace
+
+/**
+ * Publishes current device levels only when accepted client reports change them.
+ * @param instance Bound mission instance retaining previous device levels.
+ * @param sense Accepted observations for its current client generation.
+ */
+void push_device_edges(RuntimeInstance& instance,
+                       const host::SenseObservationSnapshot& sense) noexcept {
+    if (sense.sourceGeneration != instance.view.activityClientGeneration
+        || sense.observationCount > sense.observations.size()
+        || sense.valueCount > sense.values.size()) {
+        return;
+    }
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const auto& observation = sense.observations[index];
+        if (observation.sourceGeneration != sense.sourceGeneration
+            || observation.key.slotType != kDeviceSenseSlotType
+            || observation.key.senseSchema != kDeviceSenseSchema
+            || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) {
+            continue;
+        }
+        auto* const row = find_device_observation(instance, observation.key);
+        if (row == nullptr) {
+            log_line(core::log::Level::warn, &instance, "device_state", "watch_capacity");
+            continue;
+        }
+        const bool first = !row->level.observed;
+        bool reset = false;
+        const auto body =
+            std::span(sense.values).subspan(observation.firstValue, observation.valueCount);
+        if (!update_device_level(row->level, body, observation.key.schemaRow, reset)) {
+            continue;
+        }
+        row->used = true;
+        host::Event event{};
+        event.kind = host::EventKind::deviceState;
+        event.binding = instance.view.binding;
+        event.sequence = observation.sequence;
+        event.tick = observation.tick;
+        event.sourceGeneration = instance.view.activityClientGeneration;
+        event.missionSequence = instance.lastMissionSequence;
+        event.firstRegistryKey = observation.key.registryKey;
+        event.firstSlotIndex = observation.key.slotIndex;
+        event.firstSlotType = observation.key.slotType;
+        event.slotObjectTag = observation.key.objectTag;
+        event.slotSenseSchema = observation.key.senseSchema;
+        event.deviceFirstReport = first;
+        event.deviceReset = reset;
+        for (std::size_t channel = 0; channel < row->level.channels.size(); ++channel) {
+            const auto& level = row->level.channels[channel];
+            event.deviceValues[channel] = level.value;
+            event.deviceSequences[channel] = level.sequence;
+            if (level.valueKnown) {
+                event.deviceValueMask |= static_cast<std::uint8_t>(1U << channel);
+            }
+            if (level.sequenceKnown) {
+                event.deviceSequenceMask |= static_cast<std::uint8_t>(1U << channel);
+            }
+        }
+        std::array<char, 128> details{};
+        const int written = std::snprintf(details.data(),
+                                          details.size(),
+                                          "registry=%08X slot=%u position=%.4f sequence=%d "
+                                          "known=%u reset=%u",
+                                          observation.key.registryKey,
+                                          static_cast<unsigned>(observation.key.slotIndex),
+                                          static_cast<double>(event.deviceValues[0]),
+                                          event.deviceSequences[0],
+                                          static_cast<unsigned>(event.deviceValueMask),
+                                          reset ? 1U : 0U);
+        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "device_state",
+                     "changed",
+                     {details.data(), static_cast<std::size_t>(written)});
+        }
+        push_script_event(instance, event);
+    }
+}
+
+} // namespace sunrise::server::activity::mission

+ 34 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -599,6 +599,32 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         }
         return;
     }
+    case lua_vm::IntentKind::playActorSequence: {
+        host::ScriptableOutputReservation reservation{};
+        if (!reserve_delivery(instance, reservation)) {
+            if (instance.programStatus == ProgramStatus::loaded) {
+                refuse_delivery(instance,
+                                "actor_sequence_refused",
+                                "host_reservation_unavailable",
+                                host::EffectOutcome::refused);
+            }
+            return;
+        }
+        const devices::Status status =
+            intent.firstRow == intent.sequenceOwner.slotRow
+                ? devices::play_combatant_sequence_reserved(
+                      instance.view, intent.sequenceOwner, intent.secondRow, reservation)
+                : devices::Status::invalidSlot;
+        if (status == devices::Status::queued) {
+            await_host_commit(instance, now, "actor_sequence_enqueued");
+        } else if (abandon_reserved_delivery(instance, reservation)) {
+            refuse_delivery(instance,
+                            "actor_sequence_refused",
+                            devices::status_name(status),
+                            host::EffectOutcome::refused);
+        }
+        return;
+    }
     case lua_vm::IntentKind::playPerformance: {
         host::ScriptableOutputReservation reservation{};
         if (!reserve_delivery(instance, reservation)) {
@@ -755,6 +781,8 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         }
         return;
     }
+    case lua_vm::IntentKind::stopAuthoredScene:
+    case lua_vm::IntentKind::signalAuthoredScene:
     case lua_vm::IntentKind::activateAuthoredScene: {
         host::ScriptableOutputReservation reservation{};
         if (!reserve_delivery(instance, reservation)) {
@@ -767,7 +795,12 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
             return;
         }
         const scenes::SceneStatus status = scenes::activate_authored_scene_reserved(
-            instance.view, intent.firstRow, intent.secondRow, reservation);
+            instance.view,
+            intent.firstRow,
+            intent.secondRow,
+            reservation,
+            intent.sceneEventKey,
+            intent.kind == lua_vm::IntentKind::stopAuthoredScene);
         if (status == scenes::SceneStatus::queued) {
             await_host_commit(instance, now, "scene_enqueued");
         } else if (!abandon_reserved_delivery(instance, reservation)) {

+ 6 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp

@@ -83,6 +83,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
     case host::EventKind::triggerEntered:
     case host::EventKind::triggerExited:
     case host::EventKind::squadState:
+    case host::EventKind::squadProvoked:
     case host::EventKind::entitySpawned:
     case host::EventKind::entityDied:
     case host::EventKind::sceneFinished:
@@ -95,6 +96,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
     case host::EventKind::cinematicTerminated:
     case host::EventKind::actorPathState:
     case host::EventKind::damageState:
+    case host::EventKind::deviceState:
     case host::EventKind::objectState:
     case host::EventKind::fireteamState:
     case host::EventKind::objectInteracted:
@@ -122,6 +124,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
            || event.kind == host::EventKind::triggerEntered
            || event.kind == host::EventKind::triggerExited
            || event.kind == host::EventKind::squadState
+           || event.kind == host::EventKind::squadProvoked
            || event.kind == host::EventKind::entitySpawned
            || event.kind == host::EventKind::entityDied
            || event.kind == host::EventKind::sceneFinished
@@ -133,6 +136,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
            || event.kind == host::EventKind::cinematicStarted
            || event.kind == host::EventKind::actorPathState
            || event.kind == host::EventKind::damageState
+           || event.kind == host::EventKind::deviceState
            || event.kind == host::EventKind::objectState
            || event.kind == host::EventKind::fireteamState
            || event.kind == host::EventKind::objectInteracted
@@ -636,6 +640,8 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         push_damage_edges(instance, *sense);
         push_actor_path_edges(instance, *sense);
         push_squad_edges(instance, *sense);
+        push_combatant_damage_edges(instance, *sense);
+        push_device_edges(instance, *sense);
         push_scene_edges(instance, *sense);
         push_objective_edges(instance, *sense);
     }

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

@@ -16,6 +16,8 @@
 #include "../../../state/activity_sdk/runtime.h"
 #include "../host_runtime.h"
 #include "mission_script_actor_path_sense.h"
+#include "mission_script_combatant_damage_sense.h"
+#include "mission_script_device_sense.h"
 #include "mission_script_ghost_sense.h"
 #include "mission_script_object_sense.h"
 #include "mission_script_player_sense.h"
@@ -87,6 +89,8 @@ constexpr std::size_t kGhostObservationCapacity = 8;
 constexpr std::size_t kDamageObservationCapacity = 8;
 constexpr std::size_t kObjectInteractionObservationCapacity = 64;
 constexpr std::size_t kActorPathObservationCapacity = 64;
+/** Watched device levels retained per instance. */
+constexpr std::size_t kDeviceObservationCapacity = 128;
 /** One objective sensor carries this many objective blocks. */
 constexpr std::size_t kObjectiveCapacity = 24;
 /** One objective block carries this many task counters. */
@@ -125,6 +129,24 @@ struct DamageObservation final {
     bool used{};
 };
 
+/** Last damage-pool observation for one exact Type-2 source. */
+struct CombatantDamageObservation final {
+    CombatantDamageLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+
+/** Last reported current values for one exact Type-23 source. */
+struct DeviceObservation final {
+    DeviceLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+
 /** Last object and interaction level seen for one interactable object. */
 struct ObjectInteractionObservation final {
     ObjectInteractionLevel level{};
@@ -215,6 +237,8 @@ struct RuntimeInstance final {
     std::array<SquadObservation, kSquadObservationCapacity> squadObservations{};
     std::array<GhostObservation, kGhostObservationCapacity> ghostObservations{};
     std::array<DamageObservation, kDamageObservationCapacity> damageObservations{};
+    std::array<CombatantDamageObservation, kSquadObservationCapacity> combatantDamageObservations{};
+    std::array<DeviceObservation, kDeviceObservationCapacity> deviceObservations{};
     std::array<ObjectInteractionObservation, kObjectInteractionObservationCapacity>
         objectInteractionObservations{};
     std::array<ActorPathObservation, kActorPathObservationCapacity> actorPathObservations{};
@@ -289,6 +313,12 @@ void push_actor_path_edges(RuntimeInstance& instance,
 /** Raises one event per damage monitor whose health, shield or revision changed. */
 void push_damage_edges(RuntimeInstance& instance,
                        const host::SenseObservationSnapshot& sense) noexcept;
+/** Raises observed Type-2 damage pools and lifecycle resets without inferring damage. */
+void push_combatant_damage_edges(RuntimeInstance& instance,
+                                 const host::SenseObservationSnapshot& sense) noexcept;
+/** Raises current device values and sequence resets from accepted client reports. */
+void push_device_edges(RuntimeInstance& instance,
+                       const host::SenseObservationSnapshot& sense) noexcept;
 /** Raises object state and accepted interaction events per interactable object. */
 void push_object_interaction_edges(RuntimeInstance& instance,
                                    const host::SenseObservationSnapshot& sense) noexcept;

+ 18 - 4
Sunrise/src/server/activity/mission/mission_script_squad_sense.h

@@ -22,6 +22,9 @@ inline constexpr std::size_t kSquadConsumedSlotCapacity = 8;
 inline constexpr std::size_t kSquadObjectiveGroupCount = 24;
 /** Costs are saturated distances; the client never publishes more than this. */
 inline constexpr float kMaximumObjectiveCost = 2040.0F;
+/** The native schema initializes every cost to its unreachable value. */
+inline constexpr std::uint32_t kSquadObjectiveDefaultMask =
+    (std::uint32_t{1} << kSquadObjectiveGroupCount) - 1U;
 /** The alive count is six bits on the wire. */
 inline constexpr std::int64_t kMaximumAliveCount = 63;
 inline constexpr std::int64_t kMaximumCounter = 0x7FFFFFFF;
@@ -65,14 +68,25 @@ inline constexpr std::int64_t kMaximumCounter = 0x7FFFFFFF;
     return static_cast<std::uint8_t>(length);
 }
 
-/** Objective costs the client published for one squad, and the revision they answer. */
+/** Native defaults remain until a present cost delta replaces them. */
 struct SquadObjectiveCosts final {
-    std::array<float, kSquadObjectiveGroupCount> values{};
-    std::uint32_t known{};
+    std::array<float, kSquadObjectiveGroupCount> values = [] {
+        std::array<float, kSquadObjectiveGroupCount> defaults{};
+        defaults.fill(kMaximumObjectiveCost);
+        return defaults;
+    }();
+    std::uint32_t known{kSquadObjectiveDefaultMask};
     std::uint32_t revision{};
+    bool operator==(const SquadObjectiveCosts&) const = default;
 };
 
-/** Merges the cost fields of one body. @return True when a cost or the revision changed. */
+/**
+ * Merges present cost deltas over the retained native defaults.
+ * @param retained Current costs for this squad and source generation.
+ * @param values Accepted squad Sense fields.
+ * @param root Native schema identity.
+ * @return True when a cost or revision changed.
+ */
 [[nodiscard]] inline bool update_squad_objective_costs(
     SquadObjectiveCosts& retained,
     std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,

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

@@ -145,11 +145,16 @@ inline constexpr std::array<const char*, host::kEventKindCount> kEventHandlerNam
     "on_event_fireteam_state",
     "on_event_object_state",
     "on_event_damage_state",
+    "on_event_squad_provoked",
+    "on_event_device_state",
 }};
 
 static_assert([] {
-    for (const auto* name : kEventHandlerNames)
-        if (name == nullptr) return false;
+    for (const auto* name : kEventHandlerNames) {
+        if (name == nullptr) {
+            return false;
+        }
+    }
     return true;
 }());
 

+ 32 - 2
Sunrise/src/server/activity/mission/mission_script_vm.h

@@ -32,9 +32,9 @@ inline constexpr std::size_t kSquadMemberCapacity = 15;
 /** Script source is bounded before it reaches the parser. */
 inline constexpr std::size_t kSourceByteCapacity = 128U * 1024U;
 /** One callback is stopped after this many approximate VM instructions. */
-inline constexpr std::uint32_t kInstructionBudget = 100'000;
+inline constexpr std::uint32_t kInstructionBudget = 5'000'000;
 /** Generated mission modules are large declarations and execute only while a VM opens. */
-inline constexpr std::uint32_t kInitializationInstructionBudget = 2'000'000;
+inline constexpr std::uint32_t kInitializationInstructionBudget = 100'000'000;
 /** Stable storage holds everything but the arena block, which the open program owns. */
 inline constexpr std::size_t kVmStorageByteCapacity = 512U * 1024U;
 
@@ -130,6 +130,35 @@ struct PerformanceStateDefinition final {
     std::uint32_t stateCount{};
 };
 
+using ActorSequenceOwner = state::activity::mission::ActorSequenceOwner;
+
+/** One extracted sequence entry; unsupported native kinds remain readable. */
+struct ActorSequenceDefinition final {
+    std::string_view id{};
+    std::string_view name{};
+    std::string_view symbol{};
+    std::string_view sourcePath{};
+    std::uint32_t catalogRow{};
+    std::uint32_t keyHash{};
+    std::uint32_t kind{};
+    std::uint32_t resourceTag{};
+    std::uint32_t tableIndex{};
+    std::uint32_t ordinal{};
+    std::uint32_t sourceOffset{};
+    bool playable{};
+};
+
+/** These callbacks reread the exact actor owner before exposing or playing a value. */
+struct ActorSequenceApi final {
+    const void* context{};
+    bool (*owner)(const void*, std::uint32_t slotRow, ActorSequenceOwner&) noexcept {};
+    std::size_t (*count)(const void*, const ActorSequenceOwner&) noexcept {};
+    bool (*resolve)(const void*,
+                    const ActorSequenceOwner&,
+                    std::uint32_t ordinal,
+                    ActorSequenceDefinition&) noexcept {};
+};
+
 /** One peer session bound to this destination, as the roster currently holds it. */
 struct PeerSession final {
     std::uint64_t sessionId{};
@@ -351,6 +380,7 @@ struct DefinitionApi final {
     ResolveDirectiveElementRow resolveDirectiveElementRow{};
     ResolveDirectiveElement resolveDirectiveElement{};
     ResolvePerformanceState resolvePerformanceState{};
+    ActorSequenceApi actorSequences{};
     ResolveActivityMessageRow resolveActivityMessageRow{};
     ResolveActivityMessageId resolveActivityMessageId{};
     ResolveActivityMessageName resolveActivityMessageName{};

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

@@ -7,6 +7,7 @@
 
 #include "../../../middleware/crypto/sha256.h"
 #include "../../../state/build_data/scriptables/coverage.h"
+#include "mission_script_actor_sequence_bridge.h"
 #include "mission_script_manifest_sdk_bridge.h"
 #include "mission_script_sdk_bridge.h"
 #include "mission_script_world_sdk_internal.h"
@@ -704,6 +705,7 @@ lua_vm::DefinitionApi definition_api(const sdk::BoundView& view,
         return output;
     }
     output.manifest = manifest_definition_api(world);
+    attach_actor_sequences(output, world);
     output.world = {
         .context = &world,
         .scenarioName = world.scenario_name(),

+ 12 - 3
Sunrise/src/server/bap/bap_scriptable_override_route.cpp

@@ -450,7 +450,10 @@ bool request_activity_state_local_authored_scene_override(
     const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
-    const activity::host::ScriptableOutputReservation* reservation) noexcept {
+    const activity::host::ScriptableOutputReservation* reservation,
+    const activity::host::AuthoredSceneDependencies& dependencies,
+    std::uint32_t eventKey,
+    bool stop) noexcept {
     const std::lock_guard lock(session_lock());
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
@@ -461,8 +464,14 @@ bool request_activity_state_local_authored_scene_override(
         && target.stateLocalRegion == expectedRegion
         && session->activity.bindingGeneration == expectedGeneration
         && valid_state_local_authored_scene_target(target, stateLocalRosterGroup)
-        && activity::host::request_state_local_authored_scene_override(
-            binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
+        && activity::host::request_state_local_authored_scene_override(binding,
+                                                                       target,
+                                                                       stateLocalRosterGroup,
+                                                                       expectedGeneration,
+                                                                       reservation,
+                                                                       dependencies,
+                                                                       eventKey,
+                                                                       stop);
     return queued;
 }
 

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

@@ -489,8 +489,7 @@ build_roster_snapshot(Session& session,
             return refuse_override("retained_auth_install");
         }
     }
-    // Message 5 resets every registered Auth slot before applying its bodies, so the complete
-    // latest-per-ClientRef estate is re-emitted and a later action cannot erase an earlier one.
+    // Each included Auth slot is replaced, so snapshots must carry its complete retained value.
     // Squads stay in their lease, which also owns group admission and per-group revisions.
     for (const server::activity::host::PendingScriptableOverride& retained : authEstate) {
         if (retained.kind == server::activity::host::ScriptableOverrideKind::squad

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

@@ -316,7 +316,10 @@ activity_authority_reset_snapshot(const state::activity::SessionBinding& binding
     const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
-    const activity::host::ScriptableOutputReservation* reservation = nullptr) noexcept;
+    const activity::host::ScriptableOutputReservation* reservation = nullptr,
+    const activity::host::AuthoredSceneDependencies& dependencies = {},
+    std::uint32_t eventKey = 0,
+    bool stop = false) noexcept;
 
 /** Queues one authored dialogue line only while its exact mission-seed state is live. */
 [[nodiscard]] bool request_activity_state_local_dialogue_override(

+ 42 - 67
Sunrise/src/server/gameplay/actor_command_policy.cpp

@@ -6,6 +6,7 @@
 #include <array>
 #include <cstddef>
 #include <limits>
+#include <memory>
 #include <span>
 
 #include "../../middleware/gameplay/external/actor_command_runtime_codec.h"
@@ -54,8 +55,10 @@ struct PolicyPlan final {
     group::HostSessionBinding host{};
     peer::LinkIdentity linkIdentity{};
     state::activity_sdk::Snapshot snapshot{};
+    state::activity_sdk::generated_world::GeneratedWorldView worldView{};
     std::array<std::uint32_t, kSelectedActorClassCapacity> classes{};
     std::size_t classCount{};
+    std::uint64_t activityClientGeneration{};
     SelectedSquad squad{};
     std::uint32_t messageIndex{format::kAbsentIndex};
     std::uint32_t actorEventIndex{format::kAbsentIndex};
@@ -69,15 +72,20 @@ std::array<DeferredPolicyRow, kDeferredPolicyCapacity> g_deferredPolicies{};
  * Resolves the only gameplay host allowed to execute one mission policy.
  * @param binding Exact Activity Host generation that owns the policy.
  * @param output Receives the elected host only on success.
+ * @param sourceGeneration Receives the ActivityClient generation owning the policy.
  * @return True when the matching ActivityClient has an elected gameplay host.
  */
 [[nodiscard]] bool resolve_policy_host(const activity::SessionBinding& binding,
-                                       group::HostSessionBinding& output) noexcept {
+                                       group::HostSessionBinding& output,
+                                       std::uint64_t& sourceGeneration) noexcept {
     output = {};
+    sourceGeneration = 0;
     server::bap::ActivityLinkView activityLink{};
-    if (!server::bap::activity_link_view(binding, activityLink)) {
+    if (!server::bap::activity_link_view(binding, activityLink)
+        || activityLink.activityClientGeneration == 0) {
         return false;
     }
+    sourceGeneration = activityLink.activityClientGeneration;
     if (!activityLink.publicTarget) {
         // A private activity has one logical Bubble Host. A region host must never replace it.
         if (!server::gameplay::private_host_session(binding, output)
@@ -123,31 +131,11 @@ std::array<DeferredPolicyRow, kDeferredPolicyCapacity> g_deferredPolicies{};
     return contains(selected_classes(session), actorClassIndex);
 }
 
-/** @return True when one live squad matches a selected authored ClientRef. */
-[[nodiscard]] bool squad_selected(const SessionRow& session,
-                                  const SquadEntityRow& squad,
-                                  std::size_t selectedCount) noexcept {
-    selectedCount = (std::min)(selectedCount, session.selectedSquads.size());
-    return std::any_of(session.selectedSquads.begin(),
-                       session.selectedSquads.begin() + static_cast<std::ptrdiff_t>(selectedCount),
-                       [&squad](const SelectedSquad& selected) {
-                           return selected.registryKey == squad.registryKey
-                                  && selected.slotType == squad.slotType
-                                  && selected.slotIndex == squad.slotIndex;
-                       });
-}
-
 /** @return True when an exact token belongs to one selected live squad. */
 [[nodiscard]] bool token_selected(const SessionRow& session,
                                   const external::EntityToken& token,
                                   std::size_t selectedCount) noexcept {
-    return std::any_of(
-        session.squads.begin(), session.squads.end(), [&](const SquadEntityRow& row) {
-            return row.occupied && squad_selected(session, row, selectedCount)
-                   && std::any_of(row.actors.begin(),
-                                  row.actors.begin() + row.actorCount,
-                                  [&token](const auto& actor) { return same_token(actor, token); });
-        });
+    return selected_entity_member(session, token, selectedCount);
 }
 
 /** @return The first free output row, or null when the ledger is full. */
@@ -233,7 +221,7 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
     if (!activity::binding_matches(request.binding)) {
         return mission::ActorCommandPolicyStatus::refused;
     }
-    if (!resolve_policy_host(request.binding, output.host)) {
+    if (!resolve_policy_host(request.binding, output.host, output.activityClientGeneration)) {
         return mission::ActorCommandPolicyStatus::unavailable;
     }
     if (!same_binding(output.host.source, request.binding)) {
@@ -254,9 +242,20 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
         return mission::ActorCommandPolicyStatus::refused;
     }
     const format::Squad& squad = squads[request.squadRow];
+    state::activity_sdk::BoundView activityView{};
+    server::bap::ActivityLinkView activityLink{};
+    if (server::bap::activity_link_view(request.binding, activityLink)
+        && state::activity_sdk::resolve(
+               snapshot,
+               {request.binding, activityLink.matchingLinks, activityLink.activityClientGeneration},
+               activityView)
+               == state::activity_sdk::Status::ready
+        && activityView.scenarioRow == squad.scenarioIndex) {
+        static_cast<void>(
+            state::activity_sdk::generated_world::resolve(activityView, output.worldView));
+    }
     const format::ActorCommandDefinition& command = commands[request.commandRow];
-    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask
-        || squad.objectIndex >= snapshot->objects().size()
+    if (!resolve_selected_squad(*snapshot, squad, output.squad)
         || command.flags != format::kActorCommandDefinitionExact
         || command.selector != request.commandSelector
         || command.effect != format::ActorCommandEffect::setFaction
@@ -278,9 +277,6 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
     if (message == nullptr || actorEvent == nullptr || damageEvent == nullptr || members.empty()) {
         return mission::ActorCommandPolicyStatus::refused;
     }
-    output.squad.registryKey = snapshot->objects()[squad.objectIndex].objectKey;
-    output.squad.slotType = static_cast<std::uint8_t>(format::kSquadSlotType);
-    output.squad.slotIndex = squad.slotIndex;
     for (const format::SquadMember& member : members) {
         if (member.actorClassIndex == format::kAbsentIndex
             || member.actorClassIndex >= snapshot->actor_classes().size()) {
@@ -306,6 +302,8 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
                                const PolicyPlan& plan) noexcept {
     return session.policyActive && session.bindingRetained
            && same_binding(session.binding, request.binding) && session.catalog == plan.snapshot
+           && session.worldView.generation_identity() == plan.worldView.generation_identity()
+           && session.activityClientGeneration == plan.activityClientGeneration
            && session.commandIndex == request.commandRow && session.policyValue == request.value
            && session.linkIdentityRetained == plan.linkPresent
            && (!plan.linkPresent || same_link(session.linkIdentity, plan.linkIdentity));
@@ -435,8 +433,10 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
     }
     session.binding = request.binding;
     session.catalog = plan.snapshot;
+    session.worldView = plan.worldView;
     session.groupSessionId = plan.host.groupSessionId;
     session.hostGeneration = plan.host.generation;
+    session.activityClientGeneration = plan.activityClientGeneration;
     session.linkIdentity = plan.linkIdentity;
     session.linkIdentityRetained = plan.linkPresent;
     session.actorClasses = plan.classes;
@@ -450,7 +450,7 @@ plan_policy(const mission::ActorCommandPolicyRequest& request, PolicyPlan& outpu
     session.commandIndex = request.commandRow;
     session.policyValue = request.value;
     if (linkChanged) {
-        session.actors = {};
+        external::reset_actor_entity_registry(session.actors);
         session.squads = {};
     }
     session.outputs = {};
@@ -605,7 +605,7 @@ void service_deferred_policies() noexcept {
 void adopt_link_identity(SessionRow& session,
                          const peer::LinkIdentity& linkIdentity,
                          bool present) noexcept {
-    session.actors = {};
+    external::reset_actor_entity_registry(session.actors);
     session.squads = {};
     session.outputs = {};
     session.replays = {};
@@ -643,8 +643,10 @@ SRWLOCK g_lock = SRWLOCK_INIT;
 std::array<SessionRow, kSessionCapacity> g_sessions{};
 std::uint64_t g_serviceFrame{};
 
-bool same_token(const external::EntityToken& left, const external::EntityToken& right) noexcept {
-    return left.slot == right.slot && left.incarnation == right.incarnation;
+/** Reconstructs the large fixed row directly in its owned storage. */
+static void clear_session_row(SessionRow& row) noexcept {
+    std::destroy_at(&row);
+    std::construct_at(&row);
 }
 
 SessionRow* find_session(std::uint64_t groupSessionId) noexcept {
@@ -673,23 +675,6 @@ SessionRow* find_or_create_session(std::uint64_t groupSessionId) noexcept {
     return &*empty;
 }
 
-/** @return True when the policy selects the class, filling its row. */
-bool selected_entity_class(const SessionRow& session,
-                           const external::EntityToken& target,
-                           std::uint32_t& output) noexcept {
-    output = format::kAbsentIndex;
-    if (target.slot >= session.actors.slots.size()) {
-        return false;
-    }
-    const external::ActorEntitySlot& slot = session.actors.slots[target.slot];
-    if (!slot.occupied || slot.incarnation != target.incarnation) {
-        return false;
-    }
-    output = slot.actorClassIndex;
-    return class_selected(session, output)
-           && token_selected(session, target, session.selectedSquadCount);
-}
-
 /** Queues one command for the session. @return True when a queue slot was free. */
 bool queue_command(SessionRow& session,
                    std::uint32_t actorClassIndex,
@@ -702,6 +687,10 @@ bool queue_command(SessionRow& session,
     if (row == nullptr || !external::published_actor_command_catalog(session.catalog, catalog)) {
         return false;
     }
+    if (purpose == OutputPurpose::policyCommand
+        && !resolve_policy_faction(session, target, actorClassIndex, catalog.profiles, value)) {
+        return false;
+    }
     OutputRow candidate{};
     if (!encode_command(catalog, session, actorClassIndex, target, value, candidate.draft)) {
         return false;
@@ -714,20 +703,6 @@ bool queue_command(SessionRow& session,
     return true;
 }
 
-/** Drops every queued command and output aimed at one target. */
-void remove_target_state(SessionRow& session, const external::EntityToken& target) noexcept {
-    for (OutputRow& row : session.outputs) {
-        if (row.state != OutputState::empty && same_token(row.target, target)) {
-            row = {};
-        }
-    }
-    for (ReplayRow& row : session.replays) {
-        if (row.occupied && same_token(row.target, target)) {
-            row = {};
-        }
-    }
-}
-
 /** Clears transient state but retains policy. */
 void internal::reset_transport_session(std::uint64_t groupSessionId) noexcept {
     AcquireSRWLockExclusive(&g_lock);
@@ -755,7 +730,7 @@ void shutdown() noexcept {
         if (row.bindingRetained) {
             releases[releaseCount++] = row.binding;
         }
-        row = {};
+        clear_session_row(row);
     }
     for (DeferredPolicyRow& row : g_deferredPolicies) {
         if (row.bindingRetained) {
@@ -818,7 +793,7 @@ void service(std::uint64_t) noexcept {
             if (session->bindingRetained) {
                 releases[releaseCount++] = session->binding;
             }
-            *session = {};
+            clear_session_row(*session);
             continue;
         }
         const bool linkMoved = linkPresent[index]
@@ -848,7 +823,7 @@ void reset_group_session(std::uint64_t groupSessionId) noexcept {
     if (session != nullptr) {
         release = session->binding;
         retained = session->bindingRetained;
-        *session = {};
+        clear_session_row(*session);
     }
     ReleaseSRWLockExclusive(&g_lock);
     if (retained) {

+ 168 - 26
Sunrise/src/server/gameplay/actor_command_policy_lane.cpp

@@ -9,6 +9,7 @@
 #include "../../middleware/gameplay/external/composite_entity_codec.h"
 #include "../../middleware/gameplay/external/simulation_event_runtime_codec.h"
 #include "../../state/activity_sdk/runtime.h"
+#include "../activity/mission/mission_script_runtime.h"
 #include "actor_command_policy.h"
 #include "actor_command_policy_internal.h"
 #include "actor_command_policy_session.h"
@@ -159,7 +160,14 @@ struct SquadClientRefLayout final {
         });
     if ((record.flags & external::entityRemove) != 0) {
         if (row != session.squads.end()) {
+            const auto prior = *row;
             *row = {};
+            for (std::size_t index = 0; index < prior.actorCount; ++index) {
+                std::uint32_t actorClass = format::kAbsentIndex;
+                if (!selected_entity_class(session, prior.actors[index], actorClass)) {
+                    remove_target_state(session, prior.actors[index]);
+                }
+            }
         }
         return true;
     }
@@ -186,6 +194,11 @@ struct SquadClientRefLayout final {
         }
     }
     const SquadEntityRow prior = *row;
+    std::array<bool, kSquadActorCapacity> priorSelected{};
+    for (std::size_t index = 0; index < candidate.actorCount; ++index) {
+        std::uint32_t actorClass = format::kAbsentIndex;
+        priorSelected[index] = selected_entity_class(session, candidate.actors[index], actorClass);
+    }
     *row = candidate;
     const bool selected = session.policyActive && selected_squad(session, candidate);
     report(core::log::Level::debug,
@@ -198,13 +211,20 @@ struct SquadClientRefLayout final {
            selected ? 1U : 0U,
            static_cast<unsigned>(record.flags));
     if (!selected) {
+        for (std::size_t index = 0; index < prior.actorCount; ++index) {
+            std::uint32_t actorClass = format::kAbsentIndex;
+            if (!selected_entity_class(session, prior.actors[index], actorClass)) {
+                remove_target_state(session, prior.actors[index]);
+            }
+        }
         return true;
     }
     std::array<external::EntityToken, kSquadActorCapacity> queued{};
     std::size_t queuedCount = 0;
     for (std::size_t index = 0; index < candidate.actorCount; ++index) {
         std::uint32_t actorClass = format::kAbsentIndex;
-        if (!selected_entity_class(session, candidate.actors[index], actorClass)) {
+        if (priorSelected[index]
+            || !selected_entity_class(session, candidate.actors[index], actorClass)) {
             continue;
         }
         if (!queue_command(session,
@@ -227,6 +247,12 @@ struct SquadClientRefLayout final {
         }
         queued[queuedCount++] = candidate.actors[index];
     }
+    for (std::size_t index = 0; index < prior.actorCount; ++index) {
+        std::uint32_t actorClass = format::kAbsentIndex;
+        if (!selected_entity_class(session, prior.actors[index], actorClass)) {
+            remove_target_state(session, prior.actors[index]);
+        }
+    }
     return true;
 }
 
@@ -296,22 +322,50 @@ static bool accept_entity_record(std::uint64_t groupSessionId,
     const format::ActorClass* const priorClassData = session->actors.classData;
     const std::size_t priorClassCount = session->actors.classCount;
     const external::ActorEntitySlot priorSlot = session->actors.slots[record.token.slot];
+    const external::EntityToken priorToken{record.token.slot, priorSlot.incarnation};
+    std::uint32_t priorClass = format::kAbsentIndex;
+    const bool priorSelected =
+        session->policyActive && selected_entity_class(*session, priorToken, priorClass);
+    const auto priorSquad =
+        selected_entity_squad(*session, priorToken, session->selectedSquadCount);
     const external::ActorEntityApplyResult result =
         external::apply_actor_entity_record(session->actors, catalog, record);
     bool policyCommandQueued = false;
+    const OutputRow* queuedOutput = nullptr;
     bool accepted = result != external::ActorEntityApplyResult::invalid
                     && result != external::ActorEntityApplyResult::staleToken;
-    if (result == external::ActorEntityApplyResult::actorRemoved) {
-        remove_target_state(*session, record.token);
-    } else if (result == external::ActorEntityApplyResult::actorCreated && session->policyActive) {
-        std::uint32_t actorClassIndex = format::kAbsentIndex;
-        if (selected_entity_class(*session, record.token, actorClassIndex)) {
+    std::uint32_t actorClassIndex = format::kAbsentIndex;
+    const bool selected = accepted && session->policyActive
+                          && selected_entity_class(*session, record.token, actorClassIndex);
+    const auto selectedSquad =
+        selected_entity_squad(*session, record.token, session->selectedSquadCount);
+    if (selected
+        && (result == external::ActorEntityApplyResult::actorCreated || !priorSelected
+            || selectedSquad != priorSquad)) {
+        const auto empty = std::find_if(
+            session->outputs.begin(), session->outputs.end(), [](const OutputRow& output) {
+                return output.state == OutputState::empty;
+            });
+        if (empty != session->outputs.end()) {
+            queuedOutput = &*empty;
             policyCommandQueued = queue_command(*session,
                                                 actorClassIndex,
                                                 record.token,
                                                 session->policyValue,
                                                 OutputPurpose::policyCommand);
-            accepted = policyCommandQueued;
+        }
+        accepted = policyCommandQueued;
+    }
+    if (accepted) {
+        const bool replaced = result == external::ActorEntityApplyResult::actorCreated
+                              || result == external::ActorEntityApplyResult::nonActor
+                              || result == external::ActorEntityApplyResult::actorRemoved;
+        if (priorSlot.occupied && (replaced || (priorSelected && !selected))) {
+            remove_target_state(*session, priorToken, queuedOutput);
+        }
+        if (result == external::ActorEntityApplyResult::actorRemoved
+            || (!selected && session->actors.slots[record.token.slot].authoredSource.known)) {
+            remove_target_state(*session, record.token, queuedOutput);
         }
     }
     // The projection is all-or-nothing, so a refused command restores the whole slot.
@@ -335,11 +389,15 @@ static bool accept_entity_record(std::uint64_t groupSessionId,
 /** Policy projection visits every record after transport acceptance. */
 bool accept_entity_batch(std::uint64_t groupSessionId,
                          const external::EntityBatch& batch) noexcept {
-    if (groupSessionId == 0) {
+    if (groupSessionId == 0
+        || external::entity_record_count(batch) > external::kEntityBatchCapacity) {
         return false;
     }
     bool accepted = true;
     for (std::size_t index = 0; index < external::entity_record_count(batch); ++index) {
+        if (batch.ignoredRecordMask.test(index)) {
+            continue;
+        }
         accepted = accept_entity_record(groupSessionId, external::entity_record_at(batch, index))
                    && accepted;
     }
@@ -373,21 +431,65 @@ bool accept_lane0(std::uint64_t groupSessionId,
         return false;
     }
     bool accepted = true;
+    std::uint32_t eventTypes = 0;
     std::array<std::uint32_t, kReplayCapacity> createdReplays{};
     std::size_t createdReplayCount = 0;
+    std::array<std::size_t, kReplayCapacity> provokedSquads{};
+    std::size_t provokedCount = 0;
+    std::array<SelectedSquad, kReplayCapacity> notifications{};
+    std::array<OutputRow*, kOutputCapacity> stagedOutputs{};
+    std::size_t stagedCount = 0;
+    const auto binding = session->binding;
+    const auto sourceGeneration = session->activityClientGeneration;
+    const auto restore =
+        [&](std::uint32_t actorClass, external::EntityToken actor, std::uint32_t replayIndex) {
+            auto empty =
+                std::find_if(session->outputs.begin(), session->outputs.end(), [](const auto& row) {
+                    return row.state == OutputState::empty;
+                });
+            if (empty == session->outputs.end() || actorClass >= catalog.profiles.size()
+                || stagedCount == stagedOutputs.size()
+                || !queue_command(*session,
+                                  actorClass,
+                                  actor,
+                                  catalog.profiles[actorClass].defaultFaction,
+                                  OutputPurpose::restoreCommand,
+                                  replayIndex)) {
+                return false;
+            }
+            stagedOutputs[stagedCount++] = &*empty;
+            return true;
+        };
     for (std::size_t index = 0; index < batch.count; ++index) {
         external::DecodedRuntimeEvent event{};
         if (!internal::decode_event(catalog, batch, batch.records[index], event)) {
             accepted = false;
             break;
         }
+        if (batch.records[index].eventType <= external::kMaximumSimulationEventType) {
+            eventTypes |= std::uint32_t{1} << batch.records[index].eventType;
+        }
         if (event.identity.eventIndex != session->damageEventIndex) {
             continue;
         }
         external::EntityToken target{};
         std::uint32_t actorClassIndex = format::kAbsentIndex;
-        if (!internal::damage_target(event, target)
-            || !selected_entity_class(*session, target, actorClassIndex)) {
+        const bool targetPresent = internal::damage_target(event, target);
+        if (!targetPresent || !selected_entity_class(*session, target, actorClassIndex)) {
+            report(
+                core::log::Level::debug,
+                "ev=actor_policy stage=damage result=unselected target=%u slot=%u incarnation=%u",
+                targetPresent ? 1U : 0U,
+                static_cast<unsigned>(target.slot),
+                static_cast<unsigned>(target.incarnation));
+            continue;
+        }
+        const auto squad = selected_entity_squad(*session, target, session->selectedSquadCount);
+        if (squad >= session->selectedSquadCount || session->selectedSquads[squad].provoked
+            || std::find(provokedSquads.begin(),
+                         provokedSquads.begin() + static_cast<std::ptrdiff_t>(provokedCount),
+                         squad)
+                   != provokedSquads.begin() + static_cast<std::ptrdiff_t>(provokedCount)) {
             continue;
         }
         // One replay per target. A second hit on the same actor restores the same faction.
@@ -411,35 +513,75 @@ bool accept_lane0(std::uint64_t groupSessionId,
         const std::uint32_t replayIndex =
             static_cast<std::uint32_t>(emptyReplay - session->replays.begin());
         createdReplays[createdReplayCount++] = replayIndex;
-        const std::int32_t defaultFaction = catalog.profiles[actorClassIndex].defaultFaction;
-        if (!queue_command(*session,
-                           actorClassIndex,
-                           target,
-                           defaultFaction,
-                           OutputPurpose::restoreCommand,
-                           replayIndex)) {
+        if (!restore(actorClassIndex, target, replayIndex)) {
             *emptyReplay = {};
             accepted = false;
             break;
         }
-    }
-    // The lane is accepted whole, so a refused event drops every replay this call created.
-    if (!accepted) {
-        for (OutputRow& row : session->outputs) {
-            if (row.purpose != OutputPurpose::restoreCommand) {
+        for (std::size_t slot = 0; slot < session->actors.slots.size(); ++slot) {
+            const auto& actor = session->actors.slots[slot];
+            const external::EntityToken member{static_cast<std::uint16_t>(slot), actor.incarnation};
+            std::uint32_t memberClass = format::kAbsentIndex;
+            if (!actor.occupied || same_token(member, target)
+                || selected_entity_squad(*session, member, session->selectedSquadCount) != squad
+                || !selected_entity_class(*session, member, memberClass)) {
                 continue;
             }
-            const auto end =
-                createdReplays.begin() + static_cast<std::ptrdiff_t>(createdReplayCount);
-            if (std::find(createdReplays.begin(), end, row.replayIndex) != end) {
-                row = {};
+            if (!restore(memberClass, member, format::kAbsentIndex)) {
+                accepted = false;
+                break;
             }
         }
+        if (!accepted) {
+            break;
+        }
+        provokedSquads[provokedCount++] = squad;
+    }
+    // The lane is accepted whole, so a refused event drops every replay this call created.
+    if (!accepted) {
+        for (std::size_t index = 0; index < stagedCount; ++index) {
+            *stagedOutputs[index] = {};
+        }
         for (std::size_t index = 0; index < createdReplayCount; ++index) {
             session->replays[createdReplays[index]] = {};
         }
+    } else {
+        for (std::size_t index = 0; index < provokedCount; ++index) {
+            auto& squad = session->selectedSquads[provokedSquads[index]];
+            squad.provoked = true;
+            notifications[index] = squad;
+            for (auto& output : session->outputs) {
+                if (output.state != OutputState::empty
+                    && output.purpose == OutputPurpose::policyCommand
+                    && selected_entity_squad(*session, output.target, session->selectedSquadCount)
+                           == provokedSquads[index]) {
+                    output = {};
+                }
+            }
+        }
     }
     ReleaseSRWLockExclusive(&g_lock);
+    if (accepted) {
+        if (batch.count != 0) {
+            report(core::log::Level::debug,
+                   "ev=actor_policy stage=lane0 result=accepted events=%u types=0x%08X",
+                   static_cast<unsigned>(batch.count),
+                   eventTypes);
+        }
+        for (std::size_t index = 0; index < provokedCount; ++index) {
+            const auto& squad = notifications[index];
+            server::activity::mission::report_squad_provoked(
+                binding,
+                sourceGeneration,
+                squad.registryKey,
+                static_cast<std::uint16_t>(squad.slotIndex));
+            report(core::log::Level::info,
+                   "ev=actor_policy stage=provoked key=0x%08X type=%u index=%u",
+                   squad.registryKey,
+                   static_cast<unsigned>(squad.slotType),
+                   squad.slotIndex);
+        }
+    }
     return accepted;
 }
 

+ 165 - 12
Sunrise/src/server/gameplay/actor_command_policy_session.h

@@ -2,6 +2,7 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -12,6 +13,7 @@
 #include "../../middleware/gameplay/external/simulation_event_runtime_codec.h"
 #include "../../state/activity/definition.h"
 #include "../../state/activity_sdk/format.h"
+#include "../../state/activity_sdk/generated_world/runtime.h"
 #include "../../state/activity_sdk/runtime.h"
 #include "group/group_host_sessions.h"
 #include "peer/peer_transport.h"
@@ -74,8 +76,45 @@ struct SelectedSquad final {
     std::uint32_t registryKey{};
     std::uint32_t slotIndex{};
     std::uint8_t slotType{};
+    bool provoked{};
 };
 
+/**
+ * A squad's catalog slot row must resolve to the authored index carried by actor references.
+ * @param catalog Pinned SDK rows containing the squad.
+ * @param squad Exact runnable squad whose identity is requested.
+ * @param output Receives the wire reference; cleared on failure.
+ * @return True when the object, slot and native squad schemas agree.
+ */
+[[nodiscard]] inline bool resolve_selected_squad(const state::activity_sdk::Catalog& catalog,
+                                                 const state::activity_sdk::format::Squad& squad,
+                                                 SelectedSquad& output) noexcept {
+    namespace format = state::activity_sdk::format;
+    output = {};
+    const auto slots = catalog.slots();
+    const auto objects = catalog.objects();
+    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask
+        || squad.slotIndex >= slots.size() || squad.objectIndex >= objects.size()) {
+        return false;
+    }
+    const auto& slot = slots[squad.slotIndex];
+    const auto key = objects[squad.objectIndex].objectKey;
+    if (key == 0 || key == format::kAbsentIndex || slot.objectIndex != squad.objectIndex
+        || slot.slotType != format::kSquadSlotType
+        || slot.componentClass != format::kSquadComponentClass
+        || slot.authSchema != format::kSquadAuthSchema
+        || slot.senseSchema != format::kSquadSenseSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0
+        || slot.slotIndex
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return false;
+    }
+    output.registryKey = key;
+    output.slotType = static_cast<std::uint8_t>(slot.slotType);
+    output.slotIndex = slot.slotIndex;
+    return true;
+}
+
 /** One live type-1 entity and the actor tokens in its latest update. */
 struct SquadEntityRow final {
     middleware::gameplay::external::EntityToken token{};
@@ -91,6 +130,7 @@ struct SquadEntityRow final {
 struct SessionRow final {
     state::activity::SessionBinding binding{};
     state::activity_sdk::Snapshot catalog{};
+    state::activity_sdk::generated_world::GeneratedWorldView worldView{};
     middleware::gameplay::external::ActorEntityRegistry actors{};
     std::array<std::uint32_t, kSelectedActorClassCapacity> actorClasses{};
     std::array<SelectedSquad, kSelectedSquadCapacity> selectedSquads{};
@@ -99,6 +139,7 @@ struct SessionRow final {
     std::array<ReplayRow, kReplayCapacity> replays{};
     std::uint64_t groupSessionId{};
     std::uint64_t hostGeneration{};
+    std::uint64_t activityClientGeneration{};
     peer::LinkIdentity linkIdentity{};
     std::uint32_t actorEventIndex{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t damageEventIndex{state::activity_sdk::format::kAbsentIndex};
@@ -120,8 +161,11 @@ extern std::array<SessionRow, kSessionCapacity> g_sessions;
 extern std::uint64_t g_serviceFrame;
 
 /** @return True when both tokens name the same live entity incarnation. */
-[[nodiscard]] bool same_token(const middleware::gameplay::external::EntityToken& left,
-                              const middleware::gameplay::external::EntityToken& right) noexcept;
+[[nodiscard]] inline bool
+same_token(const middleware::gameplay::external::EntityToken& left,
+           const middleware::gameplay::external::EntityToken& right) noexcept {
+    return left.slot == right.slot && left.incarnation == right.incarnation;
+}
 
 /** @return The occupied row for one group session, or null. The caller holds the lock. */
 [[nodiscard]] SessionRow* find_session(std::uint64_t groupSessionId) noexcept;
@@ -130,15 +174,112 @@ extern std::uint64_t g_serviceFrame;
 [[nodiscard]] SessionRow* find_or_create_session(std::uint64_t groupSessionId) noexcept;
 
 /**
- * Resolves one committed token to its SDK actor-class row.
- * @param session Row holding the registry and the selected classes.
+ * Known actor-source state overrides legacy network-squad membership, including a clear.
+ * @param session Row holding the actor and exact selected squad references.
+ * @param target Token whose current membership is checked.
+ * @param selectedCount Selected prefix, including the shorter prefix before a policy extension.
+ * @return The selected row, or kSelectedSquadCapacity when unknown or ambiguous.
+ */
+[[nodiscard]] inline std::size_t
+selected_entity_squad(const SessionRow& session,
+                      const middleware::gameplay::external::EntityToken& target,
+                      std::size_t selectedCount) noexcept {
+    if (target.slot >= session.actors.slots.size()) {
+        return kSelectedSquadCapacity;
+    }
+    const auto& actor = session.actors.slots[target.slot];
+    if (!actor.occupied || actor.incarnation != target.incarnation) {
+        return kSelectedSquadCapacity;
+    }
+    selectedCount = (std::min)(selectedCount, session.selectedSquads.size());
+    auto source = actor.authoredSource;
+    if (const auto* world = session.worldView.snapshot(); world != nullptr) {
+        state::gameplay::entity_identity::ActorSourceReference squadSource{};
+        if (middleware::gameplay::external::resolve_combatant_squad_source(
+                *world, source, squadSource)) {
+            source = squadSource;
+        }
+    }
+    std::size_t found = kSelectedSquadCapacity;
+    for (std::size_t index = 0; index < selectedCount; ++index) {
+        const auto& selected = session.selectedSquads[index];
+        const auto matches = [&](std::uint32_t key, std::uint8_t type, std::uint32_t slot) {
+            return selected.registryKey == key && selected.slotType == type
+                   && selected.slotIndex == slot;
+        };
+        const bool member =
+            source.known
+                ? source.present && matches(source.key, source.type, source.index)
+                : std::any_of(session.squads.begin(), session.squads.end(), [&](const auto& row) {
+                      return row.occupied && row.actorCount <= row.actors.size()
+                             && matches(row.registryKey, row.slotType, row.slotIndex)
+                             && std::any_of(
+                                 row.actors.begin(),
+                                 row.actors.begin() + row.actorCount,
+                                 [&](const auto& token) { return same_token(token, target); });
+                  });
+        if (member) {
+            if (found != kSelectedSquadCapacity) {
+                return kSelectedSquadCapacity;
+            }
+            found = index;
+        }
+    }
+    return found;
+}
+
+/** Only one exact squad may own a policy actor. */
+[[nodiscard]] inline bool
+selected_entity_member(const SessionRow& session,
+                       const middleware::gameplay::external::EntityToken& target,
+                       std::size_t selectedCount) noexcept {
+    return selected_entity_squad(session, target, selectedCount) != kSelectedSquadCapacity;
+}
+
+/**
+ * The class and exact authored membership must both be selected by this policy.
+ * @param session Row holding the registry and selected classes.
  * @param target Token the caller decoded.
- * @param output Absent index, then the class row when the token is live.
- * @return True only when the token is live and its class is selected by the policy.
+ * @param output Absent index, then the live actor class.
+ * @return True when both policy selections match the current token.
+ */
+[[nodiscard]] inline bool
+selected_entity_class(const SessionRow& session,
+                      const middleware::gameplay::external::EntityToken& target,
+                      std::uint32_t& output) noexcept {
+    output = state::activity_sdk::format::kAbsentIndex;
+    if (!selected_entity_member(session, target, session.selectedSquadCount)) {
+        return false;
+    }
+    output = session.actors.slots[target.slot].actorClassIndex;
+    const auto classes = std::span(session.actorClasses).first(session.actorClassCount);
+    return std::find(classes.begin(), classes.end(), output) != classes.end();
+}
+
+/**
+ * New members of a provoked squad inherit their native faction instead of the idle policy.
+ * @param session Policy and squad phase owning the actor.
+ * @param target Current actor token.
+ * @param actorClassIndex Validated actor class row.
+ * @param profiles Profiles from the policy's pinned catalog.
+ * @param value Requested faction, replaced only for a provoked squad.
+ * @return False when a required native profile is unavailable.
  */
-[[nodiscard]] bool selected_entity_class(const SessionRow& session,
-                                         const middleware::gameplay::external::EntityToken& target,
-                                         std::uint32_t& output) noexcept;
+[[nodiscard]] inline bool
+resolve_policy_faction(const SessionRow& session,
+                       const middleware::gameplay::external::EntityToken& target,
+                       std::uint32_t actorClassIndex,
+                       std::span<const state::activity_sdk::format::ActorBehaviorProfile> profiles,
+                       std::int32_t& value) noexcept {
+    const auto squad = selected_entity_squad(session, target, session.selectedSquadCount);
+    if (squad < session.selectedSquadCount && session.selectedSquads[squad].provoked) {
+        if (actorClassIndex >= profiles.size()) {
+            return false;
+        }
+        value = profiles[actorClassIndex].defaultFaction;
+    }
+    return true;
+}
 
 /**
  * Encodes one command and queues it when the bounded output ledger can own it.
@@ -158,8 +299,20 @@ queue_command(SessionRow& session,
               OutputPurpose purpose,
               std::uint32_t replayIndex = state::activity_sdk::format::kAbsentIndex) noexcept;
 
-/** Removes pending command and replay state for one retired token. */
-void remove_target_state(SessionRow& session,
-                         const middleware::gameplay::external::EntityToken& target) noexcept;
+/** Removes target state while optionally keeping its newly queued replacement command. */
+inline void remove_target_state(SessionRow& session,
+                                const middleware::gameplay::external::EntityToken& target,
+                                const OutputRow* preserve = nullptr) noexcept {
+    for (OutputRow& row : session.outputs) {
+        if (&row != preserve && row.state != OutputState::empty && same_token(row.target, target)) {
+            row = {};
+        }
+    }
+    for (ReplayRow& row : session.replays) {
+        if (row.occupied && same_token(row.target, target)) {
+            row = {};
+        }
+    }
+}
 
 } // namespace sunrise::server::gameplay::actor_command_policy

+ 125 - 0
Sunrise/src/server/ui/activity_host/activity_host_sdk_actor_sequences.cpp

@@ -0,0 +1,125 @@
+#include "activity_host_sdk_actor_sequences.h"
+
+#include <array>
+#include <cstdio>
+#include <imgui.h>
+
+#include "../../../state/activity_sdk/actor_sequences.h"
+#include "../../activity/activity_sdk_actor_sequences.h"
+#include "../../activity/activity_sdk_device_runtime.h"
+
+namespace sunrise::server::ui::activity_host::sdk_state_pages {
+namespace {
+namespace sdk = state::activity_sdk;
+namespace sequences = server::activity::actor_sequences;
+namespace devices = server::activity::activity_sdk_devices;
+
+sequences::Owner g_owner{};
+sdk::generated_world::GeneratedWorldView g_world{};
+std::uint32_t g_entry{sdk::format::kAbsentIndex};
+const char* g_result = "";
+
+/** Exact keys distinguish entries even when their readable source names coincide. */
+[[nodiscard]] std::array<char, 256> label(const sdk::Catalog& catalog,
+                                          const sdk::format::ActorSequenceEntry& row) noexcept {
+    std::array<char, 256> output{};
+    const auto symbol = catalog.string(row.symbol);
+    std::snprintf(output.data(),
+                  output.size(),
+                  "%.*s  [0x%08X, kind %u]",
+                  static_cast<int>(symbol.size()),
+                  symbol.data(),
+                  row.keyHash,
+                  row.kind);
+    return output;
+}
+} // namespace
+
+/** The menu and playback route use the same extracted actor-owned catalog. */
+void draw_actor_sequences(const sdk::BoundView& view, std::uint32_t slotRow) noexcept {
+    ImGui::SeparatorText("Animation sequence");
+    const auto& held = g_world.activity_sdk_view();
+    if (held.catalog != view.catalog
+        || held.activityClientGeneration != view.activityClientGeneration
+        || held.activityRow != view.activityRow || held.scenarioRow != view.scenarioRow
+        || !state::activity::same_binding(held.binding, view.binding)) {
+        if (sdk::generated_world::resolve(view, g_world)
+            != sdk::generated_world::BindStatus::ready) {
+            ImGui::TextDisabled("This activity has no authenticated generated-world catalog.");
+            return;
+        }
+    }
+    sequences::Owner owner{};
+    if (!sequences::owner(g_world, slotRow, owner)) {
+        ImGui::TextDisabled("This combatant has no exact authored actor owner.");
+        return;
+    }
+    if (g_owner != owner) {
+        g_owner = owner;
+        g_entry = sdk::format::kAbsentIndex;
+        g_result = "";
+    }
+    const auto& catalog = *view.catalog;
+    const auto count = sdk::actor_sequence_count(catalog, owner.actorClassRow);
+    const auto* selected = sdk::resolve_actor_sequence(catalog, owner.actorClassRow, g_entry);
+    if (count == 0) {
+        ImGui::TextDisabled("No unambiguous sequence entries were extracted for this actor.");
+        return;
+    }
+    const auto selectedLabel = selected != nullptr ? label(catalog, *selected)
+                                                   : std::array<char, 256>{"Choose a sequence"};
+    ImGui::SetNextItemWidth(ImGui::GetFontSize() * 32.0F);
+    if (ImGui::BeginCombo("Sequence", selectedLabel.data())) {
+        for (std::size_t ordinal = 0; ordinal < count; ++ordinal) {
+            const auto* const row = sdk::actor_sequence_at(catalog, owner.actorClassRow, ordinal);
+            if (row == nullptr) {
+                continue;
+            }
+            const auto rowLabel = label(catalog, *row);
+            const auto index =
+                static_cast<std::uint32_t>(row - catalog.actor_sequence_entries().data());
+            if (ImGui::Selectable(rowLabel.data(), g_entry == index)) {
+                g_entry = index;
+                selected = row;
+                g_result = "";
+            }
+        }
+        ImGui::EndCombo();
+    }
+    if (selected != nullptr) {
+        const auto name = catalog.string(selected->name);
+        const auto path = catalog.string(selected->sourcePath);
+        ImGui::Text("Key: 0x%08X  Kind: %u  Resource: 0x%08X",
+                    selected->keyHash,
+                    selected->kind,
+                    selected->resourceTag);
+        ImGui::Text("Table: %u  Row: %u  Offset: 0x%X",
+                    selected->tableIndex,
+                    selected->ordinal,
+                    selected->sourceOffset);
+        if (!name.empty()) {
+            ImGui::Text("Name: %.*s", static_cast<int>(name.size()), name.data());
+        }
+        if (!path.empty()) {
+            ImGui::TextWrapped("Source: %.*s", static_cast<int>(path.size()), path.data());
+        }
+        if (!sdk::actor_sequence_playable(*selected)) {
+            ImGui::TextDisabled("This authored entry kind has no supported playback route.");
+        }
+    }
+    ImGui::BeginDisabled(selected == nullptr || !sdk::actor_sequence_playable(*selected));
+    if (ImGui::Button("Play sequence")) {
+        g_result = devices::status_name(devices::play_combatant_sequence(view, slotRow, g_entry));
+    }
+    ImGui::EndDisabled();
+    ImGui::SameLine();
+    if (ImGui::Button("Cancel sequence request")) {
+        g_result = devices::status_name(devices::stop_combatant_sequence(view, slotRow));
+    }
+    ImGui::TextDisabled("Current actor behavior may suppress playback.");
+    if (g_result[0] != '\0') {
+        ImGui::Text("Sequence: %s", g_result);
+    }
+}
+
+} // namespace sunrise::server::ui::activity_host::sdk_state_pages

+ 8 - 0
Sunrise/src/server/ui/activity_host/activity_host_sdk_actor_sequences.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include "../../../state/activity_sdk/runtime.h"
+
+namespace sunrise::server::ui::activity_host::sdk_state_pages {
+void draw_actor_sequences(const state::activity_sdk::BoundView& view,
+                          std::uint32_t slotRow) noexcept;
+}

+ 2 - 1
Sunrise/src/server/ui/activity_host/activity_host_sdk_state_pages.cpp

@@ -19,6 +19,7 @@
 #include "../../activity/activity_sdk_lifetime_runtime.h"
 #include "../../activity/activity_sdk_mission_runtime.h"
 #include "../../activity/host_runtime.h"
+#include "activity_host_sdk_actor_sequences.h"
 #include "activity_host_table_layout.h"
 
 namespace sunrise::server::ui::activity_host::sdk_state_pages {
@@ -71,7 +72,6 @@ int g_occupancyValue{};
 int g_lifetimeState{};
 std::array<char, 48> g_combatantChannel{};
 float g_combatantValue{};
-
 /** Last action result, so a refusal stays on screen after the frame that produced it. */
 std::array<char, 96> g_result{};
 
@@ -584,6 +584,7 @@ void draw_combatants(const sdk::BoundView& view) noexcept {
                                  view, g_combatantRow, hash, g_combatantValue)));
     }
     ImGui::TextDisabled("The value is kept for the next attach or restore.");
+    draw_actor_sequences(view, g_combatantRow);
     draw_result();
 }
 

+ 1 - 1
Sunrise/src/server/ui/activity_host/activity_host_sdk_state_pages.h

@@ -16,7 +16,7 @@ void draw_public_events(const state::activity_sdk::BoundView& view) noexcept;
 /** Draws the type-30 occupancy conditions, their filter slot and caller value. */
 void draw_occupancy(const state::activity_sdk::BoundView& view) noexcept;
 
-/** Draws the type-2 combatants, their squad binding and one retained channel. */
+/** Draws combatant bindings, retained channels and named sequence requests. */
 void draw_combatants(const state::activity_sdk::BoundView& view) noexcept;
 
 /** Draws the scenario's authored states and the host select action for one region. */

+ 1 - 1
Sunrise/src/server/ui/activity_host/activity_host_sdk_view.cpp

@@ -182,7 +182,7 @@ struct PageRow final {
 constexpr std::array<PageRow, 20> kPages{{
     {Page::squads, "Squads", "Place an authored squad.", marker::WorldPage::squads},
     {Page::idles, "Idles", "Start an actor's authored state.", marker::WorldPage::squads},
-    {Page::combatants, "Combatants", "Bind a combatant, retain a channel."},
+    {Page::combatants, "Combatants", "Bind actors, edit channels and try animation sequences."},
     {Page::devices, "Devices", "Drive a door, lift or switch.", marker::WorldPage::devices},
     {Page::triggers, "Triggers", "Fire an authored pulse.", marker::WorldPage::triggers},
     {Page::objects, "Objects", "Spawn or remove a placed object.", marker::WorldPage::objects},

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

@@ -118,10 +118,11 @@ namespace {
 /** Field-by-field equality of two typed intents. */
 [[nodiscard]] bool same_intent(const TypedIntent& left, const TypedIntent& right) noexcept {
     return left.squadCounts == right.squadCounts && 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.authSchema == right.authSchema
+           && left.sceneEventKey == right.sceneEventKey && left.authSchema == right.authSchema
            && left.actorCommandSelector == right.actorCommandSelector
            && left.actorCommandValue == right.actorCommandValue
            && left.authBitCount == right.authBitCount

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

@@ -76,6 +76,23 @@ enum class IntentKind : std::uint8_t {
     actorCommand,
     playPerformance,
     restartCheckpoint,
+    signalAuthoredScene,
+    stopAuthoredScene,
+    playActorSequence,
+};
+
+/** Actor sequence values belong to one combatant and one SDK/client generation. */
+struct ActorSequenceOwner final {
+    std::array<std::byte, 32> sdkBuildSha256{};
+    std::array<std::byte, 32> sdkPayloadSha256{};
+    std::uint64_t activityClientGeneration{};
+    std::uint64_t sessionId{};
+    std::uint64_t sessionCreatedRevision{};
+    std::uint32_t activityRow{};
+    std::uint32_t scenarioRow{};
+    std::uint32_t slotRow{};
+    std::uint32_t actorClassRow{};
+    bool operator==(const ActorSequenceOwner&) const = default;
 };
 
 /** One object a mission omits, named the way a roster group is: its tag and its registry key. */
@@ -86,6 +103,7 @@ struct MissionSeedOmission final {
 
 /** Typed action with RAII-owned body bytes and no packet or native pointers. */
 struct TypedIntent final {
+    ActorSequenceOwner sequenceOwner{};
     std::array<std::int32_t, kSquadMemberCapacity> squadCounts{};
     /** Objects the mission leaves out of the state it selects. */
     std::array<MissionSeedOmission, kMissionSeedOmitCapacity> seedOmissions{};
@@ -101,6 +119,7 @@ struct TypedIntent final {
     IntentKind kind{IntentKind::placeSquad};
     std::uint32_t firstRow{};
     std::uint32_t secondRow{};
+    std::uint32_t sceneEventKey{};
     std::uint32_t objectTag{};
     std::uint32_t registryKey{};
     std::uint32_t authSchema{};

+ 13 - 0
Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp

@@ -277,6 +277,19 @@ Catalog::sobject_rsat_field_bindings() const noexcept {
         header_, view_, format::SectionIndex::sobjectRsatFieldBindings);
 }
 
+std::span<const format::ActorSequenceTable> Catalog::actor_sequence_tables() const noexcept {
+    return rows<format::ActorSequenceTable>(
+        header_, view_, format::SectionIndex::actorSequenceTables);
+}
+std::span<const format::ActorSequenceEntry> Catalog::actor_sequence_entries() const noexcept {
+    return rows<format::ActorSequenceEntry>(
+        header_, view_, format::SectionIndex::actorSequenceEntries);
+}
+std::span<const format::ActorSequenceBinding> Catalog::actor_sequence_bindings() const noexcept {
+    return rows<format::ActorSequenceBinding>(
+        header_, view_, format::SectionIndex::actorSequenceBindings);
+}
+
 std::span<const format::ActorStateName> Catalog::actor_state_names() const noexcept {
     return rows<format::ActorStateName>(header_, view_, format::SectionIndex::actorStateNames);
 }

+ 3 - 0
Sunrise/src/state/activity_sdk/activity_sdk_loader.cpp

@@ -59,6 +59,9 @@ constexpr std::array<std::uint32_t, format::kSectionCount> kExpectedStrides{
     sizeof(format::SobjectRsatFieldBinding),
     sizeof(format::RuntimeTypeDefinition),
     sizeof(format::ActorStateName),
+    sizeof(format::ActorSequenceTable),
+    sizeof(format::ActorSequenceEntry),
+    sizeof(format::ActorSequenceBinding),
 };
 
 /** Checks all section bounds before any typed row pointer is formed. */

+ 5 - 0
Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp

@@ -2,6 +2,7 @@
 #include <cstddef>
 #include <cstdio>
 
+#include "actor_sequences.h"
 #include "internal.h"
 #include "validation_internal.h"
 
@@ -491,6 +492,10 @@ void remember_state_name(const Catalog& catalog, std::size_t refused) noexcept {
 
 /** Runs every cross-section relation check in order, remembering the first that refuses. */
 bool relations(const Catalog& catalog) {
+    if (!valid_actor_sequences(catalog)) {
+        remember("actor_sequences");
+        return false;
+    }
     std::size_t refused = 0;
     if (!actor_state_names(catalog, refused)) {
         remember_state_name(catalog, refused);

+ 249 - 0
Sunrise/src/state/activity_sdk/actor_sequences.h

@@ -0,0 +1,249 @@
+#pragma once
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <string_view>
+
+#include "../build_data/scriptables/inline_name_evidence.h"
+#include "runtime.h"
+
+namespace sunrise::state::activity_sdk {
+namespace sequence_detail {
+
+/** Duplicate declarations may share one table pair; distinct owner choices remain ambiguous. */
+[[nodiscard]] inline const format::ActorSequenceBinding*
+binding(const Catalog& catalog, std::uint32_t actorClass) noexcept {
+    if (actorClass >= catalog.actor_classes().size()) {
+        return nullptr;
+    }
+    const auto rows = catalog.actor_sequence_bindings();
+    const auto first =
+        std::lower_bound(rows.begin(), rows.end(), actorClass, [](const auto& row, auto actor) {
+            return row.actorClassIndex < actor;
+        });
+    const format::ActorSequenceBinding* found = nullptr;
+    for (auto row = first; row != rows.end() && row->actorClassIndex == actorClass; ++row) {
+        if (found != nullptr
+            && (found->globalTableIndex != row->globalTableIndex
+                || found->localTableIndex != row->localTableIndex)) {
+            return nullptr;
+        }
+        found = &*row;
+    }
+    return found;
+}
+
+/** A missing table is an empty scope; malformed ranges are never exposed. */
+[[nodiscard]] inline std::span<const format::ActorSequenceEntry>
+entries(const Catalog& catalog, std::uint32_t tableIndex) noexcept {
+    const auto tables = catalog.actor_sequence_tables();
+    const auto rows = catalog.actor_sequence_entries();
+    if (tableIndex >= tables.size()) {
+        return {};
+    }
+    const auto range = tables[tableIndex].entries;
+    return range.first <= rows.size() && range.count <= rows.size() - range.first
+               ? rows.subspan(range.first, range.count)
+               : std::span<const format::ActorSequenceEntry>{};
+}
+
+struct Match final {
+    const format::ActorSequenceEntry* row{};
+    bool present{};
+};
+
+/** Duplicate keys shadow the local scope but cannot authorize a typed playback request. */
+[[nodiscard]] inline Match
+lookup(const Catalog& catalog, std::uint32_t table, std::uint32_t key) noexcept {
+    const auto rows = entries(catalog, table);
+    const auto first =
+        std::lower_bound(rows.begin(), rows.end(), key, [](const auto& row, auto value) {
+            return row.keyHash < value;
+        });
+    if (first == rows.end() || first->keyHash != key) {
+        return {};
+    }
+    const auto next = first + 1;
+    return {next != rows.end() && next->keyHash == key ? nullptr : &*first, true};
+}
+
+} // namespace sequence_detail
+
+/** Native sequence actions use kind two; other extracted kinds stay inspectable. */
+[[nodiscard]] inline bool actor_sequence_playable(const format::ActorSequenceEntry& row) noexcept {
+    return (row.flags & format::kActorSequenceExact) != 0 && row.kind == 2 && row.keyHash != 0
+           && row.keyHash != 0x811C9DC5U && row.keyHash != format::kAbsentIndex
+           && row.resourceTag != 0 && row.resourceTag != format::kAbsentIndex;
+}
+
+/** Global keys win before the actor-local fallback is considered. */
+[[nodiscard]] inline const format::ActorSequenceEntry* actor_sequence_by_key(
+    const Catalog& catalog, std::uint32_t actorClass, std::uint32_t key) noexcept {
+    const auto* owner = sequence_detail::binding(catalog, actorClass);
+    if (owner == nullptr) {
+        return nullptr;
+    }
+    const auto global = sequence_detail::lookup(catalog, owner->globalTableIndex, key);
+    return global.present ? global.row
+                          : sequence_detail::lookup(catalog, owner->localTableIndex, key).row;
+}
+
+/** An entry row is usable only when this actor's native lookup selects that exact row. */
+[[nodiscard]] inline const format::ActorSequenceEntry* resolve_actor_sequence(
+    const Catalog& catalog, std::uint32_t actorClass, std::uint32_t entryRow) noexcept {
+    const auto entries = catalog.actor_sequence_entries();
+    if (entryRow >= entries.size()) {
+        return nullptr;
+    }
+    const auto* row = &entries[entryRow];
+    return actor_sequence_by_key(catalog, actorClass, row->keyHash) == row ? row : nullptr;
+}
+
+/** Effective order is global key order, then unshadowed local key order. */
+[[nodiscard]] inline const format::ActorSequenceEntry*
+actor_sequence_at(const Catalog& catalog, std::uint32_t actorClass, std::size_t ordinal) noexcept {
+    const auto* owner = sequence_detail::binding(catalog, actorClass);
+    if (owner == nullptr) {
+        return nullptr;
+    }
+    for (const bool global : {true, false}) {
+        if (!global && owner->globalTableIndex == owner->localTableIndex) {
+            continue;
+        }
+        const auto table = global ? owner->globalTableIndex : owner->localTableIndex;
+        for (const auto& row : sequence_detail::entries(catalog, table)) {
+            if (actor_sequence_by_key(catalog, actorClass, row.keyHash) != &row) {
+                continue;
+            }
+            if (ordinal == 0) {
+                return &row;
+            }
+            --ordinal;
+        }
+    }
+    return nullptr;
+}
+
+/** Counts the same effective rows that actor_sequence_at exposes. */
+[[nodiscard]] inline std::size_t actor_sequence_count(const Catalog& catalog,
+                                                      std::uint32_t actorClass) noexcept {
+    const auto* owner = sequence_detail::binding(catalog, actorClass);
+    if (owner == nullptr) {
+        return 0;
+    }
+    std::size_t count = 0;
+    for (const bool global : {true, false}) {
+        if (!global && owner->globalTableIndex == owner->localTableIndex) {
+            continue;
+        }
+        const auto table = global ? owner->globalTableIndex : owner->localTableIndex;
+        for (const auto& row : sequence_detail::entries(catalog, table)) {
+            if (actor_sequence_by_key(catalog, actorClass, row.keyHash) == &row) {
+                ++count;
+            }
+        }
+    }
+    return count;
+}
+
+/** Verifies the immutable sequence corpus and every exact actor-source binding. */
+[[nodiscard]] inline bool valid_actor_sequences(const Catalog& catalog) noexcept {
+    const auto tables = catalog.actor_sequence_tables();
+    const auto entries = catalog.actor_sequence_entries();
+    std::size_t cursor = 0;
+    for (std::size_t index = 0; index < tables.size(); ++index) {
+        const auto& table = tables[index];
+        const bool global = table.definitionClass == format::kActorSequenceGlobalDefinitionClass;
+        if (table.flags != format::kActorSequenceExact || table.definitionTag == 0
+            || table.definitionTag == format::kAbsentIndex
+            || (!global && table.definitionClass != format::kActorSequenceLocalDefinitionClass)
+            || table.arrayOffset
+                   != (global ? format::kActorSequenceGlobalArrayOffset
+                              : format::kActorSequenceLocalArrayOffset)
+            || table.entries.first != cursor || table.entries.count > entries.size() - cursor) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (tables[prior].definitionTag == table.definitionTag) {
+                return false;
+            }
+        }
+        for (std::uint32_t ordinal = 0; ordinal < table.entries.count; ++ordinal) {
+            const auto& row = entries[cursor + ordinal];
+            const auto name = catalog.string(row.name), symbol = catalog.string(row.symbol);
+            const auto path = catalog.string(row.sourcePath), id = catalog.string(row.id);
+            for (const auto text : {row.id, row.name, row.symbol, row.sourcePath}) {
+                if (text.offset == format::kAbsentIndex && text.length == 0) {
+                    continue;
+                }
+                if (text.offset > catalog.string_bytes().size()
+                    || text.length > catalog.string_bytes().size() - text.offset) {
+                    return false;
+                }
+            }
+            if (row.tableIndex != index || row.ordinal != ordinal || row.kind > 255
+                || row.sourceOffset < table.arrayOffset + 16U || row.flags == 0
+                || (row.flags & ~format::kActorSequenceEntryFlagMask) != 0
+                || (row.flags & format::kActorSequenceExact) == 0 || row.reserved != 0 || id.empty()
+                || symbol.empty() || id.find('\0') != std::string_view::npos
+                || path.find('\0') != std::string_view::npos
+                || name.find('\0') != std::string_view::npos
+                || (ordinal != 0
+                    && (entries[cursor + ordinal - 1].keyHash > row.keyHash
+                        || entries[cursor + ordinal - 1].sourceOffset > format::kAbsentIndex - 24U
+                        || entries[cursor + ordinal - 1].sourceOffset + 24U != row.sourceOffset))) {
+                return false;
+            }
+            if (((row.flags & format::kActorSequenceNameVerified) != 0) != !name.empty()) {
+                return false;
+            }
+            if (!name.empty()
+                && build_data::scriptables::inline_name_evidence::hash(
+                       std::as_bytes(std::span(name)))
+                       != row.keyHash) {
+                return false;
+            }
+            if ((row.flags & format::kActorSequenceSymbolFromPath) != 0 && path.empty()) {
+                return false;
+            }
+            for (const char c : symbol) {
+                if (!(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '_') {
+                    return false;
+                }
+            }
+        }
+        cursor += table.entries.count;
+    }
+    if (cursor != entries.size()) {
+        return false;
+    }
+    const auto bindings = catalog.actor_sequence_bindings();
+    for (std::size_t index = 0; index < bindings.size(); ++index) {
+        const auto& row = bindings[index];
+        if (row.actorClassIndex >= catalog.actor_classes().size()
+            || row.flags != format::kActorSequenceExact || row.configTag == 0
+            || row.configTag == format::kAbsentIndex
+            || row.sourceClass != format::kActorSequenceOwnerSourceClass
+            || (index != 0
+                && (bindings[index - 1].actorClassIndex > row.actorClassIndex
+                    || (bindings[index - 1].actorClassIndex == row.actorClassIndex
+                        && bindings[index - 1].componentOrdinal >= row.componentOrdinal)))) {
+            return false;
+        }
+        for (const bool global : {true, false}) {
+            const auto table = global ? row.globalTableIndex : row.localTableIndex;
+            if (table != format::kAbsentIndex
+                && (table >= tables.size()
+                    || tables[table].definitionClass
+                           != (global ? format::kActorSequenceGlobalDefinitionClass
+                                      : format::kActorSequenceLocalDefinitionClass))) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::state::activity_sdk

+ 61 - 4
Sunrise/src/state/activity_sdk/format.h

@@ -10,9 +10,9 @@ namespace sunrise::state::activity_sdk::format {
 /** Eight-byte identity at the start of every runtime SDK pack. */
 inline constexpr std::array<char, 8> kMagic{'S', 'R', 'S', 'D', 'K', 'P', '0', '1'};
 /** Runtime-pack schema version accepted by this reader. */
-inline constexpr std::uint32_t kVersion = 37;
+inline constexpr std::uint32_t kVersion = 38;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
-inline constexpr std::uint32_t kSectionCount = 43;
+inline constexpr std::uint32_t kSectionCount = 46;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
 /** The runtime accepts only the checked generated SDK build. Each pin is a SHA-256 digest. */
 inline constexpr std::array<std::byte, 32> kExpectedSdkBuildDigest{
@@ -282,6 +282,17 @@ inline constexpr std::uint32_t kActorStateMachineDefinitionClass = 0x8080815FU;
 inline constexpr std::uint32_t kActorStateMachineGroupHash = 0xAFB11A12U;
 inline constexpr std::uint32_t kActorStateNameExact = 0x1U;
 inline constexpr std::uint32_t kActorStateNameFlagMask = kActorStateNameExact;
+/** Sequence metadata is exact package data; names additionally require a verified FNV-1 match. */
+inline constexpr std::uint32_t kActorSequenceExact = 1;
+inline constexpr std::uint32_t kActorSequenceNameVerified = 2;
+inline constexpr std::uint32_t kActorSequenceSymbolFromPath = 4;
+inline constexpr std::uint32_t kActorSequenceEntryFlagMask = 7;
+/** Native global and actor-local definition classes own these two action table fields. */
+inline constexpr std::uint32_t kActorSequenceGlobalDefinitionClass = 0x8080816CU;
+inline constexpr std::uint32_t kActorSequenceLocalDefinitionClass = 0x8080815FU;
+inline constexpr std::uint32_t kActorSequenceGlobalArrayOffset = 152;
+inline constexpr std::uint32_t kActorSequenceLocalArrayOffset = 16;
+inline constexpr std::uint32_t kActorSequenceOwnerSourceClass = 0x808082ECU;
 inline constexpr std::uint32_t kAuthoredSceneSquadBlockClassRelativeOffset = 0xA4U;
 inline constexpr std::uint32_t kAuthoredSceneSquadBlockClass = 0x80806262U;
 inline constexpr std::uint32_t kAuthoredSceneSquadReferenceRelativeOffset = 0xB0U;
@@ -356,7 +367,7 @@ inline constexpr std::int64_t kAbsentSignedValue = (-0x7FFFFFFFFFFFFFFFLL - 1);
 
 /** Fixed packed byte sizes make producer and consumer ABI drift fail at compile time. */
 inline constexpr std::size_t kSectionSize = 16;
-inline constexpr std::size_t kHeaderSize = 848;
+inline constexpr std::size_t kHeaderSize = 896;
 inline constexpr std::size_t kStringRefSize = 8;
 inline constexpr std::size_t kRangeSize = 8;
 inline constexpr std::size_t kActivitySize = 124;
@@ -401,6 +412,9 @@ inline constexpr std::size_t kSobjectRsatDescriptorSize = 72;
 inline constexpr std::size_t kEntityTypeDefinitionSize = 56;
 inline constexpr std::size_t kSobjectRsatFieldBindingSize = 40;
 inline constexpr std::size_t kActorStateNameSize = 24;
+inline constexpr std::size_t kActorSequenceTableSize = 24;
+inline constexpr std::size_t kActorSequenceEntrySize = 64;
+inline constexpr std::size_t kActorSequenceBindingSize = 32;
 inline constexpr std::size_t kRsatDescriptorRawRowSize = 32;
 inline constexpr std::size_t kRsatSchemaFieldRawRowSize = 40;
 
@@ -686,9 +700,12 @@ enum class SectionIndex : std::uint32_t {
     sobjectRsatFieldBindings,
     runtimeTypeDefinitions,
     actorStateNames,
+    actorSequenceTables,
+    actorSequenceEntries,
+    actorSequenceBindings,
 };
 
-static_assert(static_cast<std::uint32_t>(SectionIndex::actorStateNames) + 1 == kSectionCount);
+static_assert(static_cast<std::uint32_t>(SectionIndex::actorSequenceBindings) + 1 == kSectionCount);
 
 /** Exact activity-name/root join result retained for every activity row. */
 enum class ActivityJoinStatus : std::uint32_t {
@@ -1386,6 +1403,43 @@ struct ActorStateName final {
     std::uint32_t flags{};
 };
 
+/** One package action table shared by all actors that select it. */
+struct ActorSequenceTable final {
+    std::uint32_t definitionTag{};
+    std::uint32_t definitionClass{};
+    std::uint32_t arrayOffset{};
+    Range entries{};
+    std::uint32_t flags{};
+};
+
+/** One authored action key, including unsupported kinds and exact diagnostic source text. */
+struct ActorSequenceEntry final {
+    StringRef id{};
+    StringRef name{};
+    StringRef symbol{};
+    StringRef sourcePath{};
+    std::uint32_t tableIndex{};
+    std::uint32_t ordinal{};
+    std::uint32_t sourceOffset{};
+    std::uint32_t keyHash{};
+    std::uint32_t kind{};
+    std::uint32_t resourceTag{};
+    std::uint32_t flags{};
+    std::uint32_t reserved{};
+};
+
+/** One declared actor component chooses its global-first and local-fallback action tables. */
+struct ActorSequenceBinding final {
+    std::uint32_t actorClassIndex{};
+    std::uint32_t componentOrdinal{};
+    std::uint32_t configTag{};
+    std::uint32_t sourceOffset{};
+    std::uint32_t globalTableIndex{kAbsentIndex};
+    std::uint32_t localTableIndex{kAbsentIndex};
+    std::uint32_t sourceClass{};
+    std::uint32_t flags{};
+};
+
 #pragma pack(pop)
 
 static_assert(sizeof(Section) == kSectionSize);
@@ -1434,6 +1488,9 @@ static_assert(sizeof(SobjectRsatDescriptor) == kSobjectRsatDescriptorSize);
 static_assert(sizeof(EntityTypeDefinition) == kEntityTypeDefinitionSize);
 static_assert(sizeof(SobjectRsatFieldBinding) == kSobjectRsatFieldBindingSize);
 static_assert(sizeof(ActorStateName) == kActorStateNameSize);
+static_assert(sizeof(ActorSequenceTable) == kActorSequenceTableSize);
+static_assert(sizeof(ActorSequenceEntry) == kActorSequenceEntrySize);
+static_assert(sizeof(ActorSequenceBinding) == kActorSequenceBindingSize);
 static_assert(std::is_trivially_copyable_v<ActorStateName>
               && std::is_standard_layout_v<ActorStateName>);
 static_assert(offsetof(ActorStateName, actorClassIndex) == offset::kActorStateNameActorClassIndex);

+ 6 - 0
Sunrise/src/state/activity_sdk/generated_world/codec.h

@@ -95,6 +95,12 @@ publish(const wchar_t* path, PreparedShard prepared, Digest& payloadSha256) noex
                          const build_data::scriptables::Snapshot& snapshot,
                          Digest& payloadSha256) noexcept;
 
+/** Checks current format and manifest identity without reading or trusting payload contents. */
+[[nodiscard]] bool compatible_header(const wchar_t* path,
+                                     std::uint32_t expectedScenarioTag,
+                                     const Digest& expectedSourceFingerprint,
+                                     const Digest& expectedPayloadSha256) noexcept;
+
 /**
  * Loads and validates one scenario shard without partially replacing the caller's snapshot.
  * @param path Null-terminated shard path selected by the caller.

+ 1 - 1
Sunrise/src/state/activity_sdk/generated_world/format.h

@@ -15,7 +15,7 @@ namespace catalog = build_data::scriptables;
 /** Generated-world scenario-shard identity. */
 inline constexpr std::array<char, 8> kMagic{'S', 'R', 'G', 'W', 'S', 'H', 'R', 'D'};
 /** Shard schema. Increment for row-layout or extraction-semantic changes. */
-inline constexpr std::uint32_t kVersion = 13;
+inline constexpr std::uint32_t kVersion = 14;
 /** No extracted section may exceed the largest bounded package-derived catalog bank. */
 inline constexpr std::uint32_t kMaximumRowsPerSection = 1'048'576;
 /** A corrupt local shard cannot ask the loader for an unbounded allocation. */

+ 61 - 14
Sunrise/src/state/activity_sdk/generated_world/generated_world_codec.cpp

@@ -113,16 +113,12 @@ private:
            && static_cast<std::size_t>(transferred) == size;
 }
 
-/** Loads one file after the public boundary has established exception handling. */
-[[nodiscard]] bool load_file(const wchar_t* path,
-                             std::uint32_t expectedScenarioTag,
-                             const Digest& expectedSourceFingerprint,
-                             catalog::Snapshot& snapshot,
-                             Digest& payloadSha256,
-                             LoadStatus& status) {
+/** Opens one shard for a bounded header read or complete load. */
+[[nodiscard]] HANDLE
+open_read(const wchar_t* path, std::uint32_t expectedScenarioTag, LoadStatus& status) noexcept {
     status = LoadStatus::invalid;
     if (path == nullptr || path[0] == L'\0' || expectedScenarioTag == 0) {
-        return false;
+        return INVALID_HANDLE_VALUE;
     }
     const HANDLE rawFile = CreateFileW(path,
                                        GENERIC_READ,
@@ -136,14 +132,28 @@ private:
         status = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND
                      ? LoadStatus::missing
                      : LoadStatus::invalid;
-        return false;
     }
-    FileHandle file(rawFile);
+    return rawFile;
+}
 
+/**
+ * Checks the file extent and every identity shared by header checks and full loads.
+ * @param file Read handle positioned at the file start.
+ * @param expectedScenarioTag Scenario required by the manifest.
+ * @param expectedSourceFingerprint Installed content identity.
+ * @param header Receives the validated header on success.
+ * @param status Receives the precise refusal on failure.
+ * @return True when the file can hold a current-format shard of that identity.
+ */
+[[nodiscard]] bool read_header(HANDLE file,
+                               std::uint32_t expectedScenarioTag,
+                               const Digest& expectedSourceFingerprint,
+                               format::Header& header,
+                               LoadStatus& status) noexcept {
+    status = LoadStatus::invalid;
     LARGE_INTEGER fileSize{};
-    format::Header header{};
-    bool complete = GetFileSizeEx(file.get(), &fileSize) != FALSE && fileSize.QuadPart >= 0
-                    && read_exact(file.get(), &header, sizeof header);
+    const bool complete = GetFileSizeEx(file, &fileSize) != FALSE && fileSize.QuadPart >= 0
+                          && read_exact(file, &header, sizeof header);
     if (!complete || header.magic != format::kMagic) {
         return false;
     }
@@ -162,10 +172,26 @@ private:
         status = LoadStatus::sourceMismatch;
         return false;
     }
+    return true;
+}
 
+/** Loads one file after the public boundary has established exception handling. */
+[[nodiscard]] bool load_file(const wchar_t* path,
+                             std::uint32_t expectedScenarioTag,
+                             const Digest& expectedSourceFingerprint,
+                             catalog::Snapshot& snapshot,
+                             Digest& payloadSha256,
+                             LoadStatus& status) {
+    FileHandle file(open_read(path, expectedScenarioTag, status));
+    format::Header header{};
+    if (file.get() == INVALID_HANDLE_VALUE
+        || !read_header(
+            file.get(), expectedScenarioTag, expectedSourceFingerprint, header, status)) {
+        return false;
+    }
     const std::size_t payloadSize = static_cast<std::size_t>(header.fileSize - header.headerSize);
     std::vector<std::byte> payload(payloadSize);
-    complete = read_exact(file.get(), payload.data(), payload.size());
+    bool complete = read_exact(file.get(), payload.data(), payload.size());
     complete = file.close() && complete;
     Digest digest{};
     if (!complete || !middleware::crypto::sha256::hash(std::span<const std::byte>(payload), digest)
@@ -313,6 +339,27 @@ bool write(const wchar_t* path,
            && publish(path, std::move(prepared), payloadSha256);
 }
 
+/**
+ * Checks the manifest-owned header before startup reuses a published estate.
+ * @param path Exact shard file.
+ * @param expectedScenarioTag Manifest scenario identity.
+ * @param expectedSourceFingerprint Installed content identity.
+ * @param expectedPayloadSha256 Digest declared by the authenticated manifest.
+ * @return True for a current header; full load still authenticates the payload.
+ */
+bool compatible_header(const wchar_t* path,
+                       std::uint32_t expectedScenarioTag,
+                       const Digest& expectedSourceFingerprint,
+                       const Digest& expectedPayloadSha256) noexcept {
+    LoadStatus status = LoadStatus::invalid;
+    FileHandle file(open_read(path, expectedScenarioTag, status));
+    format::Header header{};
+    return file.get() != INVALID_HANDLE_VALUE
+           && read_header(
+               file.get(), expectedScenarioTag, expectedSourceFingerprint, header, status)
+           && header.payloadSha256 == expectedPayloadSha256;
+}
+
 /** Loads and validates one scenario shard without partially replacing the caller's snapshot. */
 bool load(const wchar_t* path,
           std::uint32_t expectedScenarioTag,

+ 9 - 8
Sunrise/src/state/activity_sdk/generated_world/generated_world_scalar_codec.cpp

@@ -173,15 +173,16 @@ void decode_scalars(const format::Scalars& value, catalog::Snapshot& snapshot) n
         container.complete != 0,
     };
     const auto& type23 = value.type23PlacementDiagnostics;
+    // This disk format does not store the scenario fallback count.
     snapshot.type23PlacementDiagnostics = {
-        type23.unreadIdentifiers,
-        type23.droppedLinks,
-        type23.droppedCandidates,
-        type23.zeroIdentityMatches,
-        type23.multipleIdentityMatches,
-        type23.zeroActiveCandidates,
-        type23.multipleActiveCandidates,
-        type23.complete != 0,
+        .unreadIdentifiers = type23.unreadIdentifiers,
+        .droppedLinks = type23.droppedLinks,
+        .droppedCandidates = type23.droppedCandidates,
+        .zeroIdentityMatches = type23.zeroIdentityMatches,
+        .multipleIdentityMatches = type23.multipleIdentityMatches,
+        .zeroActiveCandidates = type23.zeroActiveCandidates,
+        .multipleActiveCandidates = type23.multipleActiveCandidates,
+        .complete = type23.complete != 0,
     };
     const auto& embedded = value.embeddedPlacementDiagnostics;
     snapshot.embeddedPlacementDiagnostics = {

+ 32 - 0
Sunrise/src/state/activity_sdk/generated_world/generated_world_store.cpp

@@ -188,4 +188,36 @@ bool load_record(std::wstring_view scenarioDirectory,
     return true;
 }
 
+/**
+ * Checks published shard headers before startup reuses a loaded core catalog.
+ * @param manifestPath Published catalog file.
+ * @param scenarioDirectory Directory of its immutable shard files.
+ * @param expectedSourceFingerprint Installed content identity.
+ * @param expectedSdk Already-loaded core catalog identity.
+ * @return True when every declared shard has a compatible manifest-owned header.
+ */
+bool compatible_catalog(const wchar_t* manifestPath,
+                        std::wstring_view scenarioDirectory,
+                        const Digest& expectedSourceFingerprint,
+                        const manifest::SdkIdentity& expectedSdk) noexcept {
+    manifest::Catalog catalog{};
+    manifest::LoadStatus manifestStatus = manifest::LoadStatus::invalid;
+    if (!manifest::load(
+            manifestPath, expectedSourceFingerprint, expectedSdk, catalog, manifestStatus)
+        || catalog.records.empty()) {
+        return false;
+    }
+    for (const manifest::Record& record : catalog.records) {
+        core::path::Buffer path;
+        if (!shard_path(scenarioDirectory, record.scenarioTag, record.shardPayloadSha256, path)
+            || !generated_world::compatible_header(path.chars.data(),
+                                                   record.scenarioTag,
+                                                   expectedSourceFingerprint,
+                                                   record.shardPayloadSha256)) {
+            return false;
+        }
+    }
+    return true;
+}
+
 } // namespace sunrise::state::activity_sdk::generated_world::store

+ 6 - 0
Sunrise/src/state/activity_sdk/generated_world/store.h

@@ -27,6 +27,12 @@ enum class RecordLoadStatus : std::uint8_t {
 [[nodiscard]] const manifest::Record* find_record(std::span<const manifest::Record> records,
                                                   std::uint32_t scenarioTag) noexcept;
 
+/** Requires every manifest-owned shard to match the loaded SDK and current extraction format. */
+[[nodiscard]] bool compatible_catalog(const wchar_t* manifestPath,
+                                      std::wstring_view scenarioDirectory,
+                                      const Digest& expectedSourceFingerprint,
+                                      const manifest::SdkIdentity& expectedSdk) noexcept;
+
 /** Builds the content-addressed path owned by one exact manifest record. */
 [[nodiscard]] bool shard_path(std::wstring_view scenarioDirectory,
                               std::uint32_t scenarioTag,

+ 6 - 0
Sunrise/src/state/activity_sdk/generation/activity_sdk_pack_writer.cpp

@@ -64,6 +64,9 @@ constexpr std::array<std::uint32_t, format::kSectionCount> kSectionStrides{
     sizeof(format::SobjectRsatFieldBinding),
     sizeof(format::RuntimeTypeDefinition),
     sizeof(format::ActorStateName),
+    sizeof(format::ActorSequenceTable),
+    sizeof(format::ActorSequenceEntry),
+    sizeof(format::ActorSequenceBinding),
 };
 /** Writer-owned siblings use the cleanup service's final.process.thread.sequence.tmp shape. */
 constexpr std::wstring_view kTemporarySuffix = L".%08lX.%08lX.%08lX.tmp";
@@ -168,6 +171,9 @@ raw_sections(const Tables& tables) noexcept {
         rows(tables.sobjectRsatFieldBindings),
         rows(tables.runtimeTypeDefinitions),
         rows(tables.actorStateNames),
+        rows(tables.actorSequenceTables),
+        rows(tables.actorSequenceEntries),
+        rows(tables.actorSequenceBindings),
     };
 }
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä