Explorar o código

Merge pull request #107 from Nyxaraa/mission-ember

Implement 1AU mission and extend SDK/native mission runtime
stan hai 2 días
pai
achega
3f068ce7bf
Modificáronse 83 ficheiros con 4321 adicións e 343 borrados
  1. 6 0
      Sunrise/resources/default_settings.json
  2. 16 0
      Sunrise/src/client/content/activity/activity_sdk_generation_scenario_shards.cpp
  3. 20 5
      Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp
  4. 29 0
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  5. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.h
  6. 141 89
      Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp
  7. 5 1
      Sunrise/src/middleware/bap/activity_message/activity_cinematic_incident_codec.cpp
  8. 26 7
      Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
  9. 39 12
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  10. 120 2
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp
  11. 5 3
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp
  12. 24 5
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
  13. 8 7
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
  14. 10 6
      Sunrise/src/middleware/bap/activity_message/activity_squad_auth_body_codec.cpp
  15. 80 0
      Sunrise/src/middleware/bap/activity_message/auth_fields.h
  16. 4 1
      Sunrise/src/middleware/bap/activity_message/cinematic_incident.h
  17. 268 0
      Sunrise/src/middleware/bap/activity_message/combatant_auth.h
  18. 47 0
      Sunrise/src/middleware/bap/activity_message/damage_monitor_auth.h
  19. 98 0
      Sunrise/src/middleware/bap/activity_message/darkness_zone_auth.h
  20. 46 0
      Sunrise/src/middleware/bap/activity_message/ghost_link_auth.h
  21. 87 0
      Sunrise/src/middleware/bap/activity_message/interactable_object_auth.h
  22. 271 0
      Sunrise/src/middleware/bap/activity_message/mission_auth_patch.h
  23. 66 0
      Sunrise/src/middleware/bap/activity_message/mission_effect_auth.h
  24. 61 0
      Sunrise/src/middleware/bap/activity_message/music_section_auth.h
  25. 42 0
      Sunrise/src/middleware/bap/activity_message/roster_presence.h
  26. 79 0
      Sunrise/src/middleware/bap/activity_message/scene_events_auth.h
  27. 9 0
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  28. 44 0
      Sunrise/src/middleware/bap/activity_message/sense_observation_packet.h
  29. 5 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  30. 9 2
      Sunrise/src/middleware/bap/activity_message/squad_auth_body.h
  31. 87 0
      Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h
  32. 84 0
      Sunrise/src/server/activity/activity_sdk_behavior_scope.h
  33. 42 1
      Sunrise/src/server/activity/activity_sdk_device_prepare.cpp
  34. 8 0
      Sunrise/src/server/activity/activity_sdk_device_runtime.cpp
  35. 4 0
      Sunrise/src/server/activity/activity_sdk_mission_internal.h
  36. 36 33
      Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp
  37. 10 10
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  38. 5 7
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  39. 57 1
      Sunrise/src/server/activity/host_runtime.h
  40. 3 3
      Sunrise/src/server/activity/host_runtime_mission_inputs.cpp
  41. 14 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  42. 36 0
      Sunrise/src/server/activity/host_runtime_scriptable.cpp
  43. 1 3
      Sunrise/src/server/activity/host_runtime_scriptable_requests.cpp
  44. 20 33
      Sunrise/src/server/activity/host_runtime_sense.cpp
  45. 95 0
      Sunrise/src/server/activity/mission/mission_script_actor_path_sense.h
  46. 4 1
      Sunrise/src/server/activity/mission/mission_script_arena.cpp
  47. 25 0
      Sunrise/src/server/activity/mission/mission_script_event_batch.h
  48. 68 0
      Sunrise/src/server/activity/mission/mission_script_ghost_sense.h
  49. 39 0
      Sunrise/src/server/activity/mission/mission_script_lua_context_api.cpp
  50. 23 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  51. 2 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_delivery.cpp
  52. 84 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp
  53. 3 0
      Sunrise/src/server/activity/mission/mission_script_lua_key_api.cpp
  54. 5 0
      Sunrise/src/server/activity/mission/mission_script_lua_names.h
  55. 607 1
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  56. 8 2
      Sunrise/src/server/activity/mission/mission_script_lua_value_api.cpp
  57. 93 0
      Sunrise/src/server/activity/mission/mission_script_object_sense.h
  58. 136 0
      Sunrise/src/server/activity/mission/mission_script_player_sense.h
  59. 125 22
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  60. 1 0
      Sunrise/src/server/activity/mission/mission_script_runtime_attach.cpp
  61. 3 0
      Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp
  62. 74 1
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  63. 298 47
      Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp
  64. 32 8
      Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp
  65. 79 1
      Sunrise/src/server/activity/mission/mission_script_runtime_internal.h
  66. 123 0
      Sunrise/src/server/activity/mission/mission_script_squad_sense.h
  67. 9 0
      Sunrise/src/server/activity/mission/mission_script_vm.cpp
  68. 19 4
      Sunrise/src/server/bap/bap_route.cpp
  69. 2 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  70. 14 9
      Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp
  71. 1 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.h
  72. 21 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  73. 36 0
      Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h
  74. 2 1
      Sunrise/src/server/bap/internal.h
  75. 33 0
      Sunrise/src/server/transport/bap_frame_batch.h
  76. 3 2
      Sunrise/src/server/transport/bap_listener.cpp
  77. 4 2
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp
  78. 4 4
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h
  79. 93 0
      Sunrise/src/state/activity/membership/activity_membership_query.cpp
  80. 22 0
      Sunrise/src/state/activity/membership/activity_membership_query.h
  81. 45 0
      Sunrise/src/state/activity/membership/definition.h
  82. 5 1
      Sunrise/src/state/activity/membership/transactions/internal.h
  83. 7 2
      Sunrise/src/state/activity/mission/definition.h

+ 6 - 0
Sunrise/resources/default_settings.json

@@ -105,6 +105,12 @@
         "spawn_set_hash": "0x811C9DC5"
       },
       "arrival_overrides": [
+        {
+          "package_name": "mission_ember",
+          "bubble": 6,
+          "slice_set": 49,
+          "spawn_set_hash": "0x2EA8FB98"
+        },
         {
           "package_name": "raid_envy_v310",
           "bubble": 7

+ 16 - 0
Sunrise/src/client/content/activity/activity_sdk_generation_scenario_shards.cpp

@@ -15,6 +15,7 @@
 #include "../../../core/logging/log.h"
 #include "../../../state/activity_sdk/generated_world/codec.h"
 #include "../../../state/activity_sdk/generation/definition.h"
+#include "../../../state/build_data/runtime.h"
 #include "activity_sdk_generation_worker_internal.h"
 
 namespace sunrise::client::content::activity::sdk_generation::worker_internal {
@@ -252,6 +253,18 @@ void publish_parallel_progress(ScenarioBuildBatch& batch, const Scenario& scenar
     ReleaseSRWLockExclusive(&batch.progressLock);
 }
 
+/** A cache made before the layout catalogue was ready omitted whole placement domains. */
+[[nodiscard]] bool placement_context_ready(const Scenario& scenario,
+                                           const catalog::Snapshot& snapshot) noexcept {
+    state::build_data::scenarios::Definition layout{};
+    const std::string_view name(scenario.name.data(), scenario.nameLength);
+    if (!state::build_data::find_scenario_layout(name, layout) || layout.spawnStemLength == 0) {
+        return true;
+    }
+    return snapshot.containerPlacementDiagnostics.contextResolved
+           && snapshot.staticSpatialContextResolved;
+}
+
 /** Builds one worker's contiguous chunks with private package and analysis caches. */
 void run_scenario_worker(ScenarioBuildBatch& batch) noexcept {
     std::unique_ptr<package_reader::Scratch> scratch(new (std::nothrow) package_reader::Scratch());
@@ -284,6 +297,9 @@ void run_scenario_worker(ScenarioBuildBatch& batch) noexcept {
                                                 scenario,
                                                 *existing,
                                                 result.snapshot);
+                if (kept && !placement_context_ready(scenario, *result.snapshot)) {
+                    kept = false;
+                }
                 if (kept
                     && !materialize_cached_record(
                         *batch.work, scenario, *existing, result.snapshot, result.snapshot)) {

+ 20 - 5
Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp

@@ -25,8 +25,10 @@
 #include "../../../state/activity_sdk/generation/internal.h"
 #include "../../../state/activity_sdk/identity.h"
 #include "../../../state/activity_sdk/runtime.h"
+#include "../../../state/build_data/runtime.h"
 #include "../../../state/content_manifest/content_manifest_state_runtime.h"
 #include "../items/packages/internal.h"
+#include "../scenarios/scenario_build.h"
 #include "activity_sdk_activity_inventory.h"
 #include "activity_sdk_generation_report.h"
 #include "activity_sdk_generation_worker_internal.h"
@@ -251,9 +253,21 @@ DWORD WINAPI thread_main(void* opaque) noexcept {
                              middleware::content::packages::tables::kActivityDefinitionCount),
                          0,
                          "building installed activity inventory");
-        bool inventoried = build_inventory(*work, source);
+        // The game normally publishes these layouts during boot. An isolated generation
+        // process has no boot worker, but container and spatial extraction still need them.
+        bool layoutsReady = state::build_data::scenario_layouts_ready();
+        if (work->offline && !layoutsReady) {
+            auto scratch = std::make_unique<package_reader::Scratch>();
+            constexpr std::size_t kMaximumLayoutSteps = 10'000;
+            for (std::size_t step = 0; step < kMaximumLayoutSteps && !cancelled() && !layoutsReady;
+                 ++step) {
+                layoutsReady = scenarios::build(source, *scratch);
+            }
+            package_reader::close_files(*scratch);
+        }
+        bool inventoried = layoutsReady && build_inventory(*work, source);
         if (!inventoried) {
-            failureDetail = "inventory_incomplete";
+            failureDetail = layoutsReady ? "inventory_incomplete" : "scenario_layouts_unavailable";
         }
 
         const std::uint32_t total = static_cast<std::uint32_t>(work->scenarios.size());
@@ -649,9 +663,10 @@ void service() noexcept {
         return;
     }
     core::path::Buffer packageDirectory;
-    const bool ready =
-        work->module != nullptr && !work->sdkDirectory.empty() && !work->packPath.empty()
-        && items::packages::package_directory(packageDirectory) && prepare_work(*work);
+    const bool ready = work->module != nullptr && !work->sdkDirectory.empty()
+                       && !work->packPath.empty() && state::build_data::scenario_layouts_ready()
+                       && items::packages::package_directory(packageDirectory)
+                       && prepare_work(*work);
     if (ready) {
         try {
             work->packageDirectory.assign(packageDirectory.chars.data(), packageDirectory.length);

+ 29 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp

@@ -87,6 +87,13 @@ local EventKind = {
     PLAYER_TRIGGER = 28,
     CINEMATIC_STARTED = 29,
     CINEMATIC_TERMINATED = 30,
+    ACTOR_PATH_STATE = 31,
+    GHOST_LINK_STATE = 32,
+    OBJECT_INTERACTED = 33,
+    CINEMATIC_SKIP_REQUESTED = 34,
+    FIRETEAM_STATE = 35,
+    OBJECT_STATE = 36,
+    DAMAGE_STATE = 37,
 }
 
 ---@class SunriseEvent
@@ -155,6 +162,13 @@ local EventKind = {
             R"lua(event: SunriseCinematicEvent)
 ---@field on_event_cinematic_terminated? fun(context: any, state: SunriseState, )lua"
             R"lua(event: SunriseCinematicEvent)
+---@field on_event_actor_path_state? SunriseEventHandler
+---@field on_event_ghost_link_state? SunriseEventHandler
+---@field on_event_object_interacted? SunriseEventHandler
+---@field on_event_fireteam_state? SunriseEventHandler
+---@field on_event_object_state? SunriseEventHandler
+---@field on_event_damage_state? SunriseEventHandler
+---@field on_event_cinematic_skip_requested? fun(context: any, state: SunriseState, event: SunriseCinematicEvent)
 
 ---@class SunriseVector3
 ---@field x number
@@ -176,6 +190,19 @@ local EventKind = {
 ---@field auth_dynamic boolean|nil
 ---@field auth_writable boolean|nil
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
+---@field assign_combat_objective fun(self: SunriseSlot, args: {objective: SunriseSlot, revision: integer, task_group: integer, reserved: boolean?}): SunriseRequestKey
+---@field play_actor_path fun(self: SunriseSlot, args: {generation: integer, revision: integer, path: SunriseSlot}): SunriseRequestKey
+---@field deliver_squads fun(self: SunriseSlot, args: {generation: integer, revision: integer, squads: SunriseSlot[]}): SunriseRequestKey
+---@field deliver_squad fun(self: SunriseSlot, args: {generation: integer, revision: integer, squad: SunriseSlot}): SunriseRequestKey
+---@field play_actor_action fun(self: SunriseSlot, args: {generation: integer, revision: integer, group: integer, action: integer}): SunriseRequestKey
+---@field retire_actor fun(self: SunriseSlot, args: {generation: integer}): SunriseRequestKey
+---@field set_darkness_zone fun(self: SunriseSlot, args: {enabled: boolean, wipe_seconds: integer?}): SunriseRequestKey
+---@field set_interactable_object fun(self: SunriseSlot, args: {generation: integer, track_owner: boolean?, active: boolean?}): SunriseRequestKey
+---@field set_music_section fun(self: SunriseSlot, args: {section: integer, enabled: boolean?}): SunriseRequestKey
+---@field watch_damage fun(self: SunriseSlot, args: {target: SunriseSlot, revision: integer}): SunriseRequestKey
+---@field set_object_filter fun(self: SunriseSlot, args: {players: boolean?, target: SunriseSlot?, inside: SunriseSlot?, inside_any: SunriseSlot[]?}): SunriseRequestKey
+---@field set_mission_effect fun(self: SunriseSlot, args: {filter: SunriseSlot?, enabled: boolean, revision: integer}): SunriseRequestKey
+---@field set_ghost_link fun(self: SunriseSlot, args: {generation: integer, enabled: boolean}): SunriseRequestKey
 ---@field bind_combatant_to_squad fun(self: SunriseSlot): SunriseRequestKey
 ---@field transition fun(SunriseSlot, SunriseDeviceTransitionArguments): SunriseRequestKey
 ---@field fire_trigger fun(self: SunriseSlot): SunriseRequestKey
@@ -207,6 +234,8 @@ local EventKind = {
 ---@class SunriseDirectiveArguments
 ---@field directive table Generated mission directive declaration.
 ---@field state? integer Defaults to 0, the native enter state.
+---@field audience? SunriseSlot Authored type-70 engagement sensor for the mission banner.
+---@field navpoint? SunriseSlot Authored type-47 navigation marker.
 
 ---@class SunriseEngagementArguments
 ---@field flags? integer Five native flag bits; the shipped constructor default is 1.

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_graph.h

@@ -158,6 +158,8 @@ struct GraphReferenceDescriptor final {
 /** One decoded raw 0x98 or 0xA0 spawner reference and its complete resolution evidence. */
 struct GraphReference final {
     std::uint32_t spawnerRow{format::kAbsentIndex};
+    /** Exact occurrence-scoped source when one config is reused by separate scenarios. */
+    std::uint32_t sourceDescriptorRow{format::kAbsentIndex};
     std::uint32_t referenceOrdinal{};
     std::uint64_t rawReference{};
     std::uint32_t targetObjectKey{};

+ 141 - 89
Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp

@@ -287,11 +287,14 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     std::uint64_t rawReference,
     const std::unordered_map<TargetKey, std::vector<std::uint32_t>, TargetKeyHash>& byTarget,
     const std::unordered_map<std::uint32_t, std::uint32_t>& rulesByConfig,
+    const std::vector<std::vector<std::uint32_t>>& scenariosByObject,
     std::map<TargetGroup, ExactTarget>& exactTargets,
-    ReferenceResolutionStatus& status) {
+    ReferenceResolutionStatus& status,
+    std::uint32_t scopedSource = format::kAbsentIndex) {
     const ObjectReference decoded = decode_reference(rawReference);
     GraphReference reference{};
     reference.spawnerRow = spawnerRow;
+    reference.sourceDescriptorRow = scopedSource;
     reference.referenceOrdinal = referenceOrdinal;
     reference.rawReference = rawReference;
     reference.targetObjectKey = decoded.objectKey;
@@ -314,8 +317,27 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     const std::vector<std::uint32_t>& candidates = found == byTarget.end() ? empty : found->second;
     std::map<TargetGroup, std::vector<std::uint32_t>> groups{};
     std::size_t authoritativeCount = 0;
+    const GraphSpawner& spawner = graph.spawners[spawnerRow];
+    const auto sourceStatus = scopedSource != format::kAbsentIndex
+        ? SourceDescriptorStatus::exact : spawner.sourceDescriptorStatus;
+    const auto sourceRow = scopedSource != format::kAbsentIndex ? scopedSource : spawner.sourceDescriptorRow;
     for (const std::uint32_t descriptorRow : candidates) {
         const GraphDescriptor& descriptor = graph.descriptors[descriptorRow];
+        // Object keys are reused by campaign and arcade scenarios. A reference can only
+        // name a target that occurs in a scenario containing its source object.
+        if (sourceStatus == SourceDescriptorStatus::exact) {
+            const GraphDescriptor& source = graph.descriptors[sourceRow];
+            const auto& sourceScenarios = scenariosByObject[source.objectIndex];
+            const auto& targetScenarios = scenariosByObject[descriptor.objectIndex];
+            const bool sharesScenario =
+                std::any_of(sourceScenarios.begin(), sourceScenarios.end(), [&](auto scenario) {
+                    return std::binary_search(
+                        targetScenarios.begin(), targetScenarios.end(), scenario);
+                });
+            if (!sharesScenario) {
+                continue;
+            }
+        }
         const topology::Slot& slot = topology.slots[descriptor.slotIndex];
         const auto rule = rulesByConfig.find(descriptor.configTag);
         const bool authoritative =
@@ -328,16 +350,17 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
         }
         graph.referenceDescriptors.push_back({referenceRow, descriptorRow, authoritative, false});
     }
-    reference.candidateDescriptors.count = static_cast<std::uint32_t>(candidates.size());
+    reference.candidateDescriptors.count =
+        static_cast<std::uint32_t>(graph.referenceDescriptors.size())
+        - reference.candidateDescriptors.first;
 
-    const GraphSpawner& spawner = graph.spawners[spawnerRow];
-    if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::missing) {
+    if (sourceStatus == SourceDescriptorStatus::missing) {
         status = ReferenceResolutionStatus::sourceDescriptorMissing;
-    } else if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::ambiguous) {
+    } else if (sourceStatus == SourceDescriptorStatus::ambiguous) {
         status = ReferenceResolutionStatus::sourceDescriptorAmbiguous;
-    } else if (candidates.empty()) {
+    } else if (reference.candidateDescriptors.count == 0) {
         status = ReferenceResolutionStatus::targetMissing;
-    } else if (authoritativeCount != candidates.size()) {
+    } else if (authoritativeCount != reference.candidateDescriptors.count) {
         status = ReferenceResolutionStatus::targetDescriptorMismatch;
     } else if (groups.size() != 1) {
         status = ReferenceResolutionStatus::targetAmbiguous;
@@ -367,6 +390,30 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     return true;
 }
 
+/** A reused config is unambiguous in disjoint scenario contexts. Never resolve two
+ * different source slots that coexist in the same scenario by arbitrary row order. */
+[[nodiscard]] std::vector<std::uint32_t> scoped_sources(const GraphSnapshot& graph,
+    const GraphSpawner& spawner, const std::vector<std::vector<std::uint32_t>>& scenarios) {
+    std::map<std::pair<std::uint32_t,std::uint32_t>,std::uint32_t> logical;
+    for (std::uint32_t i=0;i<spawner.sourceDescriptorCandidates.count;++i) {
+        const auto row=graph.sourceDescriptorCandidates[spawner.sourceDescriptorCandidates.first+i].descriptorRow;
+        const auto& d=graph.descriptors[row];logical.try_emplace({d.objectIndex,d.slotIndex},row);
+    }
+    std::vector<std::uint32_t> result;
+    for (const auto& [identity,row]:logical) {
+        const auto& owned=scenarios[identity.first];
+        const bool overlap=std::any_of(logical.begin(),logical.end(),[&](const auto& other) {
+            if (other.first==identity) return false;
+            const auto& peers=scenarios[other.first.first];
+            return std::any_of(owned.begin(),owned.end(),[&](auto scenario) {
+                return std::binary_search(peers.begin(),peers.end(),scenario);
+            });
+        });
+        if (!overlap) result.push_back(row);
+    }
+    return result;
+}
+
 /** Resolves every spawner raw reference into sorted pending authored edges. */
 [[nodiscard]] bool build_pending_edges(const topology::Snapshot& topology,
                                        GraphSnapshot& graph,
@@ -431,93 +478,98 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
         }
 
         spawner.references.first = static_cast<std::uint32_t>(graph.references.size());
-        std::map<TargetGroup, ExactTarget> exactTargets{};
-        std::array<ReferenceResolutionStatus, 2> statuses{};
-        const std::array<std::uint64_t, 2> raw{spawner.rawReference98, spawner.rawReferenceA0};
-        for (std::uint32_t ordinal = 0; ordinal < raw.size(); ++ordinal) {
-            if (!resolve_reference(topology,
-                                   graph,
-                                   spawnerRow,
-                                   ordinal,
-                                   raw[ordinal],
-                                   descriptorsByTarget,
-                                   rulesByConfig,
-                                   exactTargets,
-                                   statuses[ordinal])) {
-                return false;
+        auto sources = scoped_sources(graph,spawner,scenariosByObject);
+        if (sources.empty()) sources.push_back(format::kAbsentIndex);
+        for (const auto sourceRow : sources) {
+            std::map<TargetGroup, ExactTarget> exactTargets{};
+            std::array<ReferenceResolutionStatus, 2> statuses{};
+            const std::array<std::uint64_t, 2> raw{spawner.rawReference98, spawner.rawReferenceA0};
+            for (std::uint32_t ordinal = 0; ordinal < raw.size(); ++ordinal) {
+                if (!resolve_reference(topology,
+                                       graph,
+                                       spawnerRow,
+                                       ordinal,
+                                       raw[ordinal],
+                                       descriptorsByTarget,
+                                       rulesByConfig,
+                                       scenariosByObject,
+                                       exactTargets,
+                                       statuses[ordinal], sourceRow)) {
+                    return false;
+                }
             }
-        }
-        spawner.references.count = 2;
-        const bool hasAmbiguous =
-            std::find(statuses.begin(), statuses.end(), ReferenceResolutionStatus::targetAmbiguous)
-            != statuses.end();
-        const bool hasOther = std::any_of(statuses.begin(), statuses.end(), [](auto status) {
-            return status != ReferenceResolutionStatus::exact
-                   && status != ReferenceResolutionStatus::invalidEncoding;
-        });
-        // With both refs absent, the spawner's own point set is the target, on its own slot.
-        const bool bothAbsent = std::all_of(statuses.begin(), statuses.end(), [](auto status) {
-            return status == ReferenceResolutionStatus::invalidEncoding;
-        });
-        if (bothAbsent && spawner.hasInlinePointSet && spawner.inlineRuleRow != format::kAbsentIndex
-            && spawner.sourceDescriptorRow != format::kAbsentIndex && exactTargets.empty()) {
-            const GraphDescriptor& source = graph.descriptors[spawner.sourceDescriptorRow];
-            ExactTarget& target =
-                exactTargets[{spawner.inlineRuleRow, source.objectIndex, source.slotIndex}];
-            target.descriptors = {spawner.sourceDescriptorRow};
-        }
-        const bool associationExact =
-            spawner.sourceDescriptorStatus == SourceDescriptorStatus::exact && !hasAmbiguous
-            && !hasOther && exactTargets.size() == 1;
-        if (spawner.sourceDescriptorRow == format::kAbsentIndex) {
-            continue;
-        }
-        for (const auto& [group, target] : exactTargets) {
-            if (target.descriptors.empty()) {
-                return false;
+            const bool hasAmbiguous =
+                std::find(statuses.begin(), statuses.end(), ReferenceResolutionStatus::targetAmbiguous)
+                != statuses.end();
+            const bool hasOther = std::any_of(statuses.begin(), statuses.end(), [](auto status) {
+                return status != ReferenceResolutionStatus::exact
+                       && status != ReferenceResolutionStatus::invalidEncoding;
+            });
+            // With both refs absent, the spawner's own point set is the target, on its own slot.
+            const bool bothAbsent = std::all_of(statuses.begin(), statuses.end(), [](auto status) {
+                return status == ReferenceResolutionStatus::invalidEncoding;
+            });
+            if (bothAbsent && spawner.hasInlinePointSet && spawner.inlineRuleRow != format::kAbsentIndex
+                && sourceRow != format::kAbsentIndex && exactTargets.empty()) {
+                const GraphDescriptor& source = graph.descriptors[sourceRow];
+                ExactTarget& target =
+                    exactTargets[{spawner.inlineRuleRow, source.objectIndex, source.slotIndex}];
+                target.descriptors = {sourceRow};
             }
-            PendingEdge edge{};
-            edge.spawnerRow = spawnerRow;
-            edge.ruleRow = std::get<0>(group);
-            edge.sourceDescriptorRow = spawner.sourceDescriptorRow;
-            edge.targetObjectRow = std::get<1>(group);
-            edge.targetSlotRow = std::get<2>(group);
-            edge.referenceMask = target.referenceMask;
-            edge.targetDescriptors = target.descriptors;
-            std::sort(edge.targetDescriptors.begin(),
-                      edge.targetDescriptors.end(),
-                      [&graph](std::uint32_t first, std::uint32_t second) {
-                          return graph.descriptors[first].id < graph.descriptors[second].id;
-                      });
-            edge.associationExact = associationExact;
-            if (!edge_identity(topology,
-                               spawner,
-                               graph.rules[edge.ruleRow],
-                               graph.descriptors[edge.sourceDescriptorRow],
-                               graph.descriptors[edge.targetDescriptors.front()],
-                               edge.id)) {
-                return false;
+            const bool associationExact =
+                sourceRow != format::kAbsentIndex && !hasAmbiguous && !hasOther
+                && !exactTargets.empty();
+            if (sourceRow == format::kAbsentIndex) {
+                continue;
             }
-
-            const GraphDescriptor& source = graph.descriptors[edge.sourceDescriptorRow];
-            if (source.objectIndex >= scenariosByObject.size()) {
-                return false;
+            for (const auto& [group, target] : exactTargets) {
+                if (target.descriptors.empty()) {
+                    return false;
+                }
+                PendingEdge edge{};
+                edge.spawnerRow = spawnerRow;
+                edge.ruleRow = std::get<0>(group);
+                edge.sourceDescriptorRow = sourceRow;
+                edge.targetObjectRow = std::get<1>(group);
+                edge.targetSlotRow = std::get<2>(group);
+                edge.referenceMask = target.referenceMask;
+                edge.targetDescriptors = target.descriptors;
+                std::sort(edge.targetDescriptors.begin(),
+                          edge.targetDescriptors.end(),
+                          [&graph](std::uint32_t first, std::uint32_t second) {
+                              return graph.descriptors[first].id < graph.descriptors[second].id;
+                          });
+                edge.associationExact = associationExact;
+                if (!edge_identity(topology,
+                                   spawner,
+                                   graph.rules[edge.ruleRow],
+                                   graph.descriptors[edge.sourceDescriptorRow],
+                                   graph.descriptors[edge.targetDescriptors.front()],
+                                   edge.id)) {
+                    return false;
+                }
+
+                const GraphDescriptor& source = graph.descriptors[edge.sourceDescriptorRow];
+                if (source.objectIndex >= scenariosByObject.size()) {
+                    return false;
+                }
+                const std::vector<std::uint32_t>& sourceScenarios =
+                    scenariosByObject[source.objectIndex];
+                const auto targetScenariosFound =
+                    exactRuleScenarios.find({edge.ruleRow, edge.targetObjectRow});
+                const std::vector<std::uint32_t> empty{};
+                const std::vector<std::uint32_t>& targetScenarios =
+                    targetScenariosFound == exactRuleScenarios.end() ? empty
+                                                                     : targetScenariosFound->second;
+                std::set_intersection(sourceScenarios.begin(),
+                                      sourceScenarios.end(),
+                                      targetScenarios.begin(),
+                                      targetScenarios.end(),
+                                      std::back_inserter(edge.scenarios));
+                output.push_back(std::move(edge));
             }
-            const std::vector<std::uint32_t>& sourceScenarios =
-                scenariosByObject[source.objectIndex];
-            const auto targetScenariosFound =
-                exactRuleScenarios.find({edge.ruleRow, edge.targetObjectRow});
-            const std::vector<std::uint32_t> empty{};
-            const std::vector<std::uint32_t>& targetScenarios =
-                targetScenariosFound == exactRuleScenarios.end() ? empty
-                                                                 : targetScenariosFound->second;
-            std::set_intersection(sourceScenarios.begin(),
-                                  sourceScenarios.end(),
-                                  targetScenarios.begin(),
-                                  targetScenarios.end(),
-                                  std::back_inserter(edge.scenarios));
-            output.push_back(std::move(edge));
         }
+        spawner.references.count = static_cast<std::uint32_t>(graph.references.size()) - spawner.references.first;
     }
     std::sort(
         output.begin(), output.end(), [](const PendingEdge& first, const PendingEdge& second) {

+ 5 - 1
Sunrise/src/middleware/bap/activity_message/activity_cinematic_incident_codec.cpp

@@ -26,7 +26,7 @@ constexpr std::int32_t kSlotIndexBias = 32'768;
 /**
  * Maps one global SObject row to its cinematic signal.
  * @param target Global SObject row from the incident header.
- * @param output Set only when the row is one of the two cinematic rows.
+ * @param output Set only when the row is one of the three cinematic rows.
  * @return True when the row names a cinematic signal.
  */
 bool signal_for_target(std::uint32_t target, Signal& output) noexcept {
@@ -38,6 +38,10 @@ bool signal_for_target(std::uint32_t target, Signal& output) noexcept {
         output = Signal::terminated;
         return true;
     }
+    if (target == kSkipTarget) {
+        output = Signal::skipRequested;
+        return true;
+    }
     return false;
 }
 

+ 26 - 7
Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp

@@ -9,6 +9,19 @@
 namespace sunrise::middleware::bap::activity_message::incident {
 namespace {
 
+/** Retains byte fields even when their wire start is not byte-aligned. */
+[[nodiscard]] bool read_bytes(encoding::bits::Reader& reader,
+                              std::span<std::byte> output) noexcept {
+    for (std::byte& value : output) {
+        std::uint64_t field = 0;
+        if (!reader.read(encoding::kBitsPerByte, field)) {
+            return false;
+        }
+        value = static_cast<std::byte>(field);
+    }
+    return true;
+}
+
 /** @return True when one target index is safe to hand to the Client's table lookup. */
 [[nodiscard]] bool target_allowed(std::uint32_t target, Verdict& verdict) noexcept {
     if (target > kTargetMaximum) {
@@ -80,7 +93,8 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (!reader.read(kSelectorPresenceWidth, field)) {
         return Verdict::truncated;
     }
-    if (field != 0) {
+    parsed.hasCompressedSelector = field != 0;
+    if (parsed.hasCompressedSelector) {
         if (!reader.read(kSelectorLengthWidth, field)) {
             return Verdict::truncated;
         }
@@ -88,8 +102,7 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         if (parsed.selectorLength > kSelectorMaximum) {
             return Verdict::selectorTooLong;
         }
-        if (!reader.skip(static_cast<std::size_t>(parsed.selectorLength)
-                         * encoding::kBitsPerByte)) {
+        if (!read_bytes(reader, std::span(parsed.selector).first(parsed.selectorLength))) {
             return Verdict::truncated;
         }
     }
@@ -98,9 +111,15 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         return Verdict::truncated;
     }
     parsed.hasOptionalBlock = field != 0;
-    if (parsed.hasOptionalBlock
-        && !reader.skip(static_cast<std::size_t>(kOptionalWordWidth) * 2U)) {
-        return Verdict::truncated;
+    if (parsed.hasOptionalBlock) {
+        if (!reader.read(kOptionalWordWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.optionalWordA = static_cast<std::uint32_t>(field);
+        if (!reader.read(kOptionalWordWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.optionalWordB = static_cast<std::uint32_t>(field);
     }
 
     if (!reader.read(kPayloadLengthWidth, field)) {
@@ -110,7 +129,7 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (parsed.payloadLength > kPayloadMaximum) {
         return Verdict::payloadTooLong;
     }
-    if (!reader.skip(static_cast<std::size_t>(parsed.payloadLength) * encoding::kBitsPerByte)) {
+    if (!read_bytes(reader, std::span(parsed.payload).first(parsed.payloadLength))) {
         return Verdict::truncated;
     }
     parsed.consumedBits = static_cast<std::uint32_t>(payload.size() * encoding::kBitsPerByte

+ 39 - 12
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp

@@ -49,9 +49,16 @@ constexpr std::uint8_t kDirectiveActiveIndexWidth = 3;
     return writer.write(kNoTimerEpoch, kWideIntegerWidth) && writer.write(0, kReal32Width);
 }
 
-/** Writes one neutral 239-bit authored HUD marker subrecord. */
-[[nodiscard]] bool write_neutral_directive_marker(bits::Writer& writer) noexcept {
-    return write_absent_client_ref(writer) && write_absent_client_ref(writer) && writer.write(0, 32)
+/** Writes one 239-bit authored HUD marker subrecord, targeting the given slot or none. */
+[[nodiscard]] bool write_directive_marker(bits::Writer& writer,
+                                          const Type2LaneClientRef& target) noexcept {
+    // The client copies .0 into its target. The first hash of .2 must be the absent key, or the
+    // client runs a second authored-name lookup.
+    return writer.write(target.registryKey, 32)
+           && writer.write(static_cast<std::uint8_t>(target.slotType), kClientRefTypeWidth)
+           && writer.write(static_cast<std::uint32_t>(target.slotIndex + kClientRefIndexBias),
+                           kClientRefIndexWidth)
+           && write_absent_client_ref(writer) && writer.write(kClientRefAbsentKey, 32)
            && writer.write(0, 32) && writer.write(0, 32) && writer.write(0, 32)
            && writer.write(0, kBoolWidth);
 }
@@ -60,7 +67,8 @@ constexpr std::uint8_t kDirectiveActiveIndexWidth = 3;
 [[nodiscard]] bool write_directive_entry(bits::Writer& writer,
                                          std::uint32_t nameHash,
                                          std::int32_t elementIndex,
-                                         std::int8_t state) noexcept {
+                                         std::int8_t state,
+                                         const Type2LaneClientRef& target = {}) noexcept {
     if (!writer.write(nameHash, 32)
         || !writer.write(std::bit_cast<std::uint32_t>(elementIndex) + kSigned32Bias, 32)
         || !writer.write(static_cast<std::uint32_t>(state) + 1U, kDirectiveStateWidth)
@@ -73,11 +81,11 @@ constexpr std::uint8_t kDirectiveActiveIndexWidth = 3;
         }
     }
     if (!writer.write(1, kDirectiveStateWidth) || !write_absent_client_ref(writer)
-        || !writer.write(1, kDirectiveAuxStateWidth)) {
+        || !writer.write(target.slotIndex >= 0 ? 3U : 1U, kDirectiveAuxStateWidth)) {
         return false;
     }
     for (std::size_t index = 0; index < 4; ++index) {
-        if (!write_neutral_directive_marker(writer)) {
+        if (!write_directive_marker(writer, index == 0 ? target : Type2LaneClientRef{})) {
             return false;
         }
     }
@@ -413,19 +421,38 @@ bool encode_type68(const Type68Preset& preset,
                    std::span<std::byte> output,
                    std::size_t& written) noexcept {
     written = 0;
-    if (output.size() < kType68ByteCount || preset.state < 0 || preset.state > 2
+    const auto valid_reference = [](const Type2LaneClientRef& reference, std::int8_t slotType) {
+        return reference.slotIndex < 0
+               || (reference.slotType == slotType && reference.registryKey != 0
+                   && reference.registryKey != kClientRefAbsentKey);
+    };
+    // A navpoint rides only on a visible entry in the enter state.
+    const bool navpointAllowed = preset.navpoint.slotIndex < 0 || (preset.visible && preset.state == 0);
+    if (!valid_reference(preset.audience, kType70SlotType)
+        || !valid_reference(preset.navpoint, kType47SlotType) || !navpointAllowed
+        || output.size() < kType68ByteCount || preset.state < 0 || preset.state > 2
         || (preset.visible
             && (preset.nameHash == 0 || preset.nameHash == kClientRefAbsentKey
                 || preset.elementIndex < 0))) {
         return false;
     }
     bits::Writer writer(output.first(kType68ByteCount));
-    bool encoded = write_absent_client_ref(writer) && write_absent_client_ref(writer);
+    // The audience ClientRef stays unset when no sensor was named: type 0, index -1.
+    bool encoded =
+        writer.write(preset.audience.registryKey, 32)
+        && writer.write(static_cast<std::uint32_t>(preset.audience.slotType) + 1U,
+                        kClientRefTypeWidth)
+        && writer.write(static_cast<std::uint32_t>(preset.audience.slotIndex) + kSigned16Bias,
+                        kClientRefIndexWidth)
+        && write_absent_client_ref(writer);
     for (std::size_t index = 0; encoded && index < kType68EntryCount; ++index) {
-        encoded =
-            index == 0 && preset.visible
-                ? write_directive_entry(writer, preset.nameHash, preset.elementIndex, preset.state)
-                : write_directive_entry(writer, kClientRefAbsentKey, 0, -1);
+        encoded = index == 0 && preset.visible
+                      ? write_directive_entry(writer,
+                                              preset.nameHash,
+                                              preset.elementIndex,
+                                              preset.state,
+                                              preset.navpoint)
+                      : write_directive_entry(writer, kClientRefAbsentKey, 0, -1);
     }
     encoded = encoded && writer.write(preset.visible ? 1U : 0U, kDirectiveActiveIndexWidth);
     return encoded && writer.bit_count() == kType68BitCount && writer.finish(written)

+ 120 - 2
Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp

@@ -17,6 +17,7 @@ constexpr std::uint32_t kTypeBias = 1, kIndexBias = 32768;
 constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
 constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
 constexpr std::uint32_t kOccupancy = 0x80809531U;
+constexpr std::uint32_t kGhostLink = 0x80804D3EU;
 constexpr std::uint32_t kObject = 0x8080992EU;
 constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
 constexpr std::uint32_t kSceneEvents = 0x808094DFU, kSceneList = 0x808094E1U;
@@ -25,6 +26,18 @@ constexpr std::uint32_t kSquadReals = 0x80807ECDU;
 constexpr std::uint32_t kObjectiveBlock = 0x80807F07U, kObjectiveTasks = 0x80807F08U;
 constexpr std::uint32_t kCombatant = 0x80807DA2U, kCombatantAtoms = 0x80807F6EU;
 constexpr std::uint32_t kCombatantKeyed = 0x80807DA3U, kCombatantLanes = 0x80807DA4U;
+/** Type-13 participation root and its nested blocks, in body order. */
+constexpr std::uint32_t kPlayer = 0x80804F2FU, kPlayerState = 0x808094E4U;
+constexpr std::uint32_t kPlayerScalars = 0x80804F39U, kPlayerTail = 0x80804F35U;
+constexpr std::uint32_t kPlayerIdentity = 0x808094DDU, kPlayerKeys = 0x808094DFU;
+constexpr std::uint32_t kPlayerPairs = 0x808094E3U, kPlayerFlags = 0x80804F31U;
+constexpr std::uint32_t kPlayerOptional = 0x80804F34U;
+/** Type-20 damage monitor root: health, shield, echoed revision. */
+constexpr std::uint32_t kDamage = 0x80809562U;
+/** Registered object replies: the interaction latch and the ownership key. */
+constexpr std::uint32_t kInteractionReply = 0x80804FB7U, kOwnershipReply = 0x80809ACCU;
+/** Counted lists inside the participation body. */
+constexpr std::uint64_t kPlayerScalarCountMaximum = 4, kPlayerKeyCountMaximum = 32;
 /** Quantization ceilings the combatant reals carry in their own descriptors. */
 constexpr std::uint32_t kSpatialMaximumBits = 0x45000000U, kUnitMaximumBits = 0x3F800000U;
 
@@ -508,8 +521,25 @@ real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noe
         return NativeStatus::malformed;
     }
     values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    // Reply elements carry per-schema registered bodies with no authored layout here.
-    return count == 0 ? NativeStatus::complete : NativeStatus::unsupported;
+    for (std::uint32_t index = 0; index < count; ++index) {
+        std::uint64_t exists = 0, schema = 0;
+        if (!reader.read(1, exists)) return NativeStatus::malformed;
+        if (!exists) continue;
+        if (!reader.read(32, schema)) return NativeStatus::malformed;
+        if (schema == kInteractionReply) {
+            // The interaction latch and the revision that set it.
+            if (!read_bool(reader, values, kInteractionReply, 0, false, index)
+                || !read_signed(reader, values, kInteractionReply, 1, 32, 32,
+                    (std::numeric_limits<std::int32_t>::min)(), false, index))
+                return NativeStatus::malformed;
+        } else if (schema == kOwnershipReply) {
+            // The owner key is always written, zero when nobody holds the object.
+            if (!read_bool(reader, values, kOwnershipReply, 0, false, index)
+                || !read_unsigned(reader, values, kOwnershipReply, 1, 64, false, index))
+                return NativeStatus::malformed;
+        } else return NativeStatus::unsupported;
+    }
+    return NativeStatus::complete;
 }
 /**
  * Decodes the combatant block: the accepted spawn and event generations, the atom runner's lane
@@ -564,6 +594,78 @@ real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noe
     }
     return NativeStatus::complete;
 }
+/**
+ * Decodes the type-13 participation body. The whole body is read before its Ghost flag counts,
+ * because a player mid-load publishes a body with the flag but without a settled actor.
+ */
+[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
+    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
+    if (!read_signed(reader, values, kPlayerState, 0, 32, 32, signedBias, true)
+        || !read_unsigned(reader, values, kPlayerState, 1, 32, true)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint16_t ordinal = 2; ordinal <= 5; ++ordinal) {
+        if (!read_bool(reader, values, kPlayerState, ordinal, false)) return NativeStatus::malformed;
+    }
+    if (!read_signed(reader, values, kPlayerState, 6, 3, 8, 1, false)
+        || !read_signed(reader, values, kPlayerState, 7, 2, 8, 1, false)) {
+        return NativeStatus::malformed;
+    }
+    std::uint64_t count = 0;
+    if (!reader.read(3, count)) return NativeStatus::malformed;
+    if (count > kPlayerScalarCountMaximum) return NativeStatus::unsafeCount;
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerScalars, 0, 32, false, index)
+            || !read_real(reader, values, kPlayerScalars, 1, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_real(reader, values, kPlayerTail, 1, 32, false)
+        || !read_signed(reader, values, kPlayerTail, 2, 5, 32, 1, false)
+        || !read_signed(reader, values, kPlayerTail, 3, 32, 32, signedBias, true)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint16_t ordinal = 4; ordinal <= 6; ++ordinal) {
+        if (!read_bool(reader, values, kPlayerTail, ordinal, false)) return NativeStatus::malformed;
+    }
+    if (!read_unsigned(reader, values, kPlayerIdentity, 0, 64, true)
+        || !read_unsigned(reader, values, kPlayerIdentity, 1, 5, false)
+        || !read_signed(reader, values, kPlayerIdentity, 2, 6, 8, 3, false)) {
+        return NativeStatus::malformed;
+    }
+    if (!reader.read(6, count)) return NativeStatus::malformed;
+    if (count > kPlayerKeyCountMaximum) return NativeStatus::unsafeCount;
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerKeys, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!reader.read(6, count)) return NativeStatus::malformed;
+    if (count > kPlayerKeyCountMaximum) return NativeStatus::unsafeCount;
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerPairs, 0, 32, true, index)
+            || !read_unsigned(reader, values, kPlayerPairs, 1, 64, true, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_bool(reader, values, kPlayerFlags, 0, false)
+        || !read_unsigned(reader, values, kPlayerFlags, 1, 4, false)
+        || !read_unsigned(reader, values, kPlayerFlags, 2, 16, true)
+        || !read_unsigned(reader, values, kPlayerFlags, 3, 16, true)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) return NativeStatus::malformed;
+    if (exists && !read_unsigned(reader, values, kPlayerOptional, 0, 32, false)) {
+        return NativeStatus::malformed;
+    }
+    return read_bool(reader, values, kPlayerFlags, 5, false)
+                   && read_signed(reader, values, kPlayerFlags, 6, 8, 8, 128, false)
+                   && read_signed(reader, values, kPlayer, 4, 32, 32, signedBias, false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
 /** Decodes one root body of the given schema; the status names how far it got. */
 [[nodiscard]] NativeStatus
 decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
@@ -575,6 +677,22 @@ decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
         return NativeStatus::complete;
     }
     switch (schema) {
+    case kPlayer:
+        return decode_player(reader, values);
+    case kDamage:
+        // Three mandatory fields: health, shield, then the echoed Auth revision.
+        return read_real(reader, values, schema, 0, 32, false)
+            && read_real(reader, values, schema, 1, 32, false)
+            && read_signed(reader, values, schema, 2, 32, 32,
+                (std::numeric_limits<std::int32_t>::min)(), false)
+            ? NativeStatus::complete : NativeStatus::malformed;
+    case kGhostLink:
+        // Three mandatory fields: active, elapsed over duration, accepted Auth generation.
+        return read_bool(reader, values, kGhostLink, 0, false)
+                       && read_real(reader, values, kGhostLink, 1, 32, false)
+                       && read_signed(reader, values, kGhostLink, 2, 32, 32,
+                                      (std::numeric_limits<std::int32_t>::min)(), false)
+                   ? NativeStatus::complete : NativeStatus::malformed;
     case kDevice:
         return decode_device(reader, values);
     case kScene:

+ 5 - 3
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

@@ -1,6 +1,7 @@
 #include <algorithm>
 
 #include "sensor_auth_update.h"
+#include "roster_presence.h"
 
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 namespace {
@@ -141,9 +142,10 @@ group_state_sequence(const Roster& roster, std::uint32_t key, std::uint8_t fallb
     for (std::size_t index = 0; encoded && index < keyCount; ++index) {
         encoded = writer.write(block.keys[index], kKeyWidth);
     }
-    encoded = encoded && writer.write(1, kPresenceWidth)
-              && write_key_mask(writer, keyCount, kBubbleMaskWords)
-              && writer.write(1, kPresenceWidth) && writer.write(count, kBubbleCountWidth);
+    encoded = encoded && writer.write(1, kPresenceWidth);
+    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(
             kStateByteBias + group_state_sequence(roster, block.keys[index], stateSequence), 8);

+ 24 - 5
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp

@@ -30,6 +30,12 @@ constexpr std::size_t kConfigurationBits = 35;
 constexpr std::size_t kPackageBits = 7;
 constexpr std::size_t kQueueBits = 12;
 constexpr std::size_t kSpawnKeyBits = 32 * 32 + 1 + 32;
+/** Optional participation revive delay, a 16-bit half float: 30 s under darkness, else 3 s. */
+constexpr std::uint8_t kHalfWidth = 16;
+constexpr std::uint32_t kDarknessReviveDelayHalf = 0x4F80U;
+constexpr std::uint32_t kDefaultReviveDelayHalf = 0x4200U;
+/** A packed region is bubble times eight plus the state ordinal. */
+constexpr std::uint32_t kStatesPerBubble = 8;
 /**
  * Empty map-generator body, schema `0x80805007`.
  * Two 475-bit records, a u32, then fixed arrays of 32 and 64 u8. The fixed array lengths apply
@@ -115,12 +121,18 @@ constexpr std::size_t kSpawnKeyCount = 32;
            && writer.write(1, kPresenceWidth) && writer.write(snapshot.playerKey, 64)
            && writer.write(0, 5) && writer.write(3, 6) && writer.write(0, 6)
            && writer.write(0, 6)
-           // Byte 736 skips the respawn delay, whose countdown never expires when the content
-           // delay is negative. Byte 737 holds the spawn while the client loads.
+           // Byte 736 skips the late spawn-location hold. Byte 737 holds the spawn while the
+           // client loads. The revive delay stays authored unless a darkness policy is set.
            && writer.write(1, kPresenceWidth)
            && writer.write(snapshot.awaitClientSync ? kAwaitingClientSync : 0U, 4)
-           && writer.write(0, 3) && writer.write(0, kPresenceWidth) && writer.write(128, 8)
-           && writer.write(kSignedZero, 32);
+           && writer.write(0, kPresenceWidth)
+           && writer.write(snapshot.hasDarknessPolicy ? 1U : 0U, kPresenceWidth)
+           && (!snapshot.hasDarknessPolicy
+               || writer.write(snapshot.darknessEnabled ? kDarknessReviveDelayHalf
+                                                        : kDefaultReviveDelayHalf,
+                               kHalfWidth))
+           && writer.write(0, kPresenceWidth) && writer.write(0, kPresenceWidth)
+           && writer.write(128, 8) && writer.write(kSignedZero, 32);
 }
 
 /**
@@ -135,7 +147,13 @@ constexpr std::size_t kSpawnKeyCount = 32;
     bool encoded = writer.write(std::uint32_t{snapshot.lifetime} + kLifetimeBias, kLifetimeWidth)
                    && writer.write(1, 3) && writer.write(0, kPresenceWidth)
                    && writer.write(kSignedZero, 32) && writer.write(kEmptyNameHash, 32)
-                   && writer.write(kSignedZero, 32) && writer.write(1, 6)
+                   // Under a darkness policy the lifetime names the bubble, or -1 when disabled.
+                   && writer.write(!snapshot.hasDarknessPolicy ? kSignedZero
+                                   : snapshot.darknessEnabled && snapshot.hasRegion
+                                       ? kSignedZero + snapshot.region / kStatesPerBubble
+                                       : kSignedMinusOne,
+                                   32)
+                   && writer.write(1, 6)
                    && writer.write(kWaitingSwitchKey, 32) && writer.write(1, kPresenceWidth)
                    && writer.write(kWaitingSwitchClass, 32) && writer.write(kSignedZero, 32)
                    && writer.write(kSignedZero, 32);
@@ -215,6 +233,7 @@ auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlay
     if (slotType == kSlotTypeParticipation) {
         return carriesPlayerKey
                    ? kParticipationBits + (snapshot.hasRegion ? kParticipationRegionBits : 0)
+                         + (snapshot.hasDarknessPolicy ? kHalfWidth : 0U)
                    : 0;
     }
     if (slotType == kSlotTypeLifetime) {

+ 8 - 7
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp

@@ -71,15 +71,14 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     }
     std::size_t topLevelRecords = 0;
     for (std::size_t index = 0; index < roster.topLevelGroupCount; ++index) {
-        if (!add_client_records(roster.groups[index].slotTypes.size(), topLevelRecords)) {
+        if (roster.groups[index].retired
+            || !add_client_records(roster.groups[index].slotTypes.size(), topLevelRecords)) {
             return false;
         }
     }
     std::array<bool, kPublishedGroupCapacity> referenced{};
     for (const BubbleSubBlock& block : roster.bubbleSubBlocks) {
-        if (block.keys.size() > kClientGroupCapacity - roster.topLevelGroupCount) {
-            return false;
-        }
+        std::size_t activeGroups = roster.topLevelGroupCount;
         std::size_t activeRecords = topLevelRecords;
         for (const std::uint32_t key : block.keys) {
             std::size_t matched = roster.groupCount;
@@ -94,10 +93,11 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
                 }
                 matched = index;
             }
-            if (matched == roster.groupCount
-                || !add_client_records(roster.groups[matched].slotTypes.size(), activeRecords)) {
+            if (matched == roster.groupCount) return false;
+            if (!roster.groups[matched].retired
+                && (++activeGroups > kClientGroupCapacity
+                    || !add_client_records(roster.groups[matched].slotTypes.size(), activeRecords)))
                 return false;
-            }
             referenced[matched] = true;
         }
     }
@@ -248,6 +248,7 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     bool keyPlaced = false;
     for (std::size_t group = 0; encoded && group < snapshot.roster.groupCount; ++group) {
         const Group& row = snapshot.roster.groups[group];
+        if (row.retired) continue;
         // The filler word after the key is read and discarded.
         encoded = writer.write(1, kPresenceWidth) && writer.write(row.key, kKeyWidth)
                   && writer.write(0, kKeyWidth);

+ 10 - 6
Sunrise/src/middleware/bap/activity_message/activity_squad_auth_body_codec.cpp

@@ -31,8 +31,9 @@ constexpr std::uint8_t kModeWidth = 3;
 constexpr std::uint32_t kModeBias = 1;
 /** A present name hash is one exact unsigned 32-bit value. */
 constexpr std::uint8_t kNameHashWidth = 32;
-/** Logical active one becomes wire two after the schema bias. */
+/** Logical active one becomes wire two after the schema bias; logical zero becomes one. */
 constexpr std::uint32_t kActiveWireValue = 2;
+constexpr std::uint32_t kInactiveWireValue = 1;
 /** Fields zero through two precede the requested-count block. */
 constexpr std::size_t kLeadingAbsentFieldCount = 3;
 /** One unused dynamic field separates requested counts from generation. */
@@ -58,9 +59,7 @@ constexpr std::size_t kAfterSpawnReferenceAbsentFieldCount = 5;
         || (guard.hasLast && preset.generation <= guard.last)) {
         return false;
     }
-    const std::uint8_t mode = static_cast<std::uint8_t>(preset.mode);
-    if (mode != static_cast<std::uint8_t>(Mode::mode0)
-        && mode != static_cast<std::uint8_t>(Mode::mode2)) {
+    if (!valid_mode(preset.mode)) {
         return false;
     }
     if (!std::ranges::all_of(preset.requestedCounts,
@@ -127,8 +126,13 @@ constexpr std::size_t kAfterSpawnReferenceAbsentFieldCount = 5;
     }
     // A name the host does not own is the no-name value, never zero: `sub_7FF7421A9720`
     // compares this field against it and walks the member collection when it differs.
+    // Active reads inactive when nothing is requested, so the client destroys the owned actors
+    // instead of only killing them.
+    const bool hasRequests = std::any_of(preset.requestedCounts.begin(),
+                                         preset.requestedCounts.end(),
+                                         [](std::int32_t count) { return count > 0; });
     if (!write_absent(writer, kAfterSpawnReferenceAbsentFieldCount)
-        || !writer.write(kActiveWireValue, kActiveWidth)
+        || !writer.write(hasRequests ? kActiveWireValue : kInactiveWireValue, kActiveWidth)
         || !writer.write(static_cast<std::uint32_t>(mode) + kModeBias, kModeWidth)
         || !writer.write(1, kPresenceWidth)
         || !writer.write(preset.nameHash.value_or(kEmptyNameHash), kNameHashWidth)) {
@@ -174,7 +178,7 @@ bool encode(const Preset& preset,
     }
 
     std::array<std::byte, kMaximumByteCount> staged{};
-    bits::Writer writer(std::span(staged).first(expectedBytes));
+    bits::Writer writer{std::span(staged).first(expectedBytes)};
     std::size_t stagedBytes = 0;
     if (!write_body(writer, preset) || !writer.finish(stagedBytes)
         || stagedBytes != expectedBytes) {

+ 80 - 0
Sunrise/src/middleware/bap/activity_message/auth_fields.h

@@ -0,0 +1,80 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::bap::activity_message::auth_fields {
+
+/** One fixed-width wire field. The row that builds it states what the value asserts. */
+struct Field final {
+    std::uint64_t value{};
+    std::uint8_t width{};
+};
+
+/** Presence bit that precedes every optional schema field. */
+inline constexpr std::uint8_t kPresenceWidth = 1;
+/** A schema boolean is one bit. */
+inline constexpr std::uint8_t kBoolWidth = 1;
+/** Signed 32-bit schema fields store zero at the middle of the unsigned wire range. */
+inline constexpr std::uint32_t kSigned32Bias = 0x80000000U;
+/** Generations and revisions are 31-bit counters that the client accepts only while positive. */
+inline constexpr std::uint8_t kCounterWidth = 31;
+inline constexpr std::uint32_t kMaximumCounter = 0x7FFFFFFFU;
+/** A nested ClientRef is a 32-bit registry key, a 7-bit biased slot type and a 16-bit biased index.
+ */
+inline constexpr std::uint8_t kClientRefKeyWidth = 32;
+inline constexpr std::uint8_t kClientRefTypeWidth = 7;
+inline constexpr std::uint8_t kClientRefIndexWidth = 16;
+inline constexpr std::uint32_t kClientRefTypeBias = 1;
+inline constexpr std::uint32_t kClientRefIndexBias = 32'768;
+inline constexpr std::uint16_t kMaximumClientRefIndex = 32'767;
+/** The unset ClientRef carries the no-name hash, type zero and index -1. */
+inline constexpr std::uint32_t kClientRefAbsentKey = 0x811C9DC5U;
+inline constexpr std::size_t kClientRefBits =
+    kClientRefKeyWidth + kClientRefTypeWidth + kClientRefIndexWidth;
+
+/** Writes the fields in order. @return False when the writer runs out of room. */
+[[nodiscard]] inline bool write_fields(encoding::bits::Writer& writer,
+                                       std::span<const Field> fields) noexcept {
+    for (const Field& field : fields) {
+        if (!writer.write(field.value, field.width)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Writes one present ClientRef naming an exact slot. */
+[[nodiscard]] inline bool write_client_ref(encoding::bits::Writer& writer,
+                                           std::uint32_t registryKey,
+                                           std::uint32_t slotType,
+                                           std::uint16_t slotIndex) noexcept {
+    return writer.write(registryKey, kClientRefKeyWidth)
+           && writer.write(slotType + kClientRefTypeBias, kClientRefTypeWidth)
+           && writer.write(slotIndex + kClientRefIndexBias, kClientRefIndexWidth);
+}
+
+/** Writes the unset ClientRef. */
+[[nodiscard]] inline bool write_absent_client_ref(encoding::bits::Writer& writer) noexcept {
+    return writer.write(kClientRefAbsentKey, kClientRefKeyWidth)
+           && writer.write(0, kClientRefTypeWidth)
+           && writer.write(kClientRefIndexBias - 1U, kClientRefIndexWidth);
+}
+
+/** @return True when the writer holds exactly the expected bits and closes into the byte count. */
+[[nodiscard]] inline bool finish_exact(encoding::bits::Writer& writer,
+                                       std::size_t bits,
+                                       std::size_t bytes,
+                                       std::size_t& written) noexcept {
+    return writer.bit_count() == bits && writer.finish(written) && written == bytes;
+}
+
+/** @return True when a body's byte count is the one its bit count needs. */
+[[nodiscard]] constexpr bool bytes_match_bits(std::size_t bytes, std::size_t bits) noexcept {
+    return bytes == (bits + 7U) / 8U;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::auth_fields

+ 4 - 1
Sunrise/src/middleware/bap/activity_message/cinematic_incident.h

@@ -10,7 +10,9 @@ namespace sunrise::middleware::bap::activity_message::cinematic_incident {
 inline constexpr std::uint32_t kStartedTarget = 5'239;
 /** Global SObject row emitted for start failure or when the runtime stops owning the resource. */
 inline constexpr std::uint32_t kTerminatedTarget = 1'685;
-/** Effective type-17 incident metadata schema selected by both global SObject rows. */
+/** Global SObject row emitted when the player asks to skip a cinematic. */
+inline constexpr std::uint32_t kSkipTarget = 3'338;
+/** Effective type-17 incident metadata schema selected by the three cinematic SObject rows. */
 inline constexpr std::uint32_t kSchema = 0x808087BFU;
 /** The native schema consumes 486 meaningful bits and two zero padding bits. */
 inline constexpr std::size_t kPayloadBits = 486;
@@ -19,6 +21,7 @@ inline constexpr std::size_t kPayloadBytes = 61;
 enum class Signal : std::uint8_t {
     started,
     terminated,
+    skipRequested,
 };
 
 /** Exact Type-6 source ClientRef and runtime values retained from one cinematic incident. */

+ 268 - 0
Sunrise/src/middleware/bap/activity_message/combatant_auth.h

@@ -0,0 +1,268 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+#include "scriptable_auth_body.h"
+#include "squad_auth_body.h"
+
+// Type-2 combatant Auth bodies that drive one named actor: a movement path, a custom action, a
+// passenger delivery and a retirement. All four share the root prefix in fields .0 to .3.
+
+namespace sunrise::middleware::bap::activity_message::combatant_auth {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint32_t kSchema = scriptable_auth::kType2Schema;
+/** Root .1 mode and .2 marker are written as one; the client keeps the placement it has. */
+inline constexpr std::uint8_t kRootModeWidth = 2;
+inline constexpr std::uint8_t kRootMarkerWidth = 3;
+inline constexpr std::uint32_t kRootModeValue = 1;
+inline constexpr std::uint32_t kRootMarkerValue = 1;
+/** Field .6 program header: two 6-bit values written as 0 then 1. Meaning unverified. */
+inline constexpr std::uint8_t kProgramHeaderWidth = 6;
+inline constexpr std::uint32_t kProgramHeaderFirst = 0;
+inline constexpr std::uint32_t kProgramHeaderSecond = 1;
+/** Field .6 program kind, biased by one on the wire: 3 follows a path, 9 runs an action. */
+inline constexpr std::uint8_t kProgramKindWidth = 4;
+inline constexpr std::uint32_t kProgramKindBias = 1;
+inline constexpr std::uint32_t kPathProgramKind = 3;
+inline constexpr std::uint32_t kActionProgramKind = 9;
+/** Field .6 completion selector: logical zero with bias one, the native completion. */
+inline constexpr std::uint8_t kCompletionWidth = 2;
+inline constexpr std::uint32_t kCompletionValue = 1;
+/** A path program names a type-58 authored path and one of its two markers. */
+inline constexpr std::uint32_t kPathSlotType = 58;
+inline constexpr std::uint32_t kPathComponentClass = 0x80807D9BU;
+inline constexpr std::uint8_t kPathMarkerWidth = 8;
+/** Marker 0 is the path start, marker 1 its destination. */
+inline constexpr std::uint32_t kPathDestinationMarker = 1;
+/** An action program names an authored group and action hash, with no spatial target. */
+inline constexpr std::uint8_t kActionTargetModeWidth = 3;
+inline constexpr std::uint8_t kActionTargetMarkerWidth = 8;
+/** The 8-bit target marker stores -1 with a bias of 128. */
+inline constexpr std::uint32_t kActionNoTargetMarker = 127;
+/** Field .7 delivery manifest: a 4-bit squad count, then one type-1 squad ClientRef each. */
+inline constexpr std::uint8_t kManifestCountWidth = 4;
+inline constexpr std::size_t kMaximumManifestSquads = 8;
+/** Bits one manifest squad adds: its ClientRef. */
+inline constexpr std::size_t kManifestSquadBits = fields::kClientRefBits;
+
+/** Fixed bit and byte counts of each body. */
+inline constexpr std::size_t kPathBits = 156;
+inline constexpr std::size_t kPathBytes = 20;
+inline constexpr std::size_t kActionBits = 254;
+inline constexpr std::size_t kActionBytes = 32;
+inline constexpr std::size_t kDeliveryBits = 132;
+inline constexpr std::size_t kDeliveryBytes = 17;
+inline constexpr std::size_t kDeliveryMaximumBytes =
+    (kDeliveryBits + kManifestSquadBits * (kMaximumManifestSquads - 1) + 7) / 8;
+inline constexpr std::size_t kRetireBits = 77;
+inline constexpr std::size_t kRetireBytes = 10;
+
+/** One squad the delivery manifest names. */
+struct SquadReference final {
+    std::uint32_t registryKey{};
+    std::uint16_t squadIndex{};
+};
+
+struct PathRequest final {
+    std::uint32_t generation{};
+    std::uint32_t revision{};
+    std::uint32_t registryKey{};
+    std::uint16_t pathIndex{};
+};
+
+struct ActionRequest final {
+    std::uint32_t generation{};
+    std::uint32_t revision{};
+    std::uint32_t group{};
+    std::uint32_t action{};
+};
+
+/** @return True when a 31-bit counter is positive and representable. */
+[[nodiscard]] constexpr bool valid_counter(std::uint32_t value) noexcept {
+    return value != 0 && value <= fields::kMaximumCounter;
+}
+
+/** Writes root fields .0 to .3: the spawn generation, mode one, marker one and the enabled bit. */
+[[nodiscard]] inline bool
+write_root(encoding::bits::Writer& writer, std::uint32_t generation, bool enabled) noexcept {
+    const std::array<fields::Field, 5> root{{
+        {1, fields::kPresenceWidth},
+        {generation, fields::kCounterWidth},
+        {kRootModeValue, kRootModeWidth},
+        {kRootMarkerValue, kRootMarkerWidth},
+        {enabled ? 1U : 0U, fields::kBoolWidth},
+    }};
+    return fields::write_fields(writer, root);
+}
+
+/** Writes the .6 program header up to and including its kind and completion selector. */
+[[nodiscard]] inline bool write_program_header(encoding::bits::Writer& writer,
+                                               std::uint32_t revision,
+                                               std::uint32_t kind) noexcept {
+    const std::array<fields::Field, 9> header{{
+        {0, fields::kPresenceWidth}, // .4 absent
+        {0, fields::kPresenceWidth}, // .5 absent
+        {1, fields::kPresenceWidth}, // .6 present
+        {revision, fields::kCounterWidth},
+        {kProgramHeaderFirst, kProgramHeaderWidth},
+        {kProgramHeaderSecond, kProgramHeaderWidth},
+        {1, fields::kPresenceWidth},
+        {kind + kProgramKindBias, kProgramKindWidth},
+        {kCompletionValue, kCompletionWidth},
+    }};
+    return fields::write_fields(writer, header);
+}
+
+/**
+ * Encodes one movement program. The spawn generation stays, the program revision advances.
+ * @param output Exactly kPathBytes.
+ * @return False on an out-of-range counter or index, or a size mismatch.
+ */
+[[nodiscard]] inline bool encode_path(const PathRequest& request,
+                                      std::span<std::byte> output) noexcept {
+    if (output.size() != kPathBytes || !valid_counter(request.generation)
+        || !valid_counter(request.revision) || request.registryKey == 0
+        || request.pathIndex > fields::kMaximumClientRefIndex) {
+        return false;
+    }
+    encoding::bits::Writer writer(output);
+    std::size_t written = 0;
+    const std::array<fields::Field, 3> tail{{
+        {kPathDestinationMarker, kPathMarkerWidth},
+        {1, fields::kBoolWidth},     // follow the authored curve
+        {0, fields::kPresenceWidth}, // .7 absent
+    }};
+    return write_root(writer, request.generation, true)
+           && write_program_header(writer, request.revision, kPathProgramKind)
+           && fields::write_client_ref(
+               writer, request.registryKey, kPathSlotType, request.pathIndex)
+           && fields::write_fields(writer, tail)
+           && fields::finish_exact(writer, kPathBits, kPathBytes, written);
+}
+
+/**
+ * Encodes one custom action program with no spatial target.
+ * @param output Exactly kActionBytes.
+ * @return False on an out-of-range counter, a zero or no-name action, or a size mismatch.
+ */
+[[nodiscard]] inline bool encode_action(const ActionRequest& request,
+                                        std::span<std::byte> output) noexcept {
+    if (output.size() != kActionBytes || !valid_counter(request.generation)
+        || !valid_counter(request.revision) || request.action == 0
+        || request.action == fields::kClientRefAbsentKey) {
+        return false;
+    }
+    encoding::bits::Writer writer(output);
+    std::size_t written = 0;
+    const std::array<fields::Field, 3> identities{{
+        {request.group, 32},
+        {request.action, 32},
+        {fields::kClientRefAbsentKey, 32}, // no additional identity
+    }};
+    const std::array<fields::Field, 3> tail{{
+        {0, kActionTargetModeWidth},
+        {kActionNoTargetMarker, kActionTargetMarkerWidth},
+        {0, fields::kPresenceWidth}, // .7 absent
+    }};
+    return write_root(writer, request.generation, true)
+           && write_program_header(writer, request.revision, kActionProgramKind)
+           && fields::write_fields(writer, identities) && fields::write_absent_client_ref(writer)
+           && fields::write_fields(writer, tail)
+           && fields::finish_exact(writer, kActionBits, kActionBytes, written);
+}
+
+/**
+ * Encodes one delivery manifest: the reserved squads the actor carries. Field .6 stays absent
+ * so a delivery never replaces the movement program.
+ * @param output At least the manifest's byte count.
+ * @param written Receives the byte count. @param bits Receives the meaningful bit count.
+ * @return False on an empty, oversized or repeated manifest, or an out-of-range counter.
+ */
+[[nodiscard]] inline bool encode_delivery(std::uint32_t generation,
+                                          std::uint32_t revision,
+                                          std::span<const SquadReference> squads,
+                                          std::span<std::byte> output,
+                                          std::size_t& written,
+                                          std::size_t& bits) noexcept {
+    written = 0;
+    bits = 0;
+    if (!valid_counter(generation) || !valid_counter(revision) || squads.empty()
+        || squads.size() > kMaximumManifestSquads) {
+        return false;
+    }
+    for (std::size_t index = 0; index < squads.size(); ++index) {
+        if (squads[index].registryKey == 0
+            || squads[index].squadIndex > fields::kMaximumClientRefIndex) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (squads[index].registryKey == squads[prior].registryKey
+                && squads[index].squadIndex == squads[prior].squadIndex) {
+                return false;
+            }
+        }
+    }
+    const std::size_t expectedBits = kDeliveryBits + kManifestSquadBits * (squads.size() - 1);
+    const std::size_t expectedBytes = (expectedBits + 7) / 8;
+    if (output.size() < expectedBytes) {
+        return false;
+    }
+    encoding::bits::Writer writer(output.first(expectedBytes));
+    const std::array<fields::Field, 5> manifestHeader{{
+        {0, fields::kPresenceWidth}, // .4 absent
+        {0, fields::kPresenceWidth}, // .5 absent
+        {0, fields::kPresenceWidth}, // .6 absent
+        {1, fields::kPresenceWidth}, // .7 present
+        {squads.size(), kManifestCountWidth},
+    }};
+    if (!write_root(writer, generation, true) || !fields::write_fields(writer, manifestHeader)) {
+        return false;
+    }
+    for (const SquadReference& squad : squads) {
+        if (!fields::write_client_ref(writer,
+                                      squad.registryKey,
+                                      static_cast<std::uint32_t>(squad_auth::kSlotType),
+                                      squad.squadIndex)) {
+            return false;
+        }
+    }
+    if (!writer.write(revision, fields::kCounterWidth)
+        || !fields::finish_exact(writer, expectedBits, expectedBytes, written)) {
+        return false;
+    }
+    bits = expectedBits;
+    return true;
+}
+
+/**
+ * Encodes one retirement: root .3 disabled, an empty delivery manifest and the delivery
+ * revision set to the generation. The client retires the actor on this new generation.
+ * @param output Exactly kRetireBytes.
+ */
+[[nodiscard]] inline bool encode_retire(std::uint32_t generation,
+                                        std::span<std::byte> output) noexcept {
+    if (output.size() != kRetireBytes || !valid_counter(generation)) {
+        return false;
+    }
+    encoding::bits::Writer writer(output);
+    std::size_t written = 0;
+    const std::array<fields::Field, 6> tail{{
+        {0, fields::kPresenceWidth}, // .4 absent
+        {0, fields::kPresenceWidth}, // .5 absent
+        {0, fields::kPresenceWidth}, // .6 absent
+        {1, fields::kPresenceWidth}, // .7 present
+        {0, kManifestCountWidth},    // no passengers
+        {generation, fields::kCounterWidth},
+    }};
+    return write_root(writer, generation, false) && fields::write_fields(writer, tail)
+           && fields::finish_exact(writer, kRetireBits, kRetireBytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::combatant_auth

+ 47 - 0
Sunrise/src/middleware/bap/activity_message/damage_monitor_auth.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+
+// Type-20 damage monitor. The body binds the monitor to one type-4 object and carries a revision
+// the client echoes with the object's health and shield fractions.
+
+namespace sunrise::middleware::bap::activity_message::damage_monitor {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 20;
+inline constexpr std::uint32_t kComponentClass = 0x80809560U;
+inline constexpr std::uint32_t kAuthSchema = 0x80809563U;
+inline constexpr std::uint32_t kSenseSchema = 0x80809562U;
+/** The watched object is a type-4 object slot. */
+inline constexpr std::uint32_t kTargetSlotType = 4;
+inline constexpr std::size_t kBits = fields::kClientRefBits + 32;
+inline constexpr std::size_t kBytes = (kBits + 7) / 8;
+
+/**
+ * Encodes the monitor body.
+ * @param revision Positive revision; a change re-binds the monitor.
+ * @param written Receives kBytes.
+ */
+[[nodiscard]] inline bool encode(std::uint32_t registryKey,
+                                 std::uint16_t slotIndex,
+                                 std::int32_t revision,
+                                 std::span<std::byte> output,
+                                 std::size_t& written) noexcept {
+    written = 0;
+    if (registryKey == 0 || revision <= 0 || slotIndex > fields::kMaximumClientRefIndex
+        || output.size() < kBytes) {
+        return false;
+    }
+    encoding::bits::Writer writer(output.first(kBytes));
+    return fields::write_client_ref(writer, registryKey, kTargetSlotType, slotIndex)
+           && writer.write(static_cast<std::uint32_t>(revision) + fields::kSigned32Bias, 32)
+           && fields::finish_exact(writer, kBits, kBytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::damage_monitor

+ 98 - 0
Sunrise/src/middleware/bap/activity_message/darkness_zone_auth.h

@@ -0,0 +1,98 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+
+// Type-35 hard-wipe globals sensor. Field .0 enables the darkness restriction; the timer block
+// selects the native wipe countdown, published in whole seconds with the elapsed value clamped.
+
+namespace sunrise::middleware::bap::activity_message::darkness_zone {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 35;
+inline constexpr std::uint32_t kComponentClass = 0x808099BDU;
+inline constexpr std::uint32_t kSchema = 0x808099BFU;
+inline constexpr std::size_t kBits = 359;
+inline constexpr std::size_t kBytes = 45;
+/** Native durations count 673,200 ticks per second. */
+inline constexpr std::uint64_t kTicksPerSecond = 673'200;
+/** The wipe countdown runs three seconds. */
+inline constexpr int kMaximumWipeSeconds = 3;
+/** No countdown requested. */
+inline constexpr int kNoWipe = -1;
+/** Field .1 and .2 are written as 0 and 1. Meaning unverified. */
+inline constexpr std::uint8_t kSecondFieldWidth = 1;
+inline constexpr std::uint8_t kThirdFieldWidth = 2;
+inline constexpr std::uint32_t kThirdFieldValue = 1;
+/** Field .3 selects the countdown: 1 with bias one selects the native wipe timer. */
+inline constexpr std::uint8_t kTimerSelectWidth = 2;
+/** Five 64-bit tick words: elapsed three times, remaining, then zero. */
+inline constexpr std::size_t kTimerWordCount = 5;
+inline constexpr std::uint8_t kTimerWordWidth = 64;
+/** The trailing 32-bit scale is IEEE 1.0 while a countdown runs, else zero. */
+inline constexpr std::uint32_t kOneAsFloatBits = 0x3F800000U;
+
+/**
+ * Encodes the sensor body.
+ * @param enabled Field .0, the darkness restriction.
+ * @param wipeSeconds Remaining countdown seconds, or kNoWipe. A countdown needs enabled.
+ * @param output Exactly kBytes.
+ */
+[[nodiscard]] inline bool
+encode(bool enabled, std::span<std::byte> output, int wipeSeconds = kNoWipe) noexcept {
+    if (output.size() != kBytes || wipeSeconds < kNoWipe || wipeSeconds > kMaximumWipeSeconds
+        || (!enabled && wipeSeconds != kNoWipe)) {
+        return false;
+    }
+    const bool wipe = wipeSeconds != kNoWipe;
+    const std::uint64_t elapsed =
+        wipe ? static_cast<std::uint64_t>(kMaximumWipeSeconds - wipeSeconds) * kTicksPerSecond : 0;
+    const std::uint64_t remaining =
+        wipe ? static_cast<std::uint64_t>(wipeSeconds) * kTicksPerSecond : 0;
+    encoding::bits::Writer writer(output);
+    std::size_t written = 0;
+    const std::array<fields::Field, 5> header{{
+        {enabled ? 1U : 0U, fields::kBoolWidth},
+        {0, kSecondFieldWidth},
+        {kThirdFieldValue, kThirdFieldWidth},
+        {wipe ? 1U : 0U, kTimerSelectWidth},
+        {wipe ? 1U : 0U, fields::kBoolWidth},
+    }};
+    const std::array<fields::Field, kTimerWordCount> timer{{
+        {elapsed, kTimerWordWidth},
+        {elapsed, kTimerWordWidth},
+        {elapsed, kTimerWordWidth},
+        {remaining, kTimerWordWidth},
+        {0, kTimerWordWidth},
+    }};
+    return fields::write_fields(writer, header) && fields::write_fields(writer, timer)
+           && writer.write(wipe ? kOneAsFloatBits : 0U, 32)
+           && fields::finish_exact(writer, kBits, kBytes, written);
+}
+
+/**
+ * Reads field .0 back from a body this tree encoded, for the roster's darkness policy.
+ * @return False when the body is not this sensor's size.
+ */
+[[nodiscard]] inline bool
+read_enabled(std::span<const std::byte> body, std::size_t bits, bool& enabled) noexcept {
+    if (bits != kBits || body.size() != kBytes) {
+        return false;
+    }
+    encoding::bits::Reader reader(body);
+    std::uint64_t value = 0;
+    if (!reader.read(fields::kBoolWidth, value)) {
+        return false;
+    }
+    enabled = value != 0;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::darkness_zone

+ 46 - 0
Sunrise/src/middleware/bap/activity_message/ghost_link_auth.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+
+// Type-65 Ghost-link sensor. Field .0 is a monotonic reset generation, .1 enables the native
+// interaction, and .2 keeps the authored interaction hash when it carries the no-name hash.
+
+namespace sunrise::middleware::bap::activity_message::ghost_link {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 65;
+inline constexpr std::uint32_t kComponentClass = 0x80804D31U;
+inline constexpr std::uint32_t kAuthSchema = 0x80804D3FU;
+inline constexpr std::uint32_t kSenseSchema = 0x80804D3EU;
+inline constexpr std::size_t kBitCount = 65;
+inline constexpr std::size_t kByteCount = 9;
+/** Field .2 value that preserves the authored interaction. */
+inline constexpr std::uint32_t kAuthoredInteraction = fields::kClientRefAbsentKey;
+
+/**
+ * Encodes the sensor body.
+ * @param generation Positive reset generation.
+ * @param output At least kByteCount. @param written Receives kByteCount.
+ */
+[[nodiscard]] inline bool encode(std::int32_t generation,
+                                 bool enabled,
+                                 std::span<std::byte> output,
+                                 std::size_t& written) noexcept {
+    written = 0;
+    if (generation <= 0 || output.size() < kByteCount) {
+        return false;
+    }
+    encoding::bits::Writer writer(output.first(kByteCount));
+    return writer.write(static_cast<std::uint32_t>(generation) + fields::kSigned32Bias, 32)
+           && writer.write(enabled ? 1U : 0U, fields::kBoolWidth)
+           && writer.write(kAuthoredInteraction, 32)
+           && fields::finish_exact(writer, kBitCount, kByteCount, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::ghost_link

+ 87 - 0
Sunrise/src/middleware/bap/activity_message/interactable_object_auth.h

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+
+// Type-4 object Auth that spawns authored entry zero and subscribes to accepted player use.
+// The client answers the subscription on its Sense reply list with the interaction latch.
+
+namespace sunrise::middleware::bap::activity_message::interactable_object {
+
+namespace fields = auth_fields;
+
+/** Object Auth schema; the SDK format table carries the same value as kObjectAuthSchema. */
+inline constexpr std::uint32_t kSchema = 0x8080992FU;
+/** Registered reply the client emits for the interaction subscription below. */
+inline constexpr std::uint32_t kInteractionReply = 0x80804FB7U;
+/** Subscription schema that makes the client emit kInteractionReply. */
+inline constexpr std::uint32_t kInteractionSubscription = 0x80804FB8U;
+/** Optional ownership subscription; the client answers it with the owner key reply. */
+inline constexpr std::uint32_t kOwnershipSubscription = 0x80809ACDU;
+inline constexpr std::uint32_t kOwnershipReply = 0x80809ACCU;
+inline constexpr std::size_t kBits = 375;
+inline constexpr std::size_t kBytes = 47;
+/** The ownership subscription adds its presence bit and schema. */
+inline constexpr std::size_t kOwnerBits = kBits + fields::kPresenceWidth + 32;
+inline constexpr std::size_t kOwnerBytes = (kOwnerBits + 7) / 8;
+/** Reply subscription count: one, or two with ownership. */
+inline constexpr std::uint8_t kSubscriptionCountWidth = 2;
+/** Use-visibility mode. Wire zero is native -1, which lets use through with no player filter. */
+inline constexpr std::uint8_t kUseVisibilityWidth = 2;
+inline constexpr std::uint32_t kUseVisibilityDefault = 0;
+
+/**
+ * Encodes the object body.
+ * @param generation Positive object generation; a new one respawns the entry.
+ * @param trackOwner Adds the ownership subscription.
+ * @param active Field .2, whether the entry is active.
+ * @param written Receives kBytes or kOwnerBytes.
+ */
+[[nodiscard]] inline bool encode(std::int32_t generation,
+                                 std::span<std::byte> output,
+                                 std::size_t& written,
+                                 bool trackOwner = false,
+                                 bool active = true) noexcept {
+    written = 0;
+    const std::size_t bytes = trackOwner ? kOwnerBytes : kBytes;
+    const std::size_t bits = trackOwner ? kOwnerBits : kBits;
+    if (generation <= 0 || output.size() < bytes) {
+        return false;
+    }
+    encoding::bits::Writer writer(output.first(bytes));
+    const std::array<fields::Field, 5> head{{
+        {static_cast<std::uint32_t>(generation) + fields::kSigned32Bias, 32},
+        {fields::kSigned32Bias, 32}, // signed zero
+        {active ? 1U : 0U, fields::kBoolWidth},
+        {0, fields::kBoolWidth},
+        {fields::kSigned32Bias, 32}, // signed zero
+    }};
+    const std::array<fields::Field, 7> transform{{
+        {0, 32},
+        {0, 32},
+        {0, 32},
+        {0, fields::kBoolWidth},
+        {trackOwner ? 2U : 1U, kSubscriptionCountWidth},
+        {1, fields::kPresenceWidth},
+        {kInteractionSubscription, 32},
+    }};
+    const std::array<fields::Field, 1> visibility{{{kUseVisibilityDefault, kUseVisibilityWidth}}};
+    const std::array<fields::Field, 2> tail{{
+        {fields::kSigned32Bias, 32}, // signed zero
+        {0, fields::kBoolWidth},
+    }};
+    return fields::write_fields(writer, head) && fields::write_absent_client_ref(writer)
+           && fields::write_fields(writer, transform) && fields::write_fields(writer, visibility)
+           && fields::write_absent_client_ref(writer) && fields::write_fields(writer, tail)
+           && (!trackOwner
+               || (writer.write(1, fields::kPresenceWidth)
+                   && writer.write(kOwnershipSubscription, 32)))
+           && fields::finish_exact(writer, bits, bytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::interactable_object

+ 271 - 0
Sunrise/src/middleware/bap/activity_message/mission_auth_patch.h

@@ -0,0 +1,271 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+#include "scriptable_auth_body.h"
+#include "squad_auth_body.h"
+
+// A native sensor override replaces the whole Auth object. A mission API body carries only the
+// root fields it sets, so it is composed over the last transported body before it is staged.
+// TODO: the field tables below repeat the squad and combatant layouts their codecs own. Read the
+// layout from the codecs once they expose it.
+
+namespace sunrise::middleware::bap::activity_message::mission_auth_patch {
+
+namespace fields = auth_fields;
+
+/** Largest body either layout produces. */
+inline constexpr std::size_t kCapacity = 256;
+/** Root field counts of the two supported layouts. */
+inline constexpr std::size_t kSquadFieldCount = 21;
+inline constexpr std::size_t kCombatantFieldCount = 8;
+
+/** How one root field's width is found. */
+enum class Kind : std::uint8_t {
+    /** Fixed width. */
+    fixed,
+    /** A 4-bit count of 32-bit values. */
+    counted32,
+    /** A field this composer does not carry; a present one refuses the body. */
+    refused,
+    /** Combatant field .6: the program header, then a kind-specific body. */
+    program,
+    /** Combatant field .7: a 4-bit squad count, one ClientRef each, then a 31-bit revision. */
+    manifest,
+};
+
+/** One root field: whether a presence bit precedes it, and how wide it is. */
+struct FieldRule final {
+    Kind kind{Kind::fixed};
+    bool optional{true};
+    std::uint16_t width{};
+};
+
+/** Squad Auth root fields .0 to .20. Widths are bits after the presence bit. */
+inline constexpr std::array<FieldRule, kSquadFieldCount> kSquadRules{{
+    {Kind::fixed, true, fields::kClientRefBits}, // .0 objective ClientRef
+    {Kind::fixed, true, fields::kClientRefBits}, // .1
+    {Kind::refused, true, 0},                    // .2
+    {Kind::counted32, true, 0},                  // .3 requested counts
+    {Kind::counted32, true, 0},                  // .4
+    {Kind::fixed, true, 13},                     // .5 authored profile
+    {Kind::fixed, true, 31},                     // .6 spawn generation
+    {Kind::fixed, true, 32},                     // .7
+    {Kind::fixed, true, 32},                     // .8
+    {Kind::fixed, true, fields::kClientRefBits}, // .9
+    {Kind::fixed, true, fields::kClientRefBits}, // .10
+    {Kind::fixed, true, fields::kClientRefBits}, // .11 spawn reference
+    {Kind::fixed, true, fields::kClientRefBits}, // .12 spawn reference
+    {Kind::fixed, true, 31},                     // .13 objective revision
+    {Kind::fixed, true, 31},                     // .14
+    {Kind::fixed, true, 6},                      // .15
+    {Kind::fixed, true, 5},                      // .16 task group
+    {Kind::fixed, true, 31},                     // .17
+    {Kind::fixed, false, 2},                     // .18 active
+    {Kind::fixed, false, 3},                     // .19 mode
+    {Kind::fixed, true, 32},                     // .20 name hash
+}};
+
+/** Combatant Auth root fields .0 to .7. */
+inline constexpr std::array<FieldRule, kCombatantFieldCount> kCombatantRules{{
+    {Kind::fixed, true, 31},   // .0 spawn generation
+    {Kind::fixed, false, 2},   // .1 mode
+    {Kind::fixed, false, 3},   // .2 marker
+    {Kind::fixed, false, 1},   // .3 enabled
+    {Kind::refused, true, 0},  // .4
+    {Kind::refused, true, 0},  // .5
+    {Kind::program, true, 0},  // .6
+    {Kind::manifest, true, 0}, // .7
+}};
+
+/** Combatant .6 program layout: revision, two 6-bit header words, a presence bit, kind, completion.
+ */
+inline constexpr std::size_t kProgramLeadBits = 31 + 6;
+inline constexpr std::uint8_t kProgramHeaderWidth = 6;
+inline constexpr std::uint32_t kProgramHeaderSecond = 1;
+inline constexpr std::uint8_t kProgramKindWidth = 4;
+inline constexpr std::uint8_t kCompletionWidth = 2;
+inline constexpr std::uint32_t kCompletionValue = 1;
+/** Biased program kinds this composer carries: a path (3) and an action (9). */
+inline constexpr std::uint32_t kPathKindWire = 4;
+inline constexpr std::uint32_t kActionKindWire = 10;
+/** Bits after the completion selector: a path ClientRef, marker and flag; or the action body. */
+inline constexpr std::size_t kPathBodyBits = fields::kClientRefBits + 8 + 1;
+inline constexpr std::size_t kActionBodyBits = 32 + 32 + 32 + fields::kClientRefBits + 3 + 8;
+/** Combatant .7 manifest: a 4-bit count of ClientRefs, then a 31-bit delivery revision. */
+inline constexpr std::uint8_t kCountWidth = 4;
+inline constexpr std::size_t kMaximumCount = 8;
+
+/** Where one root field sits in a parsed body. */
+struct Field final {
+    std::size_t offset{};
+    std::size_t bits{};
+    bool present{};
+};
+
+struct Layout final {
+    std::array<Field, kSquadFieldCount> fields{};
+    std::size_t count{};
+};
+
+/** @return The rules for one supported schema, or an empty span. */
+[[nodiscard]] inline std::span<const FieldRule> rules_for(std::uint32_t schema) noexcept {
+    if (schema == squad_auth::kSchema) {
+        return kSquadRules;
+    }
+    if (schema == scriptable_auth::kType2Schema) {
+        return kCombatantRules;
+    }
+    return {};
+}
+
+/** Skips a combatant .6 program body. @return False on a kind this composer does not carry. */
+[[nodiscard]] inline bool skip_program(encoding::bits::Reader& reader) noexcept {
+    std::uint64_t value = 0;
+    if (!reader.skip(kProgramLeadBits) || !reader.read(kProgramHeaderWidth, value)
+        || value != kProgramHeaderSecond || !reader.read(fields::kPresenceWidth, value)
+        || value != 1 || !reader.read(kProgramKindWidth, value)
+        || (value != kPathKindWire && value != kActionKindWire)) {
+        return false;
+    }
+    const std::uint64_t kind = value;
+    return reader.read(kCompletionWidth, value) && value == kCompletionValue
+           && reader.skip(kind == kPathKindWire ? kPathBodyBits : kActionBodyBits);
+}
+
+/** Skips a combatant .7 manifest. */
+[[nodiscard]] inline bool skip_manifest(encoding::bits::Reader& reader) noexcept {
+    std::uint64_t count = 0;
+    return reader.read(kCountWidth, count) && count <= kMaximumCount
+           && reader.skip(fields::kClientRefBits * count + fields::kCounterWidth);
+}
+
+/**
+ * Locates every root field of one body.
+ * @param bits Meaningful bit count of the body.
+ * @return False on an unsupported schema, a refused field, a size mismatch or nonzero padding.
+ */
+[[nodiscard]] inline bool parse(std::uint32_t schema,
+                                std::span<const std::byte> body,
+                                std::size_t bits,
+                                Layout& out) noexcept {
+    const std::span<const FieldRule> rules = rules_for(schema);
+    if (rules.empty() || body.empty() || body.size() > kCapacity
+        || !fields::bytes_match_bits(body.size(), bits)) {
+        return false;
+    }
+    encoding::bits::Reader reader(body);
+    const auto position = [&] { return body.size() * 8 - reader.remaining_bits(); };
+    out = {};
+    out.count = rules.size();
+    for (std::size_t index = 0; index < rules.size(); ++index) {
+        const FieldRule& rule = rules[index];
+        Field& field = out.fields[index];
+        field.offset = position();
+        std::uint64_t present = 1;
+        if (rule.optional && !reader.read(fields::kPresenceWidth, present)) {
+            return false;
+        }
+        field.present = present != 0;
+        if (field.present) {
+            std::uint64_t count = 0;
+            bool skipped = false;
+            switch (rule.kind) {
+            case Kind::fixed:
+                skipped = reader.skip(rule.width);
+                break;
+            case Kind::counted32:
+                skipped = reader.read(kCountWidth, count) && count <= kMaximumCount
+                          && reader.skip(32 * count);
+                break;
+            case Kind::program:
+                skipped = skip_program(reader);
+                break;
+            case Kind::manifest:
+                skipped = skip_manifest(reader);
+                break;
+            case Kind::refused:
+                break;
+            }
+            if (!skipped) {
+                return false;
+            }
+        }
+        field.bits = position() - field.offset;
+    }
+    std::uint64_t padding = 0;
+    return position() == bits
+           && reader.read(static_cast<std::uint8_t>(body.size() * 8 - bits), padding)
+           && padding == 0;
+}
+
+/** Copies one located field, in 64-bit pieces, from a body into the writer. */
+[[nodiscard]] inline bool
+copy_field(encoding::bits::Writer& writer, std::span<const std::byte> body, Field field) noexcept {
+    encoding::bits::Reader reader(body);
+    if (!reader.skip(field.offset)) {
+        return false;
+    }
+    while (field.bits != 0) {
+        const auto piece = static_cast<std::uint8_t>(field.bits > 64 ? 64 : field.bits);
+        std::uint64_t value = 0;
+        if (!reader.read(piece, value) || !writer.write(value, piece)) {
+            return false;
+        }
+        field.bits -= piece;
+    }
+    return true;
+}
+
+/**
+ * Composes a patch over the previous body: a field the patch leaves absent keeps the previous
+ * value. With no previous body the patch is written as it is.
+ * @param written Receives the byte count. @param bits Receives the meaningful bit count.
+ * @return False when either body fails parse() or the result does not fit output.
+ */
+[[nodiscard]] inline bool compose(std::uint32_t schema,
+                                  std::span<const std::byte> previous,
+                                  std::size_t previousBits,
+                                  std::span<const std::byte> patch,
+                                  std::size_t patchBits,
+                                  std::span<std::byte> output,
+                                  std::size_t& written,
+                                  std::size_t& bits) noexcept {
+    written = 0;
+    bits = 0;
+    Layout old{};
+    Layout next{};
+    if (!parse(schema, patch, patchBits, next)
+        || (!previous.empty() && !parse(schema, previous, previousBits, old))) {
+        return false;
+    }
+    std::array<std::byte, kCapacity> staged{};
+    encoding::bits::Writer writer(staged);
+    for (std::size_t index = 0; index < next.count; ++index) {
+        const bool keep =
+            !previous.empty() && !next.fields[index].present && old.fields[index].present;
+        if (!copy_field(
+                writer, keep ? previous : patch, keep ? old.fields[index] : next.fields[index])) {
+            return false;
+        }
+    }
+    const std::size_t usedBits = writer.bit_count();
+    std::size_t used = 0;
+    if (!writer.finish(used) || output.size() < used) {
+        return false;
+    }
+    for (std::size_t index = 0; index < used; ++index) {
+        output[index] = staged[index];
+    }
+    written = used;
+    bits = usedBits;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::mission_auth_patch

+ 66 - 0
Sunrise/src/middleware/bap/activity_message/mission_effect_auth.h

@@ -0,0 +1,66 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+#include "scriptable_auth_body.h"
+
+// Type-26 mission effect (hop-on). A new revision detaches the old effect, then the client
+// attaches the authored effect to the entities its type-34 filter selects. No filter removes all.
+
+namespace sunrise::middleware::bap::activity_message::mission_effect {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 26;
+inline constexpr std::uint32_t kComponentClass = 0x8080953FU;
+inline constexpr std::uint32_t kSchema = 0x8080954BU;
+inline constexpr std::size_t kBits = 186;
+inline constexpr std::size_t kBytes = 24;
+
+/**
+ * Encodes the effect body.
+ * @param filter Type-34 filter slot; ignored when disabled.
+ * @param revision Positive revision; a change re-attaches.
+ * @param written Receives kBytes.
+ */
+[[nodiscard]] inline bool encode(scriptable_auth::Type2LaneClientRef filter,
+                                 bool enabled,
+                                 std::int32_t revision,
+                                 std::span<std::byte> output,
+                                 std::size_t& written) noexcept {
+    written = 0;
+    if (revision <= 0 || output.size() < kBytes
+        || (enabled
+            && (filter.slotType != scriptable_auth::kType34SlotType || filter.slotIndex < 0))) {
+        return false;
+    }
+    if (!enabled) {
+        filter = {};
+    }
+    encoding::bits::Writer writer(output.first(kBytes));
+    const std::array<fields::Field, 6> head{{
+        {0, fields::kBoolWidth},
+        {enabled ? 0U : 1U, fields::kBoolWidth}, // disabled
+        {fields::kSigned32Bias, 32},             // signed zero
+        {fields::kSigned32Bias, 32},             // signed zero
+        {fields::kSigned32Bias, 32},             // signed zero
+        {static_cast<std::uint32_t>(revision) + fields::kSigned32Bias, 32},
+    }};
+    return fields::write_fields(writer, head)
+           && writer.write(filter.registryKey, fields::kClientRefKeyWidth)
+           && writer.write(static_cast<std::uint32_t>(filter.slotType) + fields::kClientRefTypeBias,
+                           fields::kClientRefTypeWidth)
+           && writer.write(
+               static_cast<std::uint32_t>(static_cast<std::int32_t>(filter.slotIndex)
+                                          + static_cast<std::int32_t>(fields::kClientRefIndexBias)),
+               fields::kClientRefIndexWidth)
+           && writer.write(0, fields::kBoolWidth)
+           && fields::finish_exact(writer, kBits, kBytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::mission_effect

+ 61 - 0
Sunrise/src/middleware/bap/activity_message/music_section_auth.h

@@ -0,0 +1,61 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+
+// Type-11 music sensor. The body opens with a 128-bit section selection mask, then one optional
+// condition ClientRef per section plus a final one that gates the whole sensor. One set bit lets
+// the native bank play that section's transition.
+
+namespace sunrise::middleware::bap::activity_message::music_section {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 11;
+inline constexpr std::uint32_t kComponentClass = 0x80804E8EU;
+inline constexpr std::uint32_t kSchema = 0x80804F58U;
+/** The mask spans four 32-bit lanes, one bit per section. */
+inline constexpr std::size_t kSectionCount = 128;
+inline constexpr std::size_t kMaskLaneCount = 4;
+inline constexpr std::uint8_t kMaskLaneWidth = 32;
+/** One condition ClientRef per section, then the sensor gate. All are left unset. */
+inline constexpr std::size_t kConditionRefCount = kSectionCount + 1;
+inline constexpr std::size_t kBits =
+    kMaskLaneCount * kMaskLaneWidth + kConditionRefCount * fields::kClientRefBits;
+inline constexpr std::size_t kBytes = (kBits + 7) / 8;
+
+/**
+ * Encodes the sensor body with at most one section selected.
+ * @param section Section index below kSectionCount.
+ * @param enabled False clears the mask.
+ * @param written Receives kBytes.
+ */
+[[nodiscard]] inline bool encode(std::uint8_t section,
+                                 bool enabled,
+                                 std::span<std::byte> output,
+                                 std::size_t& written) noexcept {
+    written = 0;
+    if (section >= kSectionCount || output.size() < kBytes) {
+        return false;
+    }
+    encoding::bits::Writer writer(output.first(kBytes));
+    for (std::size_t lane = 0; lane < kMaskLaneCount; ++lane) {
+        const bool selected = enabled && section / kMaskLaneWidth == lane;
+        const std::uint32_t mask = selected ? std::uint32_t{1} << (section % kMaskLaneWidth) : 0U;
+        if (!writer.write(mask, kMaskLaneWidth)) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < kConditionRefCount; ++index) {
+        if (!fields::write_absent_client_ref(writer)) {
+            return false;
+        }
+    }
+    return fields::finish_exact(writer, kBits, kBytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::music_section

+ 42 - 0
Sunrise/src/middleware/bap/activity_message/roster_presence.h

@@ -0,0 +1,42 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "sensor_auth_update.h"
+
+namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
+
+/** One presence word covers 32 retained keys. */
+inline constexpr std::size_t kPresenceWordBits = 32;
+
+/** @return True when the roster holds the key and has not retired it. */
+[[nodiscard]] inline bool key_present(const Roster& roster, std::uint32_t key) noexcept {
+    for (std::size_t index = 0; index < roster.groupCount; ++index) {
+        if (roster.groups[index].key == key) {
+            return !roster.groups[index].retired;
+        }
+    }
+    return false;
+}
+
+/**
+ * Builds one presence word over the retained key array. The mask indexes that array, so a
+ * retired key clears its bit and never shifts its neighbours.
+ */
+[[nodiscard]] inline std::uint32_t presence_word(const Roster& roster,
+                                                 std::span<const std::uint32_t> keys,
+                                                 std::size_t word) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t bit = 0;
+         bit < kPresenceWordBits && word * kPresenceWordBits + bit < keys.size();
+         ++bit) {
+        if (key_present(roster, keys[word * kPresenceWordBits + bit])) {
+            mask |= std::uint32_t{1} << bit;
+        }
+    }
+    return mask;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sensor_auth_update

+ 79 - 0
Sunrise/src/middleware/bap/activity_message/scene_events_auth.h

@@ -0,0 +1,79 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.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,
+// because a new generation restarts the scene.
+
+namespace sunrise::middleware::bap::activity_message::scene_events {
+
+namespace fields = auth_fields;
+
+inline constexpr std::uint8_t kSlotType = 43;
+/** The SDK format table carries the same class and schema for authored scenes. */
+inline constexpr std::uint32_t kComponentClass = 0x80806382U;
+inline constexpr std::uint32_t kSchema = 0x8080626BU;
+/** Header: 32-bit generation, clear bit, 4-bit dependency count, 31-bit scalar, 6-bit count. */
+inline constexpr std::uint8_t kDependencyCountWidth = 4;
+inline constexpr std::uint8_t kScalarWidth = 31;
+inline constexpr std::uint8_t kEventCountWidth = 6;
+inline constexpr std::size_t kHeaderBits =
+    32 + fields::kBoolWidth + kDependencyCountWidth + kScalarWidth + kEventCountWidth;
+inline constexpr std::uint8_t kEventKeyWidth = 32;
+/** Events this API carries per body; the 6-bit count could name more. */
+inline constexpr std::size_t kMaximumEvents = 32;
+inline constexpr std::size_t kMaximumBytes =
+    (kHeaderBits + kEventKeyWidth * kMaximumEvents + 7) / 8;
+/** All-one bits is not an event key. */
+inline constexpr std::uint32_t kInvalidEventKey = 0xFFFFFFFFU;
+
+/**
+ * Encodes the scene body.
+ * @param generation Positive scene generation.
+ * @param events Distinct nonzero event keys.
+ * @param bytes Receives the byte count. @param bits Receives the meaningful bit count.
+ */
+[[nodiscard]] inline bool encode(std::int32_t generation,
+                                 std::span<const std::uint32_t> events,
+                                 std::span<std::byte> output,
+                                 std::size_t& bytes,
+                                 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);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::scene_events

+ 9 - 0
Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h

@@ -175,6 +175,7 @@ inline constexpr std::size_t kType2MaximumBodyByteCount = (kType2MaximumBodyBitC
 
 /** ClientRef slot type and Auth schema for the object-filter sensor. */
 inline constexpr std::uint8_t kType34SlotType = 34;
+inline constexpr std::uint32_t kType34ComponentClass = 0x80809568U;
 inline constexpr std::uint32_t kType34Schema = 0x8080956AU;
 inline constexpr std::size_t kType34PredicateCapacity = 8;
 
@@ -418,6 +419,10 @@ inline constexpr std::uint32_t kType68Schema = 0x80804F67;
 inline constexpr std::size_t kType68EntryCount = 3;
 inline constexpr std::size_t kType68BitCount = 4'802;
 inline constexpr std::size_t kType68ByteCount = (kType68BitCount + 7) / 8;
+/** ClientRef slot type of an authored navigation marker a directive may point at. */
+inline constexpr std::uint8_t kType47SlotType = 47;
+/** ClientRef slot type of an authored volume an object filter may test against. */
+inline constexpr std::uint8_t kType60SlotType = 60;
 /** ClientRef slot type for the encounter/player engagement observer. */
 inline constexpr std::uint8_t kType70SlotType = 70;
 inline constexpr std::uint32_t kType70Schema = 0x808094F1;
@@ -464,6 +469,10 @@ struct Type68Preset final {
     /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
     std::int8_t state{};
     bool visible{true};
+    /** Authored type-47 destination; absent removes the explicit guidance marker. */
+    Type2LaneClientRef navpoint{};
+    /** Type-70 engagement sensor the client tests before it shows the mission banner. */
+    Type2LaneClientRef audience{};
 };
 
 /** One exact type-70 state with both optional authored filter lists absent. */

+ 44 - 0
Sunrise/src/middleware/bap/activity_message/sense_observation_packet.h

@@ -0,0 +1,44 @@
+#pragma once
+
+#include <cstddef>
+
+#include "sense_update.h"
+
+namespace sunrise::middleware::bap::activity_message::sense_update {
+
+/** Complete, or partial: the envelope closed but one delimited group had an unsupported member. */
+[[nodiscard]] inline bool observation_status(DecodeStatus status) noexcept {
+    return status == DecodeStatus::complete || status == DecodeStatus::partial;
+}
+
+/**
+ * Tests whether every decoded object of a packet owns a bounded value range and a generation.
+ * A complete packet may hold no undecoded object; a partial one may, and those are skipped.
+ */
+[[nodiscard]] inline bool observation_packet(const DecodedPacket& packet) noexcept {
+    if (!observation_status(packet.status) || packet.objectsTruncated || packet.valuesTruncated
+        || packet.objectCount > packet.objects.size() || packet.valueCount > packet.values.size()) {
+        return false;
+    }
+    std::size_t end = 0;
+    std::size_t decoded = 0;
+    for (std::size_t index = 0; index < packet.objectCount; ++index) {
+        const DecodedObject& object = packet.objects[index];
+        if (object.firstValue != end || object.valueCount > packet.valueCount - end
+            || object.status == ObjectStatus::malformed) {
+            return false;
+        }
+        end += object.valueCount;
+        if (object.status == ObjectStatus::decoded) {
+            if (!object.hasGeneration) {
+                return false;
+            }
+            ++decoded;
+        } else if (packet.status == DecodeStatus::complete) {
+            return false;
+        }
+    }
+    return end == packet.valueCount && decoded == packet.objectsDecoded;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update

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

@@ -98,6 +98,8 @@ struct Group final {
     bool hasStateSequence{};
     /** True only for a generated mission group whose non-overridden slots seed empty deltas. */
     bool missionSeedOnly{};
+    /** Retain this key's ordinal but clear its presence and omit its authority bodies. */
+    bool retired{};
 };
 
 /** One exact, already-registered slot body substituted into phase 2. */
@@ -182,6 +184,9 @@ struct Snapshot final {
     bool hasGrant{};
     bool hasRegion{};
     bool hasSpawnOverride{};
+    /** Set when the estate carries a darkness sensor body; the lifetime then names the bubble. */
+    bool hasDarknessPolicy{};
+    bool darknessEnabled{};
     /** Hold the client's spawn while it loads by emitting `awaiting_client_sync`. */
     bool awaitClientSync{};
     /** Register the groups and seed no object. Separates no components from no auth state. */

+ 9 - 2
Sunrise/src/middleware/bap/activity_message/squad_auth_body.h

@@ -47,14 +47,21 @@ inline constexpr std::size_t kAuthoredProfileBitCount = 13;
 /** Buffer one squad body needs at the full requested-count length. */
 inline constexpr std::size_t kMaximumBitCount = exact_body_bit_count(kMaximumRequestedCountLength);
 inline constexpr std::size_t kMaximumByteCount = (kMaximumBitCount + 7) / 8;
+/** A retained body may carry the objective fields too, up to 1,313 bits. */
+inline constexpr std::size_t kMaximumRetainedBitCount = 1'313;
+inline constexpr std::size_t kMaximumRetainedByteCount = (kMaximumRetainedBitCount + 7U) / 8U;
 /** Spawn generation is an unsigned logical value stored in a 31-bit field. */
 inline constexpr std::uint32_t kMaximumGeneration = 0x7FFFFFFF;
 
-/** The schema accepts only its two observed numeric mode values. */
+/** Mode 3 skips ordinary placement and keeps the requests for a passenger delivery. */
 enum class Mode : std::uint8_t {
     mode0 = 0,
     mode2 = 2,
+    reserve = 3,
 };
+[[nodiscard]] constexpr bool valid_mode(Mode mode) noexcept {
+    return mode == Mode::mode0 || mode == Mode::mode2 || mode == Mode::reserve;
+}
 
 /** Last accepted positive spawn generation for one ClientRef. */
 struct GenerationGuard final {
@@ -62,7 +69,7 @@ struct GenerationGuard final {
     bool hasLast{};
 };
 
-/** One canonical activity-local squad request. Active is fixed to one on the wire. */
+/** One canonical activity-local squad request. Zero counts select native actor destruction. */
 struct Preset final {
     std::span<const std::int32_t> requestedCounts{};
     std::uint32_t generation{};

+ 87 - 0
Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_writer.h"
+#include "auth_fields.h"
+#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.
+
+namespace sunrise::middleware::bap::activity_message::squad_objective {
+
+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;
+/** Absent presence bits for fields .1 to .12. */
+inline constexpr std::uint8_t kAbsentMiddleFieldCount = 12;
+/** Root .15 is present and zero. Meaning 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;
+inline constexpr std::int32_t kTaskGroupBias = 1;
+inline constexpr std::int32_t kNoTaskGroup = -1;
+/** One objective sensor carries this many task groups. */
+inline constexpr std::int32_t kTaskGroupCount = 24;
+/** Root .18 active, 2 bits, written as the active wire value. */
+inline constexpr std::uint8_t kActiveWidth = 2;
+inline constexpr std::uint32_t kActiveValue = 2;
+/** Root .19 mode, 3 bits with bias one. Reserve keeps the counts for a delivery. */
+inline constexpr std::uint8_t kModeWidth = 3;
+inline constexpr std::uint32_t kModeBias = 1;
+
+struct Request final {
+    std::uint32_t registryKey{};
+    std::uint32_t revision{};
+    std::uint16_t objectiveIndex{};
+    std::int32_t taskGroup{kNoTaskGroup};
+    bool reserved{};
+};
+
+/**
+ * Encodes the objective assignment.
+ * @param output Exactly kBytes.
+ * @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
+        || request.revision > fields::kMaximumCounter
+        || request.objectiveIndex > fields::kMaximumClientRefIndex
+        || request.taskGroup < kNoTaskGroup || request.taskGroup >= kTaskGroupCount) {
+        return false;
+    }
+    const auto mode = static_cast<std::uint32_t>(request.reserved ? squad_auth::Mode::reserve
+                                                                  : 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
+        {1, fields::kPresenceWidth}, // .15 present
+        {0, kField15Width},
+        {1, fields::kPresenceWidth}, // .16 present
+        {static_cast<std::uint32_t>(request.taskGroup + kTaskGroupBias), kTaskGroupWidth},
+        {0, fields::kPresenceWidth}, // .17 absent
+        {kActiveValue, kActiveWidth},
+        {mode + kModeBias, kModeWidth},
+        {1, fields::kPresenceWidth},       // .20 present
+        {fields::kClientRefAbsentKey, 32}, // no name
+    }};
+    return writer.write(1, fields::kPresenceWidth)
+           && fields::write_client_ref(
+               writer, request.registryKey, scriptable_auth::kType3SlotType, request.objectiveIndex)
+           && fields::write_fields(writer, tail)
+           && fields::finish_exact(writer, kBits, kBytes, written);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::squad_objective

+ 84 - 0
Sunrise/src/server/activity/activity_sdk_behavior_scope.h

@@ -0,0 +1,84 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "../../middleware/content/packages/tables/scenario_reader.h"
+#include "../../state/activity_sdk/format.h"
+
+// A mission seed is published at the opening area and later bubbles keep that lease. Their local
+// sequences and scenes belong to the state the live region selects, not the seed state.
+
+namespace sunrise::server::activity::behavior_scope {
+
+namespace format = state::activity_sdk::format;
+
+/** Occurrence ranks: the live region's state wins over the seed state. */
+inline constexpr unsigned kRankNone = 0;
+inline constexpr unsigned kRankSeedState = 1;
+inline constexpr unsigned kRankLiveState = 2;
+
+/** @return True when the state row is the one the client's region packs. */
+[[nodiscard]] inline bool live_state(std::span<const format::State> states,
+                                     std::span<const format::Bubble> bubbles,
+                                     std::uint32_t scenario,
+                                     std::uint32_t row,
+                                     std::int32_t region) noexcept {
+    if (region < 0 || row >= states.size()) {
+        return false;
+    }
+    const format::State& state = states[row];
+    if (state.scenarioIndex != scenario || state.bubbleIndex >= bubbles.size()
+        || bubbles[state.bubbleIndex].scenarioIndex != scenario
+        || state.stateOrdinal >= middleware::content::packages::tables::kSliceSetIndexFactor) {
+        return false;
+    }
+    return static_cast<std::uint64_t>(state.sliceSetIndex) + state.stateOrdinal
+           == static_cast<std::uint32_t>(region);
+}
+
+struct Selection final {
+    std::uint32_t row{format::kAbsentIndex};
+    /** Two occurrences of equal rank name different states. */
+    bool ambiguous{};
+};
+
+/**
+ * Selects the occurrence of one object for the live region, or for the seed state when the
+ * live region has none.
+ */
+[[nodiscard]] inline Selection select(std::span<const format::Occurrence> occurrences,
+                                      std::span<const format::State> states,
+                                      std::span<const format::Bubble> bubbles,
+                                      std::uint32_t scenario,
+                                      std::uint32_t object,
+                                      std::uint32_t seedState,
+                                      std::int32_t region) noexcept {
+    Selection result{};
+    unsigned best = kRankNone;
+    for (std::uint32_t index = 0; index < occurrences.size(); ++index) {
+        const format::Occurrence& occurrence = occurrences[index];
+        if (occurrence.scenarioIndex != scenario || occurrence.objectIndex != object
+            || occurrence.stateIndex >= states.size()
+            || states[occurrence.stateIndex].scenarioIndex != scenario
+            || occurrence.bubbleIndex != states[occurrence.stateIndex].bubbleIndex) {
+            continue;
+        }
+        const unsigned rank = live_state(states, bubbles, scenario, occurrence.stateIndex, region)
+                                  ? kRankLiveState
+                              : occurrence.stateIndex == seedState ? kRankSeedState
+                                                                   : kRankNone;
+        if (rank == kRankNone || rank < best) {
+            continue;
+        }
+        if (rank > best) {
+            result = {index, false};
+            best = rank;
+        } else if (occurrences[result.row].stateIndex != occurrence.stateIndex) {
+            result.ambiguous = true;
+        }
+    }
+    return result;
+}
+
+} // namespace sunrise::server::activity::behavior_scope

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

@@ -1,7 +1,15 @@
 #include <algorithm>
 #include <limits>
 
+#include "../../middleware/bap/activity_message/damage_monitor_auth.h"
+#include "../../middleware/bap/activity_message/darkness_zone_auth.h"
+#include "../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../middleware/bap/activity_message/interactable_object_auth.h"
+#include "../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../middleware/bap/activity_message/music_section_auth.h"
+#include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../middleware/bap/activity_message/squad_objective_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_device_internal.h"
@@ -437,6 +445,38 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
     // An occupancy Auth body is exactly 87 bits, which the wire pads to 11 bytes.
     constexpr std::size_t kOccupancyAuthBits = 87;
     constexpr std::size_t kOccupancyAuthBytes = 11;
+    // Bodies this tree encodes are admitted by slot identity. Their shape belongs to the encoder.
+    namespace message = middleware::bap::activity_message;
+    const auto typed = [&](std::uint32_t type, std::uint32_t componentClass, std::uint32_t schema) {
+        return slotType == type && slot.componentClass == componentClass && authSchema == schema;
+    };
+    const bool typedSdkBody =
+        typed(auth::kType34SlotType, auth::kType34ComponentClass, auth::kType34Schema)
+        || typed(message::mission_effect::kSlotType,
+                 message::mission_effect::kComponentClass,
+                 message::mission_effect::kSchema)
+        || typed(message::music_section::kSlotType,
+                 message::music_section::kComponentClass,
+                 message::music_section::kSchema)
+        || typed(message::scene_events::kSlotType,
+                 message::scene_events::kComponentClass,
+                 message::scene_events::kSchema)
+        || typed(message::damage_monitor::kSlotType,
+                 message::damage_monitor::kComponentClass,
+                 message::damage_monitor::kAuthSchema)
+        || typed(message::darkness_zone::kSlotType,
+                 message::darkness_zone::kComponentClass,
+                 message::darkness_zone::kSchema)
+        || typed(format::kObjectSlotType,
+                 format::kObjectComponentClass,
+                 message::interactable_object::kSchema)
+        || typed(message::ghost_link::kSlotType,
+                 message::ghost_link::kComponentClass,
+                 message::ghost_link::kAuthSchema)
+        || typed(format::kSquadSlotType,
+                 format::kSquadComponentClass,
+                 message::squad_objective::kSchema)
+        || typed(auth::kType2SlotType, auth::kType2ComponentClass, auth::kType2Schema);
     const bool occupancy = slotType == format::kOccupancySlotType
                            && authSchema == format::kOccupancyAuthSchema
                            && bitCount == kOccupancyAuthBits && body.size() == kOccupancyAuthBytes;
@@ -460,7 +500,8 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
         slotType == middleware::bap::activity_message::scriptable_auth::kType2SlotType
         && authSchema == middleware::bap::activity_message::scriptable_auth::kType2Schema
         && middleware::bap::activity_message::scriptable_auth::validate_type2_body(body, bitCount);
-    if (!occupancy && !directive && !engagement && !publicEvent && !performance && !combatant) {
+    if (!typedSdkBody && !occupancy && !directive && !engagement && !publicEvent && !performance
+        && !combatant) {
         return Status::invalidBody;
     }
     return Status::ready;

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

@@ -5,7 +5,15 @@
 #include <span>
 #include <string_view>
 
+#include "../../middleware/bap/activity_message/damage_monitor_auth.h"
+#include "../../middleware/bap/activity_message/darkness_zone_auth.h"
+#include "../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../middleware/bap/activity_message/interactable_object_auth.h"
+#include "../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../middleware/bap/activity_message/music_section_auth.h"
+#include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../middleware/bap/activity_message/squad_objective_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
 #include "../../state/build_data/runtime.h"

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

@@ -24,6 +24,10 @@ struct PreparedScene final {
 [[nodiscard]] Status binding_status(const state::activity_sdk::BoundView& view,
                                     server::bap::ActivityLinkView& link) noexcept;
 
+/** Maps the shared binding result to the authored-scene refusal surface. */
+[[nodiscard]] SceneStatus scene_binding_status(const state::activity_sdk::BoundView& view,
+                                               server::bap::ActivityLinkView& link) noexcept;
+
 /** Maps the transport lease's closed set of connection and refusal outcomes. */
 [[nodiscard]] Status lease_status(server::bap::ActivityMissionSeedLeaseStatus status) noexcept;
 

+ 36 - 33
Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp

@@ -5,6 +5,7 @@
 
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../state/activity/runtime.h"
+#include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_scriptable_route.h"
 
@@ -105,38 +106,6 @@ namespace {
 
 /** Materializes into static lock-owned storage so UI stack size stays bounded. */
 
-/** Maps the shared binding result to the authored-scene refusal surface. */
-[[nodiscard]] SceneStatus scene_binding_status(const sdk::BoundView& view,
-                                               server::bap::ActivityLinkView& link) noexcept {
-    switch (binding_status(view, link)) {
-    case Status::ready:
-        // A public-target link publishes the state-local groups of the public bubbles it hosts,
-        // so a scene, task or cue in one of them is reachable only through that link.
-        return link.effectiveRegion >= 0 ? SceneStatus::ready : SceneStatus::noActivityLink;
-    case Status::staleBinding:
-        return SceneStatus::staleBinding;
-    case Status::staleActivityClient:
-        return SceneStatus::staleActivityClient;
-    case Status::noActivityLink:
-        return SceneStatus::noActivityLink;
-    case Status::invalidView:
-    case Status::missingLiveSliceSet:
-    case Status::wrongScenario:
-    case Status::wrongSliceSet:
-    case Status::missingInitialState:
-    case Status::ambiguousInitialState:
-    case Status::invalidOccurrence:
-    case Status::schemaJoinNotExact:
-    case Status::invalidRosterGroup:
-    case Status::rosterKeyConflict:
-    case Status::groupCapacityExceeded:
-    case Status::outputBusy:
-    case Status::refused:
-        return SceneStatus::invalidView;
-    }
-    return SceneStatus::invalidView;
-}
-
 /** Checks the exact enabled and published mission-seed lease for one scene state. */
 [[nodiscard]] SceneStatus scene_lease_status(const sdk::BoundView& view,
                                              const server::bap::ActivityLinkView& link,
@@ -164,7 +133,9 @@ namespace {
     if (!lease.configured || lease.revision == 0 || lease.plan.scenarioRow != view.scenarioRow) {
         return SceneStatus::missionSeedUnavailable;
     }
-    if (lease.plan.stateRow != stateRow) {
+    if (lease.plan.stateRow != stateRow
+        && !behavior_scope::live_state(view.catalog->states(), view.catalog->bubbles(),
+            view.scenarioRow, stateRow, link.effectiveRegion)) {
         return SceneStatus::wrongState;
     }
     if (lease.publicationPending || lease.publishedRevision != lease.revision) {
@@ -175,6 +146,38 @@ namespace {
 
 } // namespace
 
+/** Maps the shared binding result to the authored-scene refusal surface. */
+[[nodiscard]] SceneStatus scene_binding_status(const sdk::BoundView& view,
+                                               server::bap::ActivityLinkView& link) noexcept {
+    switch (binding_status(view, link)) {
+    case Status::ready:
+        // A public-target link publishes the state-local groups of the public bubbles it hosts,
+        // so a scene, task or cue in one of them is reachable only through that link.
+        return link.effectiveRegion >= 0 ? SceneStatus::ready : SceneStatus::noActivityLink;
+    case Status::staleBinding:
+        return SceneStatus::staleBinding;
+    case Status::staleActivityClient:
+        return SceneStatus::staleActivityClient;
+    case Status::noActivityLink:
+        return SceneStatus::noActivityLink;
+    case Status::invalidView:
+    case Status::missingLiveSliceSet:
+    case Status::wrongScenario:
+    case Status::wrongSliceSet:
+    case Status::missingInitialState:
+    case Status::ambiguousInitialState:
+    case Status::invalidOccurrence:
+    case Status::schemaJoinNotExact:
+    case Status::invalidRosterGroup:
+    case Status::rosterKeyConflict:
+    case Status::groupCapacityExceeded:
+    case Status::outputBusy:
+    case Status::refused:
+        return SceneStatus::invalidView;
+    }
+    return SceneStatus::invalidView;
+}
+
 /** Maps exact SDK binding validation to this facade's stable refusal surface. */
 [[nodiscard]] Status binding_status(const sdk::BoundView& view,
                                     server::bap::ActivityLinkView& link) noexcept {

+ 10 - 10
Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp

@@ -6,6 +6,7 @@
 
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../state/activity/runtime.h"
+#include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_scriptable_route.h"
 #include "host_runtime.h"
@@ -30,6 +31,7 @@ using detail::prepare_typed_behavior;
 using detail::PreparedScene;
 using detail::read_lease;
 using detail::same_plan;
+using detail::scene_binding_status;
 
 } // namespace
 
@@ -508,16 +510,14 @@ set_cinematic_active_reserved(const sdk::BoundView& view,
     if (slotRow >= slots.size()) {
         return SceneStatus::invalidSlot;
     }
-    for (std::uint32_t index = 0; index < occurrences.size(); ++index) {
-        const sdk::format::Occurrence& occurrence = occurrences[index];
-        if (occurrence.scenarioIndex != view.scenarioRow
-            || occurrence.stateIndex != snapshot.plan.stateRow
-            || occurrence.objectIndex != slots[slotRow].objectIndex) {
-            continue;
-        }
-        occurrenceRow = index;
-        break;
-    }
+    server::bap::ActivityLinkView link{};
+    const SceneStatus live = scene_binding_status(view, link);
+    if (live != SceneStatus::ready) return live;
+    const auto selected = behavior_scope::select(occurrences, view.catalog->states(),
+        view.catalog->bubbles(), view.scenarioRow, slots[slotRow].objectIndex,
+        snapshot.plan.stateRow, link.effectiveRegion);
+    if (selected.ambiguous) return SceneStatus::ambiguousTarget;
+    occurrenceRow = selected.row;
     return occurrenceRow == sdk::format::kAbsentIndex ? SceneStatus::targetUnavailable
                                                       : SceneStatus::ready;
 }

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

@@ -63,11 +63,6 @@ struct PreparedSquad final {
     return Status::invalidView;
 }
 
-/** @return True only for the two numeric modes accepted by the proved type-1 schema. */
-[[nodiscard]] bool valid_mode(squad_auth::Mode mode) noexcept {
-    return mode == squad_auth::Mode::mode0 || mode == squad_auth::Mode::mode2;
-}
-
 /** Validates the exact authored member vector and every requested safe bound. */
 [[nodiscard]] Status member_status(const sdk::Catalog& catalog,
                                    const format::Squad& squad,
@@ -101,8 +96,11 @@ struct PreparedSquad final {
     const auto members = sdk::squad_members(catalog, squad);
     const auto actors = catalog.actor_classes();
     bool found = false;
+    // A named type-2 member needs its parent's authored profile but zero loose actors.
+    const bool loose = std::any_of(requestedCounts.begin(), requestedCounts.end(),
+                                    [](auto count) { return count > 0; });
     for (std::size_t index = 0; index < members.size(); ++index) {
-        if (requestedCounts[index] <= 0) {
+        if (loose && requestedCounts[index] <= 0) {
             continue;
         }
         const format::SquadMember& member = members[index];
@@ -307,7 +305,7 @@ struct PreparedSquad final {
     if (liveStatus != Status::ready) {
         return liveStatus;
     }
-    if (!valid_mode(mode)) {
+    if (!squad_auth::valid_mode(mode)) {
         return Status::invalidMode;
     }
 

+ 57 - 1
Sunrise/src/server/activity/host_runtime.h

@@ -42,6 +42,8 @@ inline constexpr std::size_t kSenseObservationCapacity = 128;
 inline constexpr std::size_t kSenseObservationValueCapacity = 1024;
 /** Per-slot counts the squad Sense body can carry. Its nested array is eight elements. */
 inline constexpr std::size_t kSquadSlotCapacity = 8;
+/** Objective task groups a squad publishes one cost for. */
+inline constexpr std::size_t kSquadObjectiveGroupCount = 24;
 /** Distinct exact ClientRef counters retained for one activity generation. */
 inline constexpr std::size_t kScriptableGuardCapacity =
     state::build_data::scenarios::kRosterSlotCapacity;
@@ -120,11 +122,25 @@ enum class EventKind : std::uint8_t {
     cinematicStarted = 29,
     /** One schema-0x808087BF msg-19 reports Type-6 start failure or runtime termination. */
     cinematicTerminated = 30,
+    /** Accepted native Ghost interaction progress for one exact type-65 slot. */
+    ghostLinkState = 31,
+    /** A named actor's movement or delivery level changed. */
+    actorPathState = 32,
+    /** The client accepted a player's use of one interactable object. */
+    objectInteracted = 33,
+    /** Native cinematic_skip incident for an exact Type-6 source. */
+    cinematicSkipRequested = 34,
+    /** Life counts of the private activity's joined party changed. */
+    fireteamState = 35,
+    /** An interactable object's presence, alive or ownership level changed. */
+    objectState = 36,
+    /** A damage monitor published new health, shield or revision values. */
+    damageState = 37,
 };
 
 /** 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::cinematicTerminated) + 1U;
+    static_cast<std::size_t>(EventKind::damageState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -443,6 +459,10 @@ struct Event final {
     std::uint64_t peerSessionGeneration{};
     /** Peer client key bound by its join, or zero before one. */
     std::uint64_t peerMemberKey{};
+    /** Party life counts, for fireteamState events. */
+    std::uint16_t fireteamAlive{};
+    std::uint16_t fireteamDead{};
+    std::uint16_t fireteamUnknown{};
     /** Committed mission phase, for phaseEntered events. */
     std::uint32_t missionPhase{};
     /** Mission phase this commit replaced. */
@@ -457,6 +477,33 @@ struct Event final {
     std::int32_t triggerValue{};
     /** True when the whole watched set is inside the volume. */
     bool triggerAll{};
+    /** Health and shield fractions, for damageState events. Negative until published. */
+    float damageHealth{-1.0F};
+    float damageShield{-1.0F};
+    std::int32_t damageRevision{};
+    /** Object level, for objectState and objectInteracted events. */
+    std::int32_t objectGeneration{};
+    bool objectPresent{};
+    bool objectAlive{};
+    bool objectOwnerKnown{};
+    bool objectHasOwner{};
+    std::uint64_t objectOwnerKey{};
+    /** Ghost-link level, for ghostLinkState events. */
+    std::int32_t ghostGeneration{};
+    float ghostProgress{};
+    bool ghostActive{};
+    /** Named actor level, for actorPathState events. */
+    std::int32_t actorGeneration{};
+    std::int32_t actorPathRevision{};
+    std::int32_t actorPathState{};
+    std::int32_t actorDeliveryRevision{};
+    std::int32_t actorDeliveryState{};
+    bool actorDeliveryKnown{};
+    bool actorDead{};
+    /** Objective costs the squad published, one per task group, with a bit per known cost. */
+    std::array<float, kSquadObjectiveGroupCount> squadObjectiveCosts{};
+    std::uint32_t squadObjectiveCostMask{};
+    std::uint32_t squadObjectiveRevision{};
     /** Per-slot member counts the client published, for squadState events. */
     std::array<std::int32_t, kSquadSlotCapacity> squadSlotCounts{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
@@ -561,6 +608,8 @@ struct Event final {
     middleware::bap::activity_message::sense_update::DecodeStatus senseDecodeStatus{
         middleware::bap::activity_message::sense_update::DecodeStatus::malformed};
     state::activity::receipts::Verdict verdict{state::activity::receipts::Verdict::absent};
+    /** The packet's observations were retained, so a Lua snapshot may be built from them. */
+    bool senseSnapshotRetained{};
     bool hasFirstObject{};
     bool clientStateHasRegion{};
     bool clientStateHasCurrentRegion{};
@@ -568,6 +617,13 @@ struct Event final {
     bool clientStateHasTeleport{};
     bool hasPlayerTrigger{};
     bool hasCinematic{};
+
+    /** @return True when this Sense event carries observations a script may read. */
+    [[nodiscard]] bool has_sense_observations() const noexcept {
+        using Status = middleware::bap::activity_message::sense_update::DecodeStatus;
+        return kind == EventKind::senseUpdate && senseSnapshotRetained
+               && (senseDecodeStatus == Status::complete || senseDecodeStatus == Status::partial);
+    }
 };
 
 /** Position after one event in one reset generation. */

+ 3 - 3
Sunrise/src/server/activity/host_runtime_mission_inputs.cpp

@@ -12,6 +12,7 @@
 #include <vector>
 
 #include "../../core/logging/log.h"
+#include "../../middleware/bap/activity_message/sense_observation_packet.h"
 #include "../../state/activity/mission/runtime.h"
 #include "../../state/activity/runtime.h"
 #include "host_runtime_internal.h"
@@ -227,15 +228,14 @@ bool mission_input_sense_snapshot(std::uint64_t sequence,
     if (copied) {
         const Event& event = selected->view.event;
         const sense::DecodedPacket& packet = selected->sense;
-        copied = packet.status == sense::DecodeStatus::complete && !packet.objectsTruncated
-                 && !packet.valuesTruncated && packet.objectCount <= packet.objects.size()
-                 && packet.valueCount <= packet.values.size();
+        copied = sense::observation_packet(packet);
         if (copied) {
             output.revision = event.sequence;
             output.sourceGeneration = event.sourceGeneration;
         }
         for (std::size_t index = 0; copied && index < packet.objectCount; ++index) {
             const sense::DecodedObject& object = packet.objects[index];
+            if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration) continue;
             if (object.firstValue > packet.valueCount
                 || object.valueCount > packet.valueCount - object.firstValue) {
                 copied = false;

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

@@ -66,6 +66,20 @@ const char* event_name(EventKind kind) noexcept {
         return "player trigger";
     case EventKind::cinematicStarted:
         return "cinematic started";
+    case EventKind::actorPathState:
+        return "actor path state";
+    case EventKind::fireteamState:
+        return "fireteam state";
+    case EventKind::damageState:
+        return "damage state";
+    case EventKind::objectState:
+        return "object state";
+    case EventKind::objectInteracted:
+        return "object interacted";
+    case EventKind::ghostLinkState:
+        return "ghost link state";
+    case EventKind::cinematicSkipRequested:
+        return "cinematic skip requested";
     case EventKind::cinematicTerminated:
         return "cinematic terminated";
     }

+ 36 - 0
Sunrise/src/server/activity/host_runtime_scriptable.cpp

@@ -3,6 +3,7 @@
 #include <limits>
 #include <new>
 
+#include "../../middleware/bap/activity_message/mission_auth_patch.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../state/activity/mission/runtime.h"
 #include "../../state/activity/runtime.h"
@@ -524,6 +525,41 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
     } else {
         encoded = false;
     }
+    // A mission API body carries only the root fields it sets, and the native override replaces
+    // the whole object. Compose it over the last transported body for the same ClientRef first.
+    namespace patching = middleware::bap::activity_message::mission_auth_patch;
+    const std::span<const std::byte> patch = std::span(pending.body).first(written);
+    patching::Layout layout{};
+    const bool rootPatch =
+        encoded
+        && (request.kind == ScriptableOverrideKind::squad
+            || request.kind == ScriptableOverrideKind::sdkAuth)
+        && patching::parse(request.target.authSchema, patch, pending.bitCount, layout);
+    if (rootPatch) {
+        std::span<const std::byte> previous{};
+        std::size_t previousBits = 0;
+        // A tail never holds the same ClientRef as another pending body, so the predecessor is
+        // always in the transported estate.
+        for (const auto& retained : instance->scriptableAuthEstate) {
+            if (same_client_ref(retained.target, request.target)) {
+                previous = std::span(retained.body).first(retained.byteCount);
+                previousBits = retained.bitCount;
+                break;
+            }
+        }
+        std::size_t composedBits = 0;
+        encoded = patching::compose(request.target.authSchema,
+                                    previous,
+                                    previousBits,
+                                    patch,
+                                    pending.bitCount,
+                                    pending.body,
+                                    written,
+                                    composedBits);
+        if (encoded) {
+            pending.bitCount = static_cast<std::uint16_t>(composedBits);
+        }
+    }
     if (!encoded || written > (std::numeric_limits<std::uint16_t>::max)()) {
         ++g_refusedControls;
     } else {

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

@@ -348,7 +348,6 @@ bool request_squad_override(
     const ScriptableOutputReservation* reservation,
     std::array<std::int8_t, 4> authoredProfile,
     state::gameplay::squad_entity_retirement::Eligibility squadRetirement) noexcept {
-    const auto rawMode = static_cast<std::uint8_t>(mode);
     if (squadRetirement.enabled
         && (squadRetirement.squad.key != target.registryKey
             || squadRetirement.squad.index != target.slotIndex
@@ -365,8 +364,7 @@ bool request_squad_override(
         || requestedCounts.size() < squad::kMinimumRequestedCountLength
         || requestedCounts.size() > squad::kMaximumRequestedCountLength
         || expectedActivityClientGeneration == 0
-        || (rawMode != static_cast<std::uint8_t>(squad::Mode::mode0)
-            && rawMode != static_cast<std::uint8_t>(squad::Mode::mode2))
+        || !squad::valid_mode(mode)
         || !std::ranges::all_of(requestedCounts, [](std::int32_t count) { return count >= 0; })) {
         return false;
     }

+ 20 - 33
Sunrise/src/server/activity/host_runtime_sense.cpp

@@ -13,6 +13,7 @@
 #include <span>
 
 #include "../../core/logging/log.h"
+#include "../../middleware/bap/activity_message/sense_observation_packet.h"
 #include "../../state/activity/runtime.h"
 #include "../../state/activity_sdk/format.h"
 #include "host_runtime_internal.h"
@@ -37,40 +38,24 @@ packet_has_sense_key(const middleware::bap::activity_message::sense_update::Deco
                      const SenseObservationKey& key,
                      std::size_t first) noexcept {
     for (std::size_t index = first; index < packet.objectCount; ++index) {
-        if (same_sense_key(key, packet.objects[index])) {
+        if (packet.objects[index].status == middleware::bap::activity_message::sense_update::ObjectStatus::decoded
+            && packet.objects[index].hasGeneration && same_sense_key(key, packet.objects[index])) {
             return true;
         }
     }
     return false;
 }
 
-/** @return True when every retained object and value came from one complete decode. */
-[[nodiscard]] bool complete_sense_observation_input(const SenseInput& input) noexcept {
+/** @return True when the envelope closed and each decoded object's storage is bounded. */
+[[nodiscard]] bool valid_sense_observation_input(const SenseInput& input) noexcept {
     namespace sense = middleware::bap::activity_message::sense_update;
-    const sense::DecodedPacket& packet = input.decoded;
-    if (input.sourceGeneration == 0 || input.clientMessageSequence == 0
-        || input.verdict != state::activity::receipts::Verdict::framed
-        || input.decodeStatus != sense::DecodeStatus::complete
-        || packet.status != sense::DecodeStatus::complete || packet.objectsTruncated
-        || packet.valuesTruncated || packet.groupsSkipped != 0 || input.groupsSkipped != 0
-        || packet.objectCount > packet.objects.size() || packet.valueCount > packet.values.size()
-        || packet.objectsDecoded != packet.objectCount || packet.objectsSeen != packet.objectCount
-        || input.groupsSeen != packet.groupsSeen || input.groupsDecoded != packet.groupsDecoded
-        || input.objectsSeen != packet.objectsSeen
-        || input.objectsDecoded != packet.objectsDecoded) {
-        return false;
-    }
-    std::size_t expectedValue = 0;
-    for (std::size_t index = 0; index < packet.objectCount; ++index) {
-        const sense::DecodedObject& object = packet.objects[index];
-        if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration
-            || object.firstValue != expectedValue
-            || object.valueCount > packet.valueCount - expectedValue) {
-            return false;
-        }
-        expectedValue += object.valueCount;
-    }
-    return expectedValue == packet.valueCount;
+    const auto& packet = input.decoded;
+    return input.sourceGeneration != 0 && input.clientMessageSequence != 0
+        && input.verdict == state::activity::receipts::Verdict::framed
+        && input.decodeStatus == packet.status && sense::observation_packet(packet)
+        && input.groupsSeen == packet.groupsSeen && input.groupsDecoded == packet.groupsDecoded
+        && input.groupsSkipped == packet.groupsSkipped && input.objectsSeen == packet.objectsSeen
+        && input.objectsDecoded == packet.objectsDecoded;
 }
 
 /** Mixes one fixed-width value into the local scene change guard. */
@@ -251,7 +236,7 @@ void trace_scene_sense(Instance& instance, const SenseInput& input) noexcept {
             break;
         }
     }
-    if (!complete_sense_observation_input(input)) {
+    if (!valid_sense_observation_input(input)) {
         if (!trace.incompleteReported && (hasScene || packet.objectsTruncated)) {
             trace.incompleteReported = true;
             report_scene_sense(
@@ -275,8 +260,8 @@ void trace_scene_sense(Instance& instance, const SenseInput& input) noexcept {
     }
     for (std::size_t index = 0; index < packet.objectCount; ++index) {
         const sense::DecodedObject& object = packet.objects[index];
-        if (object.slotType
-                != static_cast<std::uint8_t>(state::activity_sdk::format::kAuthoredSceneSlotType)
+        if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration || object.slotType
+            != static_cast<std::uint8_t>(state::activity_sdk::format::kAuthoredSceneSlotType)
             || packet_has_sense_key(packet,
                                     {object.registryKey,
                                      object.objectTag,
@@ -353,11 +338,11 @@ void trace_scene_sense(Instance& instance, const SenseInput& input) noexcept {
     }
 }
 
-/** Replaces only keys present in one complete packet and keeps every omitted key. */
+/** Replaces only fully decoded keys in an accepted packet; omitted or unsupported keys stay retained. */
 [[nodiscard]] bool
 retain_sense_observations(Instance& instance, const SenseInput& input, std::uint64_t now) noexcept {
     namespace sense = middleware::bap::activity_message::sense_update;
-    if (!complete_sense_observation_input(input)) {
+    if (!valid_sense_observation_input(input)) {
         return false;
     }
     const sense::DecodedPacket& packet = input.decoded;
@@ -366,6 +351,7 @@ retain_sense_observations(Instance& instance, const SenseInput& input, std::uint
     next.sourceGeneration = input.sourceGeneration;
     for (std::size_t index = 0; index < packet.objectCount; ++index) {
         const sense::DecodedObject& object = packet.objects[index];
+        if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration) continue;
         const SenseObservationKey key{object.registryKey,
                                       object.objectTag,
                                       object.senseSchema,
@@ -520,6 +506,7 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
         event.slotSenseSchema = input.decoded.objects.front().senseSchema;
     }
     event.senseDecodeStatus = input.decodeStatus;
+    event.senseSnapshotRetained = valid_sense_observation_input(input);
     event.hasFirstObject = input.hasFirstObject;
     event.stateRevision = instance->view.stateRevision;
     event.sourceGeneration = input.sourceGeneration;
@@ -527,7 +514,7 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
     event.lifetimeState = instance->view.lifetimeState;
     event.verdict = input.verdict;
     append_event(event);
-    append_mission_input(event, complete_sense_observation_input(input) ? &input.decoded : nullptr);
+    append_mission_input(event, event.senseSnapshotRetained ? &input.decoded : nullptr);
     instance->view.lastEventSequence = g_sequence;
 }
 

+ 95 - 0
Sunrise/src/server/activity/mission/mission_script_actor_path_sense.h

@@ -0,0 +1,95 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Nested combatant program record: field 0 is the program state, field 1 its revision. */
+inline constexpr std::uint32_t kCombatantProgramSchema = 0x80807F6EU;
+/** Root ordinals of the type-2 Sense body. */
+inline constexpr std::uint16_t kActorGenerationOrdinal = 0;
+inline constexpr std::uint16_t kActorDeliveryRevisionOrdinal = 5;
+inline constexpr std::uint16_t kActorDeliveryStateOrdinal = 6;
+inline constexpr std::uint16_t kActorDeadOrdinal = 10;
+/** Which fields a level has seen. */
+inline constexpr std::uint8_t kActorSeenGeneration = 0x01;
+inline constexpr std::uint8_t kActorSeenRevision = 0x02;
+inline constexpr std::uint8_t kActorSeenState = 0x04;
+inline constexpr std::uint8_t kActorSeenDead = 0x08;
+inline constexpr std::uint8_t kActorSeenDeliveryRevision = 0x10;
+inline constexpr std::uint8_t kActorSeenDeliveryState = 0x20;
+/** A level reports once the generation, revision, state and death flag have all arrived. */
+inline constexpr std::uint8_t kActorSeenCore =
+    kActorSeenGeneration | kActorSeenRevision | kActorSeenState | kActorSeenDead;
+inline constexpr std::uint8_t kActorSeenDelivery =
+    kActorSeenDeliveryRevision | kActorSeenDeliveryState;
+
+/** Last movement and delivery levels seen for one named actor. */
+struct ActorPathLevel final {
+    std::int32_t generation{};
+    std::int32_t revision{};
+    std::int32_t state{};
+    std::int32_t deliveryRevision{};
+    std::int32_t deliveryState{};
+    std::uint8_t seen{};
+    bool dead{};
+    bool operator==(const ActorPathLevel&) const = default;
+};
+
+/**
+ * Merges one decoded type-2 body into the retained level.
+ * @param root Schema row of the body's root.
+ * @return True when the level changed and its core fields are all known.
+ */
+[[nodiscard]] inline bool update_actor_path_level(
+    ActorPathLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    ActorPathLevel next = level;
+    for (const auto& value : values) {
+        if (!value.present) {
+            continue;
+        }
+        if (value.schemaRow == kCombatantProgramSchema) {
+            if (value.fieldOrdinal == 0) {
+                next.state = static_cast<std::int32_t>(value.signedValue);
+                next.seen |= kActorSeenState;
+            } else if (value.fieldOrdinal == 1) {
+                next.revision = static_cast<std::int32_t>(value.signedValue);
+                next.seen |= kActorSeenRevision;
+            }
+            continue;
+        }
+        if (value.schemaRow != root) {
+            continue;
+        }
+        switch (value.fieldOrdinal) {
+        case kActorGenerationOrdinal:
+            next.generation = static_cast<std::int32_t>(value.signedValue);
+            next.seen |= kActorSeenGeneration;
+            break;
+        case kActorDeliveryRevisionOrdinal:
+            next.deliveryRevision = static_cast<std::int32_t>(value.signedValue);
+            next.seen |= kActorSeenDeliveryRevision;
+            break;
+        case kActorDeliveryStateOrdinal:
+            next.deliveryState = static_cast<std::int32_t>(value.signedValue);
+            next.seen |= kActorSeenDeliveryState;
+            break;
+        case kActorDeadOrdinal:
+            next.dead = value.unsignedValue != 0;
+            next.seen |= kActorSeenDead;
+            break;
+        default:
+            break;
+        }
+    }
+    const bool changed = next != level;
+    level = next;
+    return changed && (next.seen & kActorSeenCore) == kActorSeenCore;
+}
+
+} // namespace sunrise::server::activity::mission

+ 4 - 1
Sunrise/src/server/activity/mission/mission_script_arena.cpp

@@ -164,7 +164,10 @@ bool arena_initialize(Arena& arena) noexcept {
     arena.highWater = 0;
     arena.initialized = false;
     if (arena.bytes == nullptr) {
-        std::byte* const block = new (std::nothrow) std::byte[kArenaByteCapacity];
+        // The allocation function is called directly. Clang folds the nothrow array
+        // new-expression to null in the optimized build; delete[] still matches this storage.
+        auto* const block =
+            static_cast<std::byte*>(::operator new[](kArenaByteCapacity, std::nothrow));
         if (block == nullptr) {
             arena.capacity = 0;
             return false;

+ 25 - 0
Sunrise/src/server/activity/mission/mission_script_event_batch.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <cstddef>
+
+namespace sunrise::server::activity::mission {
+
+/** Derived events one instance may dispatch per service slice. */
+inline constexpr std::size_t kScriptEventBatchLimit = 64;
+
+/**
+ * Dispatches queued events while `ready` allows it, up to the batch limit. The ready test stops
+ * at the first asynchronous output, because the callbacks after it must see its committed result.
+ * @return Events dispatched.
+ */
+template <class Ready, class Dispatch>
+std::size_t drain_script_event_batch(Ready ready, Dispatch dispatch) {
+    std::size_t count = 0;
+    while (count < kScriptEventBatchLimit && ready()) {
+        dispatch();
+        ++count;
+    }
+    return count;
+}
+
+} // namespace sunrise::server::activity::mission

+ 68 - 0
Sunrise/src/server/activity/mission/mission_script_ghost_sense.h

@@ -0,0 +1,68 @@
+#pragma once
+
+#include <cmath>
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-65 Sense root ordinals: active, elapsed over required duration, accepted generation. */
+inline constexpr std::uint16_t kGhostActiveOrdinal = 0;
+inline constexpr std::uint16_t kGhostProgressOrdinal = 1;
+inline constexpr std::uint16_t kGhostGenerationOrdinal = 2;
+inline constexpr std::uint8_t kGhostSeenActive = 0x01;
+inline constexpr std::uint8_t kGhostSeenProgress = 0x02;
+inline constexpr std::uint8_t kGhostSeenGeneration = 0x04;
+inline constexpr std::uint8_t kGhostSeenAll =
+    kGhostSeenActive | kGhostSeenProgress | kGhostSeenGeneration;
+
+/** Last Ghost-link level seen for one slot. */
+struct GhostLevel final {
+    std::int32_t generation{};
+    float progress{};
+    std::uint8_t seen{};
+    bool active{};
+    bool operator==(const GhostLevel&) const = default;
+};
+
+/**
+ * Merges one decoded type-65 body into the retained level.
+ * @return True when the level changed and all three fields are known.
+ */
+[[nodiscard]] inline bool update_ghost_level(
+    GhostLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    GhostLevel next = level;
+    for (const auto& value : values) {
+        if (!value.present || value.schemaRow != root) {
+            continue;
+        }
+        switch (value.fieldOrdinal) {
+        case kGhostActiveOrdinal:
+            next.active = value.unsignedValue != 0;
+            next.seen |= kGhostSeenActive;
+            break;
+        case kGhostProgressOrdinal:
+            if (!std::isfinite(value.realValue) || value.realValue < 0) {
+                return false;
+            }
+            next.progress = value.realValue;
+            next.seen |= kGhostSeenProgress;
+            break;
+        case kGhostGenerationOrdinal:
+            next.generation = static_cast<std::int32_t>(value.signedValue);
+            next.seen |= kGhostSeenGeneration;
+            break;
+        default:
+            break;
+        }
+    }
+    const bool changed = !(next == level);
+    level = next;
+    return changed && next.seen == kGhostSeenAll;
+}
+
+} // namespace sunrise::server::activity::mission

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

@@ -1,8 +1,10 @@
+#include <charconv>
 #include <cstddef>
 #include <cstdint>
 #include <limits>
 #include <string_view>
 
+#include "../../../state/activity/membership/definition.h"
 #include "mission_script_lua_internal.h"
 #include "mission_script_lua_names.h"
 #include "mission_script_lua_peer_internal.h"
@@ -23,6 +25,41 @@ namespace {
     return 1;
 }
 
+/**
+ * Arms a native hard wipe at an authored spawn set, or releases one with its request key.
+ * The Lua caller passes `release_request` as the decimal string of the original key.
+ */
+[[nodiscard]] int context_restart_checkpoint(lua_State* state) {
+    static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{
+        "region", "spawn_set_hash", "release_request"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer region = optional_integer_argument(state, "region", -1);
+    const lua_Integer hash = optional_integer_argument(state, "spawn_set_hash", 0);
+    if (region < 0 || region > ::sunrise::state::activity::membership::kMaximumSliceSetIndex
+        || hash <= 0
+        || hash >= (std::numeric_limits<std::uint32_t>::max)()) {
+        return luaL_error(state, "checkpoint requires an authored region and spawn-set hash");
+    }
+    std::uint64_t release = 0;
+    lua_getfield(state, 2, "release_request");
+    if (!lua_isnil(state, -1)) {
+        std::size_t length = 0;
+        const char* const value = luaL_checklstring(state, -1, &length);
+        const auto parsed = std::from_chars(value, value + length, release);
+        if (parsed.ec != std::errc{} || parsed.ptr != value + length || release == 0) {
+            return luaL_error(state, "checkpoint release requires the original RequestKey.value");
+        }
+    }
+    lua_pop(state, 1);
+    Intent intent{};
+    intent.kind = IntentKind::restartCheckpoint;
+    intent.checkpointReleaseRequest = release;
+    intent.effectiveRegion = static_cast<std::int32_t>(region);
+    intent.checkpointSpawnHash = static_cast<std::uint32_t>(hash);
+    return queue_intent(state, active_frame(state), intent);
+}
+
 [[nodiscard]] int context_scene(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
     SceneDefinition definition{};
@@ -197,6 +234,8 @@ resolve_message_name(lua_State* state, std::string_view name, ActivityMessageDef
         lua_pushcfunction(state, &context_slot);
     } else if (key == "select_state") {
         lua_pushcfunction(state, &context_select_state);
+    } else if (key == "restart_checkpoint") {
+        lua_pushcfunction(state, &context_restart_checkpoint);
     } else if (key == "set_phase") {
         lua_pushcfunction(state, &context_set_phase);
     } else if (key == "set_variable") {

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

@@ -9,6 +9,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
 [[nodiscard]] std::string_view event_kind_name(lua_State* state, host::EventKind kind) noexcept {
     (void)state;
     switch (kind) {
+    case host::EventKind::fireteamState:
+        return "fireteamState";
     case host::EventKind::senseUpdate:
         return "sensorSenseUpdated";
     case host::EventKind::clientStateChanged:
@@ -45,8 +47,18 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "playerTrigger";
     case host::EventKind::cinematicStarted:
         return "cinematicStarted";
+    case host::EventKind::cinematicSkipRequested:
+        return "cinematicSkipRequested";
     case host::EventKind::cinematicTerminated:
         return "cinematicTerminated";
+    case host::EventKind::actorPathState:
+        return "actorPathState";
+    case host::EventKind::objectInteracted:
+        return "objectInteracted";
+    case host::EventKind::damageState: return "damageState";
+    case host::EventKind::objectState: return "objectState";
+    case host::EventKind::ghostLinkState:
+        return "ghostLinkState";
     case host::EventKind::clientMessageReceived:
         return "clientMessageReceived";
     case host::EventKind::authStateCommitted:
@@ -82,6 +94,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
 /** @return The view metatable one kind is pushed with. */
 [[nodiscard]] const char* event_metatable(host::EventKind kind) noexcept {
     switch (kind) {
+    case host::EventKind::fireteamState:
+        return kFireteamStateEventMetatable;
     case host::EventKind::senseUpdate:
         return kSenseUpdateEventMetatable;
     case host::EventKind::clientStateChanged:
@@ -142,8 +156,17 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return kPlayerTriggerEventMetatable;
     case host::EventKind::cinematicStarted:
         return kCinematicStartedEventMetatable;
+    case host::EventKind::cinematicSkipRequested:
     case host::EventKind::cinematicTerminated:
         return kCinematicTerminatedEventMetatable;
+    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::ghostLinkState:
+        return kGhostLinkEventMetatable;
     }
     return kSenseUpdateEventMetatable;
 }

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

@@ -46,6 +46,8 @@ push_incident_revision_member(lua_State* state, const host::Event& event, std::s
         return {};
     case ActionKind::setLifetime:
         return "lifetime.set";
+    case ActionKind::restartCheckpoint:
+        return "mission.restart_checkpoint";
     case ActionKind::fireTrigger:
         return "slot.fire_trigger";
     case ActionKind::playSequence:

+ 84 - 0
Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp

@@ -61,6 +61,17 @@ push_trigger_member(lua_State* state, const host::Event& event, std::string_view
 push_squad_state_member(lua_State* state, const host::Event& event, std::string_view key) {
     if (key == "alive_count") {
         lua_pushinteger(state, event.squadAliveCount);
+    } else if (key == "objective_revision") {
+        lua_pushinteger(state, event.squadObjectiveRevision);
+    } else if (key == "task_costs") {
+        lua_createtable(state, static_cast<int>(host::kSquadObjectiveGroupCount), 0);
+        for (unsigned group = 0; group < host::kSquadObjectiveGroupCount; ++group) {
+            if ((event.squadObjectiveCostMask & (1U << group)) == 0) {
+                continue;
+            }
+            lua_pushnumber(state, event.squadObjectiveCosts[group]);
+            lua_rawseti(state, -2, static_cast<lua_Integer>(group) + 1);
+        }
     } else if (key == "previous_alive_count") {
         lua_pushinteger(state, event.squadPreviousAliveCount);
     } else if (key == "removal_flag") {
@@ -255,6 +266,64 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return cinematic_started_index(state);
 }
 
+/** Reports the accepted member generation and native movement-path state. */
+[[nodiscard]] int actor_path_index(lua_State* state) {
+    const auto& event = check_event(state, 1);
+    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 (event_surface_visible(state, event.kind)) {
+        if (push_slot_identity_member(state, event, key)) return 1;
+        if (key == "generation") { lua_pushinteger(state, event.actorGeneration); return 1; }
+        if (key == "revision") { lua_pushinteger(state, event.actorPathRevision); return 1; }
+        if (key == "path_state") { lua_pushinteger(state, event.actorPathState); return 1; }
+        if (key == "delivery_revision" && event.actorDeliveryKnown) {
+            lua_pushinteger(state, event.actorDeliveryRevision); return 1;
+        }
+        if (key == "delivery_state" && event.actorDeliveryKnown) {
+            lua_pushinteger(state, event.actorDeliveryState); return 1;
+        }
+        if (key == "dead") { lua_pushboolean(state, event.actorDead); return 1; }
+    }
+    lua_pushnil(state); return 1;
+}
+
+[[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))) return 1;
+    if (event.kind == host::EventKind::damageState) {
+        if (key == "health") lua_pushnumber(state,event.damageHealth);
+        else if (key == "shield") lua_pushnumber(state,event.damageShield);
+        else if (key == "revision") lua_pushinteger(state,event.damageRevision);
+        else lua_pushnil(state);
+        return 1;
+    }
+    if (key == "generation") lua_pushinteger(state,event.objectGeneration);
+    else if (key == "present") lua_pushboolean(state,event.objectPresent);
+    else if (key == "alive") lua_pushboolean(state,event.objectAlive);
+    else if (key == "owner_known") lua_pushboolean(state,event.objectOwnerKnown);
+    else if (key == "has_owner") lua_pushboolean(state,event.objectHasOwner);
+    else if (key == "owner_key" && event.objectOwnerKey != 0) push_u64_string(state,event.objectOwnerKey);
+    else lua_pushnil(state);
+    return 1;
+}
+
+/** Reports native interaction progress without exposing raw Sense storage. */
+[[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)) return 1;
+    if (event_surface_visible(state, event.kind)) {
+        if (push_slot_identity_member(state, event, key)) return 1;
+        if (key == "generation") { lua_pushinteger(state, event.ghostGeneration); return 1; }
+        if (key == "progress") { lua_pushnumber(state, event.ghostProgress); return 1; }
+        if (key == "active") { lua_pushboolean(state, event.ghostActive); return 1; }
+    }
+    lua_pushnil(state);
+    return 1;
+}
+
 /** Reads one member of a squad occupancy edge. */
 [[nodiscard]] int squad_state_index(lua_State* state) {
     const host::Event& event = check_event(state, 1);
@@ -410,10 +479,22 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return 1;
 }
 
+[[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)) return 1;
+    if (key=="alive_count") lua_pushinteger(state,event.fireteamAlive);
+    else if (key=="dead_count") lua_pushinteger(state,event.fireteamDead);
+    else if (key=="unknown_count") lua_pushinteger(state,event.fireteamUnknown);
+    else lua_pushnil(state);
+    return 1;
+}
+
 } // namespace
 
 /** Installs the derived and internal view metatables. */
 void register_derived_event_metatables(lua_State* state) {
+    register_metatable(state,kFireteamStateEventMetatable,&fireteam_state_index);
     register_metatable(state, kTriggerEnteredEventMetatable, &trigger_entered_index);
     register_metatable(state, kTriggerExitedEventMetatable, &trigger_exited_index);
     register_metatable(state, kSquadStateEventMetatable, &squad_state_index);
@@ -429,6 +510,9 @@ void register_derived_event_metatables(lua_State* state) {
     register_metatable(state, kPlayerTriggerEventMetatable, &player_trigger_index);
     register_metatable(state, kCinematicStartedEventMetatable, &cinematic_started_index);
     register_metatable(state, kCinematicTerminatedEventMetatable, &cinematic_terminated_index);
+    register_metatable(state, kGhostLinkEventMetatable, &ghost_link_index);
+    register_metatable(state, kObjectInteractionEventMetatable, &object_interaction_index);
+    register_metatable(state, kActorPathEventMetatable, &actor_path_index);
 }
 
 } // namespace sunrise::server::activity::mission::lua_vm::detail

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

@@ -24,6 +24,9 @@ namespace {
     const std::string_view key = lua_string_view(state, 2);
     if (key == "matches") {
         lua_pushcfunction(state, &request_key_matches);
+    } else if (key == "value") {
+        const auto* handle = static_cast<const RequestKeyHandle*>(luaL_checkudata(state, 1, kRequestKeyMetatable));
+        push_u64_string(state, handle->key);
     } else {
         lua_pushnil(state);
     }

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

@@ -74,6 +74,11 @@ inline constexpr char kEntitySlotsRequestedEventMetatable[] =
 // Session membership, player trigger, and cinematic transitions.
 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 kObjectInteractionEventMetatable[] =
+    "sunrise.mission.event.object_interaction";
+inline constexpr char kGhostLinkEventMetatable[] = "sunrise.mission.event.ghost_link";
+inline constexpr char kFireteamStateEventMetatable[] = "sunrise.mission.event.fireteam_state";
 inline constexpr char kPlayerTriggerEventMetatable[] = "sunrise.mission.event.player_trigger";
 inline constexpr char kCinematicStartedEventMetatable[] = "sunrise.mission.event.cinematic_started";
 inline constexpr char kCinematicTerminatedEventMetatable[] =

+ 607 - 1
Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp

@@ -10,7 +10,16 @@
 #include <string_view>
 
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../middleware/bap/activity_message/combatant_auth.h"
+#include "../../../middleware/bap/activity_message/damage_monitor_auth.h"
+#include "../../../middleware/bap/activity_message/darkness_zone_auth.h"
+#include "../../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../../middleware/bap/activity_message/interactable_object_auth.h"
+#include "../../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../../middleware/bap/activity_message/music_section_auth.h"
+#include "../../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../../middleware/bap/activity_message/squad_objective_auth.h"
 #include "../../../middleware/encoding/bit_writer.h"
 #include "../../../state/activity_sdk/format.h"
 #include "../../../state/activity_sdk/runtime.h"
@@ -22,6 +31,7 @@ namespace format = state::activity_sdk::format;
 namespace slot_transport = middleware::bap::activity_message::sensor_auth_update;
 namespace scriptable_auth = middleware::bap::activity_message::scriptable_auth;
 namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
+namespace auth_fields = middleware::bap::activity_message::auth_fields;
 
 namespace {
 /** @return True when one live Slot row is an exact type-23 device. */
@@ -33,6 +43,39 @@ namespace {
            && (definition.flags & format::kSlotSchemaJoinExact) != 0;
 }
 
+/** Positive 31-bit generations and revisions are the counters every typed Auth body accepts. */
+[[nodiscard]] bool valid_counter(lua_Integer value) noexcept {
+    return value > 0 && value <= auth_fields::kMaximumCounter;
+}
+
+/**
+ * Reads one optional slot-handle argument into a ClientRef.
+ * @param slotType Slot type the referenced slot must have.
+ * @param output Left unset when the argument is nil.
+ * @return False when the argument is present but stale or of another type.
+ */
+[[nodiscard]] bool optional_slot_reference(lua_State* state,
+                                           const char* name,
+                                           std::uint32_t slotType,
+                                           scriptable_auth::Type2LaneClientRef& output) {
+    lua_getfield(state, 2, name);
+    bool valid = true;
+    if (!lua_isnil(state, -1)) {
+        const auto* const handle =
+            static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+        SlotDefinition definition{};
+        valid = current_slot(state, *handle, definition) && definition.slotType == slotType
+                && definition.slotIndex <= auth_fields::kMaximumClientRefIndex;
+        if (valid) {
+            output = {definition.registryKey,
+                      static_cast<std::int8_t>(slotType),
+                      static_cast<std::int16_t>(definition.slotIndex)};
+        }
+    }
+    lua_pop(state, 1);
+    return valid;
+}
+
 /** @return True when one live Slot row is an exact type-4 authored object. */
 [[nodiscard]] bool exact_object_slot(const SlotDefinition& definition) noexcept {
     return definition.slotType == format::kObjectSlotType
@@ -219,7 +262,8 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
     // Named arguments this call accepts. Any other key is refused.
-    static constexpr std::array<std::string_view, 2> kDeclared{"directive", "state"};
+    static constexpr std::array<std::string_view, 4> kDeclared{
+        "directive", "state", "navpoint", "audience"};
     refuse_unknown_arguments(state, kDeclared);
     SlotDefinition slot{};
     if (!current_slot(state, *handle, slot)) {
@@ -256,6 +300,18 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
                                          .elementIndex = resolved.elementIndex,
                                          .state = static_cast<std::int8_t>(directiveState),
                                          .visible = true};
+    if (!optional_slot_reference(state,
+                                 "audience",
+                                 scriptable_auth::kType70SlotType,
+                                 preset.audience)) {
+        return luaL_error(state, "directive audience requires an authored type-70 engagement sensor");
+    }
+    if (!optional_slot_reference(state,
+                                 "navpoint",
+                                 scriptable_auth::kType47SlotType,
+                                 preset.navpoint)) {
+        return luaL_error(state, "directive navpoint requires a current authored type-47 slot");
+    }
     std::array<std::byte, scriptable_auth::kType68ByteCount> body{};
     std::size_t written = 0;
     if (!scriptable_auth::encode_type68(preset, body, written) || written != body.size()) {
@@ -317,6 +373,258 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         state, slot, scriptable_auth::kType70Schema, scriptable_auth::kType70BitCount, body);
 }
 
+/** Enables the native darkness restriction; roster assembly supplies its matching bubble. */
+[[nodiscard]] int slot_set_darkness_zone(lua_State* state) {
+    namespace darkness = middleware::bap::activity_message::darkness_zone;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> kDeclared{"enabled", "wipe_seconds"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != darkness::kSlotType
+        || slot.componentClass != darkness::kComponentClass || slot.authSchema != darkness::kSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "darkness zone requires the exact hard-wipe globals sensor");
+    }
+    const lua_Integer wipe = optional_integer_argument(state, "wipe_seconds", darkness::kNoWipe);
+    std::array<std::byte, darkness::kBytes> body{};
+    if (wipe < darkness::kNoWipe || wipe > darkness::kMaximumWipeSeconds
+        || !darkness::encode(
+            optional_boolean_argument(state, "enabled", false), body, static_cast<int>(wipe))) {
+        return luaL_error(state, "darkness zone encoder failed");
+    }
+    return queue_slot_auth(state, slot, darkness::kSchema, darkness::kBits, body);
+}
+
+/** Volumes one filter may test; leaves room for the players, target and inside predicates. */
+constexpr std::size_t kMaximumFilterVolumes = 5;
+/** Type-34 predicate modes: 0 tests the flag or reference as given, 1 tests inside a volume. */
+constexpr std::int8_t kFilterModeDirect = 0;
+constexpr std::int8_t kFilterModeInside = 1;
+
+/** Native typed object filters: players, one object, and volume intersection. */
+[[nodiscard]] int slot_set_object_filter(lua_State* state) {
+    namespace auth = scriptable_auth;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 4> kDeclared{
+        "players", "target", "inside", "inside_any"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != auth::kType34SlotType
+        || slot.authSchema != auth::kType34Schema) {
+        return luaL_error(state, "object filter requires an authored type-34 sensor");
+    }
+    auth::Type34Body body{};
+    lua_getfield(state, 2, "inside_any");
+    const bool volumes = !lua_isnil(state, -1);
+    if (volumes) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const std::size_t count = lua_rawlen(state, -1);
+        if (count == 0 || count > kMaximumFilterVolumes) {
+            return luaL_error(state, "inside_any volume count is outside the filter capacity");
+        }
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            const auto* const volumeHandle =
+                static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+            SlotDefinition volume{};
+            if (!current_slot(state, *volumeHandle, volume)
+                || volume.slotType != auth::kType60SlotType) {
+                return luaL_error(state, "inside_any requires authored type-60 volumes");
+            }
+            body.predicates[body.count++] = auth::Type34ModeFlagSlotRef{
+                kFilterModeDirect,
+                true,
+                {volume.registryKey,
+                 static_cast<std::int8_t>(auth::kType60SlotType),
+                 static_cast<std::int16_t>(volume.slotIndex)}};
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
+    if (optional_boolean_argument(state, "players", false)) {
+        body.predicates[body.count++] =
+            auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes ? 1 : 0)};
+    }
+    auth::Type2LaneClientRef target{};
+    if (!optional_slot_reference(state, "target", auth::kType4SlotType, target)) {
+        return luaL_error(state, "filter target must be an authored type-4 object");
+    }
+    if (target.slotIndex >= 0) {
+        body.predicates[body.count++] = auth::Type34ModeSlotRefC{kFilterModeDirect, target};
+    }
+    auth::Type2LaneClientRef inside{};
+    if (!optional_slot_reference(state, "inside", auth::kType60SlotType, inside)) {
+        return luaL_error(state, "filter inside must be an authored type-60 volume");
+    }
+    if (inside.slotIndex >= 0) {
+        body.predicates[body.count++] = auth::Type34ModeFlagSlotRef{kFilterModeInside, false, inside};
+    }
+    std::array<std::byte, auth::kType34MaximumByteCount> bytes{};
+    std::size_t written = 0;
+    std::size_t bits = 0;
+    if (!auth::encode_type34(body, bytes, written, bits)) {
+        return luaL_error(state, "object filter encoder failed");
+    }
+    return queue_slot_auth(state, slot, auth::kType34Schema, bits, std::span(bytes).first(written));
+}
+/** Attaches the authored hop-on effect to the entities a type-34 filter selects. */
+[[nodiscard]] int slot_set_mission_effect(lua_State* state) {
+    namespace effect = middleware::bap::activity_message::mission_effect;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"filter", "enabled", "revision"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != effect::kSlotType
+        || slot.authSchema != effect::kSchema) {
+        return luaL_error(state, "mission effect requires an authored type-26 hop-on");
+    }
+    const bool enabled = optional_boolean_argument(state, "enabled", true);
+    scriptable_auth::Type2LaneClientRef filter{};
+    if (enabled) {
+        lua_getfield(state, 2, "filter");
+        const bool present = !lua_isnil(state, -1);
+        lua_pop(state, 1);
+        if (!present
+            || !optional_slot_reference(state, "filter", scriptable_auth::kType34SlotType, filter)) {
+            return luaL_error(state, "mission effect requires a type-34 filter");
+        }
+    }
+    const lua_Integer revision = optional_integer_argument(state, "revision", 1);
+    if (!valid_counter(revision)) {
+        return luaL_error(state, "effect revision must be positive");
+    }
+    std::array<std::byte, effect::kBytes> body{};
+    std::size_t written = 0;
+    if (!effect::encode(filter, enabled, static_cast<std::int32_t>(revision), body, written)) {
+        return luaL_error(state, "mission effect encoder failed");
+    }
+    return queue_slot_auth(state, slot, effect::kSchema, effect::kBits, body);
+}
+
+/** Binds an authored damage monitor to one exact object; a new revision re-binds it. */
+[[nodiscard]] int slot_watch_damage(lua_State* state) {
+    namespace damage = middleware::bap::activity_message::damage_monitor;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> kDeclared{"target", "revision"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != damage::kSlotType
+        || slot.authSchema != damage::kAuthSchema) {
+        return luaL_error(state, "damage watch requires an authored type-20 monitor");
+    }
+    lua_getfield(state, 2, "target");
+    const auto* const targetHandle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+    SlotDefinition target{};
+    const bool exact = current_slot(state, *targetHandle, target) && exact_object_slot(target);
+    lua_pop(state, 1);
+    if (!exact) {
+        return luaL_error(state, "damage target must be an authored type-4 object");
+    }
+    const lua_Integer revision = optional_integer_argument(state, "revision", 1);
+    if (!valid_counter(revision)) {
+        return luaL_error(state, "damage revision must be positive");
+    }
+    std::array<std::byte, damage::kBytes> body{};
+    std::size_t written = 0;
+    if (!damage::encode(target.registryKey,
+                        static_cast<std::uint16_t>(target.slotIndex),
+                        static_cast<std::int32_t>(revision),
+                        body,
+                        written)) {
+        return luaL_error(state, "damage monitor encoder failed");
+    }
+    return queue_slot_auth(state, slot, damage::kAuthSchema, damage::kBits, body);
+}
+
+/** Selects one authored section in a native music sensor's selection mask. */
+[[nodiscard]] int slot_set_music_section(lua_State* state) {
+    namespace music = middleware::bap::activity_message::music_section;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> kDeclared{"section", "enabled"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    const lua_Integer section = checked_integer_argument(state, "section");
+    if (!current_slot(state, *handle, slot) || slot.slotType != music::kSlotType
+        || slot.componentClass != music::kComponentClass || slot.authSchema != music::kSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0 || section < 0
+        || section >= static_cast<lua_Integer>(music::kSectionCount)) {
+        return luaL_error(state, "music requires an exact type-11 sensor and a section index");
+    }
+    std::array<std::byte, music::kBytes> body{};
+    std::size_t written = 0;
+    if (!music::encode(static_cast<std::uint8_t>(section),
+                       optional_boolean_argument(state, "enabled", true),
+                       body,
+                       written)) {
+        return luaL_error(state, "music section encoder failed");
+    }
+    return queue_slot_auth(state, slot, music::kSchema, music::kBits, body);
+}
+
+/** Spawns authored entry zero and subscribes to accepted native player use. */
+[[nodiscard]] int slot_set_interactable_object(lua_State* state) {
+    namespace object = middleware::bap::activity_message::interactable_object;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"generation", "track_owner", "active"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || !exact_object_slot(slot)) {
+        return luaL_error(state, "interaction requires an exact authored object");
+    }
+    const lua_Integer generation = optional_integer_argument(state, "generation", 1);
+    if (!valid_counter(generation)) {
+        return luaL_error(state, "object generation must be a positive int32");
+    }
+    const bool trackOwner = optional_boolean_argument(state, "track_owner", false);
+    std::array<std::byte, object::kOwnerBytes> body{};
+    std::size_t written = 0;
+    if (!object::encode(static_cast<std::int32_t>(generation),
+                        body,
+                        written,
+                        trackOwner,
+                        optional_boolean_argument(state, "active", true))) {
+        return luaL_error(state, "interactable object encoder failed");
+    }
+    return queue_slot_auth(state,
+                           slot,
+                           object::kSchema,
+                           trackOwner ? object::kOwnerBits : object::kBits,
+                           std::span(body).first(written));
+}
+
+/** Enables the authored Ghost interaction without replacing its action hash. */
+[[nodiscard]] int slot_set_ghost_link(lua_State* state) {
+    namespace ghost = middleware::bap::activity_message::ghost_link;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> kDeclared{"generation", "enabled"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != ghost::kSlotType
+        || slot.componentClass != ghost::kComponentClass || slot.authSchema != ghost::kAuthSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "activity slot is not an exact Ghost-link sensor");
+    }
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    if (!valid_counter(generation)) {
+        return luaL_error(state, "Ghost-link generation must be a positive int32");
+    }
+    const bool enabled = optional_boolean_argument(state, "enabled", true);
+    std::array<std::byte, ghost::kByteCount> body{};
+    std::size_t written = 0;
+    if (!ghost::encode(static_cast<std::int32_t>(generation), enabled, body, written)) {
+        return luaL_error(state, "Ghost-link native encoder failed");
+    }
+    return queue_slot_auth(state, slot, ghost::kAuthSchema, ghost::kBitCount, body);
+}
+
 /** Names the player, the event area and the leave timeout one public-event sensor watches. */
 [[nodiscard]] int slot_set_public_event_state(lua_State* state) {
     const auto* const handle =
@@ -379,6 +687,229 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         state, slot, scriptable_auth::kType71Schema, scriptable_auth::kType71BitCount, bytes);
 }
 
+/** @return True when one live Slot row is an exact squad in the given registry. */
+[[nodiscard]] bool exact_squad_of(const SlotDefinition& squad, const SlotDefinition& owner) noexcept {
+    return squad.slotType == format::kSquadSlotType
+           && squad.componentClass == format::kSquadComponentClass
+           && (squad.flags & format::kSlotSchemaJoinExact) != 0
+           && squad.registryKey == owner.registryKey && squad.objectTag == owner.objectTag
+           && squad.slotIndex <= auth_fields::kMaximumClientRefIndex;
+}
+
+/** Assigns a squad to a native combat objective and its selected task group. */
+[[nodiscard]] int slot_assign_combat_objective(lua_State* state) {
+    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"};
+    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);
+    SlotDefinition squad{};
+    SlotDefinition target{};
+    if (!current_slot(state, *handle, squad) || squad.slotType != format::kSquadSlotType
+        || squad.componentClass != format::kSquadComponentClass
+        || squad.authSchema != objective::kSchema
+        || (squad.flags & format::kSlotSchemaJoinExact) == 0
+        || !current_slot(state, reference, target) || !exact_objective_reset_slot(target)
+        || target.componentClass != format::kObjectiveComponentClass
+        || squad.registryKey != target.registryKey) {
+        return luaL_error(
+            state, "combat objective requires exact squad/objective slots in the same registry");
+    }
+    if (!valid_counter(revision) || group < objective::kNoTaskGroup
+        || group >= objective::kTaskGroupCount
+        || target.slotIndex > auth_fields::kMaximumClientRefIndex) {
+        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)) {
+        return luaL_error(state, "combat objective encoder failed");
+    }
+    return queue_slot_auth(state, squad, objective::kSchema, objective::kBits, body);
+}
+
+/** Creates a named actor and starts one package-authored movement path. */
+[[nodiscard]] int slot_play_actor_path(lua_State* state) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"generation", "revision", "path"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    const lua_Integer revision = checked_integer_argument(state, "revision");
+    const auto reference = checked_argument<SlotHandle>(state, "path", kSlotMetatable);
+    SlotDefinition actor{};
+    SlotDefinition path{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !current_slot(state, reference, path) || path.slotType != combatant::kPathSlotType
+        || path.componentClass != combatant::kPathComponentClass
+        || actor.objectTag != path.objectTag || actor.registryKey != path.registryKey) {
+        return luaL_error(state,
+                          "actor path requires an exact member and same-registry type-58 path");
+    }
+    if (!valid_counter(generation) || !valid_counter(revision)
+        || path.slotIndex > auth_fields::kMaximumClientRefIndex) {
+        return luaL_error(
+            state, "actor path generation, revision or index is outside its native range");
+    }
+    std::array<std::byte, combatant::kPathBytes> body{};
+    if (!combatant::encode_path({static_cast<std::uint32_t>(generation),
+                                 static_cast<std::uint32_t>(revision),
+                                 path.registryKey,
+                                 static_cast<std::uint16_t>(path.slotIndex)},
+                                body)) {
+        return luaL_error(state, "actor path encoder failed");
+    }
+    return queue_slot_auth(state, actor, combatant::kSchema, combatant::kPathBits, body);
+}
+
+/** Runs an authored native custom action without recreating its actor. */
+[[nodiscard]] int slot_play_actor_action(lua_State* state) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    constexpr lua_Integer kMaximumHash = (std::numeric_limits<std::uint32_t>::max)();
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 4> kDeclared{
+        "generation", "revision", "group", "action"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    const lua_Integer revision = checked_integer_argument(state, "revision");
+    const lua_Integer group = checked_integer_argument(state, "group");
+    const lua_Integer action = checked_integer_argument(state, "action");
+    SlotDefinition actor{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !valid_counter(generation) || !valid_counter(revision) || group < 0
+        || group > kMaximumHash || action <= 0 || action > kMaximumHash) {
+        return luaL_error(state,
+                          "actor action requires an exact member and valid native identities");
+    }
+    std::array<std::byte, combatant::kActionBytes> body{};
+    if (!combatant::encode_action({static_cast<std::uint32_t>(generation),
+                                   static_cast<std::uint32_t>(revision),
+                                   static_cast<std::uint32_t>(group),
+                                   static_cast<std::uint32_t>(action)},
+                                  body)) {
+        return luaL_error(state, "actor action encoder failed");
+    }
+    return queue_slot_auth(state, actor, combatant::kSchema, combatant::kActionBits, body);
+}
+
+/**
+ * Encodes and queues one delivery manifest for the named actor.
+ * @param squads Reserved squads in the actor's registry.
+ */
+[[nodiscard]] int queue_delivery(
+    lua_State* state,
+    const SlotDefinition& actor,
+    lua_Integer generation,
+    lua_Integer revision,
+    std::span<const middleware::bap::activity_message::combatant_auth::SquadReference> squads) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    std::array<std::byte, combatant::kDeliveryMaximumBytes> body{};
+    std::size_t written = 0;
+    std::size_t bits = 0;
+    if (!combatant::encode_delivery(static_cast<std::uint32_t>(generation),
+                                    static_cast<std::uint32_t>(revision),
+                                    squads,
+                                    body,
+                                    written,
+                                    bits)) {
+        return luaL_error(state, "invalid delivery manifest");
+    }
+    return queue_slot_auth(state, actor, combatant::kSchema, bits, std::span(body).first(written));
+}
+
+/** Gives one reserved squad to the named actor's native passenger-delivery component. */
+[[nodiscard]] int slot_deliver_squad(lua_State* state) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"generation", "revision", "squad"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    const lua_Integer revision = checked_integer_argument(state, "revision");
+    const auto reference = checked_argument<SlotHandle>(state, "squad", kSlotMetatable);
+    SlotDefinition actor{};
+    SlotDefinition squad{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !current_slot(state, reference, squad) || !exact_squad_of(squad, actor)) {
+        return luaL_error(state, "delivery requires an exact member and same-registry squad");
+    }
+    if (!valid_counter(generation) || !valid_counter(revision)) {
+        return luaL_error(state, "delivery generation or revision is outside its native range");
+    }
+    const std::array<combatant::SquadReference, 1> squads{
+        {{squad.registryKey, static_cast<std::uint16_t>(squad.slotIndex)}}};
+    return queue_delivery(state, actor, generation, revision, squads);
+}
+
+/** Sends one manifest so several reserved squads share the same ship and unload together. */
+[[nodiscard]] int slot_deliver_squads(lua_State* state) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> kDeclared{"generation", "revision", "squads"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    const lua_Integer revision = checked_integer_argument(state, "revision");
+    SlotDefinition actor{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !valid_counter(generation) || !valid_counter(revision)) {
+        return luaL_error(state, "delivery requires an exact actor and positive generation/revision");
+    }
+    lua_getfield(state, 2, "squads");
+    luaL_checktype(state, -1, LUA_TTABLE);
+    const int list = lua_gettop(state);
+    const std::size_t count = lua_rawlen(state, list);
+    if (count == 0 || count > combatant::kMaximumManifestSquads) {
+        return luaL_error(state, "delivery manifest squad count is outside its native range");
+    }
+    std::array<combatant::SquadReference, combatant::kMaximumManifestSquads> squads{};
+    for (std::size_t index = 0; index < count; ++index) {
+        lua_rawgeti(state, list, static_cast<lua_Integer>(index + 1));
+        const auto* const squadHandle =
+            static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+        SlotDefinition squad{};
+        if (!current_slot(state, *squadHandle, squad) || !exact_squad_of(squad, actor)) {
+            return luaL_error(state, "delivery squads must be exact and in the actor's registry");
+        }
+        squads[index] = {squad.registryKey, static_cast<std::uint16_t>(squad.slotIndex)};
+        lua_pop(state, 1);
+    }
+    lua_pop(state, 1);
+    return queue_delivery(state, actor, generation, revision, std::span(squads).first(count));
+}
+
+/** Retires the named actor on a new generation, which also clears retained Auth on reload. */
+[[nodiscard]] int slot_retire_actor(lua_State* state) {
+    namespace combatant = middleware::bap::activity_message::combatant_auth;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 1> kDeclared{"generation"};
+    refuse_unknown_arguments(state, kDeclared);
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    SlotDefinition actor{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !valid_counter(generation)) {
+        return luaL_error(state, "actor retirement requires an exact member and positive generation");
+    }
+    std::array<std::byte, combatant::kRetireBytes> body{};
+    if (!combatant::encode_retire(static_cast<std::uint32_t>(generation), body)) {
+        return luaL_error(state, "actor retirement encoder failed");
+    }
+    return queue_slot_auth(state, actor, combatant::kSchema, combatant::kRetireBits, body);
+}
+
 /** Binds one exact combatant to its package-authored squad member. */
 [[nodiscard]] int slot_bind_combatant_to_squad(lua_State* state) {
     const auto* const handle =
@@ -544,6 +1075,53 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
     return queue_intent(state, frame, intent);
 }
 
+/** Publishes one scene generation and its cumulative authored event keys. */
+[[nodiscard]] int slot_set_scene_events(lua_State* state) {
+    namespace scene = middleware::bap::activity_message::scene_events;
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> kDeclared{"generation", "events"};
+    refuse_unknown_arguments(state, kDeclared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != scene::kSlotType
+        || slot.componentClass != scene::kComponentClass || slot.authSchema != scene::kSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "scene events require an exact type-43 scene");
+    }
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    if (!valid_counter(generation)) {
+        return luaL_error(state, "scene generation must be a positive int32");
+    }
+    lua_getfield(state, 2, "events");
+    luaL_checktype(state, -1, LUA_TTABLE);
+    const std::size_t count = lua_rawlen(state, -1);
+    if (count > scene::kMaximumEvents) {
+        return luaL_error(state, "scene event list exceeds the manifest capacity");
+    }
+    std::array<std::uint32_t, scene::kMaximumEvents> events{};
+    for (std::size_t index = 0; index < count; ++index) {
+        lua_rawgeti(state, -1, static_cast<lua_Integer>(index + 1));
+        const lua_Integer event = luaL_checkinteger(state, -1);
+        lua_pop(state, 1);
+        if (event <= 0 || event >= static_cast<lua_Integer>(scene::kInvalidEventKey)) {
+            return luaL_error(state, "invalid scene event key");
+        }
+        events[index] = static_cast<std::uint32_t>(event);
+    }
+    lua_pop(state, 1);
+    std::array<std::byte, scene::kMaximumBytes> body{};
+    std::size_t bytes = 0;
+    std::size_t bits = 0;
+    if (!scene::encode(static_cast<std::int32_t>(generation),
+                       std::span(events).first(count),
+                       body,
+                       bytes,
+                       bits)) {
+        return luaL_error(state, "scene event keys must be unique");
+    }
+    return queue_slot_auth(state, slot, scene::kSchema, bits, std::span(body).first(bytes));
+}
+
 /** Lua `play_sequence` on a slot. Errors unless the slot is an exact type-5 sequence. */
 [[nodiscard]] int slot_play_sequence(lua_State* state) {
     const auto* const handle =
@@ -747,6 +1325,12 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         }
     } else if (key == "flags") {
         lua_pushinteger(state, definition.flags);
+    } else if (key == "set_object_filter") {
+        lua_pushcfunction(state, &slot_set_object_filter);
+    } else if (key == "set_mission_effect") {
+        lua_pushcfunction(state, &slot_set_mission_effect);
+    } else if (key == "watch_damage") {
+        lua_pushcfunction(state, &slot_watch_damage);
     } else if (key == "set_object_active") {
         lua_pushcfunction(state, &slot_set_object_active);
     } else if (key == "set_channel") {
@@ -759,10 +1343,30 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         lua_pushcfunction(state, &slot_set_directive);
     } else if (key == "clear_directives") {
         lua_pushcfunction(state, &slot_clear_directives);
+    } else if (key == "set_darkness_zone") {
+        lua_pushcfunction(state, &slot_set_darkness_zone);
+    } else if (key == "set_music_section") {
+        lua_pushcfunction(state, &slot_set_music_section);
+    } else if (key == "set_interactable_object") {
+        lua_pushcfunction(state, &slot_set_interactable_object);
+    } else if (key == "set_ghost_link") {
+        lua_pushcfunction(state, &slot_set_ghost_link);
     } else if (key == "set_engagement_state") {
         lua_pushcfunction(state, &slot_set_engagement_state);
     } else if (key == "set_public_event_state") {
         lua_pushcfunction(state, &slot_set_public_event_state);
+    } else if (key == "assign_combat_objective") {
+        lua_pushcfunction(state, &slot_assign_combat_objective);
+    } else if (key == "play_actor_path") {
+        lua_pushcfunction(state, &slot_play_actor_path);
+    } else if (key == "deliver_squad") {
+        lua_pushcfunction(state, &slot_deliver_squad);
+    } else if (key == "deliver_squads") {
+        lua_pushcfunction(state, &slot_deliver_squads);
+    } else if (key == "play_actor_action") {
+        lua_pushcfunction(state, &slot_play_actor_action);
+    } else if (key == "retire_actor") {
+        lua_pushcfunction(state, &slot_retire_actor);
     } else if (key == "bind_combatant_to_squad") {
         lua_pushcfunction(state, &slot_bind_combatant_to_squad);
     } else if (key == "run_atoms") {
@@ -771,6 +1375,8 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         lua_pushcfunction(state, &slot_fire_trigger);
     } else if (key == "play_sequence") {
         lua_pushcfunction(state, &slot_play_sequence);
+    } else if (key == "set_scene_events") {
+        lua_pushcfunction(state, &slot_set_scene_events);
     } else if (key == "set_cinematic_active") {
         lua_pushcfunction(state, &slot_set_cinematic_active);
     } else if (key == "reset_objectives") {

+ 8 - 2
Sunrise/src/server/activity/mission/mission_script_lua_value_api.cpp

@@ -11,9 +11,10 @@
 namespace sunrise::server::activity::mission::lua_vm::detail {
 namespace {
 
-/** The two placement modes the squad Auth lane accepts. Every other encoding has no name. */
+/** Ordinary placement and native transport reservation; other encodings have no name. */
 constexpr std::uint8_t kSquadModeReinforce = 0;
 constexpr std::uint8_t kSquadModeReplace = 2;
+constexpr std::uint8_t kSquadModeReserve = 3;
 
 /** Lua index for a unit scalar: its value and the bounds it was clamped to. */
 [[nodiscard]] int unit_scalar_index(lua_State* state) {
@@ -66,8 +67,10 @@ constexpr std::uint8_t kSquadModeReplace = 2;
         push_squad_mode(state, kSquadModeReinforce);
     } else if (key == "replace") {
         push_squad_mode(state, kSquadModeReplace);
+    } else if (key == "reserve") {
+        push_squad_mode(state, kSquadModeReserve);
     } else if (key == "count") {
-        lua_pushinteger(state, 2);
+        lua_pushinteger(state, 3);
     } else {
         lua_pushnil(state);
     }
@@ -141,6 +144,9 @@ std::string_view squad_mode_name(std::uint8_t mode) noexcept {
     if (mode == kSquadModeReplace) {
         return "replace";
     }
+    if (mode == kSquadModeReserve) {
+        return "reserve";
+    }
     return "unknown";
 }
 

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

@@ -0,0 +1,93 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/interactable_object_auth.h"
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-4 object Sense root ordinals. */
+inline constexpr std::uint16_t kObjectGenerationOrdinal = 0;
+inline constexpr std::uint16_t kObjectAliveOrdinal = 1;
+inline constexpr std::uint16_t kObjectPresentOrdinal = 2;
+/** Ownership reply: field 0 held, field 1 the owner key. */
+inline constexpr std::uint16_t kOwnerHeldOrdinal = 0;
+inline constexpr std::uint16_t kOwnerKeyOrdinal = 1;
+/** Interaction reply: field 0 is the one-shot used latch. */
+inline constexpr std::uint16_t kInteractedOrdinal = 0;
+
+/** Last object level seen for one slot. */
+struct ObjectInteractionLevel final {
+    std::int32_t generation{};
+    bool generationKnown{};
+    bool interacted{};
+    bool interactionKnown{};
+    bool present{};
+    bool alive{};
+    bool stateKnown{};
+    bool ownerKnown{};
+    bool hasOwner{};
+    std::uint64_t ownerKey{};
+};
+
+/**
+ * Merges one decoded type-4 body into the retained level. A new generation clears the level, so
+ * a stale interaction latch never joins a new object. A body is a full snapshot, so an absent
+ * ownership reply clears the owner.
+ * @return True when this body is the first report of an accepted interaction for its generation.
+ */
+[[nodiscard]] inline bool update_object_interaction(
+    ObjectInteractionLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    namespace object = middleware::bap::activity_message::interactable_object;
+    const ObjectInteractionLevel before = level;
+    for (const auto& value : values) {
+        if (!value.present || value.schemaRow != root
+            || value.fieldOrdinal != kObjectGenerationOrdinal) {
+            continue;
+        }
+        const auto generation = static_cast<std::int32_t>(value.signedValue);
+        if (level.generationKnown && generation < level.generation) {
+            return false;
+        }
+        if (!level.generationKnown || generation != level.generation) {
+            level = {};
+        }
+        level.generation = generation;
+        level.generationKnown = true;
+    }
+    level.ownerKnown = false;
+    level.hasOwner = false;
+    level.ownerKey = 0;
+    for (const auto& value : values) {
+        if (!value.present) {
+            continue;
+        }
+        if (value.schemaRow == root && value.fieldOrdinal == kObjectAliveOrdinal) {
+            level.alive = value.unsignedValue != 0;
+        } else if (value.schemaRow == root && value.fieldOrdinal == kObjectPresentOrdinal) {
+            level.present = value.unsignedValue != 0;
+            level.stateKnown = true;
+        } else if (value.schemaRow == object::kOwnershipReply
+                   && value.fieldOrdinal == kOwnerHeldOrdinal) {
+            level.ownerKnown = true;
+            level.hasOwner = value.unsignedValue != 0;
+        } else if (value.schemaRow == object::kOwnershipReply
+                   && value.fieldOrdinal == kOwnerKeyOrdinal) {
+            level.ownerKey = value.unsignedValue;
+        } else if (value.schemaRow == object::kInteractionReply
+                   && value.fieldOrdinal == kInteractedOrdinal) {
+            level.interacted = value.unsignedValue != 0;
+            level.interactionKnown = true;
+        }
+    }
+    return level.generationKnown && level.generation > 0 && level.interactionKnown
+           && level.interacted
+           && (!before.interactionKnown || !before.interacted
+               || before.generation != level.generation);
+}
+
+} // namespace sunrise::server::activity::mission

+ 136 - 0
Sunrise/src/server/activity/mission/mission_script_player_sense.h

@@ -0,0 +1,136 @@
+#pragma once
+
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-13 participation records carry one player each; this is their slot type and root schema. */
+inline constexpr std::uint8_t kParticipationSlotType = 13;
+inline constexpr std::uint32_t kParticipationSenseSchema = 0x80804F2FU;
+/** Object tag that owns the participation records. Assumed the same in every activity. */
+inline constexpr std::uint32_t kParticipationObjectTag = 0x80FEB3DCU;
+/** Player records occupy sixteen slots starting at slot five. */
+inline constexpr std::uint16_t kFirstParticipationSlot = 5;
+inline constexpr std::size_t kParticipationSlotCount = 16;
+/** The identity block carries the player key in field 0. */
+inline constexpr std::uint32_t kParticipationIdentitySchema = 0x808094DDU;
+/** The state block carries the region, loaded, settled and Ghost flags. */
+inline constexpr std::uint32_t kParticipationStateSchema = 0x808094E4U;
+inline constexpr std::uint16_t kParticipationKeyOrdinal = 0;
+inline constexpr std::uint16_t kParticipationRegionOrdinal = 0;
+inline constexpr std::uint16_t kParticipationLoadedOrdinal = 2;
+inline constexpr std::uint16_t kParticipationSettledOrdinal = 4;
+inline constexpr std::uint16_t kParticipationGhostOrdinal = 5;
+inline constexpr std::uint8_t kLifeSeenKey = 0x01;
+inline constexpr std::uint8_t kLifeSeenRegion = 0x02;
+inline constexpr std::uint8_t kLifeSeenLoaded = 0x04;
+inline constexpr std::uint8_t kLifeSeenSettled = 0x08;
+inline constexpr std::uint8_t kLifeSeenGhost = 0x10;
+inline constexpr std::uint8_t kLifeSeenAll =
+    kLifeSeenKey | kLifeSeenRegion | kLifeSeenLoaded | kLifeSeenSettled | kLifeSeenGhost;
+/** All-one bits is not a player key. */
+inline constexpr std::uint64_t kInvalidPlayerKey = (std::numeric_limits<std::uint64_t>::max)();
+/** The region field is a signed 16-bit slice-set index. */
+inline constexpr std::int32_t kMaximumLifeRegion = (std::numeric_limits<std::int16_t>::max)();
+
+enum class PlayerLife : std::uint8_t { unknown, alive, dead };
+
+/** Last participation level seen for one player slot. */
+struct PlayerLifeObservation final {
+    std::uint64_t playerKey{};
+    std::int32_t region{-1};
+    std::uint8_t seen{};
+    bool loaded{};
+    bool settled{};
+    bool ghost{};
+
+    /** Dead means loaded, settled and showing the Ghost. Anything incomplete is unknown. */
+    [[nodiscard]] PlayerLife life() const noexcept {
+        if (seen != kLifeSeenAll || playerKey == 0 || playerKey == kInvalidPlayerKey || region < 0
+            || region > kMaximumLifeRegion || !loaded || !settled) {
+            return PlayerLife::unknown;
+        }
+        return ghost ? PlayerLife::dead : PlayerLife::alive;
+    }
+};
+
+/**
+ * Merges one decoded type-13 body into the level. The body is a full snapshot, so an absent
+ * identity or region withdraws the old evidence.
+ */
+inline void
+update_player_life(PlayerLifeObservation& level,
+                   std::span<const middleware::bap::activity_message::sense_update::DecodedValue>
+                       values) noexcept {
+    for (const auto& value : values) {
+        const bool identity = value.schemaRow == kParticipationIdentitySchema
+                              && value.fieldOrdinal == kParticipationKeyOrdinal;
+        if (!value.present) {
+            if (identity) {
+                level.playerKey = 0;
+                level.seen &= static_cast<std::uint8_t>(~kLifeSeenKey);
+            }
+            if (value.schemaRow == kParticipationStateSchema
+                && value.fieldOrdinal == kParticipationRegionOrdinal) {
+                level.region = -1;
+                level.seen &= static_cast<std::uint8_t>(~kLifeSeenRegion);
+            }
+            continue;
+        }
+        if (identity) {
+            level.playerKey = value.unsignedValue;
+            level.seen |= kLifeSeenKey;
+        } else if (value.schemaRow == kParticipationStateSchema) {
+            switch (value.fieldOrdinal) {
+            case kParticipationRegionOrdinal:
+                level.region = static_cast<std::int32_t>(value.signedValue);
+                level.seen |= kLifeSeenRegion;
+                break;
+            case kParticipationLoadedOrdinal:
+                level.loaded = value.unsignedValue != 0;
+                level.seen |= kLifeSeenLoaded;
+                break;
+            case kParticipationSettledOrdinal:
+                level.settled = value.unsignedValue != 0;
+                level.seen |= kLifeSeenSettled;
+                break;
+            case kParticipationGhostOrdinal:
+                level.ghost = value.unsignedValue != 0;
+                level.seen |= kLifeSeenGhost;
+                break;
+            default:
+                break;
+            }
+        }
+    }
+}
+
+/** Life counts over one private activity's joined party. */
+struct FireteamLife final {
+    std::uint16_t alive{};
+    std::uint16_t dead{};
+    std::uint16_t unknown{};
+
+    /** An unknown member blocks the all-dead edge, so a loading player cannot cause a wipe. */
+    [[nodiscard]] bool all_dead() const noexcept {
+        return dead != 0 && alive == 0 && unknown == 0;
+    }
+
+    void add(PlayerLife life) noexcept {
+        if (life == PlayerLife::alive) {
+            ++alive;
+        } else if (life == PlayerLife::dead) {
+            ++dead;
+        } else {
+            ++unknown;
+        }
+    }
+
+    bool operator==(const FireteamLife&) const = default;
+};
+
+} // namespace sunrise::server::activity::mission

+ 125 - 22
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -21,6 +21,7 @@
 #include "../../../state/activity/mission/runtime.h"
 #include "../../../state/activity/runtime.h"
 #include "../host_runtime.h"
+#include "mission_script_event_batch.h"
 #include "mission_script_runtime_internal.h"
 #include "mission_script_vm.h"
 
@@ -49,6 +50,20 @@ snapshot_state_intents(const lua_vm::Vm& vm,
            || (left.deadlineTick == right.deadlineTick && left.sequence < right.sequence);
 }
 
+/** @return The life of the instance's own player, from its retained participation levels. */
+[[nodiscard]] PlayerLife own_player_life(const RuntimeInstance& instance) noexcept {
+    if (!instance.occupied
+        || instance.playerLifeGeneration != instance.view.activityClientGeneration) {
+        return PlayerLife::unknown;
+    }
+    for (const PlayerLifeObservation& life : instance.playerLife) {
+        if (instance.playerKey != 0 && life.playerKey == instance.playerKey) {
+            return life.life();
+        }
+    }
+    return PlayerLife::unknown;
+}
+
 } // namespace
 
 /**
@@ -103,6 +118,90 @@ void log_line(core::log::Level level,
     core::log::write(core::log::Channel::server, level, {line.data(), length});
 }
 
+/**
+ * Raises a fireteam event on each private instance whose party life counts changed. The party
+ * is the committed destination peers; a missing or loading member counts as unknown.
+ */
+void publish_fireteam_life(std::uint64_t now) noexcept {
+    for (RuntimeInstance& instance : g_instances) {
+        if (!instance.occupied || instance.publicTarget || !instance.sessionRosterObserved
+            || instance.programStatus != ProgramStatus::loaded) {
+            continue;
+        }
+        FireteamLife counts{};
+        counts.add(own_player_life(instance));
+        for (const SessionRosterWatch& peer : instance.sessionRoster) {
+            if (!peer.used) {
+                continue;
+            }
+            PlayerLife life = PlayerLife::unknown;
+            for (const RuntimeInstance& candidate : g_instances) {
+                if (candidate.occupied && !candidate.publicTarget
+                    && candidate.view.binding.sessionId == peer.sessionId
+                    && candidate.view.binding.createdRevision == peer.createdRevision) {
+                    life = own_player_life(candidate);
+                    break;
+                }
+            }
+            counts.add(life);
+        }
+        if (instance.fireteamLifePublished && counts == instance.lastFireteamLife) {
+            continue;
+        }
+        instance.lastFireteamLife = counts;
+        instance.fireteamLifePublished = true;
+        host::Event event{};
+        event.kind = host::EventKind::fireteamState;
+        event.binding = instance.view.binding;
+        event.sequence = instance.missionStateRevision;
+        event.sourceGeneration = instance.view.activityClientGeneration;
+        event.missionSequence = instance.lastMissionSequence;
+        event.tick = now;
+        event.fireteamAlive = counts.alive;
+        event.fireteamDead = counts.dead;
+        event.fireteamUnknown = counts.unknown;
+        push_script_event(instance, event);
+        std::array<char, 96> fields{};
+        const int length = std::snprintf(fields.data(),
+                                         fields.size(),
+                                         "alive=%u dead=%u unknown=%u",
+                                         static_cast<unsigned>(counts.alive),
+                                         static_cast<unsigned>(counts.dead),
+                                         static_cast<unsigned>(counts.unknown));
+        if (length > 0) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "fireteam_life",
+                     "changed",
+                     {fields.data(), static_cast<std::size_t>(length)});
+        }
+    }
+}
+
+/** Merges the type-13 participation records of one Sense snapshot into the instance. */
+void observe_player_life(RuntimeInstance& instance,
+                         const host::SenseObservationSnapshot& sense) noexcept {
+    if (instance.playerLifeGeneration != sense.sourceGeneration) {
+        instance.playerLife = {};
+        instance.playerLifeGeneration = sense.sourceGeneration;
+    }
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const host::SenseObservation& observation = sense.observations[index];
+        if (observation.key.slotType != kParticipationSlotType
+            || observation.key.senseSchema != kParticipationSenseSchema
+            || observation.key.objectTag != kParticipationObjectTag
+            || observation.key.slotIndex < kFirstParticipationSlot
+            || observation.key.slotIndex >= kFirstParticipationSlot + kParticipationSlotCount
+            || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) {
+            continue;
+        }
+        update_player_life(
+            instance.playerLife[observation.key.slotIndex - kFirstParticipationSlot],
+            std::span(sense.values).subspan(observation.firstValue, observation.valueCount));
+    }
+}
+
 /**
  * Appends one host-state event for the script.
  * Host-state bursts are intentionally dynamic: authored Sense updates can raise more events than
@@ -182,11 +281,15 @@ void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
     instance.startPending = false;
     instance.timerPending = false;
     instance.triggerOccupancy = {};
+    instance.ghostObservations = {};
+    instance.actorPathObservations = {};
     instance.squadObservations = {};
     instance.sceneObservations = {};
     instance.objectiveObservations = {};
     instance.sessionRoster = {};
     instance.sessionRosterObserved = false;
+    instance.playerLife={};instance.playerLifeGeneration=0;
+    instance.lastFireteamLife={};instance.fireteamLifePublished=false;
     std::vector<host::Event>{}.swap(instance.scriptEvents);
     instance.firstScriptEventAttempt = 0;
     instance.nextScriptEventAttempt = 0;
@@ -347,30 +450,30 @@ void retire_scriptless_inputs() noexcept {
     }
 }
 
-/**
- * Delivers at most one queued host-state event per instance, in arrival order.
- * These events carry no durable state, so each head is delivered once and then retired.
- */
+/** Delivers queued derived events in arrival order until one needs output or the batch is spent. */
 void service_script_events(std::uint64_t now) noexcept {
     for (RuntimeInstance& instance : g_instances) {
-        if (!instance.occupied || instance.scriptEventRead >= instance.scriptEvents.size()
-            || instance.programStatus != ProgramStatus::loaded || instance.startPending) {
-            continue;
-        }
-        lua_vm::Intent pendingIntent{};
-        if (instance.deliveryStage != DeliveryStage::idle
-            || lua_vm::pending_intent(instance.vm, pendingIntent)) {
-            continue;
-        }
-        const bool firstAttempt = instance.scriptEventAttempts == 0;
-        if (firstAttempt) {
-            instance.firstScriptEventAttempt = now;
-        }
-        ++instance.scriptEventAttempts;
-        host::Event& head = instance.scriptEvents[instance.scriptEventRead];
-        head.tick = now;
-        static_cast<void>(dispatch_event(instance, head, nullptr, nullptr, firstAttempt, now));
-        retire_script_event(instance);
+        const auto ready = [&] {
+            if (!instance.occupied || instance.scriptEventRead >= instance.scriptEvents.size()
+                || instance.programStatus != ProgramStatus::loaded || instance.startPending) {
+                return false;
+            }
+            lua_vm::Intent pendingIntent{};
+            return instance.deliveryStage == DeliveryStage::idle
+                   && !lua_vm::pending_intent(instance.vm, pendingIntent);
+        };
+        const auto dispatch = [&] {
+            const bool firstAttempt = instance.scriptEventAttempts == 0;
+            if (firstAttempt) {
+                instance.firstScriptEventAttempt = now;
+            }
+            ++instance.scriptEventAttempts;
+            host::Event& head = instance.scriptEvents[instance.scriptEventRead];
+            head.tick = now;
+            static_cast<void>(dispatch_event(instance, head, nullptr, nullptr, firstAttempt, now));
+            retire_script_event(instance);
+        };
+        static_cast<void>(drain_script_event_batch(ready, dispatch));
     }
 }
 

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

@@ -743,6 +743,7 @@ void synchronize_instances(std::uint64_t now) noexcept {
         }
     }
     const sdk::Snapshot catalog = sdk::snapshot();
+    publish_fireteam_life(now);
     for (std::size_t index = 0; index < diagnostics.instanceCount; ++index) {
         if (diagnostics.instances[index].active) {
             attach_instance(diagnostics.instances[index], catalog, now);

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

@@ -235,6 +235,9 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::setLifetime:
         result = "lifetime_staged";
         break;
+    case lua_vm::IntentKind::restartCheckpoint:
+        result = "checkpoint_staged";
+        break;
     case lua_vm::IntentKind::fireTrigger:
         result = "trigger_staged";
         break;

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

@@ -1,12 +1,16 @@
 #include <algorithm>
+#include <array>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <limits>
 #include <span>
 #include <string_view>
 
+#include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../state/activity/membership/activity_membership_query.h"
 #include "../../../state/build_data/runtime.h"
+#include "../../../state/build_data/spawn_sets/spawn_set_catalog.h"
 #include "../activity_sdk_device_runtime.h"
 #include "../activity_sdk_lifetime_runtime.h"
 #include "../activity_sdk_mission_runtime.h"
@@ -211,6 +215,7 @@ void arm_state_region_teleport(RuntimeInstance& instance,
             instance.view.binding.sessionId, membership::kAbsentSliceSetIndex, 0));
         return;
     }
+    // Each alternate scenario entry is its own packed region, so a sibling state still travels.
     const std::string_view name(reinterpret_cast<const char*>(destination.packageName.data()),
                                 destination.packageNameLength);
     ::sunrise::state::build_data::scenarios::Definition layout{};
@@ -312,6 +317,51 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         static_cast<void>(complete_local_effect(instance, "state_selected"));
         return;
     }
+    case lua_vm::IntentKind::restartCheckpoint: {
+        if (intent.checkpointReleaseRequest != 0) {
+            if (::sunrise::state::activity::membership::release_hard_wipe(
+                    instance.view.binding, intent.checkpointReleaseRequest)) {
+                static_cast<void>(complete_local_effect(instance, "checkpoint_reset_ready"));
+            } else {
+                refuse_delivery(instance,
+                                "checkpoint_refused",
+                                "wipe_not_active",
+                                host::EffectOutcome::refused);
+            }
+            return;
+        }
+        namespace data = ::sunrise::state::build_data;
+        const auto& destination = instance.view.binding.destination;
+        const std::string_view package(
+            reinterpret_cast<const char*>(destination.packageName.data()),
+            destination.packageNameLength);
+        data::scenarios::Definition layout{};
+        data::spawn_sets::NameHash spawn{};
+        // A wipe restarts the whole party at an authored spawn set of the region they are in.
+        if (instance.publicTarget || !instance.lastFireteamLife.all_dead()
+            || instance.activeRegion != intent.effectiveRegion
+            || !data::find_scenario_layout(package, layout)
+            || !data::spawn_sets::find_hash({layout.spawnStem.data(), layout.spawnStemLength},
+                                            intent.checkpointSpawnHash,
+                                            spawn)
+            || spawn.pointCount == 0) {
+            refuse_delivery(instance,
+                            "checkpoint_refused",
+                            "party_or_spawn_unavailable",
+                            host::EffectOutcome::refused);
+            return;
+        }
+        if (::sunrise::state::activity::membership::arm_hard_wipe(instance.view.binding,
+                                                                  intent.requestKey,
+                                                                  intent.effectiveRegion,
+                                                                  intent.checkpointSpawnHash)) {
+            static_cast<void>(complete_local_effect(instance, "checkpoint_wipe_armed"));
+        } else {
+            refuse_delivery(
+                instance, "checkpoint_refused", "membership_busy", host::EffectOutcome::refused);
+        }
+        return;
+    }
     case lua_vm::IntentKind::placeSquad: {
         const std::span<const std::int32_t> counts(intent.squadCounts.data(), intent.squadCount);
         const auto mode = static_cast<squad_auth::Mode>(intent.squadMode);
@@ -491,8 +541,31 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         } else if (!abandon_reserved_delivery(instance, reservation)) {
             return;
         } else if (scene_lease_still_publishing(status)) {
+            // The lease numbers say which side moved. The status dedup reports them once.
+            std::array<char, 176> detail{};
+            scenes::Snapshot lease{};
+            const scenes::Status leaseStatus = scenes::query(instance.view, lease);
+            const int written =
+                std::snprintf(detail.data(),
+                              detail.size(),
+                              "%s lease=%s configured=%d revision=%llu published=%llu "
+                              "pending=%d arrival=%d region=%d plan_region=%u state_row=%u",
+                              scenes::status_name(status),
+                              scenes::status_name(leaseStatus),
+                              lease.configured ? 1 : 0,
+                              static_cast<unsigned long long>(lease.revision),
+                              static_cast<unsigned long long>(lease.publishedRevision),
+                              lease.publicationPending ? 1 : 0,
+                              lease.regionArrivalPending ? 1 : 0,
+                              lease.effectiveRegion,
+                              static_cast<unsigned>(lease.plan.effectiveRegion),
+                              static_cast<unsigned>(lease.plan.stateRow));
             report_intent_status(
-                instance, kIntentStatusSceneLeasePending, scenes::status_name(status));
+                instance,
+                kIntentStatusSceneLeasePending,
+                written > 0
+                    ? std::string_view{detail.data(), static_cast<std::size_t>(written)}
+                    : scenes::status_name(status));
         } else {
             refuse_delivery(instance,
                             sequence ? "sequence_refused" : "cinematic_refused",

+ 298 - 47
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -1,9 +1,14 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
+#include <cmath>
 #include <cstdint>
+#include <cstdio>
 #include <span>
 
+#include "../../../middleware/bap/activity_message/damage_monitor_auth.h"
+#include "../../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../../middleware/bap/activity_message/scriptable_auth_body.h"
 #include "../../../state/activity/transactions/internal.h"
 #include "../activity_sdk_mission_runtime.h"
 #include "mission_script_cinematic.h"
@@ -25,12 +30,12 @@ constexpr std::uint16_t kTriggerAnyOrdinal = 0;
 constexpr std::uint16_t kTriggerAllOrdinal = 1;
 constexpr std::uint16_t kTriggerCountOrdinal = 2;
 constexpr std::uint16_t kTriggerThresholdOrdinal = 3;
-/** Root ordinals of the two live squad fields. The rest of the root is inert on this build. */
-constexpr std::uint16_t kSquadAliveOrdinal = 3;
+/** Root ordinal of the squad removal flag. The rest of the root is inert on this build. */
 constexpr std::uint16_t kSquadRemovalOrdinal = 8;
-/** The per-slot counts are nested: a four-bit length, then the counts themselves. */
-constexpr std::uint8_t kSquadSlotLengthWidth = 4;
-constexpr std::uint8_t kSquadSlotCountWidth = 32;
+/** Root ordinals of the type-20 damage Sense body: health, shield, then the echoed revision. */
+constexpr std::uint16_t kDamageHealthOrdinal = 0;
+constexpr std::uint16_t kDamageShieldOrdinal = 1;
+constexpr std::uint16_t kDamageRevisionOrdinal = 2;
 /** Root ordinal of the authored scene's activation token. Signed, bias -2^31. */
 constexpr std::uint16_t kSceneTokenOrdinal = 0;
 /** Root ordinal of the authored scene's completion latch. A zero-width boolean. */
@@ -94,32 +99,6 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     return event;
 }
 
-/**
- * Copies the squad's per-slot counts out of its nested length-prefixed list.
- * The length and the counts sit in nested schemas, so they are the values the root does not own.
- * Their declared widths tell the length apart from the counts.
- * @return Live entries written into output.
- */
-[[nodiscard]] std::uint8_t
-squad_slot_counts(std::span<const sense_values::DecodedValue> body,
-                  std::uint32_t rootSchemaRow,
-                  std::array<std::int32_t, host::kSquadSlotCapacity>& output) noexcept {
-    output = {};
-    std::size_t length = 0;
-    std::size_t written = 0;
-    for (const sense_values::DecodedValue& value : body) {
-        if (value.schemaRow == rootSchemaRow || !value.present) {
-            continue;
-        }
-        if (value.width == kSquadSlotLengthWidth) {
-            length = static_cast<std::size_t>(value.unsignedValue);
-        } else if (value.width == kSquadSlotCountWidth && written < output.size()) {
-            output[written++] = static_cast<std::int32_t>(value.signedValue);
-        }
-    }
-    return static_cast<std::uint8_t>((std::min)(written, length));
-}
-
 /** @return The retained record for one squad, allocating on a first observation. */
 [[nodiscard]] SquadObservation* find_squad(RuntimeInstance& instance,
                                            const host::SenseObservationKey& key) noexcept {
@@ -133,6 +112,13 @@ squad_slot_counts(std::span<const sense_values::DecodedValue> body,
             spare = &retained;
         }
     }
+    if (spare == nullptr) {
+        for (SquadObservation& retained : instance.squadObservations) {
+            bool empty = retained.aliveCount == 0;
+            for (const auto count : retained.slotCounts) empty = empty && count == 0;
+            if (empty) { retained = {}; spare = &retained; break; }
+        }
+    }
     if (spare == nullptr) {
         log_line(core::log::Level::warn, &instance, "squad", "watch_capacity");
         return nullptr;
@@ -301,20 +287,42 @@ void push_cinematic(RuntimeInstance& instance, const host::Event& incident) noex
     target.eventValue = incident.cinematicEventValue;
     cinematic::Source source{};
     const cinematic::ResolveStatus status = cinematic::resolve(*world, target, source);
+    std::array<char, 128> fields{};
+    const int written = std::snprintf(fields.data(),
+                                      fields.size(),
+                                      "registry=%08x slot_type=%d slot_index=%d target=%u",
+                                      target.registryKey,
+                                      static_cast<int>(target.slotType),
+                                      static_cast<int>(target.slotIndex),
+                                      incident.incidentTarget);
+    const std::string_view detail =
+        written > 0
+            ? std::string_view(fields.data(),
+                               (std::min)(static_cast<std::size_t>(written), fields.size() - 1))
+            : std::string_view{};
     if (status != cinematic::ResolveStatus::ready) {
         log_line(core::log::Level::warn,
                  &instance,
                  "cinematic",
                  status == cinematic::ResolveStatus::ambiguous        ? "ambiguous"
                  : status == cinematic::ResolveStatus::invalidCatalog ? "invalid_catalog"
-                                                                      : "absent");
+                                                                      : "absent",
+                 detail);
         return;
     }
+    using Signal = middleware::bap::activity_message::cinematic_incident::Signal;
+    const Signal signal = incident.cinematicSignal;
+    log_line(core::log::Level::debug,
+             &instance,
+             "cinematic",
+             signal == Signal::started         ? "started"
+             : signal == Signal::skipRequested ? "skip_requested"
+                                               : "terminated",
+             detail);
     host::Event event = incident;
-    event.kind = incident.cinematicSignal
-                         == middleware::bap::activity_message::cinematic_incident::Signal::started
-                     ? host::EventKind::cinematicStarted
-                     : host::EventKind::cinematicTerminated;
+    event.kind = signal == Signal::started         ? host::EventKind::cinematicStarted
+                 : signal == Signal::skipRequested ? host::EventKind::cinematicSkipRequested
+                                                   : host::EventKind::cinematicTerminated;
     event.firstRegistryKey = source.registryKey;
     event.slotObjectTag = source.objectTag;
     event.firstSlotIndex = source.slotIndex;
@@ -379,6 +387,240 @@ void push_trigger_edges(RuntimeInstance& instance,
     }
 }
 
+/** @return True when the observation is one complete body of the given slot type and schema. */
+[[nodiscard]] bool observation_of(const host::SenseObservation& observation,
+                                  const host::SenseObservationSnapshot& sense,
+                                  std::uint32_t slotType,
+                                  std::uint32_t senseSchema) noexcept {
+    return observation.key.slotType == slotType && observation.key.senseSchema == senseSchema
+           && observation.valueCount != 0 && observation.firstValue <= sense.valueCount
+           && observation.valueCount <= sense.valueCount - observation.firstValue;
+}
+
+/** @return The decoded values of one observation. */
+[[nodiscard]] std::span<const sense_values::DecodedValue>
+observation_values(const host::SenseObservation& observation,
+                   const host::SenseObservationSnapshot& sense) noexcept {
+    return std::span(sense.values).subspan(observation.firstValue, observation.valueCount);
+}
+
+/**
+ * Finds the retained row for one slot, allocating a free row on a first observation.
+ * @return Null when every row is in use.
+ */
+template <typename Row, std::size_t N>
+[[nodiscard]] Row* find_slot_row(std::array<Row, N>& rows,
+                                 const host::SenseObservationKey& key) noexcept {
+    Row* spare = nullptr;
+    for (Row& retained : rows) {
+        if (retained.used && retained.registryKey == key.registryKey
+            && retained.objectTag == key.objectTag && retained.slotIndex == key.slotIndex) {
+            return &retained;
+        }
+        if (!retained.used && spare == nullptr) {
+            spare = &retained;
+        }
+    }
+    if (spare != nullptr) {
+        spare->used = true;
+        spare->registryKey = key.registryKey;
+        spare->objectTag = key.objectTag;
+        spare->slotIndex = key.slotIndex;
+    }
+    return spare;
+}
+
+void push_actor_path_edges(RuntimeInstance& instance,
+                           const host::SenseObservationSnapshot& sense) noexcept {
+    namespace auth = middleware::bap::activity_message::scriptable_auth;
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const host::SenseObservation& observation = sense.observations[index];
+        if (!observation_of(observation, sense, auth::kType2SlotType, auth::kType2SenseSchema)) {
+            continue;
+        }
+        ActorPathObservation* const slot = find_slot_row(instance.actorPathObservations, observation.key);
+        if (slot == nullptr
+            || !update_actor_path_level(
+                slot->level, observation_values(observation, sense), observation.key.schemaRow)) {
+            continue;
+        }
+        host::Event event = sense_edge_event(instance, observation);
+        event.kind = host::EventKind::actorPathState;
+        event.actorGeneration = slot->level.generation;
+        event.actorPathRevision = slot->level.revision;
+        event.actorPathState = slot->level.state;
+        event.actorDeliveryRevision = slot->level.deliveryRevision;
+        event.actorDeliveryState = slot->level.deliveryState;
+        event.actorDeliveryKnown = (slot->level.seen & kActorSeenDelivery) == kActorSeenDelivery;
+        event.actorDead = slot->level.dead;
+        std::array<char, 160> details{};
+        const int written = std::snprintf(details.data(),
+                                          details.size(),
+                                          "registry=%08X slot=%u generation=%d revision=%d "
+                                          "state=%d dead=%u delivery_revision=%d delivery_state=%d",
+                                          slot->registryKey,
+                                          static_cast<unsigned>(slot->slotIndex),
+                                          slot->level.generation,
+                                          slot->level.revision,
+                                          slot->level.state,
+                                          slot->level.dead ? 1U : 0U,
+                                          event.actorDeliveryKnown ? slot->level.deliveryRevision : -1,
+                                          event.actorDeliveryKnown ? slot->level.deliveryState : -1);
+        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "actor_path",
+                     "changed",
+                     {details.data(), static_cast<std::size_t>(written)});
+        }
+        push_script_event(instance, event);
+    }
+}
+
+void push_damage_edges(RuntimeInstance& instance,
+                       const host::SenseObservationSnapshot& sense) noexcept {
+    namespace damage = middleware::bap::activity_message::damage_monitor;
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const host::SenseObservation& observation = sense.observations[index];
+        if (!observation_of(observation, sense, damage::kSlotType, damage::kSenseSchema)) {
+            continue;
+        }
+        float health = -1.0F;
+        float shield = -1.0F;
+        std::int32_t revision = 0;
+        bool revisionKnown = false;
+        for (const sense_values::DecodedValue& value : observation_values(observation, sense)) {
+            if (!value.present || value.schemaRow != observation.key.schemaRow) {
+                continue;
+            }
+            if (value.fieldOrdinal == kDamageHealthOrdinal) {
+                health = value.realValue;
+            } else if (value.fieldOrdinal == kDamageShieldOrdinal) {
+                shield = value.realValue;
+            } else if (value.fieldOrdinal == kDamageRevisionOrdinal) {
+                revision = static_cast<std::int32_t>(value.signedValue);
+                revisionKnown = true;
+            }
+        }
+        if (!revisionKnown || revision <= 0 || !std::isfinite(health) || !std::isfinite(shield)) {
+            continue;
+        }
+        DamageObservation* const row = find_slot_row(instance.damageObservations, observation.key);
+        if (row == nullptr || revision < row->revision
+            || (row->revision == revision && row->health == health && row->shield == shield)) {
+            continue;
+        }
+        row->revision = revision;
+        row->health = health;
+        row->shield = shield;
+        host::Event event = sense_edge_event(instance, observation);
+        event.kind = host::EventKind::damageState;
+        event.damageHealth = health;
+        event.damageShield = shield;
+        event.damageRevision = revision;
+        std::array<char, 128> details{};
+        const int written = std::snprintf(details.data(),
+                                          details.size(),
+                                          "registry=%08X slot=%u revision=%d health=%.4f shield=%.4f",
+                                          observation.key.registryKey,
+                                          static_cast<unsigned>(observation.key.slotIndex),
+                                          revision,
+                                          static_cast<double>(health),
+                                          static_cast<double>(shield));
+        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "damage",
+                     "changed",
+                     {details.data(), static_cast<std::size_t>(written)});
+        }
+        push_script_event(instance, event);
+    }
+}
+
+void push_object_interaction_edges(RuntimeInstance& instance,
+                                   const host::SenseObservationSnapshot& sense) noexcept {
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const host::SenseObservation& observation = sense.observations[index];
+        if (!observation_of(
+                observation, sense, format::kObjectSlotType, format::kObjectSenseSchema)) {
+            continue;
+        }
+        ObjectInteractionObservation* const slot =
+            find_slot_row(instance.objectInteractionObservations, observation.key);
+        if (slot == nullptr) {
+            continue;
+        }
+        const ObjectInteractionLevel before = slot->level;
+        const bool interacted = update_object_interaction(
+            slot->level, observation_values(observation, sense), observation.key.schemaRow);
+        const ObjectInteractionLevel& level = slot->level;
+        host::Event event = sense_edge_event(instance, observation);
+        event.objectGeneration = level.generation;
+        event.objectPresent = level.present;
+        event.objectAlive = level.alive;
+        event.objectOwnerKnown = level.ownerKnown;
+        event.objectHasOwner = level.hasOwner;
+        event.objectOwnerKey = level.ownerKey;
+        const bool stateChanged =
+            level.generationKnown && level.generation > 0 && level.stateKnown
+            && (!before.stateKnown || before.generation != level.generation
+                || before.present != level.present || before.alive != level.alive
+                || before.ownerKnown != level.ownerKnown || before.hasOwner != level.hasOwner
+                || before.ownerKey != level.ownerKey);
+        if (stateChanged) {
+            event.kind = host::EventKind::objectState;
+            std::array<char, 128> details{};
+            const int written = std::snprintf(details.data(),
+                                              details.size(),
+                                              "registry=%08X slot=%u generation=%d present=%u "
+                                              "alive=%u owner_known=%u has_owner=%u",
+                                              observation.key.registryKey,
+                                              static_cast<unsigned>(observation.key.slotIndex),
+                                              level.generation,
+                                              level.present ? 1U : 0U,
+                                              level.alive ? 1U : 0U,
+                                              level.ownerKnown ? 1U : 0U,
+                                              level.hasOwner ? 1U : 0U);
+            if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+                log_line(core::log::Level::debug,
+                         &instance,
+                         "object",
+                         "changed",
+                         {details.data(), static_cast<std::size_t>(written)});
+            }
+            push_script_event(instance, event);
+        }
+        if (interacted) {
+            event.kind = host::EventKind::objectInteracted;
+            push_script_event(instance, event);
+        }
+    }
+}
+
+void push_ghost_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept {
+    namespace ghost = middleware::bap::activity_message::ghost_link;
+    for (std::size_t index = 0; index < sense.observationCount; ++index) {
+        const host::SenseObservation& observation = sense.observations[index];
+        if (!observation_of(observation, sense, ghost::kSlotType, ghost::kSenseSchema)) {
+            continue;
+        }
+        GhostObservation* const slot = find_slot_row(instance.ghostObservations, observation.key);
+        if (slot == nullptr
+            || !update_ghost_level(
+                slot->level, observation_values(observation, sense), observation.key.schemaRow)) {
+            continue;
+        }
+        host::Event event = sense_edge_event(instance, observation);
+        event.kind = host::EventKind::ghostLinkState;
+        event.ghostGeneration = slot->level.generation;
+        event.ghostProgress = slot->level.progress;
+        event.ghostActive = slot->level.active;
+        push_script_event(instance, event);
+    }
+}
+
 /**
  * Raises the squad events derived from one msg 6 body.
  * The client publishes levels, so a first sighting only records them. A slot count that rose is the
@@ -395,29 +637,38 @@ void push_squad_edges(RuntimeInstance& instance,
         const std::span<const sense_values::DecodedValue> body(
             &sense.values[observation.firstValue], observation.valueCount);
         const std::uint32_t root = observation.key.schemaRow;
-        // An unchanged squad sends an empty delta, which must not read as a squad of zero.
-        if (sense_value(body, root, kSquadAliveOrdinal) == nullptr) {
-            continue;
-        }
-        const std::int32_t alive = sense_number(body, root, kSquadAliveOrdinal);
-        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
-        std::array<std::int32_t, host::kSquadSlotCapacity> counts{};
-        const std::uint8_t countLength = squad_slot_counts(body, root, counts);
-
         SquadObservation* const squad = find_squad(instance, observation.key);
-        if (squad == nullptr) {
+        if (squad == nullptr) continue;
+        const bool costsChanged = update_squad_objective_costs(squad->objectiveCosts, body, root);
+        // Cost-only deltas retain combat counts; they must never manufacture a death.
+        std::int32_t alive = squad->aliveCount;
+        const bool hasAlive = read_squad_alive(body, root, alive);
+        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
+        auto counts = squad->slotCounts;
+        auto countLength = squad->slotCountLength;
+        std::array<std::int32_t, host::kSquadSlotCapacity> incomingCounts{};
+        const auto incomingLength = read_squad_consumed_counts(body, incomingCounts);
+        if (incomingLength != 0) {
+            counts = incomingCounts;
+            countLength = incomingLength;
+        }
+        if (!hasAlive && !costsChanged && incomingLength == 0) {
             continue;
         }
         const bool first = !squad->used;
         squad->used = true;
         const std::int32_t previousAlive = first ? 0 : squad->aliveCount;
-        const bool changed = first || squad->aliveCount != alive || squad->removalFlag != removal
+        const bool changed = costsChanged || first || squad->aliveCount != alive
+                             || squad->removalFlag != removal
                              || squad->slotCountLength != countLength
                              || squad->slotCounts != counts;
         if (!changed) {
             continue;
         }
         host::Event state = sense_edge_event(instance, observation);
+        state.squadObjectiveCosts = squad->objectiveCosts.values;
+        state.squadObjectiveCostMask = squad->objectiveCosts.known;
+        state.squadObjectiveRevision = squad->objectiveCosts.revision;
         state.squadAliveCount = alive;
         state.squadPreviousAliveCount = previousAlive;
         state.squadRemovalFlag = removal;

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

@@ -91,7 +91,14 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
     case host::EventKind::sessionLeft:
     case host::EventKind::playerTrigger:
     case host::EventKind::cinematicStarted:
+    case host::EventKind::cinematicSkipRequested:
     case host::EventKind::cinematicTerminated:
+    case host::EventKind::actorPathState:
+    case host::EventKind::damageState:
+    case host::EventKind::objectState:
+    case host::EventKind::fireteamState:
+    case host::EventKind::objectInteracted:
+    case host::EventKind::ghostLinkState:
         return false;
     default:
         return true;
@@ -124,11 +131,16 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
            || event.kind == host::EventKind::sessionLeft
            || event.kind == host::EventKind::playerTrigger
            || event.kind == host::EventKind::cinematicStarted
+           || event.kind == host::EventKind::actorPathState
+           || event.kind == host::EventKind::damageState
+           || event.kind == host::EventKind::objectState
+           || event.kind == host::EventKind::fireteamState
+           || event.kind == host::EventKind::objectInteracted
+           || event.kind == host::EventKind::ghostLinkState
+           || event.kind == host::EventKind::cinematicSkipRequested
            || event.kind == host::EventKind::cinematicTerminated
            || delivery_lifecycle_event(event.kind)
-           || (event.kind == host::EventKind::senseUpdate
-               && event.senseDecodeStatus
-                      == middleware::bap::activity_message::sense_update::DecodeStatus::complete);
+           || event.has_sense_observations();
 }
 
 /** Faults the instance unless the ordered mission input arrives with no gap, starting at one. */
@@ -192,9 +204,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
         return false;
     }
     clear_pending_event(*pending);
-    if (input.event.kind == host::EventKind::senseUpdate
-        && input.event.senseDecodeStatus
-               == middleware::bap::activity_message::sense_update::DecodeStatus::complete) {
+    if (input.event.has_sense_observations()) {
         pending->senseAvailable =
             host::mission_input_sense_snapshot(input.sequence, pending->sense);
     }
@@ -603,15 +613,29 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         }
     }
     const lua_vm::CallStatus status = lua_vm::dispatch(instance.vm, event, clientMessage, now);
-    if (event.kind == host::EventKind::clientStateChanged && event.clientStateHasRegion) {
-        instance.activeRegion = event.regionIndex;
+    if (event.kind == host::EventKind::clientStateChanged) {
+        // A pending-region report can name the next slice while the player still holds the old
+        // one, so the held region wins.
+        if (event.heldRegionIndex >= 0) {
+            instance.activeRegion = event.heldRegionIndex;
+        } else if (event.currentRegionIndex >= 0) {
+            instance.activeRegion = event.currentRegionIndex;
+        }
     }
     if (firstAttempt && event.kind == host::EventKind::incidentReceived) {
         push_player_trigger(instance, event);
         push_cinematic(instance, event);
     }
     if (event.kind == host::EventKind::senseUpdate && sense != nullptr) {
+        if (firstAttempt) {
+            observe_player_life(instance, *sense);
+            publish_fireteam_life(now);
+        }
         push_trigger_edges(instance, *sense);
+        push_ghost_edges(instance, *sense);
+        push_object_interaction_edges(instance, *sense);
+        push_damage_edges(instance, *sense);
+        push_actor_path_edges(instance, *sense);
         push_squad_edges(instance, *sense);
         push_scene_edges(instance, *sense);
         push_objective_edges(instance, *sense);

+ 79 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -15,7 +15,12 @@
 #include "../../../state/activity_sdk/generated_world/runtime.h"
 #include "../../../state/activity_sdk/runtime.h"
 #include "../host_runtime.h"
+#include "mission_script_actor_path_sense.h"
+#include "mission_script_ghost_sense.h"
+#include "mission_script_object_sense.h"
+#include "mission_script_player_sense.h"
 #include "mission_script_runtime.h"
+#include "mission_script_squad_sense.h"
 #include "mission_script_vm.h"
 
 // What the seven mission-runtime translation units share: the instance table and service slice,
@@ -75,7 +80,13 @@ constexpr std::size_t kSquadObservationCapacity = 160;
 /** Watched authored scenes retained per instance. */
 constexpr std::size_t kSceneObservationCapacity = 32;
 /** Watched objective sensors retained per instance. */
-constexpr std::size_t kObjectiveObservationCapacity = 8;
+constexpr std::size_t kObjectiveObservationCapacity = 32;
+static_assert(kSquadObjectiveGroupCount == host::kSquadObjectiveGroupCount);
+/** Watched Ghost links, damage monitors, interactable objects and named actors per instance. */
+constexpr std::size_t kGhostObservationCapacity = 8;
+constexpr std::size_t kDamageObservationCapacity = 8;
+constexpr std::size_t kObjectInteractionObservationCapacity = 64;
+constexpr std::size_t kActorPathObservationCapacity = 64;
 /** One objective sensor carries this many objective blocks. */
 constexpr std::size_t kObjectiveCapacity = 24;
 /** One objective block carries this many task counters. */
@@ -94,8 +105,47 @@ struct TriggerOccupancy final {
     bool used{};
 };
 
+/** Last movement and delivery level seen for one named actor, so only a change raises an event. */
+struct ActorPathObservation final {
+    ActorPathLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+
+/** Last health, shield and revision seen for one damage monitor. */
+struct DamageObservation final {
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    std::int32_t revision{};
+    float health{-1.0F};
+    float shield{-1.0F};
+    bool used{};
+};
+
+/** Last object and interaction level seen for one interactable object. */
+struct ObjectInteractionObservation final {
+    ObjectInteractionLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+
+/** Last Ghost-link level seen for one sensor. */
+struct GhostObservation final {
+    GhostLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+
 /** Last squad counters seen for one watched object, so only a change raises an event. */
 struct SquadObservation final {
+    SquadObjectiveCosts objectiveCosts{};
     std::array<std::int32_t, host::kSquadSlotCapacity> slotCounts{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
@@ -163,10 +213,21 @@ struct RuntimeInstance final {
     std::uint64_t nextTimerAttempt{};
     std::array<TriggerOccupancy, kTriggerOccupancyCapacity> triggerOccupancy{};
     std::array<SquadObservation, kSquadObservationCapacity> squadObservations{};
+    std::array<GhostObservation, kGhostObservationCapacity> ghostObservations{};
+    std::array<DamageObservation, kDamageObservationCapacity> damageObservations{};
+    std::array<ObjectInteractionObservation, kObjectInteractionObservationCapacity>
+        objectInteractionObservations{};
+    std::array<ActorPathObservation, kActorPathObservationCapacity> actorPathObservations{};
     std::array<SceneObservation, kSceneObservationCapacity> sceneObservations{};
     std::array<ObjectiveObservation, kObjectiveObservationCapacity> objectiveObservations{};
     // The table is exactly as large as the session table, so it can never overflow.
     std::array<SessionRosterWatch, state::activity::kSessionCapacity> sessionRoster{};
+    /** Participation levels of the sixteen player slots, for the client generation below. */
+    std::array<PlayerLifeObservation, kParticipationSlotCount> playerLife{};
+    std::uint64_t playerLifeGeneration{};
+    /** Last published party life counts. Published once, then only on a change. */
+    FireteamLife lastFireteamLife{};
+    bool fireteamLifePublished{};
     /** Dynamically sized host-state reports waiting for this script, in arrival order. */
     std::vector<host::Event> scriptEvents{};
     std::uint64_t firstScriptEventAttempt{};
@@ -209,6 +270,11 @@ void log_line(core::log::Level level,
               std::string_view error = {}) noexcept;
 /** Appends one host-state event for the script. */
 void push_script_event(RuntimeInstance& instance, const host::Event& event) noexcept;
+/** Raises a fireteam event on each private instance whose party life counts changed. */
+void publish_fireteam_life(std::uint64_t now) noexcept;
+/** Merges the type-13 participation records of one Sense snapshot into the instance. */
+void observe_player_life(RuntimeInstance& instance,
+                         const host::SenseObservationSnapshot& sense) noexcept;
 
 /** Raises one event per watched trigger volume whose occupancy changed. */
 void push_trigger_edges(RuntimeInstance& instance,
@@ -217,6 +283,18 @@ void push_trigger_edges(RuntimeInstance& instance,
 void push_player_trigger(RuntimeInstance& instance, const host::Event& incident) noexcept;
 /** Raises one exact Type-6 start/finish edge from a decoded schema-0x808087BF msg-19 payload. */
 void push_cinematic(RuntimeInstance& instance, const host::Event& incident) noexcept;
+/** Raises one event per named actor whose movement or delivery level changed. */
+void push_actor_path_edges(RuntimeInstance& instance,
+                           const host::SenseObservationSnapshot& sense) noexcept;
+/** Raises one event per damage monitor whose health, shield or revision changed. */
+void push_damage_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;
+/** Raises one event per Ghost link whose level changed. */
+void push_ghost_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept;
 /** Raises the squad state, spawn and death events derived from one msg 6 body. */
 void push_squad_edges(RuntimeInstance& instance,
                       const host::SenseObservationSnapshot& sense) noexcept;

+ 123 - 0
Sunrise/src/server/activity/mission/mission_script_squad_sense.h

@@ -0,0 +1,123 @@
+#pragma once
+
+#include <array>
+#include <cmath>
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+/** Type-1 squad Sense nested schemas: the consumed-request list, its counts, and the cost reals. */
+inline constexpr std::uint32_t kSquadConsumedListSchema = 0x80807ECFU;
+inline constexpr std::uint32_t kSquadConsumedCountSchema = 0x80809491U;
+inline constexpr std::uint32_t kSquadObjectiveCostSchema = 0x80807ECDU;
+/** Root ordinals: field 1 echoes the objective revision, field 3 is the alive count. */
+inline constexpr std::uint16_t kSquadObjectiveRevisionOrdinal = 1;
+inline constexpr std::uint16_t kSquadAliveOrdinal = 3;
+/** The consumed list has at most eight slots. */
+inline constexpr std::size_t kSquadConsumedSlotCapacity = 8;
+/** One cost per authored objective task group. */
+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 alive count is six bits on the wire. */
+inline constexpr std::int64_t kMaximumAliveCount = 63;
+inline constexpr std::int64_t kMaximumCounter = 0x7FFFFFFF;
+
+/**
+ * Reads the consumed-request list. Deaths and failed placements advance these counts, so a rise
+ * is not a spawn.
+ * @param output Receives one count per slot; slots past the list length read zero.
+ * @return The list length, or zero when the list is absent or incomplete.
+ */
+[[nodiscard]] inline std::uint8_t read_squad_consumed_counts(
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::span<std::int32_t> output) noexcept {
+    std::uint32_t length = 0;
+    std::uint32_t known = 0;
+    std::array<std::int32_t, kSquadConsumedSlotCapacity> counts{};
+    for (const auto& value : values) {
+        if (!value.present) {
+            continue;
+        }
+        if (value.schemaRow == kSquadConsumedListSchema && value.fieldOrdinal == 0) {
+            if (value.unsignedValue > counts.size()) {
+                return 0;
+            }
+            length = static_cast<std::uint32_t>(value.unsignedValue);
+        } else if (value.schemaRow == kSquadConsumedCountSchema && value.fieldOrdinal == 0
+                   && value.occurrence < counts.size()) {
+            if (value.signedValue < 0 || value.signedValue > kMaximumCounter) {
+                return 0;
+            }
+            counts[value.occurrence] = static_cast<std::int32_t>(value.signedValue);
+            known |= 1U << value.occurrence;
+        }
+    }
+    if (length == 0 || length > output.size() || known != (1U << length) - 1) {
+        return 0;
+    }
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        output[index] = index < length ? counts[index] : 0;
+    }
+    return static_cast<std::uint8_t>(length);
+}
+
+/** Objective costs the client published for one squad, and the revision they answer. */
+struct SquadObjectiveCosts final {
+    std::array<float, kSquadObjectiveGroupCount> values{};
+    std::uint32_t known{};
+    std::uint32_t revision{};
+};
+
+/** Merges the cost fields of one body. @return True when a cost or the revision changed. */
+[[nodiscard]] inline bool update_squad_objective_costs(
+    SquadObjectiveCosts& retained,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    bool changed = false;
+    for (const auto& value : values) {
+        if (!value.present) {
+            continue;
+        }
+        if (value.schemaRow == root && value.fieldOrdinal == kSquadObjectiveRevisionOrdinal
+            && value.signedValue >= 0 && value.signedValue <= kMaximumCounter) {
+            const auto revision = static_cast<std::uint32_t>(value.signedValue);
+            changed = changed || retained.revision != revision;
+            retained.revision = revision;
+        } else if (value.schemaRow == kSquadObjectiveCostSchema && value.fieldOrdinal == 0
+                   && value.occurrence < kSquadObjectiveGroupCount && std::isfinite(value.realValue)
+                   && value.realValue >= 0 && value.realValue <= kMaximumObjectiveCost) {
+            const std::uint32_t bit = 1U << value.occurrence;
+            changed = changed || (retained.known & bit) == 0
+                      || retained.values[value.occurrence] != value.realValue;
+            retained.known |= bit;
+            retained.values[value.occurrence] = value.realValue;
+        }
+    }
+    return changed;
+}
+
+/**
+ * Reads the root alive count. An optional field still yields a row when absent, and only a
+ * present count is evidence of a population change; zero is a real count.
+ */
+[[nodiscard]] inline bool read_squad_alive(
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root,
+    std::int32_t& alive) noexcept {
+    for (const auto& value : values) {
+        if (value.schemaRow == root && value.fieldOrdinal == kSquadAliveOrdinal && value.present) {
+            if (value.signedValue < 0 || value.signedValue > kMaximumAliveCount) {
+                return false;
+            }
+            alive = static_cast<std::int32_t>(value.signedValue);
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::server::activity::mission

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

@@ -138,8 +138,17 @@ inline constexpr std::array<const char*, host::kEventKindCount> kEventHandlerNam
     "on_event_player_trigger",
     "on_event_cinematic_started",
     "on_event_cinematic_terminated",
+    "on_event_ghost_link_state",
+    "on_event_actor_path_state",
+    "on_event_object_interacted",
+    "on_event_cinematic_skip_requested",
+    "on_event_fireteam_state",
+    "on_event_object_state",
+    "on_event_damage_state",
 }};
 
+static_assert([] { for (const auto* name : kEventHandlerNames) if (name == nullptr) return false; return true; }());
+
 /** Captures immutable callback references while table access remains inside lua_pcall. */
 [[nodiscard]] int capture_program(lua_State* state) {
     Impl* const impl = impl_from_state(state);

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

@@ -11,6 +11,7 @@
 
 #include "../../core/logging/log.h"
 #include "../../state/activity/runtime.h"
+#include "../../state/activity/membership/activity_membership_query.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/matchmaking/matchmaking_state.h"
 #include "../../state/runtime/runtime.h"
@@ -18,6 +19,7 @@
 #include "activity_authority_query_owner.h"
 #include "activity_authority_reset_owner.h"
 #include "activity_mission_seed_lease.h"
+#include "encrypted/push/activity/mission_seed_world_change.h"
 #include "core/threading/srw_lock.h"
 #include "encrypted/bap_connection_publication.h"
 #include "internal.h"
@@ -502,7 +504,16 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
     }
     if (status == ActivityMissionSeedLeaseStatus::ready) {
         MissionSeedLease& lease = session->activityMissionSeed;
+        // Ordinary traversal can reach a plan's region before the script selects it, so an open
+        // arrival window closes here when the client already holds that region.
+        const auto placement = state::activity::membership::reported_placement(binding.sessionId);
+        const auto heldRegion = state::activity::membership::instantiated_region(placement);
+        const bool targetHeld = encrypted::push::activity::mission_seed_arrival_window_closed(
+            heldRegion, plan.effectiveRegion);
         if (lease.configured && same_mission_seed_plan(lease.plan, plan)) {
+            if (targetHeld) {
+                lease.regionArrivalPending = false;
+            }
             // The script may select the plan the roster adopted by default. That is a selection.
             lease.scriptSelected = true;
             return ActivityMissionSeedLeaseStatus::ready;
@@ -528,12 +539,16 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
                 }
                 lease.registeredRegions[lease.registeredRegionCount++] = plan.effectiveRegion;
             }
-            // A region change replaces the instantiated world. Publications keep answering the
-            // previous plan until the client's post-arrival solicited answer advances the region
-            // epoch, because registering the new region's groups mid-teardown races the teardown.
-            if (lease.configured && lease.plan.effectiveRegion != plan.effectiveRegion) {
+            // A selection that replaces the world waits for the client's arrival there. One that
+            // does not must close any window an earlier selection left open, because an open
+            // window blocks publication and nothing else clears it.
+            if (lease.configured
+                && encrypted::push::activity::mission_seed_selection_needs_arrival(
+                    lease.plan.effectiveRegion, plan.effectiveRegion, heldRegion)) {
                 lease.previousPlan = lease.plan;
                 lease.regionArrivalPending = true;
+            } else {
+                lease.regionArrivalPending = false;
             }
             lease.plan = plan;
             lease.bindingGeneration = session->activity.bindingGeneration;

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

@@ -440,7 +440,8 @@ bool consume_activity_keepalive(Session& session,
     // arm stops counting as armed once the client reports the region, which ends this.
     const bool needsRepublish =
         (advertisedRegionReady
-         || state::activity::membership::host_teleport_armed(session.activity.session.sessionId))
+         || state::activity::membership::host_teleport_armed(session.activity.session.sessionId)
+         || state::activity::membership::hard_wipe_needs_publish(session.activity.session.sessionId))
         && state::activity::membership::acknowledged(session.activity.session.sessionId);
     bool preparedRepublish = needsRepublish
                              && state::activity::membership::prepare_republish(

+ 14 - 9
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -1,5 +1,6 @@
 #include "activity_mission_seed_roster.h"
 
+#include <algorithm>
 #include <array>
 #include <cstdio>
 #include <limits>
@@ -26,8 +27,9 @@ namespace layouts = state::build_data::scenarios;
                                       static_cast<int>(reason.size()),
                                       reason.data());
     if (written > 0) {
+        // A refused seed leaves the selected state unpublished, so the refusal is not debug volume.
         core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
+                         core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
     return MissionSeedRosterResult::refused;
@@ -368,9 +370,9 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
     const state::activity::membership::ClientPlacement placement =
         client_placement(session, refresh);
     const std::int32_t heldRegion = state::activity::membership::instantiated_region(placement);
-    const bool pendingRegionHeld =
-        heldRegion >= 0 && static_cast<std::uint32_t>(heldRegion) == lease.plan.effectiveRegion;
-    if (!adopting && lease.regionArrivalPending && pendingRegionHeld) {
+    // The window closes on the exact packed region, so a sibling state of one bubble counts.
+    if (!adopting && lease.regionArrivalPending
+        && mission_seed_arrival_window_closed(heldRegion, lease.plan.effectiveRegion)) {
         lease.regionArrivalPending = false;
     }
     const bool arrivalWindow = !adopting && lease.regionArrivalPending;
@@ -488,10 +490,8 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
             publicRegion = isPublic;
         }
     }
-    const bool transitionPublication =
-        !lease.fullSetPublished
-        && ((!lease.scriptSelected && !publicRegion) || heldRegion < 0
-            || static_cast<std::uint32_t>(heldRegion) != selectedRegion);
+    const bool transitionPublication = mission_seed_transition_subset_only(
+        lease.fullSetPublished, lease.scriptSelected, publicRegion, heldRegion, selectedRegion);
     for (std::size_t source = 0; source < foldGroupCount; ++source) {
         const layouts::RosterGroup& candidate = materialized[source];
         if (!layouts::valid_roster_group(candidate)) {
@@ -586,7 +586,12 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
                 }
             }
             if (managed && !active) {
-                continue;
+                // Removal is a cleared presence bit at the old key ordinal, not omission.
+                for (std::size_t group = 0; group < snapshot.roster.groupCount; ++group) {
+                    if (snapshot.roster.groups[group].key == key) {
+                        snapshot.roster.groups[group].retired = true;
+                    }
+                }
             }
             if (retainedCount >= scratch.rosterSubBlockKeys[blockIndex].size()) {
                 return refuse_seed("managed_key_capacity");

+ 1 - 0
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.h

@@ -4,6 +4,7 @@
 #include <cstdint>
 
 #include "internal.h"
+#include "mission_seed_world_change.h"
 
 namespace sunrise::server::bap::encrypted::push::activity {
 

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

@@ -7,6 +7,7 @@
 
 #include "../../../../../middleware/content/packages/tables/region_reader.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
+#include "../../../../../middleware/bap/activity_message/darkness_zone_auth.h"
 #include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/activity/runtime.h"
@@ -462,7 +463,6 @@ build_roster_snapshot(Session& session,
     if (!pendingAddsGroup && pendingStateLocal && pendingGroupIndex < lease.groupCount) {
         pendingGroupPosition = retainedGroupPositions[pendingGroupIndex];
     }
-    bool pendingInstalled = false;
     for (std::size_t index = 0; retainedSquad && index < lease.authCount; ++index) {
         message::AuthOverride retainedAuth{};
         if (!retained_squad_auth(lease, index, retainedAuth)) {
@@ -488,7 +488,6 @@ build_roster_snapshot(Session& session,
                                    effectiveStateLocal)) {
             return refuse_override("retained_auth_install");
         }
-        pendingInstalled = pendingInstalled || replace;
     }
     // 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.
@@ -575,7 +574,8 @@ build_roster_snapshot(Session& session,
             }
         }
     }
-    if (authOverride != nullptr && !pendingInstalled
+    // The pending body applies last, so it wins over an older estate body for the same target.
+    if (authOverride != nullptr
         && !install_auth_override(layout,
                                   region,
                                   scratch,
@@ -599,6 +599,18 @@ build_roster_snapshot(Session& session,
             return refuse_override("tail_auth_apply");
         }
     }
+    // Read from the merged estate, so a pending disable wins over a retained enable.
+    namespace darkness = middleware::bap::activity_message::darkness_zone;
+    for (const auto& value : snapshot.authOverrides) {
+        bool enabled = false;
+        if (value.sdkCompiled && value.present && value.slotType == darkness::kSlotType
+            && value.authSchema == darkness::kSchema && value.byteCount <= value.body.size()
+            && darkness::read_enabled(
+                std::span(value.body).first(value.byteCount), value.bitCount, enabled)) {
+            snapshot.hasDarknessPolicy = true;
+            snapshot.darknessEnabled = enabled;
+        }
+    }
     // Staging runs before the connection field is published, so a body answering message 52 has to
     // take the epoch from that message instead of from the connection.
     snapshot.patchEpoch = epoch != nullptr ? *epoch : session.activityPatchEpoch.value;
@@ -624,6 +636,12 @@ build_roster_snapshot(Session& session,
         region.index >= 0 ? static_cast<std::uint32_t>(region.index) : region.arrival;
     snapshot.spawnSetHash =
         state::activity::destination::attachable_spawn_set_hash(selection, fallback.spawnSetHash);
+    // An armed wipe respawns at its checkpoint spawn set, not at the arrival override.
+    const std::uint32_t checkpoint = state::activity::membership::checkpoint_spawn_hash(
+        session.activity.source.sessionId, region.index);
+    if (checkpoint != 0) {
+        snapshot.spawnSetHash = checkpoint;
+    }
     snapshot.hasSpawnOverride =
         snapshot.spawnSetHash != 0 && snapshot.spawnSetHash != message::kAbsentSpawnSetHash;
     advance_region_epoch(session, refresh);

+ 36 - 0
Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstdint>
+
+// The client registers each alternate scenario entry as its own packed region, bubble times eight
+// plus the state ordinal. Two states of one bubble are two worlds, so travel and arrival compare
+// the complete packed region.
+
+namespace sunrise::server::bap::encrypted::push::activity {
+
+/** @return True when the client holds the exact region a pending selection names. */
+[[nodiscard]] constexpr bool
+mission_seed_arrival_window_closed(std::int32_t heldRegion,
+                                   std::uint32_t pendingEffectiveRegion) noexcept {
+    return heldRegion >= 0 && static_cast<std::uint32_t>(heldRegion) == pendingEffectiveRegion;
+}
+
+/** @return True when a selection replaces the world and the client has not reached it yet. */
+[[nodiscard]] constexpr bool mission_seed_selection_needs_arrival(
+    std::uint32_t oldRegion, std::uint32_t newRegion, std::int32_t heldRegion) noexcept {
+    return oldRegion != newRegion && !mission_seed_arrival_window_closed(heldRegion, newRegion);
+}
+
+/** @return True while only the transition subset may publish: before the exact arrival. */
+[[nodiscard]] constexpr bool
+mission_seed_transition_subset_only(bool fullSetPublished,
+                                    bool scriptSelected,
+                                    bool publicRegion,
+                                    std::int32_t heldRegion,
+                                    std::uint32_t selectedRegion) noexcept {
+    return !fullSetPublished
+           && ((!scriptSelected && !publicRegion)
+               || !mission_seed_arrival_window_closed(heldRegion, selectedRegion));
+}
+
+} // namespace sunrise::server::bap::encrypted::push::activity

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

@@ -159,7 +159,8 @@ struct RosterPublication {
 
 /** Compact retained squad body; shared target fields and the generated group live on its group. */
 struct RetainedSquadAuth {
-    std::array<std::byte, middleware::bap::activity_message::squad_auth::kMaximumByteCount> body{};
+    std::array<std::byte, middleware::bap::activity_message::squad_auth::kMaximumRetainedByteCount>
+        body{};
     std::uint32_t generation{};
     std::uint16_t rosterSlotOffset{};
     std::uint16_t slotIndex{};

+ 33 - 0
Sunrise/src/server/transport/bap_frame_batch.h

@@ -0,0 +1,33 @@
+#pragma once
+
+#include <cstddef>
+
+namespace sunrise::server::transport {
+
+/** Coalesced frames one connection may drain per service slice before others get the thread. */
+inline constexpr std::size_t kFrameBatchLimit = 16;
+
+/**
+ * Drains coalesced TCP frames one at a time, flushing each response before the next frame so
+ * an unsent response is never overwritten. Stops when the stream does not shrink, which means
+ * the next frame is incomplete.
+ * @return False when the drain or flush failed and the peer must close.
+ */
+template <class Peer, class Drain, class Flush>
+bool drain_frame_batch(Peer& peer, Drain drain, Flush flush) {
+    for (std::size_t count = 0; count < kFrameBatchLimit && peer.outputSize == 0; ++count) {
+        const auto before = peer.streamSize;
+        if (!drain(peer)) {
+            return false;
+        }
+        if (before == peer.streamSize) {
+            break;
+        }
+        if (peer.outputSize != 0 && !flush(peer)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::server::transport

+ 3 - 2
Sunrise/src/server/transport/bap_listener.cpp

@@ -1,4 +1,5 @@
 #include "bap_listener.h"
+#include "bap_frame_batch.h"
 
 #include <WS2tcpip.h>
 #include <WinSock2.h>
@@ -111,7 +112,7 @@ void receive_peer(Peer& peer) noexcept {
 }
 
 /**
- * Services one peer with one read, frame, write and due-poll budget.
+ * Services one peer with one read, a bounded frame batch and one due poll.
  * @param peer Live peer.
  * @param readable Ready-read set from select.
  * @param writable Ready-write set from select.
@@ -134,7 +135,7 @@ void service_peer(
     if (peer.socket == INVALID_SOCKET) {
         return;
     }
-    if (peer.outputSize == 0 && !drain_stream(peer)) {
+    if (peer.outputSize == 0 && !drain_frame_batch(peer, drain_stream, flush_peer)) {
         close_peer(peer);
         return;
     }

+ 4 - 2
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp

@@ -137,11 +137,13 @@ std::uint16_t spawn_set_slice_set(const DestinationSelection& selection,
     return arrivalSliceSet;
 }
 
-/** Drops a spawn set the destination cannot load. Only a proved miss is dropped. */
+/** Filters inferred spawn sets while preserving explicit authored/operator overrides. */
 std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
                                         std::uint32_t fallback) noexcept {
     const std::uint32_t hash = resolve_spawn_set_hash(selection, fallback);
-    if (hash == 0 || hash == kAbsentSpawnSetHash) {
+    // The direct scenario package list is not a transitive dependency inventory. An explicit
+    // override may name a set in a referenced activity package, so it must win over this filter.
+    if (selection.hasSpawnSetOverride || hash == 0 || hash == kAbsentSpawnSetHash) {
         return hash;
     }
     const std::string_view name = name_of(selection);

+ 4 - 4
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h

@@ -7,12 +7,12 @@
 namespace sunrise::state::activity::destination {
 
 /**
- * Picks the spawn-set hash to send, dropping one the destination does not load.
- * A set declared only by a package the destination never loads cannot attach, and the player
- * arrives with no spawn point. The absent hash goes out instead, so the Client picks its own.
+ * Picks the spawn-set hash to send, preserving explicit authored/operator overrides.
+ * Inferred hashes are filtered against the known map/direct activity packages. The list is not
+ * a transitive dependency inventory, so a missing package must not veto an explicit override.
  * @param selection Committed destination.
  * @param fallback Authored fallback hash.
- * @return The resolved hash, or the absent hash when the set cannot attach.
+ * @return The explicit override, or the inferred hash after package filtering.
  */
 [[nodiscard]] std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
                                                       std::uint32_t fallback) noexcept;

+ 93 - 0
Sunrise/src/state/activity/membership/activity_membership_query.cpp

@@ -2,6 +2,8 @@
 
 #include <Windows.h>
 
+#include <limits>
+
 #include "../../runtime/storage/internal.h"
 #include "../transactions/internal.h"
 
@@ -72,6 +74,97 @@ bool arm_host_teleport(std::uint64_t sessionId,
     return changed;
 }
 
+/** All-one bits is not a spawn set hash. */
+constexpr std::uint32_t kInvalidSpawnSetHash = (std::numeric_limits<std::uint32_t>::max)();
+
+/** Arms one hard wipe; the same request key is accepted again while it is armed. */
+bool arm_hard_wipe(const SessionBinding& binding,
+                   std::uint64_t requestKey,
+                   std::int32_t region,
+                   std::uint32_t spawnSetHash) noexcept {
+    if (requestKey == 0 || region < 0 || spawnSetHash == 0 || spawnSetHash == kInvalidSpawnSetHash) {
+        return false;
+    }
+    bool accepted = false;
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    ActivityState& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, binding.sessionId);
+    if (target != kInvalidSessionSlot
+        && state.sessions[target].createdRevision == binding.createdRevision) {
+        MembershipState& membership = state.sessions[target].membership;
+        HardWipeState& wipe = membership.hardWipe;
+        if (wipe.requestKey == requestKey) {
+            accepted = true;
+        } else if (!wipe.active && !membership.hasHostTeleport) {
+            wipe.requestKey = requestKey;
+            wipe.region = region;
+            wipe.spawnSetHash = spawnSetHash;
+            wipe.host = {};
+            wipe.host.state = kHardWipeStartState;
+            // The token must differ from the client's current block, or the client ignores it.
+            wipe.host.opaqueByte = static_cast<std::uint8_t>(membership.spawn.opaqueByte + 1U);
+            wipe.active = true;
+            wipe.resetReady = false;
+            wipe.clientWaiting = false;
+            accepted = true;
+        }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return accepted;
+}
+
+/** @return True while the client has not yet mirrored the armed wipe's spawn block. */
+bool hard_wipe_needs_publish(std::uint64_t sessionId) noexcept {
+    bool pending = false;
+    AcquireSRWLockShared(&runtime::storage::g_stateLock);
+    const ActivityState& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, sessionId);
+    if (target != kInvalidSessionSlot) {
+        const MembershipState& member = state.sessions[target].membership;
+        const HardWipeState& wipe = member.hardWipe;
+        pending = wipe.active
+                  && (wipe.host.state == kHardWipeReleaseState
+                      || member.spawn.opaqueByte != wipe.host.opaqueByte
+                      || member.spawn.state < kHardWipeStartState);
+    }
+    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    return pending;
+}
+
+/** Releases the armed wipe named by its request key. */
+bool release_hard_wipe(const SessionBinding& binding, std::uint64_t requestKey) noexcept {
+    bool accepted = false;
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    ActivityState& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, binding.sessionId);
+    if (target != kInvalidSessionSlot
+        && state.sessions[target].createdRevision == binding.createdRevision) {
+        HardWipeState& wipe = state.sessions[target].membership.hardWipe;
+        if (wipe.active && wipe.requestKey == requestKey) {
+            wipe.release();
+            accepted = true;
+        }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return accepted;
+}
+
+/** @return The checkpoint spawn set a wipe named for this region, or zero. */
+std::uint32_t checkpoint_spawn_hash(std::uint64_t sessionId, std::int32_t region) noexcept {
+    std::uint32_t result = 0;
+    AcquireSRWLockShared(&runtime::storage::g_stateLock);
+    const ActivityState& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, sessionId);
+    if (target != kInvalidSessionSlot) {
+        const HardWipeState& wipe = state.sessions[target].membership.hardWipe;
+        if (wipe.requestKey != 0 && wipe.region == region) {
+            result = wipe.spawnSetHash;
+        }
+    }
+    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    return result;
+}
+
 /** Reports whether a host-named teleport is still waiting for the client to move. */
 bool host_teleport_armed(std::uint64_t sessionId) noexcept {
     if (sessionId == kAbsentSessionId) {

+ 22 - 0
Sunrise/src/state/activity/membership/activity_membership_query.h

@@ -99,6 +99,28 @@ struct PendingMutation final {
                                      std::int32_t sliceSetIndex,
                                      std::uint32_t sliceSetHash) noexcept;
 
+/**
+ * Arms one native hard wipe on an exact session generation. Arming the same request key again
+ * is accepted; another key is refused while a wipe or a host teleport is active.
+ * @param requestKey Script request that owns the wipe. @param region Region the party is in.
+ * @param spawnSetHash Authored spawn set the party respawns at.
+ */
+[[nodiscard]] bool arm_hard_wipe(const SessionBinding& binding,
+                                 std::uint64_t requestKey,
+                                 std::int32_t region,
+                                 std::uint32_t spawnSetHash) noexcept;
+
+/** @return True while an armed wipe's spawn block still has to reach the client. */
+[[nodiscard]] bool hard_wipe_needs_publish(std::uint64_t sessionId) noexcept;
+
+/** Releases the wipe the request key armed, so the client's wait is answered. */
+[[nodiscard]] bool release_hard_wipe(const SessionBinding& binding,
+                                     std::uint64_t requestKey) noexcept;
+
+/** @return The spawn set the armed wipe named for this region, or zero. */
+[[nodiscard]] std::uint32_t checkpoint_spawn_hash(std::uint64_t sessionId,
+                                                  std::int32_t region) noexcept;
+
 /**
  * @param sessionId Joined activity session.
  * @return True while a host-named teleport is armed and unspent.

+ 45 - 0
Sunrise/src/state/activity/membership/definition.h

@@ -69,6 +69,50 @@ struct SpawnState final {
     std::uint64_t opaqueValue{};
 };
 
+/** Host spawn state that starts the client's hard-wipe machine. */
+inline constexpr std::int8_t kHardWipeStartState = 1;
+/** Spawn state both sides reach when the wipe may release: the client waits, the host answers. */
+inline constexpr std::int8_t kHardWipeReleaseState = 4;
+/** Client spawn state that reports the wipe finished. */
+inline constexpr std::int8_t kHardWipeDoneState = 0;
+
+/**
+ * One armed hard wipe. The host publishes its spawn block in place of the client's while the
+ * wipe is active; the client's own block reports where its machine is.
+ */
+struct HardWipeState final {
+    SpawnState host{};
+    std::uint64_t requestKey{};
+    std::uint32_t spawnSetHash{};
+    std::int32_t region{-1};
+    bool active{};
+    /** The script released the wipe; the host answers the client's wait as soon as it sees it. */
+    bool resetReady{};
+    bool clientWaiting{};
+
+    void release() noexcept {
+        resetReady = true;
+        if (active && clientWaiting) {
+            host.state = kHardWipeReleaseState;
+        }
+    }
+
+    /** Advances on the client's spawn block. Only a block with the wipe's own token counts. */
+    void observe(const SpawnState& client) noexcept {
+        if (!active || client.opaqueByte != host.opaqueByte) {
+            return;
+        }
+        if (client.state == kHardWipeReleaseState) {
+            clientWaiting = true;
+            if (resetReady) {
+                host.state = kHardWipeReleaseState;
+            }
+        } else if (host.state == kHardWipeReleaseState && client.state == kHardWipeDoneState) {
+            active = false;
+        }
+    }
+};
+
 /** Teleport state kept for the current activity host, in no wire form. */
 struct TeleportState final {
     std::int8_t state{};
@@ -139,6 +183,7 @@ struct MembershipState final {
      */
     TeleportState hostTeleport{};
     bool hasHostTeleport{};
+    HardWipeState hardWipe{};
     /** Region of the slice set the client holds; -1 while it holds none. */
     RegionState currentRegion{};
     /** Pending region leg as last reported; -1 once a transition has completed. */

+ 5 - 1
Sunrise/src/state/activity/membership/transactions/internal.h

@@ -90,6 +90,7 @@ inline MembershipState merge(const MembershipState& state,
     }
     if (update.hasSpawn) {
         merged.spawn = update.spawn;
+        merged.hardWipe.observe(update.spawn);
     }
     if (update.hasTeleport) {
         merged.teleport = update.teleport;
@@ -158,6 +159,8 @@ inline bool equal_authoritative(const MembershipState& first,
            && first.hasTransitionToken == second.hasTransitionToken
            && equal(first.spawn, second.spawn) && equal(first.teleport, second.teleport)
            && first.hasHostTeleport == second.hasHostTeleport
+           && first.hardWipe.active == second.hardWipe.active
+           && equal(first.hardWipe.host, second.hardWipe.host)
            && equal(first.hostTeleport, second.hostTeleport)
            && equal(first.currentRegion, second.currentRegion) && equal(first.region, second.region)
            && first.currentReported == second.currentReported
@@ -188,7 +191,8 @@ inline Snapshot make_snapshot(const MembershipState& state,
                               std::uint32_t revision) noexcept {
     Snapshot snapshot{};
     snapshot.identity = identity;
-    snapshot.spawn = state.spawn;
+    // An armed wipe publishes the host's spawn block; the client's own block returns after it.
+    snapshot.spawn = state.hardWipe.active ? state.hardWipe.host : state.spawn;
     // An armed host teleport replaces the mirror, because these are the fields the client's
     // teleport arm reads. Everything else about the block stays the client's own report.
     snapshot.teleport = state.hasHostTeleport ? state.hostTeleport : state.teleport;

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

@@ -7,8 +7,8 @@
 
 namespace sunrise::state::activity::mission {
 
-/** One activity generation retains at most sixty-four script variables. */
-inline constexpr std::size_t kVariableCapacity = 64;
+/** One activity generation retains at most 512 script variables. */
+inline constexpr std::size_t kVariableCapacity = 512;
 /** One activity generation retains at most thirty-two authoritative timers. */
 inline constexpr std::size_t kTimerCapacity = 32;
 /** Durable variable and timer names contain at most sixty-three bytes. */
@@ -75,6 +75,7 @@ enum class IntentKind : std::uint8_t {
     bindCombatantToSquad,
     actorCommand,
     playPerformance,
+    restartCheckpoint,
 };
 
 /** One object a mission omits, named the way a roster group is: its tag and its registry key. */
@@ -105,6 +106,10 @@ struct TypedIntent final {
     std::uint32_t authSchema{};
     /** SDK-selected actor-command selector, never a wire constant owned by Mission State. */
     std::uint32_t actorCommandSelector{};
+    /** Spawn set a checkpoint restart respawns at. */
+    std::uint32_t checkpointSpawnHash{};
+    /** Request key of the wipe a checkpoint release ends; zero arms one instead. */
+    std::uint64_t checkpointReleaseRequest{};
     /** Authored effective region selected by the generated mission-state table. */
     std::int32_t effectiveRegion{-1};
     std::int32_t entryIndex{};