소스 검색

Restore the trigger, squad and object edge rules for every activity

Entered and exited fire on any occupancy change again, and a region change
no longer resets object or trigger levels. A squad report keeps the values
it omits instead of reading a gap as a new registration, and the object
rows go back to 64 without recycling.

trigger_state keeps its counter continuity as information only.
registration_reset is removed, and objective_revision now reports the
revision the squad echoes with its costs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
stan 4 일 전
부모
커밋
d7fc96d4c1

+ 0 - 1
Sunrise/Sunrise.vcxproj

@@ -2217,7 +2217,6 @@
     <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_observation_epoch.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" />

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

@@ -578,10 +578,8 @@ struct Event final {
     /** Spawn generation the squad Sense echoes from its placement Auth; not the record counter. */
     std::int32_t squadSpawnGeneration{};
     bool squadHasSpawnGeneration{};
-    /** The alive counts were reported; otherwise they are zero placeholders. */
+    /** An alive count was reported for this squad; until then the counts are zero placeholders. */
     bool squadPopulationAvailable{};
-    /** The report counter went backwards or the spawn generation changed: a new registration. */
-    bool squadRegistrationReset{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
     std::int32_t squadAliveCount{};
     /** Alive count this observation replaced, for entityDied events. */
@@ -596,12 +594,10 @@ struct Event final {
     std::uint8_t squadSlotOrdinal{};
     /** Client flag written on its actor death or removal path. The meaning is unproved. */
     bool squadRemovalFlag{};
-    /** The removal flag was reported for this spawn lifetime. */
-    bool squadHasRemovalFlag{};
     /** Msg-6 record counter plus one; not a squad or actor lifetime identity. */
     std::uint32_t senseGenerationPlusOne{};
     bool hasSenseGeneration{};
-    /** A baseline report: it restates current levels and never proves a death. */
+    /** 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{};

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

@@ -234,12 +234,13 @@ inline constexpr MemberSpec kSquadStateFields[]{
      .returns = kTaskGroupReturns,
      .note = "the group the squad holds for this objective"},
     field("slot_counts", kIntegerList),
-    field("initial_observation", kBoolean, "a baseline report; it never proves a death"),
-    field("registration_reset", kBoolean, "the counter went back or the spawn generation changed"),
+    field("initial_observation", kBoolean, "the first report of this squad; never a death"),
     field("population_available",
           kBoolean,
-          "false when the counts are zero placeholders for a report without them"),
-    field("objective_revision", kInteger, "1 while a script objective assignment is retained"),
+          "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[]{
@@ -661,8 +662,8 @@ inline constexpr EventSpec kEvents[]{
      "on_event_trigger_state",
      "SunriseTriggerStateEvent",
      kTriggerStateGroups,
-     "every accepted occupancy report, including baselines and invalidations; entered and "
-     "exited still fire on a proven change"},
+     "every accepted occupancy report and its counter continuity; it never changes when "
+     "entered and exited fire"},
 };
 
 // --- Program table -----------------------------------------------------------------------------

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

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

+ 2 - 11
Sunrise/src/server/activity/mission/mission_script_lua_event_fields.cpp

@@ -382,13 +382,8 @@ constexpr EventBinding kSquadState[]{
          }
      }},
     {"initial_observation", nullptr, &boolean_field<&E::initialObservation>},
-    {"registration_reset", nullptr, &boolean_field<&E::squadRegistrationReset>},
     {"population_available", nullptr, &boolean_field<&E::squadPopulationAvailable>},
-    {"objective_revision",
-     nullptr,
-     [](lua_State* state, const E& event) {
-         lua_pushinteger(state, event.squadObjectiveRegistryKey != 0 ? 1 : 0);
-     }},
+    {"objective_revision", nullptr, &integer_field<&E::squadObjectiveRevision>},
     {"task_costs",
      nullptr,
      [](lua_State* state, const E& event) {
@@ -520,11 +515,7 @@ constexpr EventBinding kSenseGeneration[]{
      &present_integer<&E::senseGenerationPlusOne, &E::hasSenseGeneration>},
 };
 constexpr EventBinding kSquadRemoval[]{
-    {"removal_flag",
-     nullptr,
-     [](lua_State* state, const E& event) {
-         lua_pushboolean(state, event.squadHasRemovalFlag && event.squadRemovalFlag ? 1 : 0);
-     }},
+    {"removal_flag", nullptr, &boolean_field<&E::squadRemovalFlag>},
 };
 // An invalidation carries no level, so its level fields read nil.
 constexpr EventBinding kTriggerLevel[]{

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

@@ -317,7 +317,6 @@ void clear_sense_levels(RuntimeInstance& instance) noexcept {
     instance.entityDamageObservations = {};
     instance.deviceObservations = {};
     instance.objectInteractionObservations = {};
-    instance.objectObservationHeldRegion = -1;
     instance.actorPathObservations = {};
     instance.sceneObservations = {};
     instance.objectiveObservations = {};

+ 98 - 229
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -89,14 +89,23 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     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;
+}
+
 /** @return The retained record for one squad, allocating on a first observation. */
 [[nodiscard]] SquadObservation* find_squad(RuntimeInstance& instance,
                                            const host::SenseObservationKey& key) noexcept {
     SquadObservation* spare = nullptr;
     for (SquadObservation& retained : instance.squadObservations) {
         if (retained.used && retained.registryKey == key.registryKey
-            && retained.objectTag == key.objectTag && retained.slotIndex == key.slotIndex
-            && retained.slotType == key.slotType && retained.schemaRow == key.schemaRow) {
+            && retained.objectTag == key.objectTag && retained.slotIndex == key.slotIndex) {
             return &retained;
         }
         if (!retained.used && spare == nullptr) {
@@ -123,8 +132,6 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     spare->registryKey = key.registryKey;
     spare->objectTag = key.objectTag;
     spare->slotIndex = key.slotIndex;
-    spare->slotType = key.slotType;
-    spare->schemaRow = key.schemaRow;
     return spare;
 }
 
@@ -206,25 +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
 
 /**
- * Publishes qualified trigger levels and derives ordinary enter/exit edges from consecutive levels.
- * A first observation publishes a baseline state, because a first observation is not an entry.
+ * 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,
-                        std::uint64_t missionSequence) noexcept {
+                        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 != trigger_observation::kSlotType
-            || observation.key.senseSchema != trigger_observation::kSchema
-            || observation.key.schemaRow != trigger_observation::kSchema
-            || observation.sourceGeneration == 0
-            || observation.sourceGeneration != instance.view.activityClientGeneration
-            || observation.sequence == 0) {
+        if (observation.key.slotType != format::kOccupancySlotType
+            || observation.valueCount < trigger::kValueCount
+            || observation.firstValue + trigger::kValueCount > sense.valueCount) {
             continue;
         }
+        const std::span<const sense_values::DecodedValue> body(
+            &sense.values[observation.firstValue], trigger::kValueCount);
+        const std::uint32_t root = observation.key.schemaRow;
+        const bool occupied = sense_flag(body, root, trigger::kOccupiedOrdinal);
         TriggerOccupancy* slot = nullptr;
         TriggerOccupancy* spare = nullptr;
         for (TriggerOccupancy& retained : instance.triggerOccupancy) {
@@ -238,7 +278,8 @@ 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;
@@ -247,42 +288,21 @@ void push_trigger_edges(RuntimeInstance& instance,
             slot->registryKey = observation.key.registryKey;
             slot->objectTag = observation.key.objectTag;
             slot->slotIndex = observation.key.slotIndex;
+            slot->occupied = occupied;
             slot->used = true;
         }
-        trigger_observation::Snapshot current{};
-        // An exact source with a malformed body must invalidate the prior baseline as well.
-        const bool bounded = observation.firstValue <= sense.valueCount
-                             && observation.valueCount <= sense.valueCount - observation.firstValue;
-        const bool valid = bounded
-                           && trigger_observation::read(
-                               std::span(sense.values.data(), sense.valueCount)
-                                   .subspan(observation.firstValue, observation.valueCount),
-                               current);
-        const auto result = slot->tracker.observe(valid ? &current : nullptr,
-                                                  instance.view.activityClientGeneration,
-                                                  observation.sourceGeneration,
-                                                  observation.sequence,
-                                                  observation.generationPlusOne,
-                                                  observation.hasGeneration);
-        if (!result.notify) {
+        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;
         }
-        host::Event event = sense_edge_event(instance, observation);
-        event.missionSequence = missionSequence;
-        event.kind = host::EventKind::triggerState;
-        event.triggerAvailable = result.available;
-        event.triggerOccupied = current.occupied;
-        event.triggerContinuity = static_cast<std::uint8_t>(result.continuity);
-        event.triggerCount = current.count;
-        event.triggerValue = current.threshold;
-        event.triggerAll = current.all;
+        slot->occupied = occupied;
+        event.kind = occupied ? host::EventKind::triggerEntered : host::EventKind::triggerExited;
         push_script_event(instance, event);
-        if (result.edge != trigger_observation::Edge::none) {
-            event.kind = result.edge == trigger_observation::Edge::entered
-                             ? host::EventKind::triggerEntered
-                             : host::EventKind::triggerExited;
-            push_script_event(instance, event);
-        }
     }
 }
 
@@ -420,46 +440,6 @@ void push_damage_edges(RuntimeInstance& instance,
     }
 }
 
-/**
- * Finds the retained row for one interactable object, allocating a free row on a first
- * observation. At capacity the least recently observed row is recycled, so an object first seen
- * late in a mission still publishes its levels. The recycled row starts without history: its next
- * report publishes as a baseline and raises no interaction.
- */
-[[nodiscard]] ObjectInteractionObservation*
-find_object_row(RuntimeInstance& instance, const host::SenseObservation& observation) noexcept {
-    ObjectInteractionObservation* spare = nullptr;
-    ObjectInteractionObservation* oldest = nullptr;
-    for (ObjectInteractionObservation& retained : instance.objectInteractionObservations) {
-        if (retained.used && retained.registryKey == observation.key.registryKey
-            && retained.objectTag == observation.key.objectTag
-            && retained.slotIndex == observation.key.slotIndex) {
-            retained.sequence = observation.sequence;
-            return &retained;
-        }
-        if (!retained.used) {
-            if (spare == nullptr) {
-                spare = &retained;
-            }
-        } else if (oldest == nullptr || retained.sequence < oldest->sequence) {
-            oldest = &retained;
-        }
-    }
-    ObjectInteractionObservation* const row = spare != nullptr ? spare : oldest;
-    if (row == nullptr) {
-        return nullptr;
-    }
-    const bool recycled = spare == nullptr;
-    *row = {};
-    row->used = true;
-    row->interactionBaseline = recycled;
-    row->sequence = observation.sequence;
-    row->registryKey = observation.key.registryKey;
-    row->objectTag = observation.key.objectTag;
-    row->slotIndex = observation.key.slotIndex;
-    return row;
-}
-
 /** Raises object state and accepted interaction events per interactable object. */
 void push_object_interaction_edges(RuntimeInstance& instance,
                                    const host::SenseObservationSnapshot& sense) noexcept {
@@ -469,7 +449,8 @@ void push_object_interaction_edges(RuntimeInstance& instance,
                 observation, sense, format::kObjectSlotType, format::kObjectSenseSchema)) {
             continue;
         }
-        ObjectInteractionObservation* const slot = find_object_row(instance, observation);
+        ObjectInteractionObservation* const slot =
+            find_slot_row(instance.objectInteractionObservations, observation.key);
         if (slot == nullptr) {
             continue;
         }
@@ -494,8 +475,7 @@ void push_object_interaction_edges(RuntimeInstance& instance,
                 || before.present != level.present
                 || before.interactionOpen != level.interactionOpen
                 || before.ownerKnown != level.ownerKnown || before.hasOwner != level.hasOwner
-                || before.ownerKey != level.ownerKey || before.entryIndex != level.entryIndex
-                || before.spawnMask != level.spawnMask);
+                || before.ownerKey != level.ownerKey);
         if (stateChanged) {
             event.kind = host::EventKind::objectState;
             std::array<char, 128> details{};
@@ -519,11 +499,10 @@ void push_object_interaction_edges(RuntimeInstance& instance,
             }
             push_script_event(instance, event);
         }
-        if (interacted && !slot->interactionBaseline) {
+        if (interacted) {
             event.kind = host::EventKind::objectInteracted;
             push_script_event(instance, event);
         }
-        slot->interactionBaseline = false;
     }
 }
 
@@ -592,147 +571,49 @@ void push_squad_edges(RuntimeInstance& instance,
         if (squad == nullptr) {
             continue;
         }
-        if (observation.sourceGeneration == 0
-            || observation.sourceGeneration != instance.view.activityClientGeneration) {
-            continue;
-        }
-        const bool hadPrevious = squad->epoch.used;
-        const bool sameSource =
-            hadPrevious && squad->epoch.sourceGeneration == observation.sourceGeneration;
-        if (observation.sequence == 0
-            || (sameSource && observation.sequence <= squad->epoch.sequence)) {
-            continue;
-        }
-        const sense_values::DecodedValue* const generationValue =
-            sense_value(body, root, kSquadSpawnGenerationOrdinal);
-        const bool bodyHasGeneration = generationValue != nullptr && generationValue->present
-                                       && generationValue->signedValue >= 0
-                                       && generationValue->signedValue <= kMaximumCounter;
-        // A body naming a newer placement than the retained one is never a duplicate or replay.
-        const bool newerLifetime = bodyHasGeneration && squad->hasSpawnGeneration
-                                   && generationValue->signedValue > squad->spawnGeneration;
-        if (sameSource && squad->hasReportCounter && observation.hasGeneration
-            && observation.generationPlusOne == squad->reportCounter && !newerLifetime) {
-            continue;
-        }
-        // After a Sense rebase the client re-sends already delivered counters, often with a fuller
-        // delta against an older baseline. A replay repeats known state; treating it as a counter
-        // reset tells the scripts the squad lost its registration and spawn echo. A native
-        // re-registration restarts at one, or names a newer placement, and still reconciles below.
-        if (sameSource && squad->hasReportCounter && observation.hasGeneration
-            && observation.generationPlusOne > 1
-            && observation.generationPlusOne < squad->reportCounter && !newerLifetime) {
-            continue;
-        }
-        // Retained levels only bridge a proven consecutive report; after a gap, wrap or
-        // re-registration a field this body omits is unknown.
-        const bool consecutive = sameSource && squad->hasReportCounter && observation.hasGeneration
-                                 && squad->reportCounter != UINT32_MAX
-                                 && observation.generationPlusOne == squad->reportCounter + 1;
-        // A root-absent delta (a cost-only change, say) only continues the retained levels. After
-        // a gap it carries nothing to reconcile from, so it is ignored.
-        const bool rootPresent =
-            std::any_of(body.begin(), body.end(), [root](const sense_values::DecodedValue& value) {
-                return value.schemaRow == root;
-            });
-        if (!rootPresent && !consecutive) {
-            continue;
-        }
-
-        std::int32_t spawnGeneration =
-            bodyHasGeneration ? static_cast<std::int32_t>(generationValue->signedValue) : 0;
-        bool hasSpawnGeneration = bodyHasGeneration;
-        if (!bodyHasGeneration && consecutive) {
-            spawnGeneration = squad->spawnGeneration;
-            hasSpawnGeneration = squad->hasSpawnGeneration;
-        }
-        const bool lifetimeChanged =
-            bodyHasGeneration
-            && (!squad->hasSpawnGeneration || squad->spawnGeneration != spawnGeneration);
-        const bool sameLifetime = consecutive && !lifetimeChanged;
-        // Costs are deltas, so they only carry over a consecutive report of the same lifetime. A
-        // reset counts as a change even when this body reports no cost.
-        const SquadObjectiveCosts priorCosts = squad->objectiveCosts;
-        if (!sameLifetime) {
-            squad->objectiveCosts = {};
-        }
-        static_cast<void>(update_squad_objective_costs(squad->objectiveCosts, body, root));
-        const bool costsChanged = squad->objectiveCosts != priorCosts;
-
-        std::int32_t alive = sameLifetime ? squad->aliveCount : 0;
-        bool hasAlive = read_squad_alive(body, root, alive);
-        if (!hasAlive && sameLifetime) {
-            hasAlive = squad->hasAlive;
-        }
-        const sense_values::DecodedValue* const removalValue =
-            sense_value(body, root, kSquadRemovalOrdinal);
-        bool hasRemoval = removalValue != nullptr && removalValue->present;
-        bool removal = hasRemoval && removalValue->unsignedValue != 0;
-        if (!hasRemoval && sameLifetime) {
-            removal = squad->removalFlag;
-            hasRemoval = squad->hasRemoval;
-        }
-        // A forward gap whose body names the retained spawn generation is still that lifetime: its
-        // reported population compares with the retained one, so a death in the gap is not lost.
-        const bool gapInLifetime =
-            !consecutive && sameSource && squad->hasReportCounter && observation.hasGeneration
-            && observation.generationPlusOne > squad->reportCounter && bodyHasGeneration
-            && squad->hasSpawnGeneration && squad->spawnGeneration == spawnGeneration;
-        // Created counts only rise within one lifetime, so a report proven to continue it keeps the
-        // retained counts it omits and compares new ones against them.
-        const bool countsContinue = squad->used && (sameLifetime || gapInLifetime);
-        auto counts = countsContinue ? squad->slotCounts : decltype(squad->slotCounts){};
-        auto countLength = countsContinue ? squad->slotCountLength : std::uint8_t{};
+        const bool costsChanged = update_squad_objective_costs(squad->objectiveCosts, body, root);
+        // Cost-only deltas retain combat counts; they must never manufacture a death.
+        std::int32_t alive = squad->aliveCount;
+        const bool hasAlive = read_squad_alive(body, root, alive);
+        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
+        auto counts = squad->slotCounts;
+        auto countLength = squad->slotCountLength;
         std::array<std::int32_t, host::kSquadSlotCapacity> incomingCounts{};
         const auto incomingLength = read_squad_created_counts(body, incomingCounts);
         if (incomingLength != 0) {
             counts = incomingCounts;
             countLength = incomingLength;
         }
-
-        auto transition = observation_epoch::accept(squad->epoch,
-                                                    instance.view.activityClientGeneration,
-                                                    observation.sourceGeneration,
-                                                    observation.sequence,
-                                                    static_cast<std::uint32_t>(spawnGeneration),
-                                                    hasSpawnGeneration && spawnGeneration > 0);
-        const bool counterReset = hadPrevious && !consecutive && !gapInLifetime;
-        if (transition != observation_epoch::Transition::discard
-            && (counterReset || !squad->hasAlive || !hasAlive)) {
-            transition = observation_epoch::Transition::baseline;
-        }
-        if (transition == observation_epoch::Transition::discard) {
+        if (!hasAlive && !costsChanged && incomingLength == 0) {
             continue;
         }
-        squad->reportCounter = observation.generationPlusOne;
-        squad->hasReportCounter = observation.hasGeneration;
-
-        const bool first = transition == observation_epoch::Transition::baseline;
+        const bool first = !squad->used;
         squad->used = true;
-        const std::int32_t previousAlive = first ? alive : squad->aliveCount;
+        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->hasAlive != hasAlive
-            || squad->removalFlag != removal || squad->hasRemoval != hasRemoval
-            || squad->spawnGeneration != spawnGeneration
-            || squad->hasSpawnGeneration != hasSpawnGeneration
+            costsChanged || first || squad->aliveCount != alive || squad->removalFlag != removal
             || squad->slotCountLength != countLength || squad->slotCounts != counts;
         if (!changed) {
             continue;
         }
-        host::Event state = sense_edge_event(instance, observation);
+        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);
-        // Scripts compare these counts as integers; zero stands in when none was reported.
-        state.squadAliveCount = hasAlive ? alive : 0;
-        state.squadPopulationAvailable = hasAlive;
-        state.squadSpawnGeneration = spawnGeneration;
-        state.squadHasSpawnGeneration = hasSpawnGeneration;
-        state.squadRegistrationReset = counterReset || (hadPrevious && lifetimeChanged);
-        state.squadPreviousAliveCount = hasAlive ? previousAlive : 0;
+        state.squadAliveCount = alive;
+        state.squadPreviousAliveCount = previousAlive;
+        state.squadPopulationAvailable = squad->populationKnown;
         state.squadRemovalFlag = removal;
-        state.squadHasRemovalFlag = hasRemoval;
         state.squadSlotCounts = counts;
         state.squadSlotCountLength = countLength;
         state.initialObservation = first;
@@ -743,43 +624,31 @@ void push_squad_edges(RuntimeInstance& instance,
 
         for (std::uint8_t slot = 0; slot < countLength; ++slot) {
             const std::int32_t previous =
-                countsContinue && slot < squad->slotCountLength ? squad->slotCounts[slot] : 0;
+                !first && slot < squad->slotCountLength ? squad->slotCounts[slot] : 0;
             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;
-            spawned.squadSpawnGeneration = spawnGeneration;
-            spawned.squadHasSpawnGeneration = hasSpawnGeneration;
             spawned.kind = host::EventKind::entitySpawned;
             if (instance.programStatus == ProgramStatus::loaded) {
                 push_script_event(instance, spawned);
             }
         }
-        // Only reports of one spawn lifetime, consecutive or across a gap the body confirms, may
-        // prove a population decrease.
-        if (!first && hasAlive && squad->hasAlive && alive < squad->aliveCount) {
-            host::Event died = sense_edge_event(instance, observation);
+        if (!first && alive < squad->aliveCount) {
+            host::Event died = squad_edge_event(instance, observation, *squad);
             died.squadAliveCount = alive;
-            died.squadPopulationAvailable = true;
-            died.squadSpawnGeneration = spawnGeneration;
-            died.squadHasSpawnGeneration = hasSpawnGeneration;
             died.squadPreviousAliveCount = squad->aliveCount;
             died.squadRemovalFlag = removal;
-            died.squadHasRemovalFlag = hasRemoval;
             died.kind = host::EventKind::entityDied;
             if (instance.programStatus == ProgramStatus::loaded) {
                 push_script_event(instance, died);
             }
         }
         squad->aliveCount = alive;
-        squad->hasAlive = hasAlive;
         squad->removalFlag = removal;
-        squad->hasRemoval = hasRemoval;
-        squad->spawnGeneration = spawnGeneration;
-        squad->hasSpawnGeneration = hasSpawnGeneration;
         squad->slotCounts = counts;
         squad->slotCountLength = countLength;
     }

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

@@ -553,27 +553,6 @@ void clear_feed_cursors() noexcept {
     g_missionInputCursor = {};
 }
 
-/** A committed held-region change re-baselines object and trigger levels; replay cursors stay. */
-void reconcile_object_region(RuntimeInstance& instance, const host::Event& event) noexcept {
-    if (event.kind != host::EventKind::clientStateChanged
-        || instance.objectObservationHeldRegion == event.heldRegionIndex) {
-        return;
-    }
-    // Clearing only the state flag republishes the next level as a baseline without replaying an
-    // already-reported interaction latch.
-    for (auto& observation : instance.objectInteractionObservations) {
-        if (observation.used) {
-            observation.level.stateKnown = false;
-        }
-    }
-    for (auto& observation : instance.triggerOccupancy) {
-        if (observation.used) {
-            observation.tracker.invalidate_levels();
-        }
-    }
-    instance.objectObservationHeldRegion = event.heldRegionIndex;
-}
-
 /**
  * Runs one event through the VM, commits what it changed, and faults on a script failure.
  * @param sense Values owned by a Sense row, or null.
@@ -637,7 +616,7 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
     if (event.kind == host::EventKind::senseUpdate && sense != nullptr) {
         observe_player_life(instance, *sense);
         publish_fireteam_life(now);
-        push_trigger_edges(instance, *sense, event.missionSequence);
+        push_trigger_edges(instance, *sense);
         push_ghost_edges(instance, *sense);
         push_object_interaction_edges(instance, *sense);
         push_damage_edges(instance, *sense);
@@ -671,7 +650,6 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         if (!commit_mission_state(instance, true, nextInputSequence)) {
             return lua_vm::CallStatus::scriptError;
         }
-        reconcile_object_region(instance, event);
         ++instance.eventsCommitted;
         lua_vm::Snapshot diagnostics{};
         lua_vm::snapshot(instance.vm, diagnostics);
@@ -694,11 +672,9 @@ lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
         return status;
     }
     if (status == lua_vm::CallStatus::noHandler) {
-        if (!commit_mission_state(instance, true, nextInputSequence)) {
-            return lua_vm::CallStatus::scriptError;
-        }
-        reconcile_object_region(instance, event);
-        return status;
+        return commit_mission_state(instance, true, nextInputSequence)
+                   ? status
+                   : lua_vm::CallStatus::scriptError;
     }
     if (status == lua_vm::CallStatus::inactive) {
         return status;

+ 11 - 28
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -16,7 +16,6 @@
 #include "../../../state/activity_sdk/generated_world/runtime.h"
 #include "../../../state/activity_sdk/runtime.h"
 #include "../host_runtime.h"
-#include "mission_observation_epoch.h"
 #include "mission_script_actor_path_sense.h"
 #include "mission_script_combatant_damage_sense.h"
 #include "mission_script_device_sense.h"
@@ -98,7 +97,7 @@ static_assert(kSquadObjectiveGroupCount == host::kSquadObjectiveGroupCount);
 /** Watched Ghost links, damage monitors, interactable objects and named actors per instance. */
 constexpr std::size_t kGhostObservationCapacity = kGhostLinkCapacity;
 constexpr std::size_t kDamageObservationCapacity = 8;
-constexpr std::size_t kObjectInteractionObservationCapacity = 160;
+constexpr std::size_t kObjectInteractionObservationCapacity = 64;
 constexpr std::size_t kActorPathObservationCapacity = 64;
 /** Watched device levels retained per instance. */
 constexpr std::size_t kDeviceObservationCapacity = 128;
@@ -111,12 +110,14 @@ 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{};
+    bool occupied{};
     bool used{};
 };
 
@@ -161,13 +162,6 @@ struct DeviceObservation final {
 /** Last object and interaction level seen for one interactable object. */
 struct ObjectInteractionObservation final {
     ObjectInteractionLevel level{};
-    /** Sense sequence of the last accepted observation; the oldest row yields at capacity. */
-    std::uint64_t sequence{};
-    /**
-     * Set on a row recycled at capacity: its first report cannot tell a new interaction from one
-     * the evicted row already raised, so that report only records the interaction latch.
-     */
-    bool interactionBaseline{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
     std::uint16_t slotIndex{};
@@ -187,25 +181,17 @@ struct GhostObservation final {
 struct SquadObservation final {
     SquadObjectiveCosts objectiveCosts{};
     std::array<std::int32_t, host::kSquadSlotCapacity> slotCounts{};
-    /** Source, sequence and spawn-generation continuity of the last accepted report. */
-    observation_epoch::Cursor epoch{};
-    /** Sense record counter plus one of the last accepted report. */
-    std::uint32_t reportCounter{};
-    bool hasReportCounter{};
-    /** Spawn generation the squad Sense echoes from its placement Auth. */
-    std::int32_t spawnGeneration{};
-    bool hasSpawnGeneration{};
-    /** The alive count and removal flag below were reported, not defaulted. */
-    bool hasAlive{};
-    bool hasRemoval{};
     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{};
-    std::uint32_t schemaRow{};
-    std::uint8_t slotType{};
+    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. */
@@ -276,8 +262,6 @@ struct RuntimeInstance final {
     std::array<DeviceObservation, kDeviceObservationCapacity> deviceObservations{};
     std::array<ObjectInteractionObservation, kObjectInteractionObservationCapacity>
         objectInteractionObservations{};
-    /** Held region whose object levels are current; a region change re-baselines them. */
-    std::int32_t objectObservationHeldRegion{-1};
     std::array<ActorPathObservation, kActorPathObservationCapacity> actorPathObservations{};
     std::array<SceneObservation, kSceneObservationCapacity> sceneObservations{};
     std::array<ObjectiveObservation, kObjectiveObservationCapacity> objectiveObservations{};
@@ -333,10 +317,9 @@ 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,
-                        std::uint64_t missionSequence) noexcept;
+                        const host::SenseObservationSnapshot& sense) noexcept;
 /** Raises one dedicated type-31 edge from a decoded schema-0x8080879F msg-19 payload. */
 void push_player_trigger(RuntimeInstance& instance, const host::Event& incident) noexcept;
 /** Raises one exact Type-6 start/finish edge from a decoded schema-0x808087BF msg-19 payload. */

+ 42 - 47
Sunrise/src/server/activity/mission/mission_trigger_observation.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <array>
+#include <cstddef>
 #include <cstdint>
 #include <limits>
 #include <span>
@@ -12,7 +13,15 @@ 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::uint8_t kSlotType = 30;
+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 {
@@ -20,40 +29,40 @@ struct Snapshot final {
     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() != 4) {
+    if (body.size() != kValueCount) {
         return false;
     }
-    std::array<const sense::DecodedValue*, 4> fields{};
+    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 < 2) {
-            if (value.kind != sense::ValueKind::boolean || value.width != 1
+        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 != 32
+        } 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[0]->unsignedValue != 0,
-              fields[1]->unsignedValue != 0,
-              static_cast<std::int32_t>(fields[2]->signedValue),
-              static_cast<std::int32_t>(fields[3]->signedValue)};
+    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;
 }
 
-enum class Edge : std::uint8_t { none, entered, exited };
 /** How one report relates to the last accepted one, as scripts see it. */
 enum class Continuity : std::uint8_t {
     invalid,
@@ -89,9 +98,8 @@ enum class Continuity : std::uint8_t {
         return "invalid";
     }
 }
-/** What one report means: an optional edge, its continuity, and whether scripts hear of it. */
+/** What one report means to scripts: its continuity, and whether they hear of it. */
 struct Observation final {
-    Edge edge{Edge::none};
     Continuity continuity{Continuity::invalid};
     /** Scripts receive a triggerState event for this report. */
     bool notify{};
@@ -100,19 +108,20 @@ struct Observation final {
 };
 
 /**
- * Tracks one volume's reports. Only consecutive record counters from one source prove an edge; a
- * repeated counter never confirms a level, and a conflicting repeat invalidates it.
+ * 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{}, occupied{};
+    bool hasCounter{}, known{};
     Snapshot snapshot{};
-    void invalidate_levels() noexcept {
-        known = false;
-    }
 
-    /** @param current The decoded level, or null when the body was malformed. */
+    /**
+     * 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,
@@ -124,46 +133,32 @@ struct Tracker final {
             return {};
         }
         const bool sameSource = source == nextSource;
-        if (!current) {
-            source = nextSource;
-            sequence = nextSequence;
+        source = nextSource;
+        sequence = nextSequence;
+        if (current == nullptr) {
             known = false;
             hasCounter = false;
-            return {Edge::none, Continuity::invalid, true, false};
+            return {Continuity::invalid, true, false};
         }
         if (sameSource && hasCounter && nextHasCounter && counter == nextCounter) {
-            const bool same = snapshot.occupied == current->occupied && snapshot.all == current->all
-                              && snapshot.count == current->count
-                              && snapshot.threshold == current->threshold;
-            if (!same) {
-                invalidate_levels();
-            }
-            sequence = nextSequence;
-            return {Edge::none, same ? Continuity::duplicate : Continuity::conflict, !same, false};
+            const bool same = snapshot == *current;
+            known = known && same;
+            return {same ? Continuity::duplicate : Continuity::conflict, !same, false};
         }
         Continuity continuity = Continuity::baseline;
-        const bool consecutive = known && sameSource && hasCounter && nextHasCounter
-                                 && counter != UINT32_MAX && nextCounter == counter + 1;
         if (!nextHasCounter) {
             continuity = Continuity::missingCounter;
-        } else if (consecutive) {
-            continuity = Continuity::consecutive;
-        } else if (sameSource && hasCounter && known) {
-            continuity = counter == UINT32_MAX   ? Continuity::wrap
-                         : nextCounter < counter ? Continuity::reset
-                                                 : Continuity::gap;
+        } 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;
         }
-        const Edge edge = !consecutive || occupied == current->occupied ? Edge::none
-                          : current->occupied                           ? Edge::entered
-                                                                        : Edge::exited;
-        source = nextSource;
-        sequence = nextSequence;
         counter = nextCounter;
         hasCounter = nextHasCounter;
         known = nextHasCounter;
-        occupied = current->occupied;
         snapshot = *current;
-        return {edge, continuity, true, nextHasCounter};
+        return {continuity, true, nextHasCounter};
     }
 };