Jelajahi Sumber

Merge pull request #128 from Confetti3/strange-terrain-pr

SDK and runtime support for the Strange Terrain (Nokris) strike
stan 4 hari lalu
induk
melakukan
b8e4cda716
59 mengubah file dengan 2358 tambahan dan 856 penghapusan
  1. 5 0
      Sunrise/Sunrise.vcxproj
  2. 48 0
      Sunrise/src/client/content/activity/activity_sdk_spawn_reference.h
  3. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_config_parse.cpp
  4. 5 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.cpp
  5. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.h
  6. 3 0
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory.h
  7. 193 0
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp
  8. 1 1
      Sunrise/src/core/logging/log.cpp
  9. 2 0
      Sunrise/src/core/logging/log.h
  10. 2 0
      Sunrise/src/core/settings/settings_parser.cpp
  11. 2 1
      Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.cpp
  12. 43 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp
  13. 1 1
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  14. 686 0
      Sunrise/src/middleware/bap/activity_message/activity_sense_body_decoder.cpp
  15. 5 672
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp
  16. 1 1
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  17. 97 0
      Sunrise/src/middleware/bap/activity_message/sense_update_body.h
  18. 35 0
      Sunrise/src/middleware/bap/activity_message/volume_toggle_auth.h
  19. 7 0
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec.h
  20. 13 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h
  21. 13 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h
  22. 5 0
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_internal.h
  23. 38 0
      Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp
  24. 85 30
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp
  25. 2 0
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.h
  26. 8 0
      Sunrise/src/middleware/gameplay/external/composite_entity_codec_internal.h
  27. 29 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp
  28. 6 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.h
  29. 16 1
      Sunrise/src/server/activity/activity_sdk_device_prepare.cpp
  30. 8 4
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  31. 54 5
      Sunrise/src/server/activity/host_runtime.h
  32. 8 5
      Sunrise/src/server/activity/host_runtime_incidents.cpp
  33. 19 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  34. 8 5
      Sunrise/src/server/activity/host_runtime_sense.cpp
  35. 74 7
      Sunrise/src/server/activity/mission/mission_api_events.h
  36. 33 1
      Sunrise/src/server/activity/mission/mission_api_slot.h
  37. 1 2
      Sunrise/src/server/activity/mission/mission_script_actor_command_policy.cpp
  38. 76 16
      Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp
  39. 2 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  40. 63 1
      Sunrise/src/server/activity/mission/mission_script_lua_event_fields.cpp
  41. 52 0
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  42. 46 0
      Sunrise/src/server/activity/mission/mission_script_lua_state_api.cpp
  43. 11 0
      Sunrise/src/server/activity/mission/mission_script_object_sense.h
  44. 44 0
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  45. 12 0
      Sunrise/src/server/activity/mission/mission_script_runtime.h
  46. 86 34
      Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp
  47. 84 26
      Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp
  48. 2 0
      Sunrise/src/server/activity/mission/mission_script_runtime_feed.cpp
  49. 20 2
      Sunrise/src/server/activity/mission/mission_script_runtime_internal.h
  50. 2 2
      Sunrise/src/server/activity/mission/mission_script_sdk_bridge.cpp
  51. 165 0
      Sunrise/src/server/activity/mission/mission_trigger_observation.h
  52. 10 4
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_framing.cpp
  53. 16 5
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  54. 3 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_report.cpp
  55. 1 1
      Sunrise/src/server/gameplay/actor_command_policy.h
  56. 62 5
      Sunrise/src/server/gameplay/actor_command_policy_lane.cpp
  57. 17 10
      Sunrise/src/server/gameplay/actor_command_policy_session.h
  58. 7 1
      Sunrise/src/server/gameplay/actor_command_policy_transport.cpp
  59. 17 2
      Sunrise/src/state/activity_sdk/format.h

+ 5 - 0
Sunrise/Sunrise.vcxproj

@@ -1281,6 +1281,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_telemetry_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_start_activity_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_decoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sense_body_decoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_roster_delta.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_incident_grants.cpp" />
@@ -2197,6 +2198,7 @@
     <ClInclude Include="src\middleware\bap\activity_message\start_activity.h" />
     <ClInclude Include="src\middleware\bap\activity_message\sense_roster_delta.h" />
     <ClInclude Include="src\middleware\bap\activity_message\sense_update.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\sense_update_body.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\receipts\activity_incident_grants.h" />
     <ClInclude Include="src\state\activity\receipts\definition.h" />
@@ -2217,6 +2219,9 @@
     <ClInclude Include="src\middleware\bap\activity_message\interactable_object_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_auth_patch.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_effect_auth.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_spawn_reference.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\volume_toggle_auth.h" />
+    <ClInclude Include="src\server\activity\mission\mission_trigger_observation.h" />
     <ClInclude Include="src\middleware\bap\activity_message\music_section_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\roster_presence.h" />
     <ClInclude Include="src\middleware\bap\activity_message\scene_events_auth.h" />

+ 48 - 0
Sunrise/src/client/content/activity/activity_sdk_spawn_reference.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::content::activity::sdk_generation::squad_inventory {
+
+/** How one raw 64-bit spawn reference (key32, type8, padding8, index16) reads. */
+enum class SpawnReferenceState : std::uint8_t {
+    invalid,
+    absent,
+    present,
+};
+
+/**
+ * The client treats a reference as unset when its type is 0xFF or its index is 0xFFFF. Nonzero
+ * padding or an out-of-range type is malformed, which is not the same as absent.
+ */
+[[nodiscard]] constexpr SpawnReferenceState spawn_reference_state(std::uint64_t raw) noexcept {
+    const auto key = static_cast<std::uint32_t>(raw);
+    const auto type = static_cast<std::uint8_t>(raw >> 32U);
+    const auto padding = static_cast<std::uint8_t>(raw >> 40U);
+    const auto index = static_cast<std::uint16_t>(raw >> 48U);
+    if (padding != 0) {
+        return SpawnReferenceState::invalid;
+    }
+    if (type == 0xFFU || index == 0xFFFFU) {
+        return SpawnReferenceState::absent;
+    }
+    return key != 0 && key != 0xFFFFFFFFU && type >= 1 && type <= 72 ? SpawnReferenceState::present
+                                                                     : SpawnReferenceState::invalid;
+}
+
+/**
+ * A spawner needs a selected rule when both of its rule references (at offsets 0x98 and 0xA0) are
+ * absent and a complete parse found no inline point set. This proves only the source; the selected
+ * rule and its occurrence are validated separately.
+ */
+[[nodiscard]] constexpr bool requires_selected_spawn_rule(std::uint64_t reference98,
+                                                          std::uint64_t referenceA0,
+                                                          bool complete,
+                                                          bool inlinePointSetInspected,
+                                                          bool hasInlinePointSet) noexcept {
+    return complete && inlinePointSetInspected && !hasInlinePointSet
+           && spawn_reference_state(reference98) == SpawnReferenceState::absent
+           && spawn_reference_state(referenceA0) == SpawnReferenceState::absent;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::squad_inventory

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

@@ -59,6 +59,7 @@ namespace tables = middleware::content::packages::tables;
         || left.secondaryComponentClass != right.secondaryComponentClass
         || left.members.size() != right.members.size()
         || left.hasInlinePointSet != right.hasInlinePointSet
+        || left.inlinePointSetInspected != right.inlinePointSetInspected
         || left.inlinePointSetOffset != right.inlinePointSetOffset
         || left.inlinePlacementComponentOffset != right.inlinePlacementComponentOffset
         || left.inlineInitialPointIndex != right.inlineInitialPointIndex
@@ -132,6 +133,7 @@ parse_spawner(std::uint32_t tag, std::span<const std::byte> blob, SpawnerFact& o
         if (!tables::authored_squad_inline_point_set(blob, tag, source, inlinePresent, inlineSet)) {
             return false;
         }
+        output.inlinePointSetInspected = true;
         if (inlinePresent) {
             if (inlineSet.points.count >= format::kAbsentIndex) {
                 return false;

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

@@ -49,6 +49,11 @@ template <typename Row>
         row.members.count = static_cast<std::uint32_t>(source.members.size());
         row.complete = source.complete;
         row.hasInlinePointSet = source.hasInlinePointSet;
+        row.requiresSelectedRule = requires_selected_spawn_rule(source.rawReference98,
+                                                                source.rawReferenceA0,
+                                                                source.complete,
+                                                                source.inlinePointSetInspected,
+                                                                source.hasInlinePointSet);
         if (!insert_config(byConfig, row, spawnerRow)) {
             return false;
         }

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

@@ -52,6 +52,8 @@ struct GraphSpawner final {
     bool complete{};
     /** The rule row standing for the spawner's own point set, when it carries one. */
     bool hasInlinePointSet{};
+    /** Both native references are absent and canonical parsing proved no inline fallback. */
+    bool requiresSelectedRule{};
     std::uint32_t inlineRuleRow{format::kAbsentIndex};
 };
 

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

@@ -10,6 +10,7 @@
 
 #include "../../../state/activity_sdk/format.h"
 #include "activity_sdk_external_placements.h"
+#include "activity_sdk_spawn_reference.h"
 #include "activity_sdk_topology_inventory.h"
 
 namespace sunrise::client::content::activity::sdk_generation::squad_inventory {
@@ -90,6 +91,8 @@ struct SpawnerFact final {
     std::uint32_t secondaryComponentClass{};
     /** Set when the spawner carries its own point set instead of a type-66 reference. */
     bool hasInlinePointSet{};
+    /** Successful canonical parsing distinguishes absent from unread inline data. */
+    bool inlinePointSetInspected{};
     std::uint64_t inlinePointSetOffset{};
     std::uint64_t inlinePlacementComponentOffset{};
     std::uint32_t inlineInitialPointIndex{};

+ 193 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp

@@ -196,6 +196,194 @@ struct ConfigOccurrenceCountKeyHash final {
                       value.row.spawnRuleConfigTag);
 }
 
+/**
+ * Tests that one descriptor's whole schema tuple matches its slot's proven schema. The slot type
+ * alone never implies a schema.
+ */
+[[nodiscard]] bool unbound_descriptor_exact(
+    const topology::Snapshot& topology,
+    const std::unordered_map<std::uint32_t, const GraphSlotSchemaProvenance*>& schemas,
+    const GraphDescriptor& descriptor) {
+    if (!descriptor.complete || descriptor.slotIndex >= topology.slots.size()
+        || descriptor.objectIndex >= topology.objects.size()) {
+        return false;
+    }
+    const auto& slot = topology.slots[descriptor.slotIndex];
+    if (slot.objectIndex != descriptor.objectIndex || slot.slotType != format::kSquadSlotType) {
+        return false;
+    }
+    const auto found = schemas.find(descriptor.slotIndex);
+    const auto* match = found == schemas.end() ? nullptr : found->second;
+    return match != nullptr && match->exact && match->componentClass == descriptor.componentClass
+           && match->senseSchema == descriptor.senseSchema
+           && match->authSchema == descriptor.authSchema;
+}
+
+/**
+ * Tests that one occurrence places the descriptor's config exactly once. A config path repeated
+ * in one occurrence is ambiguous, even when every copy has the same coordinates.
+ */
+[[nodiscard]] bool unbound_occurrence_exact(const topology::Snapshot& topology,
+                                            const std::vector<const GraphConfigContext*>& contexts,
+                                            const GraphDescriptor& descriptor,
+                                            std::uint32_t occurrenceIndex) {
+    if (occurrenceIndex >= topology.occurrences.size()) {
+        return false;
+    }
+    const auto& occurrence = topology.occurrences[occurrenceIndex];
+    if (occurrence.objectIndex != descriptor.objectIndex) {
+        return false;
+    }
+    std::uint32_t count = 0;
+    bool exact = false;
+    for (const GraphConfigContext* context : contexts) {
+        if (context->configTag != descriptor.configTag
+            || context->occurrenceIndex != occurrenceIndex) {
+            continue;
+        }
+        ++count;
+        exact = context->complete && context->objectIndex == descriptor.objectIndex
+                && context->scenarioIndex == occurrence.scenarioIndex;
+    }
+    return count == 1 && exact;
+}
+
+/**
+ * Appends one squad per exact occurrence of a spawner that names no rule of its own. Such a squad
+ * has no rule edge or anchors, so it is placeable only with a selected type-66 rule slot. The
+ * authored graph's edges are left untouched.
+ */
+[[nodiscard]] bool project_unbound_squads(const topology::Snapshot& topology,
+                                          const Facts& facts,
+                                          const GraphSnapshot& graph,
+                                          SquadIdHasher& hasher,
+                                          ActorResolver actorResolver,
+                                          void* actorContext,
+                                          std::vector<PendingSquad>& pending) {
+    std::unordered_map<std::uint32_t, const GraphSlotSchemaProvenance*> schemas{};
+    for (const auto& schema : graph.slotSchemas) {
+        if (!schemas.emplace(schema.slotIndex, &schema).second) {
+            return false;
+        }
+    }
+    std::unordered_map<std::uint32_t, std::size_t> spawnersByConfig{};
+    std::unordered_map<std::uint32_t, std::vector<const GraphConfigContext*>> contextsByConfig{};
+    for (std::size_t index = 0; index < graph.spawners.size(); ++index) {
+        if (!spawnersByConfig.emplace(graph.spawners[index].configTag, index).second) {
+            return false;
+        }
+    }
+    for (const auto& context : graph.configContexts) {
+        contextsByConfig[context.configTag].push_back(&context);
+    }
+    for (const auto& descriptor : graph.descriptors) {
+        const auto foundSpawner = spawnersByConfig.find(descriptor.configTag);
+        if (foundSpawner == spawnersByConfig.end()) {
+            continue;
+        }
+        const std::size_t spawnerIndex = foundSpawner->second;
+        if (spawnerIndex >= facts.spawners.size()) {
+            return false;
+        }
+        const GraphSpawner& spawner = graph.spawners[spawnerIndex];
+        const SpawnerFact& source = facts.spawners[spawnerIndex];
+        if (!spawner.requiresSelectedRule) {
+            continue;
+        }
+        if (spawner.configTag != source.configTag) {
+            return false;
+        }
+        if (descriptor.componentClass != format::kSquadComponentClass
+            || descriptor.senseSchema != format::kSquadSenseSchema
+            || descriptor.authSchema != format::kSquadAuthSchema
+            || !unbound_descriptor_exact(topology, schemas, descriptor)) {
+            continue;
+        }
+        const auto foundContexts = contextsByConfig.find(descriptor.configTag);
+        if (foundContexts == contextsByConfig.end()) {
+            continue;
+        }
+        std::vector<std::uint32_t> occurrenceIndexes{};
+        for (const GraphConfigContext* context : foundContexts->second) {
+            occurrenceIndexes.push_back(context->occurrenceIndex);
+        }
+        std::sort(occurrenceIndexes.begin(), occurrenceIndexes.end());
+        occurrenceIndexes.erase(std::unique(occurrenceIndexes.begin(), occurrenceIndexes.end()),
+                                occurrenceIndexes.end());
+        for (const std::uint32_t occurrenceIndex : occurrenceIndexes) {
+            if (!unbound_occurrence_exact(
+                    topology, foundContexts->second, descriptor, occurrenceIndex)) {
+                continue;
+            }
+            const auto& occurrence = topology.occurrences[occurrenceIndex];
+            std::string_view occurrenceId{};
+            if (!text_view(occurrence.id, occurrenceId)) {
+                return false;
+            }
+            const std::array<std::string_view, 2> parts{descriptor.id, occurrenceId};
+            std::string digest{};
+            if (!domain_hash(hasher, "sunrise-runtime-unbound-squad-v1", parts, digest)) {
+                return false;
+            }
+            PendingSquad squad{};
+            squad.row.id = "squad/" + digest;
+            squad.row.scenarioIndex = occurrence.scenarioIndex;
+            squad.row.objectIndex = descriptor.objectIndex;
+            squad.row.slotIndex = descriptor.slotIndex;
+            squad.row.spawnerConfigTag = descriptor.configTag;
+            squad.row.spawnRuleConfigTag = format::kAbsentIndex;
+            squad.row.occurrenceIndex = occurrenceIndex;
+            squad.row.flags = format::kSquadRequiresSelectedRule
+                              | format::kSquadSourceDescriptorExact
+                              | format::kSquadScenarioOccurrenceExact;
+            bool sourceActorLinksComplete = true;
+            for (std::uint32_t memberIndex = 0; memberIndex < source.members.size();
+                 ++memberIndex) {
+                SquadMember member{};
+                if (!detail::build_member(source.members[memberIndex],
+                                          digest,
+                                          memberIndex,
+                                          actorResolver,
+                                          actorContext,
+                                          member,
+                                          sourceActorLinksComplete)) {
+                    return false;
+                }
+                squad.members.push_back(std::move(member));
+            }
+            // An unresolved rule-less squad is skipped; it must not fail the authored squads.
+            if (!sourceActorLinksComplete) {
+                continue;
+            }
+            const bool countValid = squad.members.size() >= format::kSquadMinimumMemberCount
+                                    && squad.members.size() <= format::kSquadMaximumMemberCount;
+            if (countValid) {
+                squad.row.flags |= format::kSquadMemberCountValid;
+            }
+            if (countValid
+                && std::all_of(
+                    squad.members.begin(), squad.members.end(), [](const SquadMember& member) {
+                        return (member.flags & format::kSquadMemberInvariantReadyMask)
+                                   == format::kSquadMemberInvariantReadyMask
+                               && member.defaultCount > 0;
+                    })) {
+                squad.row.flags |= format::kSquadCandidateCountsInvariantComplete;
+            }
+            std::vector<std::uint32_t> memberKeys{};
+            for (const SquadMember& member : squad.members) {
+                memberKeys.push_back(member.memberKey);
+            }
+            std::sort(memberKeys.begin(), memberKeys.end());
+            if (std::adjacent_find(memberKeys.begin(), memberKeys.end()) != memberKeys.end()) {
+                return false;
+            }
+            // No rule edge or anchors: placement refuses this squad unless a rule slot is selected.
+            pending.push_back(std::move(squad));
+        }
+    }
+    return true;
+}
+
 } // namespace
 
 /** Normalizes the authored graph once, then derives the narrower runnable squad projection. */
@@ -521,6 +709,11 @@ bool link(const topology::Snapshot& topology,
             }
         }
 
+        if (!project_unbound_squads(
+                topology, facts, graph, squadIdHasher, actorResolver, actorContext, pending)) {
+            return false;
+        }
+
         std::sort(pending.begin(),
                   pending.end(),
                   [](const PendingSquad& first, const PendingSquad& second) {

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

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

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

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

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

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

+ 2 - 1
Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.cpp

@@ -41,12 +41,13 @@ constexpr std::array<ChannelChoice, 6> kChannelChoices{{
     {"Middleware", true, log::Channel::middleware},
 }};
 /** Ordered choices map the level combo index to one exact emitted severity. */
-constexpr std::array<LevelChoice, 5> kLevelChoices{{
+constexpr std::array<LevelChoice, 6> kLevelChoices{{
     {"All levels", false, log::Level::error},
     {"Error", true, log::Level::error},
     {"Warn", true, log::Level::warn},
     {"Info", true, log::Level::info},
     {"Debug", true, log::Level::debug},
+    {"Trace", true, log::Level::trace},
 }};
 
 struct SelectionState {

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

@@ -5,6 +5,7 @@
 #include <limits>
 
 #include "scriptable_auth_internal.h"
+#include "volume_toggle_auth.h"
 
 // The fixed-width scriptable-auth bodies that drive device state: authored objects, device
 // channels, configured-action pulses, triggers and timers. Each family has one encoder and one
@@ -454,4 +455,46 @@ bool encode_type24(const Type24Body& body,
     return true;
 }
 
+/** Encodes one schema-0x8080955A body: the volume's ClientRef, then its on or off state. */
+bool encode_type32_volume(const Type32VolumeBody& body,
+                          std::span<std::byte> output,
+                          std::size_t& written) noexcept {
+    written = 0;
+    if (body.registryKey == 0 || body.registryKey == (std::numeric_limits<std::uint32_t>::max)()
+        || body.slotIndex < 0 || output.size() < kType32ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType32ByteCount));
+    return auth_fields::write_client_ref(writer,
+                                         body.registryKey,
+                                         kVolumeSlotType,
+                                         static_cast<std::uint16_t>(body.slotIndex))
+           && writer.write(body.active ? kType32StateOn : kType32StateOff, kType32StateWidth)
+           && auth_fields::finish_exact(writer, kType32BitCount, kType32ByteCount, written);
+}
+
+/** Decodes one volume toggle; the no-op state, a non-volume target and nonzero padding fail. */
+bool decode_type32_volume(std::span<const std::byte> input,
+                          std::size_t bitCount,
+                          Type32VolumeBody& body) noexcept {
+    if (bitCount != kType32BitCount || input.size() != kType32ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t key = 0, type = 0, index = 0, state = 0;
+    if (!reader.read(auth_fields::kClientRefKeyWidth, key) || key == 0
+        || key == (std::numeric_limits<std::uint32_t>::max)()
+        || !reader.read(kClientRefTypeWidth, type)
+        || type != kVolumeSlotType + auth_fields::kClientRefTypeBias
+        || !reader.read(kClientRefIndexWidth, index) || index < kClientRefIndexBias
+        || !reader.read(kType32StateWidth, state)
+        || (state != kType32StateOff && state != kType32StateOn) || !finish_padding(reader)) {
+        return false;
+    }
+    body = {static_cast<std::uint32_t>(key),
+            static_cast<std::int16_t>(index - kClientRefIndexBias),
+            state == kType32StateOn};
+    return true;
+}
+
 } // namespace sunrise::middleware::bap::activity_message::scriptable_auth

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

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

+ 686 - 0
Sunrise/src/middleware/bap/activity_message/activity_sense_body_decoder.cpp

@@ -0,0 +1,686 @@
+#include <bit>
+#include <limits>
+
+#include "sense_update_body.h"
+
+// The per-schema msg-6 Sense body readers. Each reads one root body inside its object budget.
+
+namespace sunrise::middleware::bap::activity_message::sense_update::body {
+namespace {
+
+/** Schema tags the sense-update objects and their nested arrays declare. */
+constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
+constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
+constexpr std::uint32_t kOccupancy = 0x80809531U;
+constexpr std::uint32_t kAttachment = 0x8080954AU;
+constexpr std::uint32_t kGhostLink = 0x80804D3EU;
+constexpr std::uint32_t kObject = 0x8080992EU;
+constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
+constexpr std::uint32_t kSceneEvents = 0x808094DFU, kSceneList = 0x808094E1U;
+constexpr std::uint32_t kSquadCounts = 0x80809491U, kSquadList = 0x80807ECFU;
+constexpr std::uint32_t kSquadReals = 0x80807ECDU;
+constexpr std::uint32_t kObjectiveBlock = 0x80807F07U, kObjectiveTasks = 0x80807F08U;
+constexpr std::uint32_t kCombatant = 0x80807DA2U, kCombatantAtoms = 0x80807F6EU;
+constexpr std::uint32_t kCombatantKeyed = 0x80807DA3U, kCombatantLanes = 0x80807DA4U;
+constexpr std::uint32_t kEngagement = 0x808094F0U, kEngagementList = 0x808094F8U;
+/** The engagement list: a five-bit element count that the native list caps at 16. */
+constexpr std::uint8_t kEngagementCountWidth = 5;
+constexpr std::uint64_t kEngagementCapacity = 16;
+/** Engagement ordinal 1 is a 16-bit value biased by 32768. */
+constexpr std::uint16_t kEngagementValueOrdinal = 1;
+constexpr std::uint8_t kEngagementValueWidth = 16;
+constexpr std::int32_t kEngagementValueBias = 32'768;
+/** The type-26 attachment body: three required signed 32-bit counters, then one flag. */
+constexpr std::uint16_t kAttachmentCounterCount = 3;
+constexpr std::uint8_t kAttachmentCounterWidth = 32;
+constexpr std::uint16_t kAttachmentFlagOrdinal = kAttachmentCounterCount;
+/** 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;
+
+/** @param width Field width in bits. @return Low-bit mask of that width. */
+[[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
+    return width == 64 ? (std::numeric_limits<std::uint64_t>::max)()
+                       : (std::uint64_t{1} << width) - 1;
+}
+[[nodiscard]] std::int64_t
+signed_value(std::uint64_t raw, std::int32_t bias, std::uint8_t storageWidth) noexcept {
+    const std::uint64_t decoded =
+        (raw - static_cast<std::uint64_t>(static_cast<std::int64_t>(bias))) & mask(storageWidth);
+    if (storageWidth == 64) {
+        return static_cast<std::int64_t>(decoded);
+    }
+    const std::uint64_t sign = std::uint64_t{1} << (storageWidth - 1);
+    return static_cast<std::int64_t>((decoded ^ sign) - sign);
+}
+
+/** Required fields are present; optional fields consume one presence bit within the budget. */
+[[nodiscard]] bool present(Reader& reader, bool optional, bool& output) noexcept {
+    output = true;
+    if (!optional) {
+        return true;
+    }
+    std::uint64_t raw = 0;
+    if (!reader.read(1, raw)) {
+        return false;
+    }
+    output = raw != 0;
+    return true;
+}
+/** Records an unsigned value only when its presence bit and selected value fit. */
+[[nodiscard]] bool read_unsigned(Reader& reader,
+                                 Values& values,
+                                 std::uint32_t schema,
+                                 std::uint16_t ordinal,
+                                 std::uint8_t width,
+                                 bool optional,
+                                 std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(width, raw)) {
+        return false;
+    }
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? width : 0,
+               ValueKind::unsignedInteger,
+               raw,
+               0,
+               0.0F,
+               exists);
+    return true;
+}
+/** Removes wire bias and sign-extends to the host width after reading a complete selected field. */
+[[nodiscard]] bool read_signed(Reader& reader,
+                               Values& values,
+                               std::uint32_t schema,
+                               std::uint16_t ordinal,
+                               std::uint8_t wireWidth,
+                               std::uint8_t storageWidth,
+                               std::int32_t bias,
+                               bool optional,
+                               std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(wireWidth, raw)) {
+        return false;
+    }
+    const std::int64_t decoded = exists ? signed_value(raw, bias, storageWidth) : 0;
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? wireWidth : 0,
+               ValueKind::signedInteger,
+               static_cast<std::uint64_t>(decoded),
+               decoded,
+               0.0F,
+               exists);
+    return true;
+}
+/** Reads one boolean field into the value table. */
+[[nodiscard]] bool read_bool(Reader& reader,
+                             Values& values,
+                             std::uint32_t schema,
+                             std::uint16_t ordinal,
+                             bool optional,
+                             std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(1, raw)) {
+        return false;
+    }
+    values.put(
+        schema, ordinal, occurrence, at, exists ? 1 : 0, ValueKind::boolean, raw, 0, 0.0F, exists);
+    return true;
+}
+/** Quantized endpoints stay exact; width 32 carries raw float bits. */
+[[nodiscard]] float
+real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noexcept {
+    if (width == 32) {
+        return std::bit_cast<float>(static_cast<std::uint32_t>(raw));
+    }
+    const float maximum = std::bit_cast<float>(maximumBits);
+    const std::uint64_t levels = std::uint64_t{1} << width;
+    if (raw == 0) {
+        return 0.0F;
+    }
+    if (raw == levels - 1) {
+        return maximum;
+    }
+    const float step = maximum / static_cast<float>(levels - 2);
+    return static_cast<float>(raw - 1) * step + step * 0.5F;
+}
+/** Records a real only when its presence bit and selected value fit. */
+[[nodiscard]] bool read_real(Reader& reader,
+                             Values& values,
+                             std::uint32_t schema,
+                             std::uint16_t ordinal,
+                             std::uint8_t width,
+                             bool optional,
+                             std::uint32_t occurrence = 0,
+                             std::uint32_t maximumBits = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(width, raw)) {
+        return false;
+    }
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? width : 0,
+               ValueKind::real32,
+               raw,
+               0,
+               exists ? real_value(raw, width, maximumBits) : 0.0F,
+               exists);
+    return true;
+}
+
+/** Decodes three device channels within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_device(Reader& reader, Values& values) noexcept {
+    for (std::uint16_t channel = 0; channel < 3; ++channel) {
+        if (!read_real(reader, values, kDevice, channel * 2, 32, true)
+            || !read_signed(reader,
+                            values,
+                            kDevice,
+                            channel * 2 + 1,
+                            32,
+                            32,
+                            (std::numeric_limits<std::int32_t>::min)(),
+                            true)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes the occupancy flags and signed values within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_occupancy(Reader& reader, Values& values) noexcept {
+    return read_bool(reader, values, kOccupancy, 0, false)
+                   && read_bool(reader, values, kOccupancy, 1, false)
+                   && read_signed(reader,
+                                  values,
+                                  kOccupancy,
+                                  2,
+                                  32,
+                                  32,
+                                  (std::numeric_limits<std::int32_t>::min)(),
+                                  false)
+                   && read_signed(reader,
+                                  values,
+                                  kOccupancy,
+                                  3,
+                                  32,
+                                  32,
+                                  (std::numeric_limits<std::int32_t>::min)(),
+                                  false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+/** Decodes a scene and refuses event counts above the native capacity. */
+[[nodiscard]] NativeStatus decode_scene(Reader& reader, Values& values) noexcept {
+    if (!read_signed(
+            reader, values, kScene, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
+        || !read_bool(reader, values, kScene, 1, false)
+        || !read_signed(reader, values, kScene, 2, 31, 32, 0, true)
+        || !read_signed(reader, values, kScene, 3, 2, 8, 1, false)) {
+        return NativeStatus::malformed;
+    }
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(6, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > 32) {
+        return NativeStatus::unsafeCount;
+    }
+    values.put(kSceneList, 0, 0, at, 6, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+    for (std::uint32_t index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kSceneEvents, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes squad fields and bounded member lists within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_squad(Reader& reader, Values& values) noexcept {
+    if (!read_signed(reader, values, kSquad, 0, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 1, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 2, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 3, 6, 32, 0, true)
+        || !read_real(reader, values, kSquad, 4, 7, true, 0, 1157562368U)
+        || !read_signed(reader, values, kSquad, 5, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 6, 2, 8, 1, false)
+        || !read_signed(reader, values, kSquad, 7, 3, 8, 1, false)
+        || !read_bool(reader, values, kSquad, 8, false)
+        || !read_bool(reader, values, kSquad, 9, false)
+        || !read_bool(reader, values, kSquad, 10, false)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        std::uint64_t count = 0;
+        const auto at = static_cast<std::uint32_t>(reader.position());
+        if (!reader.read(4, count)) {
+            return NativeStatus::malformed;
+        }
+        if (count > 8) {
+            return NativeStatus::unsafeCount;
+        }
+        values.put(kSquadList, 0, 0, at, 4, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+        for (std::uint32_t index = 0; index < count; ++index) {
+            if (!read_signed(reader,
+                             values,
+                             kSquadCounts,
+                             0,
+                             32,
+                             32,
+                             (std::numeric_limits<std::int32_t>::min)(),
+                             false,
+                             index)) {
+                return NativeStatus::malformed;
+            }
+        }
+    }
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        for (std::uint32_t index = 0; index < 24; ++index) {
+            if (!read_real(reader, values, kSquadReals, 0, 7, true, index, 1157562368U)) {
+                return NativeStatus::malformed;
+            }
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes the optional objective blocks and their fixed task arrays. */
+[[nodiscard]] NativeStatus decode_objective(Reader& reader, Values& values) noexcept {
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        for (std::uint32_t block = 0; block < 24; ++block) {
+            if (!read_signed(reader, values, kObjectiveBlock, 0, 7, 8, 0, true, block)
+                || !read_unsigned(reader, values, kObjectiveBlock, 1, 1, false, block)) {
+                return NativeStatus::malformed;
+            }
+            bool tasks = false;
+            if (!present(reader, true, tasks)) {
+                return NativeStatus::malformed;
+            }
+            if (!tasks) {
+                continue;
+            }
+            for (std::uint32_t task = 0; task < 24; ++task) {
+                if (!read_signed(
+                        reader, values, kObjectiveTasks, 0, 7, 8, 0, true, block * 24 + task)) {
+                    return NativeStatus::malformed;
+                }
+            }
+        }
+    }
+    return read_signed(reader, values, kObjective, 1, 31, 32, 0, true)
+                   && read_unsigned(reader, values, kObjective, 2, 32, true)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+/** Decodes an object and accepts only the registered interaction and ownership replies. */
+[[nodiscard]] NativeStatus decode_object(Reader& reader, Values& values) noexcept {
+    if (!read_signed(
+            reader, values, kObject, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
+        || !read_bool(reader, values, kObject, 1, false)
+        || !read_bool(reader, values, kObject, 2, false)
+        || !read_signed(reader,
+                        values,
+                        kObject,
+                        3,
+                        32,
+                        32,
+                        (std::numeric_limits<std::int32_t>::min)(),
+                        false)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint32_t index = 0; index < 2; ++index) {
+        if (!read_unsigned(reader, values, kObjectSpawnMask, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(2, count)) {
+        return NativeStatus::malformed;
+    }
+    values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+    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 combatant generations, program state, keyed lanes and root flags within the budget. */
+[[nodiscard]] NativeStatus decode_combatant(Reader& reader, Values& values) noexcept {
+    if (!read_signed(reader, values, kCombatant, 0, 31, 32, 0, true)
+        || !read_real(reader, values, kCombatant, 1, 9, true, 0, kSpatialMaximumBits)
+        || !read_signed(reader, values, kCombatant, 2, 31, 32, 0, true)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists
+        && (!read_signed(reader, values, kCombatantAtoms, 0, 6, 32, 0, true)
+            || !read_signed(reader, values, kCombatantAtoms, 1, 31, 32, 0, true)
+            || !read_signed(reader, values, kCombatantAtoms, 2, 31, 32, 0, true)
+            || !read_bool(reader, values, kCombatantAtoms, 3, false))) {
+        return NativeStatus::malformed;
+    }
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        bool lanes = false;
+        if (!present(reader, true, lanes)) {
+            return NativeStatus::malformed;
+        }
+        for (std::uint32_t index = 0; lanes && index < 8; ++index) {
+            if (!read_signed(reader, values, kCombatantLanes, 0, 31, 32, 0, true, index)) {
+                return NativeStatus::malformed;
+            }
+        }
+        if (!read_unsigned(reader, values, kCombatantKeyed, 1, 32, true)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_signed(reader, values, kCombatant, 5, 31, 32, 0, true)
+        || !read_signed(reader, values, kCombatant, 6, 2, 8, 1, false)
+        || !read_signed(reader, values, kCombatant, 7, 31, 32, 0, true)
+        || !read_real(reader, values, kCombatant, 8, 7, true, 0, kUnitMaximumBits)
+        || !read_real(reader, values, kCombatant, 9, 7, true, 0, kUnitMaximumBits)
+        || !read_bool(reader, values, kCombatant, 10, false)
+        || !read_bool(reader, values, kCombatant, 11, false)) {
+        return NativeStatus::malformed;
+    }
+    return NativeStatus::complete;
+}
+/**
+ * Decodes the engagement body. Only an empty element list is supported: the element codec is
+ * unknown, and its storage size says nothing about its wire width.
+ */
+[[nodiscard]] NativeStatus decode_engagement(Reader& reader, Values& values) noexcept {
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(kEngagementCountWidth, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > kEngagementCapacity) {
+        return NativeStatus::unsafeCount;
+    }
+    if (count != 0) {
+        return NativeStatus::unsupported;
+    }
+    values.put(kEngagementList,
+               0,
+               0,
+               at,
+               kEngagementCountWidth,
+               ValueKind::unsignedInteger,
+               count,
+               0,
+               0.0F,
+               true);
+    return read_signed(reader,
+                       values,
+                       kEngagement,
+                       kEngagementValueOrdinal,
+                       kEngagementValueWidth,
+                       kEngagementValueWidth,
+                       kEngagementValueBias,
+                       false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+/** Decodes the type-26 attachment body. */
+[[nodiscard]] NativeStatus decode_attachment(Reader& reader, Values& values) noexcept {
+    for (std::uint16_t ordinal = 0; ordinal < kAttachmentCounterCount; ++ordinal) {
+        if (!read_signed(reader,
+                         values,
+                         kAttachment,
+                         ordinal,
+                         kAttachmentCounterWidth,
+                         kAttachmentCounterWidth,
+                         (std::numeric_limits<std::int32_t>::min)(),
+                         false)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return read_bool(reader, values, kAttachment, kAttachmentFlagOrdinal, false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+/**
+ * Decodes the type-13 participation body. The whole body is read before its Ghost flag counts,
+ * because a player mid-load publishes a body with the flag but without a settled actor.
+ */
+[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
+    // The participation field uses a signed 32-bit midpoint bias.
+    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
+    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;
+}
+
+} // namespace
+
+/** 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 {
+    std::uint64_t root = 0;
+    if (!reader.read(1, root)) {
+        return NativeStatus::malformed;
+    }
+    if (root == 0) {
+        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:
+        return decode_scene(reader, values);
+    case kSquad:
+        return decode_squad(reader, values);
+    case kObjective:
+        return decode_objective(reader, values);
+    case kOccupancy:
+        return decode_occupancy(reader, values);
+    case kAttachment:
+        return decode_attachment(reader, values);
+    case kObject:
+        return decode_object(reader, values);
+    case kCombatant:
+        return decode_combatant(reader, values);
+    case kEngagement:
+        return decode_engagement(reader, values);
+    default:
+        return NativeStatus::unsupported;
+    }
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::body

+ 5 - 672
Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp

@@ -1,10 +1,8 @@
-#include <bit>
-#include <limits>
-
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/byte_order.h"
 #include "sense_roster_delta.h"
 #include "sense_update.h"
+#include "sense_update_body.h"
 
 namespace sunrise::middleware::bap::activity_message::sense_update {
 namespace {
@@ -14,677 +12,12 @@ namespace bits = middleware::encoding::bits;
 constexpr std::uint8_t kKeyWidth = 32, kTypeWidth = 7, kIndexWidth = 16;
 /** The slot type and index are sent unsigned, biased so their absent value is zero. */
 constexpr std::uint32_t kTypeBias = 1, kIndexBias = 32768;
-/** Schema tags the sense-update objects and their nested arrays declare. */
-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;
-constexpr std::uint32_t kSquadCounts = 0x80809491U, kSquadList = 0x80807ECFU;
-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;
-
-enum class NativeStatus : std::uint8_t { complete, unsupported, unsafeCount, malformed };
-
-/** Bit reader with its own budget, so one object cannot consume the whole packet. */
-class Reader final {
-public:
-    Reader(bits::Reader& source, std::size_t budget, std::size_t total) noexcept
-        : source_(source), left_(budget), total_(total) {}
-    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
-        if (width > left_ || !source_.read(width, value)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] bool skip(std::size_t width) noexcept {
-        if (width > left_ || !source_.skip(width)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] std::size_t left() const noexcept {
-        return left_;
-    }
-    [[nodiscard]] std::size_t position() const noexcept {
-        return total_ - source_.remaining_bits();
-    }
-
-private:
-    bits::Reader& source_;
-    std::size_t left_{};
-    std::size_t total_{};
-};
 
-/** @param width Field width in bits. @return Low-bit mask of that width. */
-[[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
-    return width == 64 ? (std::numeric_limits<std::uint64_t>::max)()
-                       : (std::uint64_t{1} << width) - 1;
-}
-[[nodiscard]] std::int64_t
-signed_value(std::uint64_t raw, std::int32_t bias, std::uint8_t storageWidth) noexcept {
-    const std::uint64_t decoded =
-        (raw - static_cast<std::uint64_t>(static_cast<std::int64_t>(bias))) & mask(storageWidth);
-    if (storageWidth == 64) {
-        return static_cast<std::int64_t>(decoded);
-    }
-    const std::uint64_t sign = std::uint64_t{1} << (storageWidth - 1);
-    return static_cast<std::int64_t>((decoded ^ sign) - sign);
-}
-
-/** Collects decoded values into the packet, dropping them once the object's room runs out. */
-class Values final {
-public:
-    Values(DecodedPacket& packet, DecodedObject* object) noexcept
-        : packet_(packet), object_(object) {}
-    /** Retains one field value, marking truncation when the packet's storage is full. */
-    void put(std::uint32_t schema,
-             std::uint16_t ordinal,
-             std::uint32_t occurrence,
-             std::uint32_t at,
-             std::uint8_t width,
-             ValueKind kind,
-             std::uint64_t raw,
-             std::int64_t signedRaw,
-             float real,
-             bool present) noexcept {
-        if (object_ == nullptr) {
-            return;
-        }
-        if (packet_.valueCount == packet_.values.size()) {
-            packet_.valuesTruncated = true;
-            return;
-        }
-        DecodedValue& value = packet_.values[packet_.valueCount++];
-        value.schemaRow = schema;
-        value.fieldRow = ordinal;
-        value.fieldOrdinal = ordinal;
-        value.occurrence = occurrence;
-        value.bitOffset = at;
-        value.width = width;
-        value.kind = kind;
-        value.unsignedValue = raw;
-        value.signedValue = signedRaw;
-        value.realValue = real;
-        value.present = present;
-    }
-
-private:
-    DecodedPacket& packet_;
-    DecodedObject* object_{};
-};
-
-/** Required fields are present; optional fields consume one presence bit within the budget. */
-[[nodiscard]] bool present(Reader& reader, bool optional, bool& output) noexcept {
-    output = true;
-    if (!optional) {
-        return true;
-    }
-    std::uint64_t raw = 0;
-    if (!reader.read(1, raw)) {
-        return false;
-    }
-    output = raw != 0;
-    return true;
-}
-/** Records an unsigned value only when its presence bit and selected value fit. */
-[[nodiscard]] bool read_unsigned(Reader& reader,
-                                 Values& values,
-                                 std::uint32_t schema,
-                                 std::uint16_t ordinal,
-                                 std::uint8_t width,
-                                 bool optional,
-                                 std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(width, raw)) {
-        return false;
-    }
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? width : 0,
-               ValueKind::unsignedInteger,
-               raw,
-               0,
-               0.0F,
-               exists);
-    return true;
-}
-/** Removes wire bias and sign-extends to the host width after reading a complete selected field. */
-[[nodiscard]] bool read_signed(Reader& reader,
-                               Values& values,
-                               std::uint32_t schema,
-                               std::uint16_t ordinal,
-                               std::uint8_t wireWidth,
-                               std::uint8_t storageWidth,
-                               std::int32_t bias,
-                               bool optional,
-                               std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(wireWidth, raw)) {
-        return false;
-    }
-    const std::int64_t decoded = exists ? signed_value(raw, bias, storageWidth) : 0;
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? wireWidth : 0,
-               ValueKind::signedInteger,
-               static_cast<std::uint64_t>(decoded),
-               decoded,
-               0.0F,
-               exists);
-    return true;
-}
-/** Reads one boolean field into the value table. */
-[[nodiscard]] bool read_bool(Reader& reader,
-                             Values& values,
-                             std::uint32_t schema,
-                             std::uint16_t ordinal,
-                             bool optional,
-                             std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(1, raw)) {
-        return false;
-    }
-    values.put(
-        schema, ordinal, occurrence, at, exists ? 1 : 0, ValueKind::boolean, raw, 0, 0.0F, exists);
-    return true;
-}
-/** Quantized endpoints stay exact; width 32 carries raw float bits. */
-[[nodiscard]] float
-real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noexcept {
-    if (width == 32) {
-        return std::bit_cast<float>(static_cast<std::uint32_t>(raw));
-    }
-    const float maximum = std::bit_cast<float>(maximumBits);
-    const std::uint64_t levels = std::uint64_t{1} << width;
-    if (raw == 0) {
-        return 0.0F;
-    }
-    if (raw == levels - 1) {
-        return maximum;
-    }
-    const float step = maximum / static_cast<float>(levels - 2);
-    return static_cast<float>(raw - 1) * step + step * 0.5F;
-}
-/** Records a real only when its presence bit and selected value fit. */
-[[nodiscard]] bool read_real(Reader& reader,
-                             Values& values,
-                             std::uint32_t schema,
-                             std::uint16_t ordinal,
-                             std::uint8_t width,
-                             bool optional,
-                             std::uint32_t occurrence = 0,
-                             std::uint32_t maximumBits = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(width, raw)) {
-        return false;
-    }
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? width : 0,
-               ValueKind::real32,
-               raw,
-               0,
-               exists ? real_value(raw, width, maximumBits) : 0.0F,
-               exists);
-    return true;
-}
+using body::decode_body;
+using body::NativeStatus;
+using body::Reader;
+using body::Values;
 
-/** Decodes three device channels within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_device(Reader& reader, Values& values) noexcept {
-    for (std::uint16_t channel = 0; channel < 3; ++channel) {
-        if (!read_real(reader, values, kDevice, channel * 2, 32, true)
-            || !read_signed(reader,
-                            values,
-                            kDevice,
-                            channel * 2 + 1,
-                            32,
-                            32,
-                            (std::numeric_limits<std::int32_t>::min)(),
-                            true)) {
-            return NativeStatus::malformed;
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes the occupancy flags and signed values within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_occupancy(Reader& reader, Values& values) noexcept {
-    return read_bool(reader, values, kOccupancy, 0, false)
-                   && read_bool(reader, values, kOccupancy, 1, false)
-                   && read_signed(reader,
-                                  values,
-                                  kOccupancy,
-                                  2,
-                                  32,
-                                  32,
-                                  (std::numeric_limits<std::int32_t>::min)(),
-                                  false)
-                   && read_signed(reader,
-                                  values,
-                                  kOccupancy,
-                                  3,
-                                  32,
-                                  32,
-                                  (std::numeric_limits<std::int32_t>::min)(),
-                                  false)
-               ? NativeStatus::complete
-               : NativeStatus::malformed;
-}
-/** Decodes a scene and refuses event counts above the native capacity. */
-[[nodiscard]] NativeStatus decode_scene(Reader& reader, Values& values) noexcept {
-    if (!read_signed(
-            reader, values, kScene, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
-        || !read_bool(reader, values, kScene, 1, false)
-        || !read_signed(reader, values, kScene, 2, 31, 32, 0, true)
-        || !read_signed(reader, values, kScene, 3, 2, 8, 1, false)) {
-        return NativeStatus::malformed;
-    }
-    std::uint64_t count = 0;
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    if (!reader.read(6, count)) {
-        return NativeStatus::malformed;
-    }
-    if (count > 32) {
-        return NativeStatus::unsafeCount;
-    }
-    values.put(kSceneList, 0, 0, at, 6, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    for (std::uint32_t index = 0; index < count; ++index) {
-        if (!read_unsigned(reader, values, kSceneEvents, 0, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes squad fields and bounded member lists within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_squad(Reader& reader, Values& values) noexcept {
-    if (!read_signed(reader, values, kSquad, 0, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 1, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 2, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 3, 6, 32, 0, true)
-        || !read_real(reader, values, kSquad, 4, 7, true, 0, 1157562368U)
-        || !read_signed(reader, values, kSquad, 5, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 6, 2, 8, 1, false)
-        || !read_signed(reader, values, kSquad, 7, 3, 8, 1, false)
-        || !read_bool(reader, values, kSquad, 8, false)
-        || !read_bool(reader, values, kSquad, 9, false)
-        || !read_bool(reader, values, kSquad, 10, false)) {
-        return NativeStatus::malformed;
-    }
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        std::uint64_t count = 0;
-        const auto at = static_cast<std::uint32_t>(reader.position());
-        if (!reader.read(4, count)) {
-            return NativeStatus::malformed;
-        }
-        if (count > 8) {
-            return NativeStatus::unsafeCount;
-        }
-        values.put(kSquadList, 0, 0, at, 4, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-        for (std::uint32_t index = 0; index < count; ++index) {
-            if (!read_signed(reader,
-                             values,
-                             kSquadCounts,
-                             0,
-                             32,
-                             32,
-                             (std::numeric_limits<std::int32_t>::min)(),
-                             false,
-                             index)) {
-                return NativeStatus::malformed;
-            }
-        }
-    }
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        for (std::uint32_t index = 0; index < 24; ++index) {
-            if (!read_real(reader, values, kSquadReals, 0, 7, true, index, 1157562368U)) {
-                return NativeStatus::malformed;
-            }
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes the optional objective blocks and their fixed task arrays. */
-[[nodiscard]] NativeStatus decode_objective(Reader& reader, Values& values) noexcept {
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        for (std::uint32_t block = 0; block < 24; ++block) {
-            if (!read_signed(reader, values, kObjectiveBlock, 0, 7, 8, 0, true, block)
-                || !read_unsigned(reader, values, kObjectiveBlock, 1, 1, false, block)) {
-                return NativeStatus::malformed;
-            }
-            bool tasks = false;
-            if (!present(reader, true, tasks)) {
-                return NativeStatus::malformed;
-            }
-            if (!tasks) {
-                continue;
-            }
-            for (std::uint32_t task = 0; task < 24; ++task) {
-                if (!read_signed(
-                        reader, values, kObjectiveTasks, 0, 7, 8, 0, true, block * 24 + task)) {
-                    return NativeStatus::malformed;
-                }
-            }
-        }
-    }
-    return read_signed(reader, values, kObjective, 1, 31, 32, 0, true)
-                   && read_unsigned(reader, values, kObjective, 2, 32, true)
-               ? NativeStatus::complete
-               : NativeStatus::malformed;
-}
-/** Decodes an object and accepts only the registered interaction and ownership replies. */
-[[nodiscard]] NativeStatus decode_object(Reader& reader, Values& values) noexcept {
-    if (!read_signed(
-            reader, values, kObject, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
-        || !read_bool(reader, values, kObject, 1, false)
-        || !read_bool(reader, values, kObject, 2, false)
-        || !read_signed(reader,
-                        values,
-                        kObject,
-                        3,
-                        32,
-                        32,
-                        (std::numeric_limits<std::int32_t>::min)(),
-                        false)) {
-        return NativeStatus::malformed;
-    }
-    for (std::uint32_t index = 0; index < 2; ++index) {
-        if (!read_unsigned(reader, values, kObjectSpawnMask, 0, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    std::uint64_t count = 0;
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    if (!reader.read(2, count)) {
-        return NativeStatus::malformed;
-    }
-    values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    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 combatant generations, program state, keyed lanes and root flags within the budget. */
-[[nodiscard]] NativeStatus decode_combatant(Reader& reader, Values& values) noexcept {
-    if (!read_signed(reader, values, kCombatant, 0, 31, 32, 0, true)
-        || !read_real(reader, values, kCombatant, 1, 9, true, 0, kSpatialMaximumBits)
-        || !read_signed(reader, values, kCombatant, 2, 31, 32, 0, true)) {
-        return NativeStatus::malformed;
-    }
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists
-        && (!read_signed(reader, values, kCombatantAtoms, 0, 6, 32, 0, true)
-            || !read_signed(reader, values, kCombatantAtoms, 1, 31, 32, 0, true)
-            || !read_signed(reader, values, kCombatantAtoms, 2, 31, 32, 0, true)
-            || !read_bool(reader, values, kCombatantAtoms, 3, false))) {
-        return NativeStatus::malformed;
-    }
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        bool lanes = false;
-        if (!present(reader, true, lanes)) {
-            return NativeStatus::malformed;
-        }
-        for (std::uint32_t index = 0; lanes && index < 8; ++index) {
-            if (!read_signed(reader, values, kCombatantLanes, 0, 31, 32, 0, true, index)) {
-                return NativeStatus::malformed;
-            }
-        }
-        if (!read_unsigned(reader, values, kCombatantKeyed, 1, 32, true)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_signed(reader, values, kCombatant, 5, 31, 32, 0, true)
-        || !read_signed(reader, values, kCombatant, 6, 2, 8, 1, false)
-        || !read_signed(reader, values, kCombatant, 7, 31, 32, 0, true)
-        || !read_real(reader, values, kCombatant, 8, 7, true, 0, kUnitMaximumBits)
-        || !read_real(reader, values, kCombatant, 9, 7, true, 0, kUnitMaximumBits)
-        || !read_bool(reader, values, kCombatant, 10, false)
-        || !read_bool(reader, values, kCombatant, 11, false)) {
-        return NativeStatus::malformed;
-    }
-    return NativeStatus::complete;
-}
-/** Validates the complete participation body before its Ghost flag becomes usable. */
-[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
-    // The participation field uses a signed 32-bit midpoint bias.
-    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
-    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 {
-    std::uint64_t root = 0;
-    if (!reader.read(1, root)) {
-        return NativeStatus::malformed;
-    }
-    if (root == 0) {
-        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:
-        return decode_scene(reader, values);
-    case kSquad:
-        return decode_squad(reader, values);
-    case kObjective:
-        return decode_objective(reader, values);
-    case kOccupancy:
-        return decode_occupancy(reader, values);
-    case kObject:
-        return decode_object(reader, values);
-    case kCombatant:
-        return decode_combatant(reader, values);
-    default:
-        return NativeStatus::unsupported;
-    }
-}
 [[nodiscard]] bool skip_group(Reader& reader) noexcept {
     if (reader.left() == 0 || !reader.skip(reader.left() - 1)) {
         return false;

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

@@ -552,7 +552,7 @@ struct Type23Body final {
 struct Type68Preset final {
     std::uint32_t nameHash{};
     std::int32_t elementIndex{};
-    /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
+    /** Native directive state: -1 disables, 0 enters, 1 completes, 2 exits. */
     std::int8_t state{};
     bool visible{true};
     /** Lane progress values, current then maximum first; the HUD reads them as a counter. */

+ 97 - 0
Sunrise/src/middleware/bap/activity_message/sense_update_body.h

@@ -0,0 +1,97 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "sense_update.h"
+
+// Shared by the msg-6 packet framing and the per-schema Sense body readers.
+
+namespace sunrise::middleware::bap::activity_message::sense_update::body {
+
+/** How far one body decode got. */
+enum class NativeStatus : std::uint8_t { complete, unsupported, unsafeCount, malformed };
+
+/** Bit reader with its own budget, so one object cannot consume the whole packet. */
+class Reader final {
+public:
+    Reader(encoding::bits::Reader& source, std::size_t budget, std::size_t total) noexcept
+        : source_(source), left_(budget), total_(total) {}
+    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
+        if (width > left_ || !source_.read(width, value)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] bool skip(std::size_t width) noexcept {
+        if (width > left_ || !source_.skip(width)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] std::size_t left() const noexcept {
+        return left_;
+    }
+    [[nodiscard]] std::size_t position() const noexcept {
+        return total_ - source_.remaining_bits();
+    }
+
+private:
+    encoding::bits::Reader& source_;
+    std::size_t left_{};
+    std::size_t total_{};
+};
+
+/** Collects decoded values into the packet, dropping them once the object's room runs out. */
+class Values final {
+public:
+    Values(DecodedPacket& packet, DecodedObject* object) noexcept
+        : packet_(packet), object_(object) {}
+    /** Retains one field value, marking truncation when the packet's storage is full. */
+    void put(std::uint32_t schema,
+             std::uint16_t ordinal,
+             std::uint32_t occurrence,
+             std::uint32_t at,
+             std::uint8_t width,
+             ValueKind kind,
+             std::uint64_t raw,
+             std::int64_t signedRaw,
+             float real,
+             bool present) noexcept {
+        if (object_ == nullptr) {
+            return;
+        }
+        if (packet_.valueCount == packet_.values.size()) {
+            packet_.valuesTruncated = true;
+            return;
+        }
+        DecodedValue& value = packet_.values[packet_.valueCount++];
+        value.schemaRow = schema;
+        value.fieldRow = ordinal;
+        value.fieldOrdinal = ordinal;
+        value.occurrence = occurrence;
+        value.bitOffset = at;
+        value.width = width;
+        value.kind = kind;
+        value.unsignedValue = raw;
+        value.signedValue = signedRaw;
+        value.realValue = real;
+        value.present = present;
+    }
+
+private:
+    DecodedPacket& packet_;
+    DecodedObject* object_{};
+};
+
+/**
+ * Decodes one root body of the given schema within the reader's budget.
+ * @return How far it got; any status but malformed leaves the group skippable.
+ */
+[[nodiscard]] NativeStatus
+decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::body

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

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::scriptable_auth {
+
+/** Type-32 toggle Auth: a volume reference followed by a signed state in {-1, 0, 1}. */
+inline constexpr std::uint8_t kType32SlotType = 32;
+inline constexpr std::uint32_t kType32ComponentClass = 0x80809556;
+inline constexpr std::uint32_t kType32Schema = 0x8080955A;
+inline constexpr std::uint8_t kVolumeSlotType = 60;
+inline constexpr std::size_t kType32BitCount = 57;
+inline constexpr std::size_t kType32ByteCount = 8;
+/** The state follows the volume ClientRef as two bits of state plus one; zero is the no-op. */
+inline constexpr std::uint8_t kType32StateWidth = 2;
+inline constexpr std::uint32_t kType32StateOff = 1;
+inline constexpr std::uint32_t kType32StateOn = 2;
+/** One volume and an explicit on/off state; the no-op state -1 is not representable. */
+struct Type32VolumeBody final {
+    std::uint32_t registryKey{};
+    std::int16_t slotIndex{-1};
+    bool active{};
+};
+/** Encodes one volume toggle into exactly kType32ByteCount bytes. */
+[[nodiscard]] bool encode_type32_volume(const Type32VolumeBody& body,
+                                        std::span<std::byte> output,
+                                        std::size_t& written) noexcept;
+/** Decodes one exact volume toggle body. */
+[[nodiscard]] bool decode_type32_volume(std::span<const std::byte> input,
+                                        std::size_t bitCount,
+                                        Type32VolumeBody& body) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::scriptable_auth

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

@@ -21,6 +21,13 @@ namespace runtime {
 
 /** Row index meaning "no row"; the SDK never numbers a real row this high. */
 inline constexpr std::uint32_t kAbsentRuntimeRow = 0xFFFFFFFFU;
+/** Field type 1 walks one nested schema row. */
+inline constexpr std::uint8_t kNestedType = 1;
+/** Field type 34 carries its schema handle and reads through the selected-schema reader. */
+inline constexpr std::uint8_t kSelectedType = 34;
+/** Field types 12 and 35 are raw 64-bit words, read and written whole. */
+inline constexpr std::uint8_t kRaw64AliasType = 12;
+inline constexpr std::uint8_t kRaw64Type = 35;
 
 /** One runtime schema row supplied by the generated Activity SDK. */
 struct SchemaView final {

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

@@ -786,6 +786,14 @@ private:
                 && resolver_.isZeroBitType(resolver_.context, field.typeCode)) {
                 return RuntimeWalkStatus::complete;
             }
+            // The native union reader dispatches through the per-type table, where a nested
+            // arm is a no-op and a type-34 arm is the ordinary selected-schema reader.
+            if (field.typeCode == runtime::kNestedType) {
+                return RuntimeWalkStatus::complete;
+            }
+            if (field.typeCode == runtime::kSelectedType) {
+                return walk_selected(owner, field, occurrence, depth);
+            }
             return walk_inline_type(owner, field, occurrence, memory, depth + 1);
         }
         if (field.typeCode == 28) {
@@ -817,10 +825,11 @@ private:
                          ValueRole::customIndex);
             return RuntimeWalkStatus::complete;
         }
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const std::uint8_t nativeWidth = raw64 ? 64 : storage_width(field.typeCode);
         const std::uint8_t width = raw64 ? 64 : runtime_declared_width(field);
-        if (nativeWidth == 0 || width == 0) {
+        // A declared width of zero, as a union field's own parameters give, reads no bits.
+        if (nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         const std::uint32_t at = static_cast<std::uint32_t>(reader_.position());
@@ -861,7 +870,7 @@ private:
                             || field.typeCode == 27 || field.typeCode == 28 || field.typeCode == 39
                             || field.typeCode == 40 || field.typeCode == 42 || field.typeCode == 43
                             || field.typeCode == 44 || field.typeCode == 45;
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);
         // A selected field carries its schema handle on the wire, so it is structural but must
@@ -876,7 +885,7 @@ private:
         }
         const std::uint8_t nativeWidth = structural || raw64 ? 0 : storage_width(field.typeCode);
         const std::uint8_t declaredWidth = structural || raw64 ? 0 : runtime_declared_width(field);
-        if (!structural && !raw64 && (nativeWidth == 0 || declaredWidth == 0)) {
+        if (!structural && !raw64 && nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         for (std::uint32_t index = 0; index < repeats; ++index) {

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

@@ -58,7 +58,7 @@ struct RuntimeAuthoredValue final {
     if (field.typeCode == 11) {
         return value.kind == ValueKind::real32 && std::isfinite(value.realValue);
     }
-    if (field.typeCode == 35) {
+    if (is_raw64(field.typeCode)) {
         return value.kind == ValueKind::unsignedInteger;
     }
     const std::uint8_t width = storage_width(field.typeCode);
@@ -234,10 +234,11 @@ private:
         if (!valid_runtime_scalar(field, value)) {
             return RuntimeWalkStatus::unsupportedField;
         }
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const std::uint8_t nativeWidth = raw64 ? 64 : storage_width(field.typeCode);
         const std::uint8_t declaredWidth = raw64 ? 64 : runtime_declared_width(field);
-        if (nativeWidth == 0 || declaredWidth == 0) {
+        // Mirrors the decoder: a zero declared width writes no bits.
+        if (nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         std::uint64_t decoded = 0;
@@ -784,6 +785,14 @@ private:
                 && resolver_.isZeroBitType(resolver_.context, field.typeCode)) {
                 return RuntimeWalkStatus::complete;
             }
+            // The native union reader dispatches through the per-type table, where a nested
+            // arm is a no-op and a type-34 arm is the ordinary selected-schema reader.
+            if (field.typeCode == runtime::kNestedType) {
+                return RuntimeWalkStatus::complete;
+            }
+            if (field.typeCode == runtime::kSelectedType) {
+                return write_selected(owner, field, occurrence, depth);
+            }
             return write_inline_type(owner, field, occurrence, memory, depth + 1);
         }
         if (field.typeCode == 28) {
@@ -842,7 +851,7 @@ private:
         const bool nestedSchemaKind =
             nested || command || entityReference || schemaReference || custom;
         const bool unknownOptional = field.presence != 0 && !nestedSchemaKind && !selected
-                                     && !zeroBit && field.typeCode != 35
+                                     && !zeroBit && !is_raw64(field.typeCode)
                                      && storage_width(field.typeCode) == 0;
         const bool structural = nestedSchemaKind || selected || zeroBit || unknownOptional;
         if (!nestedSchemaKind && field.nestedSchemaRow != runtime::kAbsentRuntimeRow) {

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

@@ -114,6 +114,11 @@ public:
     }
 }
 
+/** @return True for a raw 64-bit field type. */
+[[nodiscard]] constexpr bool is_raw64(std::uint8_t typeCode) noexcept {
+    return typeCode == runtime::kRaw64AliasType || typeCode == runtime::kRaw64Type;
+}
+
 /** @return A low bit mask of the given width. Width 0 masks everything away. */
 [[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
     return width == 64  ? (std::numeric_limits<std::uint64_t>::max)()

+ 38 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp

@@ -1,4 +1,5 @@
 #include <array>
+#include <bitset>
 #include <cstddef>
 #include <limits>
 #include <memory>
@@ -370,6 +371,43 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
         }
         return context.registry->slots[slot];
     };
+    // The client's anchor pass runs before its remove pass: an entity that receives a record
+    // detaches to root every descendant that received none in the same batch. A parent sent as a
+    // direct element therefore loses its children, and they outlive its removal as roots.
+    std::bitset<kMaximumEntitySlot + 1U> recorded{};
+    for (std::size_t index = 0; index < count; ++index) {
+        if (!candidate.ignoredRecordMask.test(index)) {
+            recorded.set(entity_record_at(batch, index).token.slot);
+        }
+    }
+    const auto& retained = context.registry->slots;
+    for (std::size_t slot = 0; slot < retained.size(); ++slot) {
+        if (recorded.test(slot) || !retained[slot].occupied || !retained[slot].anchorPresent) {
+            continue;
+        }
+        // The walk detaches such a child without descending into it, so the child's own subtree
+        // moves with it: only the direct parent's record matters.
+        const EntityToken parent = retained[slot].anchor;
+        bool detach = false;
+        if (recorded.test(parent.slot)) {
+            const auto& staged = current_slot(parent.slot);
+            detach = staged.occupied && staged.incarnation == parent.incarnation;
+        }
+        if (!detach) {
+            continue;
+        }
+        if (changes == kEntityBatchCapacity) {
+            return false;
+        }
+        auto& change = change_at(changes++);
+        change.slot = static_cast<std::uint16_t>(slot);
+        change.expected = retained[slot];
+        change.replacement = change.expected;
+        change.replacement.anchor = {};
+        change.replacement.anchorPresent = false;
+        change.replacement.anchorOrder = 0;
+        ++candidate.detachedCount;
+    }
     std::array<EntityToken, kEntityBatchCapacity> terminals{};
     std::size_t terminalCount = 0;
     const auto append_terminal = [&](EntityToken token) {

+ 85 - 30
Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp

@@ -1,5 +1,6 @@
 #include <algorithm>
 #include <array>
+#include <cmath>
 #include <cstring>
 #include <limits>
 #include <memory>
@@ -18,6 +19,21 @@ namespace bits = middleware::encoding::bits;
 constexpr std::uint32_t kMaximumByteField = 0xFFU;
 /** A schema reference the mirror retains must fit one 32-bit tag. */
 constexpr std::uint64_t kMaximumSemanticTag = 0xFFFFFFFFULL;
+/** Field type 22 is the native nullable-u32 reader: one present bit, then 32 bits. */
+constexpr std::uint8_t kNullableU32Type = 22;
+/** Field type 21 reads through the nullable-u32 reader in every family. */
+constexpr std::uint8_t kNullableU32AliasType = 21;
+/** In mode one, field type 37 is a plain nested walk of kNestedCustomSchema. */
+constexpr std::uint8_t kNestedCustomType = 37;
+/** The type-37 schema: a 19-bit real and a 5-bit int8, 24 bits in all. */
+constexpr std::uint32_t kNestedCustomSchema = 0x808092FFU;
+/** The damage component replicates each level pool as its own walk of kDamagePoolSchema. */
+constexpr std::uint32_t kDamageComponent = 0x80804BEEU;
+/** One pool level; its single field is the only value it decodes. */
+constexpr std::uint32_t kDamagePoolSchema = 0x80804C5CU;
+/** Pool 0 is health and pool 1 the shield. */
+constexpr std::uint8_t kHealthPool = 0;
+constexpr std::uint8_t kShieldPool = 1;
 
 /** Resolves one schema only when it supports the selected codec family. */
 [[nodiscard]] bool
@@ -79,6 +95,46 @@ read_schema(const void* raw, std::uint32_t rowIndex, wire::runtime::SchemaView&
            && output.row == rowIndex;
 }
 
+/** Reference types that share one native reader decode through one walker case. */
+[[nodiscard]] std::uint8_t reference_alias(format::RuntimeCodecFamily family,
+                                           std::uint8_t type) noexcept {
+    if (family != format::RuntimeCodecFamily::sobjectModeZero && type == 30) {
+        return 18;
+    }
+    if (type == kNullableU32AliasType) {
+        return kNullableU32Type;
+    }
+    if (family == format::RuntimeCodecFamily::sobjectModeOne) {
+        // Types 18, 20, 30 and 31 share one native mode-one reader.
+        if (type == 20 || type == 31) {
+            return 18;
+        }
+        if (type == 29) {
+            return 43;
+        }
+    }
+    return type;
+}
+
+/** Rewrites one field view to the type its family's native reader implements. */
+[[nodiscard]] bool canonicalize_field(const void* raw,
+                                      format::RuntimeCodecFamily family,
+                                      wire::runtime::FieldView& output) noexcept {
+    output.typeCode = reference_alias(family, output.typeCode);
+    if (family == format::RuntimeCodecFamily::sobjectModeOne
+        && output.typeCode == kNestedCustomType) {
+        wire::runtime::SchemaView nested{};
+        if (!find_schema(raw, kNestedCustomSchema, nested)) {
+            return false;
+        }
+        output.typeCode = wire::runtime::kNestedType;
+        output.nestedSchemaRow = nested.row;
+        // Its fields carry no presence bits, so it needs no slot in the component bitmap.
+        output.hasBitmapOffset = true;
+    }
+    return true;
+}
+
 /** Converts one SDK field row into the generic runtime walker view. */
 [[nodiscard]] bool
 read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& output) noexcept {
@@ -101,17 +157,7 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
             }
             output.nestedSchemaRow = target.row;
         }
-        if ((context->family != format::RuntimeCodecFamily::sobjectModeZero
-             && output.typeCode == 30)
-            || (context->family == format::RuntimeCodecFamily::sobjectModeOne
-                && output.typeCode == 20)) {
-            output.typeCode = 18;
-        }
-        if (context->family == format::RuntimeCodecFamily::sobjectModeOne
-            && output.typeCode == 29) {
-            output.typeCode = 43;
-        }
-        return true;
+        return canonicalize_field(raw, context->family, output);
     }
     const format::RuntimeField& row = context->catalog->runtimeFields[rowIndex];
     if (row.schemaIndex >= context->catalog->runtimeSchemas.size()
@@ -161,13 +207,8 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
                                     ? 0
                                     : static_cast<std::int32_t>(row.bits);
     output.typeCode = static_cast<std::uint8_t>(row.typeCode);
-    if ((context->family != format::RuntimeCodecFamily::sobjectModeZero && output.typeCode == 30)
-        || (context->family == format::RuntimeCodecFamily::sobjectModeOne
-            && output.typeCode == 20)) {
-        output.typeCode = 18;
-    }
-    if (context->family == format::RuntimeCodecFamily::sobjectModeOne && output.typeCode == 29) {
-        output.typeCode = 43;
+    if (!canonicalize_field(raw, context->family, output)) {
+        return false;
     }
     output.presence =
         static_cast<std::uint8_t>((row.flags & format::kRuntimeFieldPresenceBit) != 0);
@@ -229,18 +270,7 @@ static std::uint8_t canonical_type(const void* raw, std::uint8_t type) noexcept
     if (context.family == format::RuntimeCodecFamily::sobjectModeZero && type == 27) {
         return 0;
     }
-    if (context.family != format::RuntimeCodecFamily::sobjectModeZero && type == 30) {
-        return 18;
-    }
-    if (context.family == format::RuntimeCodecFamily::sobjectModeOne) {
-        if (type == 20) {
-            return 18;
-        }
-        if (type == 29) {
-            return 43;
-        }
-    }
-    return type;
+    return reference_alias(context.family, type);
 }
 
 /** The native action selector chooses one exact SDK-declared payload schema. */
@@ -532,6 +562,31 @@ runtime_schema(const CompositeEntityCodecContext& context, std::uint32_t handle)
         }
         *semanticTag = tag;
     }
+    if (resolverContext.componentTag == kDamageComponent) {
+        for (const auto& value : std::span(values).first(result.valueCount)) {
+            if (value.schemaHandle != kDamagePoolSchema || !value.present) {
+                continue;
+            }
+            const double level = value.kind == wire::ValueKind::real32
+                                     ? static_cast<double>(value.realValue)
+                                 : value.kind == wire::ValueKind::signedInteger
+                                     ? static_cast<double>(value.signedValue)
+                                     : static_cast<double>(value.unsignedValue);
+            if (!(level >= 0.0 && level <= static_cast<double>(kDamageLevelFull))) {
+                continue;
+            }
+            const auto rounded = static_cast<std::uint16_t>(std::lround(level));
+            if (mirror.damagePools == kHealthPool) {
+                mirror.damageHealth = rounded;
+                mirror.damageKnown = true;
+            } else if (mirror.damagePools == kShieldPool) {
+                mirror.damageShield = rounded;
+            }
+            if (mirror.damagePools != (std::numeric_limits<std::uint8_t>::max)()) {
+                ++mirror.damagePools;
+            }
+        }
+    }
     if (resolverContext.componentTag == 0x80C70EDCU && schemaHandle == 0x80C70EDCU) {
         wire::runtime::SchemaView component{}, source{}, reference{};
         wire::runtime::FieldView componentField{}, sourceField{};

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

@@ -101,6 +101,8 @@ struct EntityBaselineMutation final : EntityBaselineChange {
     bool scoped{};
     std::array<EntityBaselineChange, kEntityBatchCapacity - 1> additionalChanges{};
     std::uint16_t additionalChangeCount{};
+    /** Descendants this batch detached because an ancestor received a record and they did not. */
+    std::uint16_t detachedCount{};
     std::uint64_t expectedAnchorOrder{}, replacementAnchorOrder{};
 };
 

+ 8 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_codec_internal.h

@@ -45,6 +45,11 @@ struct ResolverContext final {
 class MirrorBuilder final {
 public:
     state::gameplay::entity_identity::ActorSourceReference actorSource{};
+    std::uint16_t damageHealth{};
+    std::uint16_t damageShield{};
+    bool damageKnown{};
+    /** Damage pools seen so far; each pool is its own nested walk. */
+    std::uint8_t damagePools{};
     /** Opens an empty fixed-capacity bit writer. */
     MirrorBuilder() noexcept : writer_(bytes_) {}
 
@@ -74,6 +79,9 @@ public:
         std::memcpy(candidate.state.data() + sizeof(header), bytes_.data(), byteCount);
         candidate.byteCount = static_cast<std::uint16_t>(sizeof(header) + byteCount);
         candidate.actorSource = actorSource;
+        candidate.damageHealth = damageHealth;
+        candidate.damageShield = damageShield;
+        candidate.damageKnown = damageKnown;
         output = candidate;
         return true;
     }

+ 29 - 0
Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp

@@ -3,6 +3,7 @@
 #include <array>
 #include <memory>
 #include <new>
+#include <span>
 
 namespace sunrise::middleware::gameplay::external {
 namespace {
@@ -236,6 +237,33 @@ static TypePayloadCodec batch_codec(BatchCodecContext& context) noexcept {
     return (record.flags & entityUpdate) != 0 || record.update.byteCount == 0;
 }
 
+/**
+ * The client sends a child that is being removed as its own direct element, not inside its parent's
+ * implicit group. Drops those children from one resolved group; the anchor at index zero stays.
+ */
+static void drop_leaving_children(const EntityBatch& batch,
+                                  std::span<EntityToken> tokens,
+                                  std::size_t& count) noexcept {
+    const auto leaving = [&](const EntityToken& token) noexcept {
+        for (std::size_t index = 0; index < entity_record_count(batch); ++index) {
+            const auto& direct = entity_record_at(batch, index);
+            if (!direct.implicitToken && (direct.flags & entityRemove) != 0
+                && direct.token.slot == token.slot
+                && direct.token.incarnation == token.incarnation) {
+                return true;
+            }
+        }
+        return false;
+    };
+    std::size_t kept = 1;
+    for (std::size_t index = 1; index < count; ++index) {
+        if (!leaving(tokens[index])) {
+            tokens[kept++] = tokens[index];
+        }
+    }
+    count = count == 0 ? 0 : kept;
+}
+
 /** Writes one token in slot-then-incarnation order. */
 [[nodiscard]] bool write_token(encoding::bits::Writer& writer, const EntityToken& token) noexcept {
     return valid_token(token) && writer.write(token.slot, kEntitySlotWidth)
@@ -405,6 +433,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
                     || anchorCount == 0 || anchorCount > anchorTokens.size()) {
                     return false;
                 }
+                drop_leaving_children(batch, anchorTokens, anchorCount);
                 anchorIndex = 0;
             }
             if (anchorIndex >= anchorCount || anchorTokens[anchorIndex].slot != record.token.slot

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

@@ -27,6 +27,8 @@ inline constexpr std::uint16_t kNoEntityCell = 0xFFFF;
 inline constexpr std::size_t kMaximumTypePayloadBits = 16'384;
 /** Callback state can retain one complete bounded body plus its typed mirror header. */
 inline constexpr std::size_t kTypePayloadStateCapacity = kMaximumTypePayloadBits / 8U + 16U;
+/** A damage pool replicates a ten-bit level, so 1023 is full. */
+inline constexpr std::uint16_t kDamageLevelFull = 1023;
 
 /** Record flags transported by the five explicit envelope bits. */
 enum EntityRecordFlag : std::uint16_t {
@@ -68,6 +70,10 @@ struct TypePayload {
     std::array<std::byte, kTypePayloadStateCapacity> state{};
     std::uint16_t byteCount{};
     state::gameplay::entity_identity::ActorSourceReference actorSource{};
+    /** Damage component 0x80804BEE pools 0 and 1, ten-bit levels; valid when damageKnown. */
+    std::uint16_t damageHealth{};
+    std::uint16_t damageShield{};
+    bool damageKnown{};
 };
 
 /** One generic channel-2 record and its callback-owned type state. */

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

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

+ 8 - 4
Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp

@@ -299,7 +299,7 @@ struct PreparedSquad final {
     if (squad.scenarioIndex != view.scenarioRow) {
         return Status::wrongScenario;
     }
-    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask) {
+    if (!format::squad_runnable(squad.flags)) {
         return Status::notRunnable;
     }
     const Status members = member_status(catalog, squad, squadRow, requestedCounts);
@@ -437,11 +437,15 @@ retirement_eligibility(const sdk::BoundView& view,
                                         std::optional<std::uint32_t> spawnRuleSlotRow,
                                         std::optional<squad_auth::SpawnRule>& output) noexcept {
     output.reset();
-    if (!spawnRuleSlotRow.has_value()) {
-        return Status::ready;
-    }
     const auto squads = catalog.squads();
     const auto slots = catalog.slots();
+    if (!spawnRuleSlotRow.has_value()) {
+        // A spawner with no rule of its own cannot be placed without a selected one.
+        return squadRow < squads.size()
+                       && (squads[squadRow].flags & format::kSquadRequiresSelectedRule) != 0
+                   ? Status::notRunnable
+                   : Status::ready;
+    }
     if (squadRow >= squads.size() || *spawnRuleSlotRow >= slots.size()) {
         return Status::invalidSquad;
     }

+ 54 - 5
Sunrise/src/server/activity/host_runtime.h

@@ -148,11 +148,13 @@ enum class EventKind : std::uint8_t {
     regionChanged = 40,
     /** A watched authored scene newly reported one of its output gates; from one msg 6 body. */
     sceneOutput = 41,
+    /** Validated type-30 level or explicit continuity invalidation. */
+    triggerState = 42,
 };
 
 /** 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::sceneOutput) + 1U;
+    static_cast<std::size_t>(EventKind::triggerState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -526,6 +528,10 @@ struct Event final {
     std::int32_t triggerValue{};
     /** True when the whole watched set is inside the volume. */
     bool triggerAll{};
+    /** For triggerState events: whether a level was reported, and the volume's occupancy. */
+    bool triggerAvailable{}, triggerOccupied{};
+    /** trigger_observation::Continuity of the report, for triggerState events. */
+    std::uint8_t triggerContinuity{};
     /** Health and shield fractions, for damageState events. Negative until published. */
     float damageHealth{-1.0F};
     float damageShield{-1.0F};
@@ -569,6 +575,11 @@ struct Event final {
     bool squadObjectiveCostQualified{};
     /** Per-slot member counts the client published, for squadState events. */
     std::array<std::int32_t, kSquadSlotCapacity> squadSlotCounts{};
+    /** Spawn generation the squad Sense echoes from its placement Auth; not the record counter. */
+    std::int32_t squadSpawnGeneration{};
+    bool squadHasSpawnGeneration{};
+    /** An alive count was reported for this squad; until then the counts are zero placeholders. */
+    bool squadPopulationAvailable{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
     std::int32_t squadAliveCount{};
     /** Alive count this observation replaced, for entityDied events. */
@@ -583,6 +594,14 @@ struct Event final {
     std::uint8_t squadSlotOrdinal{};
     /** Client flag written on its actor death or removal path. The meaning is unproved. */
     bool squadRemovalFlag{};
+    /** Msg-6 record counter plus one; not a squad or actor lifetime identity. */
+    std::uint32_t senseGenerationPlusOne{};
+    bool hasSenseGeneration{};
+    /** First report of this squad or object generation; it never proves a death. */
+    bool initialObservation{};
+    /** Authored entry index and spawn-mask words, for objectState events. */
+    std::int32_t objectEntryIndex{};
+    std::array<std::uint32_t, 2> objectSpawnMask{};
     /** Activation token the authored scene echoed, for sceneFinished and sceneOutput events. */
     std::int32_t sceneActivationToken{};
     /** Output gate key the scene newly reported, for sceneOutput events. */
@@ -924,11 +943,41 @@ request_squad_objective(const state::activity::SessionBinding& binding,
                         std::uint64_t expectedActivityClientGeneration,
                         const ScriptableOutputReservation& reservation) noexcept;
 
-/** Queues one owned msg-6 prefix for the Activity Host service. */
-[[nodiscard]] bool submit_sense(const SenseInput& input) noexcept;
+/** Names the gate that declined one ingress submission. */
+enum class IngressRefusal : std::uint8_t {
+    /** The submission was queued. */
+    none,
+    /** The named session no longer matches a live binding. */
+    binding,
+    /** The submission carried no source generation to bind it to. */
+    generation,
+    /** The shared codec refused a bounded outer field. */
+    payload,
+    /** The pending-input ring held no free slot. */
+    queue,
+};
+
+/**
+ * Names the ingress gate that declined, for the skip diagnostic. Every name starts with
+ * `host_ingress_refused`.
+ * @param refusal Gate reported by one failed submission.
+ * @return Stable reason name.
+ */
+[[nodiscard]] const char* ingress_refusal_name(IngressRefusal refusal) noexcept;
 
-/** Queues one owned, outer-valid client msg 19 for the Activity Host service. */
-[[nodiscard]] bool submit_incident(const IncidentInput& input) noexcept;
+/**
+ * Queues one owned msg-6 prefix for the Activity Host service.
+ * @param input Owned prefix and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_sense(const SenseInput& input) noexcept;
+
+/**
+ * Queues one owned, outer-valid client msg 19 for the Activity Host service.
+ * @param input Parsed incident and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_incident(const IncidentInput& input) noexcept;
 
 /** Queues one committed client msg-22 numeric after-image for the Activity Host service. */
 [[nodiscard]] bool submit_client_state_change(const ClientStateChangeInput& input) noexcept;

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

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

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

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

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

@@ -587,9 +587,12 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
 } // namespace detail
 
 /** Queues one owned msg-6 prefix for the Activity Host service. */
-bool submit_sense(const SenseInput& input) noexcept {
-    if (!state::activity::binding_matches(input.binding) || input.sourceGeneration == 0) {
-        return false;
+IngressRefusal submit_sense(const SenseInput& input) noexcept {
+    if (!state::activity::binding_matches(input.binding)) {
+        return IngressRefusal::binding;
+    }
+    if (input.sourceGeneration == 0) {
+        return IngressRefusal::generation;
     }
     AcquireSRWLockExclusive(&g_lock);
     PendingInput pending{};
@@ -598,11 +601,11 @@ bool submit_sense(const SenseInput& input) noexcept {
     if (!append_pending(pending)) {
         ++g_droppedIngress;
         ReleaseSRWLockExclusive(&g_lock);
-        return false;
+        return IngressRefusal::queue;
     }
     ++g_queuedIngress;
     ReleaseSRWLockExclusive(&g_lock);
-    return true;
+    return IngressRefusal::none;
 }
 
 /** Copies initialized recovery state for one exact ActivityClient generation. */

+ 74 - 7
Sunrise/src/server/activity/mission/mission_api_events.h

@@ -52,6 +52,9 @@ enum class FieldGroupId : std::uint8_t {
     ghostLink,
     squadProvoked,
     fireteam,
+    senseGeneration,
+    squadRemoval,
+    triggerLevel,
     count,
 };
 
@@ -116,6 +119,16 @@ inline constexpr ValueSpec kTaskGroupReturns[]{
 };
 inline constexpr ValueSpec kAppliedRequestArguments[]{
     {.name = "channel", .kind = ValueKind::enumValue, .ref = "device_channels"}};
+inline constexpr ValueSpec kTaskCosts[]{
+    {.kind = ValueKind::table,
+     .element = kNumber,
+     .note = "reported cost per task group, keyed from 1; a group without one is absent"}};
+inline constexpr ValueSpec kContinuity[]{
+    {.kind = ValueKind::string,
+     .note = "baseline, consecutive, duplicate, conflict, gap, reset, missing_counter, wrap or "
+             "invalid"}};
+inline constexpr ValueSpec kOptionalBoolean[]{{.kind = ValueKind::boolean, .optional = true}};
+inline constexpr std::string_view kUnavailableLevel = "nil when available is false";
 
 // --- Field groups ------------------------------------------------------------------------------
 // Members of each group, then the group itself.
@@ -220,8 +233,15 @@ inline constexpr MemberSpec kSquadStateFields[]{
      .arguments = kTaskGroupQueryArguments,
      .returns = kTaskGroupReturns,
      .note = "the group the squad holds for this objective"},
-    field("removal_flag", kBoolean),
     field("slot_counts", kIntegerList),
+    field("initial_observation", kBoolean, "the first report of this squad; never a death"),
+    field("population_available",
+          kBoolean,
+          "false until an alive count is reported; until then the counts are zero placeholders"),
+    field("objective_revision",
+          kInteger,
+          "objective revision the squad echoed with its costs; 0 before any"),
+    field("task_costs", kTaskCosts),
 };
 inline constexpr MemberSpec kEntitySpawnedFields[]{
     field("member_slot", kInteger),
@@ -294,6 +314,10 @@ inline constexpr MemberSpec kObjectFields[]{
     field("owner_known", kBoolean),
     field("has_owner", kBoolean),
     field("owner_key", kOptionalDecimal, "nil without an owner"),
+    field("initial_observation", kBoolean, "first report of this generation"),
+    field("entry_index", kInteger, "authored entry the object instantiated"),
+    field("spawn_mask_low", kInteger),
+    field("spawn_mask_high", kInteger),
 };
 inline constexpr MemberSpec kGhostLinkFields[]{
     field("generation", kInteger),
@@ -310,6 +334,22 @@ inline constexpr MemberSpec kFireteamFields[]{
     field("dead_count", kInteger),
     field("unknown_count", kInteger),
 };
+inline constexpr MemberSpec kSenseGenerationFields[]{
+    field("spawn_generation",
+          kOptionalInteger,
+          "spawn generation the squad Sense echoes from its placement; nil when unreported"),
+    field("sense_generation", kOptionalInteger, "msg-6 record counter plus one; nil when absent"),
+};
+inline constexpr MemberSpec kSquadRemovalFields[]{
+    field("removal_flag", kBoolean, "false when the report did not carry it")};
+inline constexpr MemberSpec kTriggerLevelFields[]{
+    field("available", kBoolean, "the report carried a complete occupancy level"),
+    field("continuity", kContinuity),
+    field("occupied", kOptionalBoolean, kUnavailableLevel),
+    field("member_count", kOptionalInteger, kUnavailableLevel),
+    field("value", kOptionalInteger, kUnavailableLevel),
+    field("all_inside", kOptionalBoolean, kUnavailableLevel),
+};
 
 /** Every field group, indexed by FieldGroupId. */
 inline constexpr std::array<FieldGroup, static_cast<std::size_t>(FieldGroupId::count)> kFieldGroups{
@@ -350,6 +390,9 @@ inline constexpr std::array<FieldGroup, static_cast<std::size_t>(FieldGroupId::c
         {"ghost_link", kGhostLinkFields},
         {"squad_provoked", kSquadProvokedFields},
         {"fireteam", kFireteamFields},
+        {"sense_generation", kSenseGenerationFields},
+        {"squad_removal", kSquadRemovalFields},
+        {"trigger_level", kTriggerLevelFields},
     }};
 
 /** @return The address of one field group, for an event's group list. */
@@ -391,15 +434,29 @@ inline constexpr const FieldGroup* kPhaseGroups[]{
     group(G::common), group(G::missionSequence), group(G::phase), group(G::stateRevision)};
 inline constexpr const FieldGroup* kTriggerGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::trigger)};
+inline constexpr const FieldGroup* kTriggerStateGroups[]{group(G::common),
+                                                         group(G::missionSequence),
+                                                         group(G::slotIdentity),
+                                                         group(G::senseGeneration),
+                                                         group(G::triggerLevel)};
 inline constexpr const FieldGroup* kSquadStateGroups[]{group(G::common),
                                                        group(G::missionSequence),
                                                        group(G::slotIdentity),
+                                                       group(G::senseGeneration),
                                                        group(G::squadAlive),
+                                                       group(G::squadRemoval),
                                                        group(G::squadState)};
-inline constexpr const FieldGroup* kEntitySpawnedGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::entitySpawned)};
-inline constexpr const FieldGroup* kEntityDiedGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::squadAlive)};
+inline constexpr const FieldGroup* kEntitySpawnedGroups[]{group(G::common),
+                                                          group(G::missionSequence),
+                                                          group(G::slotIdentity),
+                                                          group(G::senseGeneration),
+                                                          group(G::entitySpawned)};
+inline constexpr const FieldGroup* kEntityDiedGroups[]{group(G::common),
+                                                       group(G::missionSequence),
+                                                       group(G::slotIdentity),
+                                                       group(G::senseGeneration),
+                                                       group(G::squadAlive),
+                                                       group(G::squadRemoval)};
 inline constexpr const FieldGroup* kSceneFinishedGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::sceneFinished)};
 inline constexpr const FieldGroup* kObjectiveProgressGroups[]{group(G::common),
@@ -420,8 +477,11 @@ inline constexpr const FieldGroup* kGhostLinkGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::ghostLink)};
 inline constexpr const FieldGroup* kActorPathGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::actorPath)};
-inline constexpr const FieldGroup* kObjectGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::object)};
+inline constexpr const FieldGroup* kObjectGroups[]{group(G::common),
+                                                   group(G::missionSequence),
+                                                   group(G::slotIdentity),
+                                                   group(G::senseGeneration),
+                                                   group(G::object)};
 inline constexpr const FieldGroup* kFireteamGroups[]{
     group(G::common), group(G::missionSequence), group(G::fireteam)};
 inline constexpr const FieldGroup* kDamageGroups[]{
@@ -597,6 +657,13 @@ inline constexpr EventSpec kEvents[]{
     {39, "DEVICE_STATE", "on_event_device_state", "SunriseDeviceStateEvent", kDeviceGroups},
     {40, "REGION_CHANGED", "on_event_region_changed", "SunriseRegionChangedEvent", kRegionGroups},
     {41, "SCENE_OUTPUT", "on_event_scene_output", "SunriseSceneOutputEvent", kSceneOutputGroups},
+    {42,
+     "TRIGGER_STATE",
+     "on_event_trigger_state",
+     "SunriseTriggerStateEvent",
+     kTriggerStateGroups,
+     "every accepted occupancy report and its counter continuity; it never changes when "
+     "entered and exited fire"},
 };
 
 // --- Program table -----------------------------------------------------------------------------

+ 33 - 1
Sunrise/src/server/activity/mission/mission_api_slot.h

@@ -10,6 +10,7 @@
 #include "../../../middleware/bap/activity_message/music_section_auth.h"
 #include "../../../middleware/bap/activity_message/scriptable_auth_body.h"
 #include "../../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "../../../state/activity/mission/definition.h"
 #include "../../../state/activity_sdk/format.h"
 #include "mission_api_types.h"
@@ -58,6 +59,8 @@ inline constexpr ValueSpec kAuthType[]{{.kind = ValueKind::string,
 // --- Slot method arguments ----------------------------------------------------------------------
 inline constexpr ValueSpec kVolume[]{
     {.kind = ValueKind::handle, .ref = "slot", .slot = type_gate(slot_auth::kType60SlotType)}};
+inline constexpr ValueSpec kFilterObject[]{
+    {.kind = ValueKind::handle, .ref = "slot", .slot = type_gate(slot_format::kObjectSlotType)}};
 inline constexpr ValueSpec kObjectFilterArguments[]{
     {.name = "players",
      .kind = ValueKind::boolean,
@@ -70,6 +73,18 @@ inline constexpr ValueSpec kObjectFilterArguments[]{
      .optional = true,
      .slot = type_gate(slot_format::kObjectSlotType),
      .note = "adds a test: this one object"},
+    {.name = "targets",
+     .kind = ValueKind::list,
+     .optional = true,
+     .bound = between(1, static_cast<std::int64_t>(slot_auth::kType34PredicateCapacity)),
+     .element = kFilterObject,
+     .note = "adds one test per object"},
+    {.name = "squad",
+     .kind = ValueKind::handle,
+     .ref = "slot",
+     .optional = true,
+     .slot = type_gate(slot_format::kSquadSlotType),
+     .note = "adds a test: this squad"},
     {.name = "inside",
      .kind = ValueKind::handle,
      .ref = "slot",
@@ -83,6 +98,15 @@ inline constexpr ValueSpec kObjectFilterArguments[]{
      .element = kVolume,
      .note = "adds a test: objects inside any of these volumes"},
 };
+inline constexpr ValueSpec kVolumeActiveArguments[]{
+    {.name = "volume",
+     .kind = ValueKind::handle,
+     .ref = "slot",
+     .slot = type_gate(slot_auth::kVolumeSlotType),
+     .scope = Scope::sameObject,
+     .note = "an authored volume of the toggle's own object"},
+    {.name = "active", .kind = ValueKind::boolean, .note = "true turns it on, false off"},
+};
 inline constexpr ValueSpec kWatchDamageArguments[]{
     {.name = "target", .kind = ValueKind::handle, .ref = "slot", .slot = kExactObject},
 };
@@ -433,7 +457,15 @@ inline constexpr MemberSpec kSlotMembers[]{
      .call = CallForm::named,
      .gate = schema_gate(slot_auth::kType34SlotType),
      .arguments = kObjectFilterArguments,
-     .returns = kRequestKey},
+     .returns = kRequestKey,
+     .note = "all tests share eight predicates; a filter that needs more is refused"},
+    {.name = "set_volume_active",
+     .kind = MemberKind::request,
+     .call = CallForm::named,
+     .gate = exact_class_gate(slot_auth::kType32SlotType),
+     .arguments = kVolumeActiveArguments,
+     .returns = kRequestKey,
+     .note = "turns one authored volume on or off through its type-32 toggle"},
     {.name = "watch_damage",
      .kind = MemberKind::request,
      .call = CallForm::named,

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

@@ -31,8 +31,7 @@ dispatch_actor_command(const RuntimeInstance& instance, const lua_vm::Intent& in
     }
     const auto squads = published->squads();
     if (intent.firstRow >= squads.size()
-        || (squads[intent.firstRow].flags & format::kSquadRunnableMask)
-               != format::kSquadRunnableMask) {
+        || !format::squad_runnable(squads[intent.firstRow].flags)) {
         return ActorCommandPolicyStatus::refused;
     }
     const auto commands = published->actor_command_definitions();

+ 76 - 16
Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp

@@ -131,12 +131,22 @@ constexpr const api::MemberSpec& kApplied = api::slot_member("applied");
 constexpr std::size_t kOccupancyAuthBitCount = 87;
 constexpr std::size_t kOccupancyAuthByteCount = 11;
 
-/** Volumes one filter may test; leaves room for the players, target and inside predicates. */
+/** Volumes one filter may test. Every predicate still shares the type-34 capacity. */
 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;
 
+/** Appends one predicate. @return False when the type-34 body is already full. */
+[[nodiscard]] bool append_predicate(scriptable_auth::Type34Body& body,
+                                    const scriptable_auth::Type34Predicate& predicate) noexcept {
+    if (body.count >= scriptable_auth::kType34PredicateCapacity) {
+        return false;
+    }
+    body.predicates[body.count++] = predicate;
+    return true;
+}
+
 } // namespace
 
 /**
@@ -210,7 +220,10 @@ constexpr std::int8_t kFilterModeInside = 1;
     return queue_slot_auth(state, slot, darkness::kSchema, darkness::kBits, body);
 }
 
-/** Native typed object filters: players, one object, and volume intersection. */
+/**
+ * Native typed object filters: players, one or several objects, a squad, and volume intersection.
+ * Every predicate shares the type-34 capacity; a filter that would exceed it is refused.
+ */
 [[nodiscard]] int slot_set_object_filter(lua_State* state) {
     namespace auth = scriptable_auth;
     const auto* const handle =
@@ -221,6 +234,7 @@ constexpr std::int8_t kFilterModeInside = 1;
         || slot.authSchema != auth::kType34Schema) {
         return luaL_error(state, "object filter requires an authored type-34 sensor");
     }
+    constexpr const char* kCapacityError = "object filter exceeds the type-34 predicate capacity";
     auth::Type34Body body{};
     lua_getfield(state, 2, optional_key(kObjectFilter, "inside_any", api::ValueKind::list));
     const bool volumes = !lua_isnil(state, -1);
@@ -239,19 +253,22 @@ constexpr std::int8_t kFilterModeInside = 1;
                 || 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)}};
+            if (!append_predicate(
+                    body,
+                    auth::Type34ModeFlagSlotRef{kFilterModeDirect,
+                                                true,
+                                                {volume.registryKey,
+                                                 static_cast<std::int8_t>(auth::kType60SlotType),
+                                                 static_cast<std::int16_t>(volume.slotIndex)}})) {
+                return luaL_error(state, kCapacityError);
+            }
             lua_pop(state, 1);
         }
     }
     lua_pop(state, 1);
-    if (optional_boolean_argument(state, boolean_option(kObjectFilter, "players"))) {
-        body.predicates[body.count++] =
-            auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes ? 1 : 0)};
+    if (optional_boolean_argument(state, boolean_option(kObjectFilter, "players"))
+        && !append_predicate(body, auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes)})) {
+        return luaL_error(state, kCapacityError);
     }
     auth::Type2LaneClientRef target{};
     if (!optional_slot_reference(state,
@@ -260,8 +277,51 @@ constexpr std::int8_t kFilterModeInside = 1;
                                  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};
+    if (target.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeSlotRefC{kFilterModeDirect, target})) {
+        return luaL_error(state, kCapacityError);
+    }
+    // Several authored objects at once (a beam hop-on names every crystal it tethers).
+    lua_getfield(state, 2, optional_key(kObjectFilter, "targets", api::ValueKind::list));
+    if (!lua_isnil(state, -1)) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const std::size_t count = lua_rawlen(state, -1);
+        if (count == 0) {
+            return luaL_error(state, "targets must name at least one object");
+        }
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            const auto* const targetHandle =
+                static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+            SlotDefinition object{};
+            if (!current_slot(state, *targetHandle, object)
+                || object.slotType != auth::kType4SlotType
+                || object.slotIndex > auth_fields::kMaximumClientRefIndex) {
+                return luaL_error(state, "targets require authored type-4 objects");
+            }
+            if (!append_predicate(
+                    body,
+                    auth::Type34ModeSlotRefC{kFilterModeDirect,
+                                             {object.registryKey,
+                                              static_cast<std::int8_t>(auth::kType4SlotType),
+                                              static_cast<std::int16_t>(object.slotIndex)}})) {
+                return luaL_error(state, kCapacityError);
+            }
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
+    // A squad reference selects the boss a shield or tether hop-on hosts on.
+    auth::Type2LaneClientRef squad{};
+    if (!optional_slot_reference(state,
+                                 optional_key(kObjectFilter, "squad", api::ValueKind::handle),
+                                 format::kSquadSlotType,
+                                 squad)) {
+        return luaL_error(state, "filter squad must be an authored type-1 squad");
+    }
+    if (squad.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeSlotRefC{kFilterModeDirect, squad})) {
+        return luaL_error(state, kCapacityError);
     }
     auth::Type2LaneClientRef inside{};
     if (!optional_slot_reference(state,
@@ -270,9 +330,9 @@ constexpr std::int8_t kFilterModeInside = 1;
                                  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};
+    if (inside.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeFlagSlotRef{kFilterModeInside, false, inside})) {
+        return luaL_error(state, kCapacityError);
     }
     std::array<std::byte, auth::kType34MaximumByteCount> bytes{};
     std::size_t written = 0;

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

@@ -28,6 +28,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "triggerEntered";
     case host::EventKind::triggerExited:
         return "triggerExited";
+    case host::EventKind::triggerState:
+        return "triggerState";
     case host::EventKind::squadState:
         return "squadState";
     case host::EventKind::squadProvoked:

+ 63 - 1
Sunrise/src/server/activity/mission/mission_script_lua_event_fields.cpp

@@ -10,6 +10,7 @@
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_event_internal.h"
 #include "mission_script_lua_internal.h"
+#include "mission_trigger_observation.h"
 
 namespace sunrise::server::activity::mission::lua_vm::detail {
 namespace {
@@ -65,6 +66,16 @@ void first_object_field(lua_State* state, const host::Event& event) {
     }
 }
 
+/** Pushes one flag only when its presence flag is set. */
+template <auto Field, auto Present>
+void present_boolean(lua_State* state, const host::Event& event) {
+    if (event.*Present) {
+        lua_pushboolean(state, event.*Field ? 1 : 0);
+    } else {
+        lua_pushnil(state);
+    }
+}
+
 /** Pushes one integer field only when its presence flag is set. */
 template <auto Field, auto Present>
 void present_integer(lua_State* state, const host::Event& event) {
@@ -360,7 +371,6 @@ constexpr EventBinding kSquadAlive[]{
 constexpr EventBinding kSquadState[]{
     {"task_cost", &event_task_cost},
     {"task_group", &event_task_group},
-    {"removal_flag", nullptr, &boolean_field<&E::squadRemovalFlag>},
     {"slot_counts",
      nullptr,
      [](lua_State* state, const E& event) {
@@ -371,6 +381,20 @@ constexpr EventBinding kSquadState[]{
              lua_rawseti(state, -2, static_cast<lua_Integer>(index + 1));
          }
      }},
+    {"initial_observation", nullptr, &boolean_field<&E::initialObservation>},
+    {"population_available", nullptr, &boolean_field<&E::squadPopulationAvailable>},
+    {"objective_revision", nullptr, &integer_field<&E::squadObjectiveRevision>},
+    {"task_costs",
+     nullptr,
+     [](lua_State* state, const E& event) {
+         lua_createtable(state, static_cast<int>(host::kSquadObjectiveGroupCount), 0);
+         for (unsigned group = 0; group < host::kSquadObjectiveGroupCount; ++group) {
+             if ((event.squadObjectiveCostMask & (1U << group)) != 0) {
+                 lua_pushnumber(state, event.squadObjectiveCosts[group]);
+                 lua_rawseti(state, -2, static_cast<lua_Integer>(group) + 1);
+             }
+         }
+     }},
 };
 constexpr EventBinding kEntitySpawned[]{
     {"member_slot", nullptr, &integer_field<&E::squadSlotOrdinal>},
@@ -456,6 +480,14 @@ constexpr EventBinding kObject[]{
              lua_pushnil(state);
          }
      }},
+    {"initial_observation", nullptr, &boolean_field<&E::initialObservation>},
+    {"entry_index", nullptr, &integer_field<&E::objectEntryIndex>},
+    {"spawn_mask_low",
+     nullptr,
+     [](lua_State* state, const E& event) { lua_pushinteger(state, event.objectSpawnMask[0]); }},
+    {"spawn_mask_high",
+     nullptr,
+     [](lua_State* state, const E& event) { lua_pushinteger(state, event.objectSpawnMask[1]); }},
 };
 constexpr EventBinding kGhostLink[]{
     {"generation", nullptr, &integer_field<&E::ghostGeneration>},
@@ -473,6 +505,33 @@ constexpr EventBinding kFireteam[]{
     {"dead_count", nullptr, &integer_field<&E::fireteamDead>},
     {"unknown_count", nullptr, &integer_field<&E::fireteamUnknown>},
 };
+// Copied from the exact Sense observation, never from an actor token.
+constexpr EventBinding kSenseGeneration[]{
+    {"spawn_generation",
+     nullptr,
+     &present_integer<&E::squadSpawnGeneration, &E::squadHasSpawnGeneration>},
+    {"sense_generation",
+     nullptr,
+     &present_integer<&E::senseGenerationPlusOne, &E::hasSenseGeneration>},
+};
+constexpr EventBinding kSquadRemoval[]{
+    {"removal_flag", nullptr, &boolean_field<&E::squadRemovalFlag>},
+};
+// An invalidation carries no level, so its level fields read nil.
+constexpr EventBinding kTriggerLevel[]{
+    {"available", nullptr, &boolean_field<&E::triggerAvailable>},
+    {"continuity",
+     nullptr,
+     [](lua_State* state, const E& event) {
+         lua_pushstring(state,
+                        trigger_observation::name(
+                            static_cast<trigger_observation::Continuity>(event.triggerContinuity)));
+     }},
+    {"occupied", nullptr, &present_boolean<&E::triggerOccupied, &E::triggerAvailable>},
+    {"member_count", nullptr, &present_integer<&E::triggerCount, &E::triggerAvailable>},
+    {"value", nullptr, &present_integer<&E::triggerValue, &E::triggerAvailable>},
+    {"all_inside", nullptr, &present_boolean<&E::triggerAll, &E::triggerAvailable>},
+};
 
 /** Every group's bindings, indexed by FieldGroupId. */
 constexpr std::array<EventBindings, static_cast<std::size_t>(G::count)> kGroupBindings{{
@@ -512,6 +571,9 @@ constexpr std::array<EventBindings, static_cast<std::size_t>(G::count)> kGroupBi
     kGhostLink,
     kSquadProvoked,
     kFireteam,
+    kSenseGeneration,
+    kSquadRemoval,
+    kTriggerLevel,
 }};
 static_assert([]() consteval {
     for (std::size_t index = 0; index < kGroupBindings.size(); ++index) {

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

@@ -7,6 +7,7 @@
 
 #include "../../../middleware/bap/activity_message/auth_fields.h"
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_internal.h"
 
@@ -104,6 +105,56 @@ namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
 
 namespace {
 
+constexpr const api::MemberSpec& kVolumeActive = api::slot_member("set_volume_active");
+
+/** Reads one required boolean argument; any other type is an error. */
+[[nodiscard]] bool required_boolean(lua_State* state, const char* name) {
+    lua_getfield(state, 2, name);
+    if (!lua_isboolean(state, -1)) {
+        luaL_error(state, "%s must be a boolean", name);
+    }
+    const bool value = lua_toboolean(state, -1) != 0;
+    lua_pop(state, 1);
+    return value;
+}
+
+/** Turns one authored type-60 volume of the same object on or off through its type-32 toggle. */
+[[nodiscard]] int slot_set_volume_active(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    refuse_unknown_arguments(state, kVolumeActive);
+    const SlotHandle target = checked_argument<SlotHandle>(
+        state, required_key(kVolumeActive, "volume", api::ValueKind::handle), kSlotMetatable);
+    const bool active =
+        required_boolean(state, required_key(kVolumeActive, "active", api::ValueKind::boolean));
+    SlotDefinition source{}, volume{};
+    if (!current_slot(state, *handle, source) || !current_slot(state, target, volume)) {
+        return luaL_error(state, "toggle or volume is stale or invalid");
+    }
+    if (source.slotType != scriptable_auth::kType32SlotType
+        || source.componentClass != scriptable_auth::kType32ComponentClass
+        || source.authSchema != scriptable_auth::kType32Schema
+        || (source.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "source must be an exact type-32 toggle sensor");
+    }
+    if (volume.slotType != scriptable_auth::kVolumeSlotType || volume.objectTag != source.objectTag
+        || volume.registryKey != source.registryKey
+        || volume.slotIndex
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return luaL_error(state, "volume must be a same-owner type-60 slot");
+    }
+    std::array<std::byte, scriptable_auth::kType32ByteCount> body{};
+    std::size_t written = 0;
+    if (!scriptable_auth::encode_type32_volume(
+            {volume.registryKey, static_cast<std::int16_t>(volume.slotIndex), active},
+            body,
+            written)) {
+        return luaL_error(state, "native toggle encoder rejected the volume");
+    }
+    return queue_slot_auth(
+        state, source, scriptable_auth::kType32Schema, scriptable_auth::kType32BitCount, body);
+}
+
 /** Pushes one property of the slot's Auth catalog entry, or nil when it has none. */
 template <typename Push> void push_auth(lua_State* state, const SlotDefinition& slot, Push push) {
     const auth_catalog::Type* const auth =
@@ -220,6 +271,7 @@ constexpr std::array<SlotBinding, std::size(api::kSlotMembers)> kSlotBindings{{
      nullptr,
      [](lua_State* state, const SlotDefinition& slot) { lua_pushinteger(state, slot.flags); }},
     {"set_object_filter", &slot_set_object_filter},
+    {"set_volume_active", &slot_set_volume_active},
     {"watch_damage", &slot_watch_damage},
     {"set_object_active", &slot_set_object_active},
     {"set_channel", &slot_set_channel},

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

@@ -3,9 +3,11 @@
 #include <cmath>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <limits>
 #include <string_view>
 
+#include "../../../core/logging/log.h"
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_internal.h"
 
@@ -179,6 +181,49 @@ static_assert(bound_to<Impl>(api::kStateMembers, kStateBindings));
     frame.candidate.phaseChanged = true;
     return 0;
 }
+/** Logs one staged variable write, so the gate state a controller keeps in variables is visible. */
+void log_variable_write(const StateKey& key, const VariableValue& value) noexcept {
+    std::array<char, 32> scalar{};
+    std::string_view text{};
+    int scalarWritten = 0;
+    switch (value.kind) {
+    case VariableValueKind::boolean:
+        text = value.booleanValue ? "true" : "false";
+        break;
+    case VariableValueKind::integer:
+        scalarWritten = std::snprintf(
+            scalar.data(), scalar.size(), "%lld", static_cast<long long>(value.integerValue));
+        break;
+    case VariableValueKind::real:
+        scalarWritten = std::snprintf(scalar.data(), scalar.size(), "%.6g", value.realValue);
+        break;
+    case VariableValueKind::string:
+        text = {value.stringValue.data(),
+                (std::min)(std::size_t{value.stringLength}, value.stringValue.size())};
+        break;
+    }
+    if (scalarWritten > 0) {
+        text = {scalar.data(),
+                (std::min)(static_cast<std::size_t>(scalarWritten), scalar.size() - 1)};
+    }
+    const std::string_view name = state_key_view(key);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=mission_script stage=variable result=set name=%.*s value=%.*s",
+                      static_cast<int>(name.size()),
+                      name.data(),
+                      static_cast<int>(text.size()),
+                      text.data());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::server,
+            core::log::Level::debug,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
 /** Stages one variable write that commits with the enclosing callback. */
 [[nodiscard]] int context_set_variable(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
@@ -204,6 +249,7 @@ static_assert(bound_to<Impl>(api::kStateMembers, kStateBindings));
         ++frame.candidate.variableCount;
     }
     frame.candidate.variables[row] = {key, value};
+    log_variable_write(key, value);
     return 0;
 }
 /** Stages one variable removal that commits with the enclosing callback. */

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

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

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

@@ -18,6 +18,7 @@
 
 #include "../../../core/logging/log.h"
 #include "../../../core/settings/settings.h"
+#include "../../../middleware/gameplay/external/external_entity_codec.h"
 #include "../../../state/activity/mission/runtime.h"
 #include "../../../state/activity/runtime.h"
 #include "../../gameplay/squad_entity_retirement.h"
@@ -254,6 +255,48 @@ void report_squad_provoked(const state::activity::SessionBinding& binding,
     ReleaseSRWLockExclusive(&g_lock);
 }
 
+/**
+ * Queues one replicated damage level for a squad slot, once per change.
+ * @param binding Activity generation that accepted the entity update.
+ * @param sourceGeneration ActivityClient generation that owns the policy.
+ * @param registryKey Authored registry key.
+ * @param slotType Authored slot type: the squad's, or the combatant's own.
+ * @param slotIndex Authored slot index.
+ * @param health Pool 0 of the damage component, 0 to kDamageLevelFull.
+ * @param shield Pool 1 of the damage component, 0 to kDamageLevelFull.
+ */
+void report_squad_damage(const state::activity::SessionBinding& binding,
+                         std::uint64_t sourceGeneration,
+                         std::uint32_t registryKey,
+                         std::uint8_t slotType,
+                         std::uint16_t slotIndex,
+                         std::uint16_t health,
+                         std::uint16_t shield) noexcept {
+    constexpr std::uint16_t full = middleware::gameplay::external::kDamageLevelFull;
+    if (sourceGeneration == 0 || registryKey == 0 || health > full || shield > full
+        || slotIndex > static_cast<std::uint16_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    auto* instance = find_instance(binding);
+    if (instance != nullptr && instance->view.activityClientGeneration == sourceGeneration) {
+        push_entity_damage(*instance,
+                           registryKey,
+                           slotType,
+                           slotIndex,
+                           static_cast<float>(health) / static_cast<float>(full),
+                           static_cast<float>(shield) / static_cast<float>(full),
+                           GetTickCount64());
+    } else {
+        // Nothing else records a level that finds no mission.
+        log_line(core::log::Level::debug,
+                 instance,
+                 "combatant_damage",
+                 instance == nullptr ? "no_instance" : "stale_generation");
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
 /** Retains the last VM stage and status shown on the panel. */
 void note_vm_status(RuntimeInstance& instance,
                     std::string_view stage,
@@ -269,6 +312,7 @@ void clear_sense_levels(RuntimeInstance& instance) noexcept {
     instance.ghostObservations = {};
     instance.damageObservations = {};
     instance.combatantDamageObservations = {};
+    instance.entityDamageObservations = {};
     instance.deviceObservations = {};
     instance.objectInteractionObservations = {};
     instance.actorPathObservations = {};

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

@@ -93,6 +93,18 @@ void report_squad_provoked(const state::activity::SessionBinding& binding,
                            std::uint32_t registryKey,
                            std::uint16_t slotIndex) noexcept;
 
+/**
+ * Publishes an authored actor's replicated damage levels as a damageState event when they change.
+ * Levels are ten-bit fractions of full (1023).
+ */
+void report_squad_damage(const state::activity::SessionBinding& binding,
+                         std::uint64_t sourceGeneration,
+                         std::uint32_t registryKey,
+                         std::uint8_t slotType,
+                         std::uint16_t slotIndex,
+                         std::uint16_t health,
+                         std::uint16_t shield) noexcept;
+
 /** Starts the server mission-script manager when its setting is enabled. */
 void initialize() noexcept;
 /** Writes the authored controller path, `<name>/<name>.lua`, for one 1-based SDK activity row. */

+ 86 - 34
Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp

@@ -5,6 +5,57 @@
 #include "mission_script_runtime_internal.h"
 
 namespace sunrise::server::activity::mission {
+namespace {
+
+/**
+ * Raises one damageState event for a level that just changed, and logs it.
+ * @param key Row identity; an entity-sourced row has no object tag or Sense schema to publish.
+ * @param source "sense" or "entity", for the log only.
+ */
+void push_damage_state(RuntimeInstance& instance,
+                       const host::SenseObservationKey& key,
+                       const CombatantDamageLevel& level,
+                       std::uint64_t sequence,
+                       std::uint64_t tick,
+                       const char* source) noexcept {
+    host::Event event{};
+    event.kind = host::EventKind::damageState;
+    event.binding = instance.view.binding;
+    event.sequence = sequence;
+    event.tick = tick;
+    event.sourceGeneration = instance.view.activityClientGeneration;
+    event.missionSequence = instance.lastMissionSequence;
+    event.firstRegistryKey = key.registryKey;
+    event.firstSlotIndex = key.slotIndex;
+    event.firstSlotType = key.slotType;
+    event.slotObjectTag = key.senseSchema != 0 ? key.objectTag : 0;
+    event.slotSenseSchema = key.senseSchema;
+    event.damageHealth = level.primary;
+    event.damageShield = level.secondary;
+    event.damageRevision = level.revision;
+    std::array<char, 128> details{};
+    const int written = std::snprintf(details.data(),
+                                      details.size(),
+                                      "registry=%08X type=%u slot=%u revision=%d primary=%.4f "
+                                      "secondary=%.4f source=%s",
+                                      key.registryKey,
+                                      static_cast<unsigned>(key.slotType),
+                                      static_cast<unsigned>(key.slotIndex),
+                                      level.revision,
+                                      static_cast<double>(level.primary),
+                                      static_cast<double>(level.secondary),
+                                      source);
+    if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+        log_line(core::log::Level::debug,
+                 &instance,
+                 "combatant_damage",
+                 "changed",
+                 {details.data(), static_cast<std::size_t>(written)});
+    }
+    push_script_event(instance, event);
+}
+
+} // namespace
 
 /**
  * Raises damage-state observations without inferring actor attachment or provocation.
@@ -37,41 +88,42 @@ void push_combatant_damage_edges(RuntimeInstance& instance,
         if (!update_combatant_damage(row->level, body, observation.key.schemaRow)) {
             continue;
         }
-        const auto& level = row->level;
-        host::Event event{};
-        event.kind = host::EventKind::damageState;
-        event.binding = instance.view.binding;
-        event.sequence = observation.sequence;
-        event.tick = observation.tick;
-        event.sourceGeneration = instance.view.activityClientGeneration;
-        event.missionSequence = instance.lastMissionSequence;
-        event.firstRegistryKey = observation.key.registryKey;
-        event.firstSlotIndex = observation.key.slotIndex;
-        event.firstSlotType = observation.key.slotType;
-        event.slotObjectTag = observation.key.objectTag;
-        event.slotSenseSchema = observation.key.senseSchema;
-        event.damageHealth = level.primary;
-        event.damageShield = level.secondary;
-        event.damageRevision = level.revision;
-        std::array<char, 128> details{};
-        const int written = std::snprintf(details.data(),
-                                          details.size(),
-                                          "registry=%08X slot=%u revision=%d primary=%.4f "
-                                          "secondary=%.4f",
-                                          observation.key.registryKey,
-                                          static_cast<unsigned>(observation.key.slotIndex),
-                                          level.revision,
-                                          static_cast<double>(level.primary),
-                                          static_cast<double>(level.secondary));
-        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
-            log_line(core::log::Level::debug,
-                     &instance,
-                     "combatant_damage",
-                     "changed",
-                     {details.data(), static_cast<std::size_t>(written)});
-        }
-        push_script_event(instance, event);
+        push_damage_state(
+            instance, observation.key, row->level, observation.sequence, observation.tick, "sense");
+    }
+}
+
+/**
+ * Raises a damage state from an entity's replicated damage component when its levels change.
+ * Entity rows have no object tag, so the slot type fills that key field and keeps a squad and a
+ * combatant with the same authored index apart.
+ */
+void push_entity_damage(RuntimeInstance& instance,
+                        std::uint32_t registryKey,
+                        std::uint8_t slotType,
+                        std::uint16_t slotIndex,
+                        float primary,
+                        float secondary,
+                        std::uint64_t tick) noexcept {
+    host::SenseObservationKey key{};
+    key.registryKey = registryKey;
+    key.objectTag = slotType;
+    key.senseSchema = 0;
+    key.slotIndex = slotIndex;
+    key.slotType = slotType;
+    auto* const row = find_slot_row(instance.entityDamageObservations, key);
+    if (row == nullptr) {
+        log_line(core::log::Level::warn, &instance, "combatant_damage", "watch_capacity");
+        return;
+    }
+    if (row->level.observed && row->level.primary == primary && row->level.secondary == secondary) {
+        return;
     }
+    row->level.primary = primary;
+    row->level.secondary = secondary;
+    row->level.observed = true;
+    ++row->level.revision;
+    push_damage_state(instance, key, row->level, instance.missionStateRevision, tick, "entity");
 }
 
 } // namespace sunrise::server::activity::mission

+ 84 - 26
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -20,14 +20,10 @@
 namespace sunrise::server::activity::mission {
 namespace {
 
-/** Values in one occupancy body: any, all, intersection count, and copied Auth value. */
-constexpr std::size_t kTriggerOccupancyValueCount = 4;
-constexpr std::uint16_t kTriggerAnyOrdinal = 0;
-constexpr std::uint16_t kTriggerAllOrdinal = 1;
-constexpr std::uint16_t kTriggerCountOrdinal = 2;
-constexpr std::uint16_t kTriggerThresholdOrdinal = 3;
 /** Root ordinal of the squad removal flag. The rest of the root is inert on this build. */
 constexpr std::uint16_t kSquadRemovalOrdinal = 8;
+/** Root ordinal 0 echoes the spawn generation the squad's placement Auth carries. */
+constexpr std::uint16_t kSquadSpawnGenerationOrdinal = 0;
 /** Root ordinals of the type-20 damage Sense body: health, shield, then the echoed revision. */
 constexpr std::uint16_t kDamageHealthOrdinal = 0;
 constexpr std::uint16_t kDamageShieldOrdinal = 1;
@@ -88,6 +84,18 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     event.firstSlotType = observation.key.slotType;
     event.slotObjectTag = observation.key.objectTag;
     event.slotSenseSchema = observation.key.senseSchema;
+    event.senseGenerationPlusOne = observation.generationPlusOne;
+    event.hasSenseGeneration = observation.hasGeneration;
+    return event;
+}
+
+/** @return A squad edge carrying the squad's retained spawn generation. */
+[[nodiscard]] host::Event squad_edge_event(const RuntimeInstance& instance,
+                                           const host::SenseObservation& observation,
+                                           const SquadObservation& squad) noexcept {
+    host::Event event = sense_edge_event(instance, observation);
+    event.squadSpawnGeneration = squad.spawnGeneration;
+    event.squadHasSpawnGeneration = squad.hasSpawnGeneration;
     return event;
 }
 
@@ -205,26 +213,58 @@ void objective_task_counters(std::span<const sense_values::DecodedValue> body,
     output.blocks = static_cast<std::uint8_t>((std::min)(blocks, output.value.size()));
 }
 
+/**
+ * Raises one triggerState event for a report its tracker does not drop. The event only informs;
+ * entered and exited follow the level alone.
+ * @param event The report's level, already filled in.
+ */
+void push_trigger_state(RuntimeInstance& instance,
+                        const host::SenseObservationSnapshot& sense,
+                        const host::SenseObservation& observation,
+                        TriggerOccupancy& slot,
+                        host::Event event) noexcept {
+    trigger_observation::Snapshot level{};
+    const bool valid =
+        observation.valueCount <= sense.valueCount - observation.firstValue
+        && trigger_observation::read(std::span(sense.values.data(), sense.valueCount)
+                                         .subspan(observation.firstValue, observation.valueCount),
+                                     level);
+    const auto result = slot.tracker.observe(valid ? &level : nullptr,
+                                             instance.view.activityClientGeneration,
+                                             observation.sourceGeneration,
+                                             observation.sequence,
+                                             observation.generationPlusOne,
+                                             observation.hasGeneration);
+    if (!result.notify) {
+        return;
+    }
+    event.kind = host::EventKind::triggerState;
+    event.triggerAvailable = result.available;
+    event.triggerContinuity = static_cast<std::uint8_t>(result.continuity);
+    push_script_event(instance, event);
+}
+
 } // namespace
 
 /**
- * Raises one event per watched volume whose occupancy changed.
+ * Raises one event per watched volume whose occupancy changed, after its triggerState report.
  * The client publishes occupancy as a level, so the edge is ours to derive. A volume seen for the
  * first time only records its level, because a first observation is not an entry.
  */
 void push_trigger_edges(RuntimeInstance& instance,
                         const host::SenseObservationSnapshot& sense) noexcept {
+    namespace trigger = trigger_observation;
     for (std::size_t index = 0; index < sense.observationCount; ++index) {
         const host::SenseObservation& observation = sense.observations[index];
         if (observation.key.slotType != format::kOccupancySlotType
-            || observation.valueCount < kTriggerOccupancyValueCount
-            || observation.firstValue + kTriggerOccupancyValueCount > sense.valueCount) {
+            || observation.valueCount < trigger::kValueCount
+            || observation.firstValue + trigger::kValueCount > sense.valueCount) {
             continue;
         }
         const std::span<const sense_values::DecodedValue> body(
-            &sense.values[observation.firstValue], kTriggerOccupancyValueCount);
+            &sense.values[observation.firstValue], trigger::kValueCount);
         const std::uint32_t root = observation.key.schemaRow;
-        const bool occupied = sense_flag(body, root, kTriggerAnyOrdinal);
+        const bool occupied = sense_flag(body, root, trigger::kOccupiedOrdinal);
         TriggerOccupancy* slot = nullptr;
         TriggerOccupancy* spare = nullptr;
         for (TriggerOccupancy& retained : instance.triggerOccupancy) {
@@ -238,26 +278,29 @@ void push_trigger_edges(RuntimeInstance& instance,
                 spare = &retained;
             }
         }
-        if (slot == nullptr) {
+        const bool first = slot == nullptr;
+        if (first) {
             if (spare == nullptr) {
                 log_line(core::log::Level::warn, &instance, "trigger", "watch_capacity");
                 continue;
             }
-            spare->registryKey = observation.key.registryKey;
-            spare->objectTag = observation.key.objectTag;
-            spare->slotIndex = observation.key.slotIndex;
-            spare->occupied = occupied;
-            spare->used = true;
-            continue;
+            slot = spare;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+            slot->occupied = occupied;
+            slot->used = true;
         }
-        if (slot->occupied == occupied) {
+        host::Event event = sense_edge_event(instance, observation);
+        event.triggerOccupied = occupied;
+        event.triggerAll = sense_flag(body, root, trigger::kAllOrdinal);
+        event.triggerCount = sense_number(body, root, trigger::kCountOrdinal);
+        event.triggerValue = sense_number(body, root, trigger::kThresholdOrdinal);
+        push_trigger_state(instance, sense, observation, *slot, event);
+        if (first || slot->occupied == occupied) {
             continue;
         }
         slot->occupied = occupied;
-        host::Event event = sense_edge_event(instance, observation);
-        event.triggerAll = sense_flag(body, root, kTriggerAllOrdinal);
-        event.triggerCount = sense_number(body, root, kTriggerCountOrdinal);
-        event.triggerValue = sense_number(body, root, kTriggerThresholdOrdinal);
         event.kind = occupied ? host::EventKind::triggerEntered : host::EventKind::triggerExited;
         push_script_event(instance, event);
     }
@@ -416,6 +459,9 @@ void push_object_interaction_edges(RuntimeInstance& instance,
             slot->level, observation_values(observation, sense), observation.key.schemaRow);
         const ObjectInteractionLevel& level = slot->level;
         host::Event event = sense_edge_event(instance, observation);
+        event.initialObservation = !before.stateKnown || before.generation != level.generation;
+        event.objectEntryIndex = level.entryIndex;
+        event.objectSpawnMask = level.spawnMask;
         event.objectGeneration = level.generation;
         event.objectPresent = level.present;
         // The alive lane carries Sense ordinal 1, which the script reads as interaction_open.
@@ -543,6 +589,15 @@ void push_squad_edges(RuntimeInstance& instance,
         }
         const bool first = !squad->used;
         squad->used = true;
+        squad->populationKnown = squad->populationKnown || hasAlive;
+        // An absent spawn generation keeps the last one reported, like the other retained levels.
+        const sense_values::DecodedValue* const spawn =
+            sense_value(body, root, kSquadSpawnGenerationOrdinal);
+        if (spawn != nullptr && spawn->present && spawn->signedValue >= 0
+            && spawn->signedValue <= kMaximumCounter) {
+            squad->spawnGeneration = static_cast<std::int32_t>(spawn->signedValue);
+            squad->hasSpawnGeneration = true;
+        }
         const std::int32_t previousAlive = first ? 0 : squad->aliveCount;
         const bool changed =
             costsChanged || first || squad->aliveCount != alive || squad->removalFlag != removal
@@ -550,16 +605,18 @@ void push_squad_edges(RuntimeInstance& instance,
         if (!changed) {
             continue;
         }
-        host::Event state = sense_edge_event(instance, observation);
+        host::Event state = squad_edge_event(instance, observation, *squad);
         state.squadObjectiveCosts = squad->objectiveCosts.values;
         state.squadObjectiveCostMask = squad->objectiveCosts.known;
         state.squadObjectiveRevision = squad->objectiveCosts.revision;
         qualify_objective_costs(instance, state);
         state.squadAliveCount = alive;
         state.squadPreviousAliveCount = previousAlive;
+        state.squadPopulationAvailable = squad->populationKnown;
         state.squadRemovalFlag = removal;
         state.squadSlotCounts = counts;
         state.squadSlotCountLength = countLength;
+        state.initialObservation = first;
         state.kind = host::EventKind::squadState;
         if (instance.programStatus == ProgramStatus::loaded) {
             push_script_event(instance, state);
@@ -571,7 +628,7 @@ void push_squad_edges(RuntimeInstance& instance,
             if (counts[slot] <= previous) {
                 continue;
             }
-            host::Event spawned = sense_edge_event(instance, observation);
+            host::Event spawned = squad_edge_event(instance, observation, *squad);
             spawned.squadSlotOrdinal = slot;
             spawned.squadSlotValue = counts[slot];
             spawned.squadPreviousSlotValue = previous;
@@ -581,9 +638,10 @@ void push_squad_edges(RuntimeInstance& instance,
             }
         }
         if (!first && alive < squad->aliveCount) {
-            host::Event died = sense_edge_event(instance, observation);
+            host::Event died = squad_edge_event(instance, observation, *squad);
             died.squadAliveCount = alive;
             died.squadPreviousAliveCount = squad->aliveCount;
+            died.squadRemovalFlag = removal;
             died.kind = host::EventKind::entityDied;
             if (instance.programStatus == ProgramStatus::loaded) {
                 push_script_event(instance, died);

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

@@ -78,6 +78,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
     case host::EventKind::phaseEntered:
     case host::EventKind::triggerEntered:
     case host::EventKind::triggerExited:
+    case host::EventKind::triggerState:
     case host::EventKind::squadState:
     case host::EventKind::squadProvoked:
     case host::EventKind::entitySpawned:
@@ -123,6 +124,7 @@ void clear_pending_event(PendingMissionEvent& pending) noexcept {
            || event.kind == host::EventKind::effectResult
            || event.kind == host::EventKind::phaseEntered
            || event.kind == host::EventKind::triggerEntered
+           || event.kind == host::EventKind::triggerState
            || event.kind == host::EventKind::triggerExited
            || event.kind == host::EventKind::squadState
            || event.kind == host::EventKind::squadProvoked

+ 20 - 2
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -25,6 +25,7 @@
 #include "mission_script_runtime.h"
 #include "mission_script_squad_sense.h"
 #include "mission_script_vm.h"
+#include "mission_trigger_observation.h"
 
 // What the mission-runtime translation units share: the instance table and service slice,
 // the attach pipeline, the two Host feeds and the VM callback, the panel rows, the delivery state
@@ -109,8 +110,10 @@ constexpr std::uint64_t kTransportTimeoutMs = 15'000;
 /** How long an enqueued Host output may take to reach the reducer before it is reconciled. */
 constexpr std::uint64_t kHostCommitTimeoutMs = 2'000;
 
-/** Last occupancy seen for one watched volume, so only a change raises an event. */
+/** Last occupancy seen for one watched volume, so only a change raises an edge. */
 struct TriggerOccupancy final {
+    /** Counter continuity of the volume's reports, for triggerState events only. */
+    trigger_observation::Tracker tracker{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::uint16_t slotIndex{};
@@ -181,9 +184,14 @@ struct SquadObservation final {
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::int32_t aliveCount{};
+    /** Last spawn generation the squad Sense echoed from its placement Auth. */
+    std::int32_t spawnGeneration{};
     std::uint16_t slotIndex{};
     std::uint8_t slotCountLength{};
     bool removalFlag{};
+    bool hasSpawnGeneration{};
+    /** An alive count was reported; until then aliveCount is a zero placeholder. */
+    bool populationKnown{};
     bool used{};
 };
 /** Last completion latch seen for one watched authored scene, so only the edge raises an event. */
@@ -249,6 +257,8 @@ struct RuntimeInstance final {
     std::array<GhostObservation, kGhostObservationCapacity> ghostObservations{};
     std::array<DamageObservation, kDamageObservationCapacity> damageObservations{};
     std::array<CombatantDamageObservation, kSquadObservationCapacity> combatantDamageObservations{};
+    /** Damage levels replicated by authored entities, kept apart from the Type-2 Sense rows. */
+    std::array<CombatantDamageObservation, kSquadObservationCapacity> entityDamageObservations{};
     std::array<DeviceObservation, kDeviceObservationCapacity> deviceObservations{};
     std::array<ObjectInteractionObservation, kObjectInteractionObservationCapacity>
         objectInteractionObservations{};
@@ -307,7 +317,7 @@ void publish_fireteam_life(std::uint64_t now) noexcept;
 void observe_player_life(RuntimeInstance& instance,
                          const host::SenseObservationSnapshot& sense) noexcept;
 
-/** Raises one event per watched trigger volume whose occupancy changed. */
+/** Raises one triggerState per report, and one edge per watched volume whose occupancy changed. */
 void push_trigger_edges(RuntimeInstance& instance,
                         const host::SenseObservationSnapshot& sense) noexcept;
 /** Raises one dedicated type-31 edge from a decoded schema-0x8080879F msg-19 payload. */
@@ -323,6 +333,14 @@ void push_damage_edges(RuntimeInstance& instance,
 /** Raises observed Type-2 damage pools and lifecycle resets without inferring damage. */
 void push_combatant_damage_edges(RuntimeInstance& instance,
                                  const host::SenseObservationSnapshot& sense) noexcept;
+/** Raises a damage state from an entity's replicated damage levels (fractions of full). */
+void push_entity_damage(RuntimeInstance& instance,
+                        std::uint32_t registryKey,
+                        std::uint8_t slotType,
+                        std::uint16_t slotIndex,
+                        float primary,
+                        float secondary,
+                        std::uint64_t tick) noexcept;
 /** Raises current device values and sequence resets from accepted client reports. */
 void push_device_edges(RuntimeInstance& instance,
                        const host::SenseObservationSnapshot& sense) noexcept;

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

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

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

@@ -0,0 +1,165 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission::trigger_observation {
+
+namespace sense = middleware::bap::activity_message::sense_update;
+/** Type-30 occupancy Sense: two required flags, then two required signed 32-bit values. */
+constexpr std::uint32_t kSchema = 0x80809531U;
+constexpr std::size_t kValueCount = 4;
+/** Root ordinals: any member inside, all inside, intersection count, copied Auth value. */
+constexpr std::uint16_t kOccupiedOrdinal = 0;
+constexpr std::uint16_t kAllOrdinal = 1;
+constexpr std::uint16_t kCountOrdinal = 2;
+constexpr std::uint16_t kThresholdOrdinal = 3;
+/** The two flags are one bit wide and the two numbers 32. */
+constexpr std::uint8_t kFlagWidth = 1;
+constexpr std::uint8_t kNumberWidth = 32;
+
+/** One complete occupancy report. */
+struct Snapshot final {
+    bool occupied{};
+    bool all{};
+    std::int32_t count{};
+    std::int32_t threshold{};
+    bool operator==(const Snapshot&) const = default;
+};
+
+/** Reads one occupancy body. An incomplete or ambiguous body is refused, never defaulted. */
+[[nodiscard]] inline bool read(std::span<const sense::DecodedValue> body,
+                               Snapshot& output) noexcept {
+    if (body.size() != kValueCount) {
+        return false;
+    }
+    std::array<const sense::DecodedValue*, kValueCount> fields{};
+    for (const auto& value : body) {
+        if (value.schemaRow != kSchema || value.fieldOrdinal >= fields.size()
+            || value.occurrence != 0 || !value.present || fields[value.fieldOrdinal]) {
+            return false;
+        }
+        if (value.fieldOrdinal < kCountOrdinal) {
+            if (value.kind != sense::ValueKind::boolean || value.width != kFlagWidth
+                || value.unsignedValue > 1) {
+                return false;
+            }
+        } else if (value.kind != sense::ValueKind::signedInteger || value.width != kNumberWidth
+                   || value.signedValue < (std::numeric_limits<std::int32_t>::min)()
+                   || value.signedValue > (std::numeric_limits<std::int32_t>::max)()) {
+            return false;
+        }
+        fields[value.fieldOrdinal] = &value;
+    }
+    output = {fields[kOccupiedOrdinal]->unsignedValue != 0,
+              fields[kAllOrdinal]->unsignedValue != 0,
+              static_cast<std::int32_t>(fields[kCountOrdinal]->signedValue),
+              static_cast<std::int32_t>(fields[kThresholdOrdinal]->signedValue)};
+    return true;
+}
+
+/** How one report relates to the last accepted one, as scripts see it. */
+enum class Continuity : std::uint8_t {
+    invalid,
+    baseline,
+    consecutive,
+    duplicate,
+    conflict,
+    gap,
+    reset,
+    missingCounter,
+    wrap
+};
+/** @return The stable script-facing name of one continuity value. */
+[[nodiscard]] inline const char* name(Continuity value) noexcept {
+    switch (value) {
+    case Continuity::baseline:
+        return "baseline";
+    case Continuity::consecutive:
+        return "consecutive";
+    case Continuity::duplicate:
+        return "duplicate";
+    case Continuity::conflict:
+        return "conflict";
+    case Continuity::gap:
+        return "gap";
+    case Continuity::reset:
+        return "reset";
+    case Continuity::missingCounter:
+        return "missing_counter";
+    case Continuity::wrap:
+        return "wrap";
+    default:
+        return "invalid";
+    }
+}
+/** What one report means to scripts: its continuity, and whether they hear of it. */
+struct Observation final {
+    Continuity continuity{Continuity::invalid};
+    /** Scripts receive a triggerState event for this report. */
+    bool notify{};
+    /** The report carried a complete level the event may expose. */
+    bool available{};
+};
+
+/**
+ * Classifies one volume's reports by their record counter. It never decides an enter or exit edge;
+ * a repeated counter never confirms a level, and a conflicting repeat invalidates it.
+ */
+struct Tracker final {
+    std::uint64_t source{}, sequence{};
+    std::uint32_t counter{};
+    bool hasCounter{}, known{};
+    Snapshot snapshot{};
+
+    /**
+     * Classifies one report against the last accepted one.
+     * @param current The decoded level, or null when the body was malformed.
+     * @return Nothing to notify for a stale or foreign report or an exact duplicate.
+     */
+    [[nodiscard]] Observation observe(const Snapshot* current,
+                                      std::uint64_t expectedSource,
+                                      std::uint64_t nextSource,
+                                      std::uint64_t nextSequence,
+                                      std::uint32_t nextCounter,
+                                      bool nextHasCounter) noexcept {
+        if (nextSource == 0 || nextSource != expectedSource || nextSequence == 0
+            || (source == nextSource && nextSequence <= sequence)) {
+            return {};
+        }
+        const bool sameSource = source == nextSource;
+        source = nextSource;
+        sequence = nextSequence;
+        if (current == nullptr) {
+            known = false;
+            hasCounter = false;
+            return {Continuity::invalid, true, false};
+        }
+        if (sameSource && hasCounter && nextHasCounter && counter == nextCounter) {
+            const bool same = snapshot == *current;
+            known = known && same;
+            return {same ? Continuity::duplicate : Continuity::conflict, !same, false};
+        }
+        Continuity continuity = Continuity::baseline;
+        if (!nextHasCounter) {
+            continuity = Continuity::missingCounter;
+        } else if (known && sameSource && hasCounter) {
+            continuity = counter == (std::numeric_limits<std::uint32_t>::max)() ? Continuity::wrap
+                         : nextCounter == counter + 1 ? Continuity::consecutive
+                         : nextCounter < counter      ? Continuity::reset
+                                                      : Continuity::gap;
+        }
+        counter = nextCounter;
+        hasCounter = nextHasCounter;
+        known = nextHasCounter;
+        snapshot = *current;
+        return {continuity, true, nextHasCounter};
+    }
+};
+
+} // namespace sunrise::server::activity::mission::trigger_observation

+ 10 - 4
Sunrise/src/server/bap/encrypted/activity_message/activity_message_framing.cpp

@@ -216,8 +216,11 @@ bool frame_only(const ActivityClientBinding& binding,
         input.verdict = framed.verdict;
         input.decoded = parsedSense.decoded;
         input.hasFirstObject = parsedSense.hasFirstObject;
-        if (!server::activity::host::submit_sense(input)) {
-            report_message(request.messageType, request.sessionId, "host_ingress_refused");
+        const auto refusal = server::activity::host::submit_sense(input);
+        if (refusal != server::activity::host::IngressRefusal::none) {
+            report_message(request.messageType,
+                           request.sessionId,
+                           server::activity::host::ingress_refusal_name(refusal));
         }
     } else if (isIncident && framed.verdict == store::Verdict::framed) {
         server::activity::host::IncidentInput input{};
@@ -239,8 +242,11 @@ bool frame_only(const ActivityClientBinding& binding,
                 std::span(parsedIncident.payload).first(parsedIncident.payloadLength),
                 input.cinematic);
         }
-        if (!server::activity::host::submit_incident(input)) {
-            report_message(request.messageType, request.sessionId, "host_ingress_refused");
+        const auto refusal = server::activity::host::submit_incident(input);
+        if (refusal != server::activity::host::IngressRefusal::none) {
+            report_message(request.messageType,
+                           request.sessionId,
+                           server::activity::host::ingress_refusal_name(refusal));
         }
     }
     return true;

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

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

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

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

+ 1 - 1
Sunrise/src/server/gameplay/actor_command_policy.h

@@ -32,7 +32,7 @@ lane0_payload_codec() noexcept;
 
 /** Accepts one channel-2 batch only after the peer committed its external wrapper. */
 [[nodiscard]] bool
-accept_entity_batch(std::uint64_t groupSessionId,
+accept_entity_batch(const state::gameplay::entity_identity::Source& source,
                     const middleware::gameplay::external::EntityBatch& batch) noexcept;
 
 /** Decodes one lane-0 batch without assigning it to a peer session. */

+ 62 - 5
Sunrise/src/server/gameplay/actor_command_policy_lane.cpp

@@ -8,6 +8,7 @@
 
 #include "../../middleware/gameplay/external/composite_entity_codec.h"
 #include "../../middleware/gameplay/external/simulation_event_runtime_codec.h"
+#include "../../state/activity/runtime.h"
 #include "../../state/activity_sdk/runtime.h"
 #include "../activity/mission/mission_script_runtime.h"
 #include "actor_command_policy.h"
@@ -258,9 +259,63 @@ struct SquadClientRefLayout final {
 
 } // namespace
 
+/** One replicated damage level bound for its mission, staged under the policy lock. */
+struct DamageReport final {
+    state::gameplay::entity_identity::ActorSourceReference source{};
+    state::activity::SessionBinding binding{};
+    std::uint64_t generation{};
+    bool policyBinding{};
+    bool reported{};
+};
+
+/**
+ * An authored actor's replicated damage levels go to its mission as a damage state, keyed by its
+ * squad when the world view knows one and by its own authored slot otherwise. The caller holds the
+ * lock.
+ */
+[[nodiscard]] static DamageReport
+stage_damage_report(const SessionRow& session,
+                    const state::gameplay::entity_identity::Source& origin,
+                    const external::EntityRecord& record,
+                    bool accepted) noexcept {
+    DamageReport report{};
+    const auto& actor = session.actors.slots[record.token.slot];
+    report.source = authored_squad_source(session, actor.authoredSource);
+    report.reported = accepted && (record.flags & external::entityUpdate) != 0
+                      && record.update.damageKnown && actor.occupied
+                      && actor.incarnation == record.token.incarnation && report.source.known
+                      && report.source.present;
+    // A session learns its binding only from a policy; without one the packet's source names it.
+    report.policyBinding = session.policyActive && session.bindingRetained;
+    report.binding = session.binding;
+    report.generation =
+        report.policyBinding ? session.activityClientGeneration : origin.activityClientGeneration;
+    return report;
+}
+
+/** Hands a staged level to the mission runtime once the policy lock is released. */
+static void publish_damage_report(DamageReport report,
+                                  const state::gameplay::entity_identity::Source& origin,
+                                  const external::EntityRecord& record) noexcept {
+    if (!report.reported
+        || (!report.policyBinding
+            && !(state::activity::snapshot_binding(origin.activitySessionId, report.binding)
+                 && report.binding.createdRevision == origin.activityRevision))) {
+        return;
+    }
+    server::activity::mission::report_squad_damage(report.binding,
+                                                   report.generation,
+                                                   report.source.key,
+                                                   report.source.type,
+                                                   report.source.index,
+                                                   record.update.damageHealth,
+                                                   record.update.damageShield);
+}
+
 /** Optional policy projection retains only its supported entity metadata. */
-static bool accept_entity_record(std::uint64_t groupSessionId,
+static bool accept_entity_record(const state::gameplay::entity_identity::Source& origin,
                                  const external::EntityRecord& record) noexcept {
+    const std::uint64_t groupSessionId = origin.groupSessionId;
     if (groupSessionId == 0) {
         return false;
     }
@@ -375,7 +430,9 @@ static bool accept_entity_record(std::uint64_t groupSessionId,
         session->actors.classCount = priorClassCount;
         session->actors.slots[record.token.slot] = priorSlot;
     }
+    const DamageReport damage = stage_damage_report(*session, origin, record, accepted);
     ReleaseSRWLockExclusive(&g_lock);
+    publish_damage_report(damage, origin, record);
     if (policyCommandQueued) {
         report(core::log::Level::info,
                "ev=actor_policy stage=command result=queued group=0x%016llX slot=%u incarnation=%u",
@@ -387,9 +444,9 @@ static bool accept_entity_record(std::uint64_t groupSessionId,
 }
 
 /** Policy projection visits every record after transport acceptance. */
-bool accept_entity_batch(std::uint64_t groupSessionId,
+bool accept_entity_batch(const state::gameplay::entity_identity::Source& source,
                          const external::EntityBatch& batch) noexcept {
-    if (groupSessionId == 0
+    if (source.groupSessionId == 0
         || external::entity_record_count(batch) > external::kEntityBatchCapacity) {
         return false;
     }
@@ -398,8 +455,8 @@ bool accept_entity_batch(std::uint64_t groupSessionId,
         if (batch.ignoredRecordMask.test(index)) {
             continue;
         }
-        accepted = accept_entity_record(groupSessionId, external::entity_record_at(batch, index))
-                   && accepted;
+        accepted =
+            accept_entity_record(source, external::entity_record_at(batch, index)) && accepted;
     }
     return accepted;
 }

+ 17 - 10
Sunrise/src/server/gameplay/actor_command_policy_session.h

@@ -93,8 +93,8 @@ struct SelectedSquad final {
     output = {};
     const auto slots = catalog.slots();
     const auto objects = catalog.objects();
-    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask
-        || squad.slotIndex >= slots.size() || squad.objectIndex >= objects.size()) {
+    if (!format::squad_runnable(squad.flags) || squad.slotIndex >= slots.size()
+        || squad.objectIndex >= objects.size()) {
         return false;
     }
     const auto& slot = slots[squad.slotIndex];
@@ -173,6 +173,20 @@ same_token(const middleware::gameplay::external::EntityToken& left,
 /** @return The row for one group session, allocating it when free. The caller holds the lock. */
 [[nodiscard]] SessionRow* find_or_create_session(std::uint64_t groupSessionId) noexcept;
 
+/** @return The authored source, raised to its squad when the world view maps a combatant to one. */
+[[nodiscard]] inline state::gameplay::entity_identity::ActorSourceReference
+authored_squad_source(const SessionRow& session,
+                      state::gameplay::entity_identity::ActorSourceReference source) noexcept {
+    if (const auto* world = session.worldView.snapshot(); world != nullptr) {
+        state::gameplay::entity_identity::ActorSourceReference squadSource{};
+        if (middleware::gameplay::external::resolve_combatant_squad_source(
+                *world, source, squadSource)) {
+            return squadSource;
+        }
+    }
+    return source;
+}
+
 /**
  * Known actor-source state overrides legacy network-squad membership, including a clear.
  * @param session Row holding the actor and exact selected squad references.
@@ -192,14 +206,7 @@ selected_entity_squad(const SessionRow& session,
         return kSelectedSquadCapacity;
     }
     selectedCount = (std::min)(selectedCount, session.selectedSquads.size());
-    auto source = actor.authoredSource;
-    if (const auto* world = session.worldView.snapshot(); world != nullptr) {
-        state::gameplay::entity_identity::ActorSourceReference squadSource{};
-        if (middleware::gameplay::external::resolve_combatant_squad_source(
-                *world, source, squadSource)) {
-            source = squadSource;
-        }
-    }
+    const auto source = authored_squad_source(session, actor.authoredSource);
     std::size_t found = kSelectedSquadCapacity;
     for (std::size_t index = 0; index < selectedCount; ++index) {
         const auto& selected = session.selectedSquads[index];

+ 7 - 1
Sunrise/src/server/gameplay/actor_command_policy_transport.cpp

@@ -241,6 +241,12 @@ void reset_lane0_adapter(const void*, std::uint64_t groupSessionId) noexcept {
                           && external::prepare_scoped_entity_batch(
                               store, source, batch, sequence, hasSequence, ordinal, mutation);
     ReleaseSRWLockExclusive(&g_transportLock);
+    if (accepted && mutation.detachedCount != 0) {
+        report(core::log::Level::debug,
+               "ev=entity_identity stage=anchor_prune detached=%u records=%zu",
+               static_cast<unsigned>(mutation.detachedCount),
+               external::entity_record_count(batch));
+    }
     return accepted;
 }
 
@@ -309,7 +315,7 @@ void observed_entity_adapter(const void* context,
         filtered->ignoredRecordMask.reset();
         projected = filtered.get();
     }
-    if (!accept_entity_batch(source.groupSessionId, *projected)) {
+    if (!accept_entity_batch(source, *projected)) {
         report(core::log::Level::debug,
                "ev=entity_identity stage=actor_projection result=unavailable");
     }

+ 17 - 2
Sunrise/src/state/activity_sdk/format.h

@@ -9,8 +9,8 @@ namespace sunrise::state::activity_sdk::format {
 
 /** Eight-byte identity at the start of every runtime SDK pack. */
 inline constexpr std::array<char, 8> kMagic{'S', 'R', 'S', 'D', 'K', 'P', '0', '1'};
-/** Runtime-pack schema version accepted by this reader. */
-inline constexpr std::uint32_t kVersion = 43;
+/** Runtime-pack schema version accepted by this reader. 44 adds rule-less squads. */
+inline constexpr std::uint32_t kVersion = 44;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
 inline constexpr std::uint32_t kSectionCount = 51;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
@@ -260,6 +260,21 @@ inline constexpr std::uint32_t kSquadFlagMask =
     | kSquadAllPointsExact | kSquadMemberCountValid | kSquadCandidateCountsInvariantComplete;
 /** A squad runs only with every extraction fact present. */
 inline constexpr std::uint32_t kSquadRunnableMask = kSquadFlagMask;
+/** The spawner names no rule of its own: a placement must select a type-66 rule slot. */
+inline constexpr std::uint32_t kSquadRequiresSelectedRule = 0x40U;
+/** Runnable without the authored edge and its points, which a selected rule replaces. */
+inline constexpr std::uint32_t kSquadSourcePrerequisiteMask =
+    kSquadRunnableMask & ~(kSquadSpawnerRuleEdgeExact | kSquadAllPointsExact);
+/**
+ * @return True when a squad row may be placed or commanded. A rule-less squad is exempt from the
+ * rule edge and points that its selected rule supplies.
+ */
+[[nodiscard]] constexpr bool squad_runnable(std::uint32_t flags) noexcept {
+    const std::uint32_t required = (flags & kSquadRequiresSelectedRule) != 0
+                                       ? kSquadSourcePrerequisiteMask
+                                       : kSquadRunnableMask;
+    return (flags & required) == required;
+}
 /** Member flags separate actor resolution from count-array completeness. */
 inline constexpr std::uint32_t kSquadMemberActorClassExact = 0x1U;
 inline constexpr std::uint32_t kSquadMemberCandidateCountsComplete = 0x2U;