Browse Source

Report an authored actor's replicated damage levels to its mission as damageState

The damage component 80804BEE replicates on lane 2 with one ten-bit level per
pool; pool 0 is health, pool 1 shield. The codec keeps the two levels on the
update payload, the policy lane keys them by the actor's authored source (its
squad when the world view maps one) and finds the mission from the session's
policy binding or, without one, from the packet source. The mission runtime
raises damageState through the same row lookup and event path as Sense levels.

On 86657 a bound combatant never republishes Sense, so this is the only source
of boss health: Nokris's clamp holds at 880, 614 and 338 of 1023 arrive here.
Confetti3 1 month ago
parent
commit
9b018e9910

+ 28 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp

@@ -554,6 +554,34 @@ runtime_schema(const CompositeEntityCodecContext& context, std::uint32_t handle)
         }
         *semanticTag = tag;
     }
+    // The damage component sends each level pool as its own ten-bit walk; pool 0 is health.
+    constexpr std::uint32_t kDamageComponent = 0x80804BEEU;
+    // Schema 80804C5C is one pool level; its single field is the only value it decodes.
+    constexpr std::uint32_t kDamagePoolSchema = 0x80804C5CU;
+    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 <= 1023.0)) {
+                continue;
+            }
+            if (mirror.damagePools == 0) {
+                mirror.damageHealth = static_cast<std::uint16_t>(level + 0.5);
+                mirror.damageKnown = true;
+            } else if (mirror.damagePools == 1) {
+                mirror.damageShield = static_cast<std::uint16_t>(level + 0.5);
+            }
+            if (mirror.damagePools != 0xFF) {
+                ++mirror.damagePools;
+            }
+        }
+    }
     if (resolverContext.componentTag == 0x80C70EDCU && schemaHandle == 0x80C70EDCU) {
         wire::runtime::SchemaView component{}, source{}, reference{};
         wire::runtime::FieldView componentField{}, sourceField{};

+ 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;
     }

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

@@ -63,6 +63,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. */

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

@@ -267,6 +267,51 @@ 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..1023.
+ * @param shield Pool 1 of the damage component, 0..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 {
+    if (sourceGeneration == 0 || registryKey == 0 || health > 1023 || shield > 1023
+        || 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) / 1023.0F,
+                           static_cast<float>(shield) / 1023.0F,
+                           GetTickCount64());
+    } else {
+        // Bounded evidence: a report that finds no mission is otherwise silent.
+        static unsigned refused = 0;
+        if (refused < 8) {
+            ++refused;
+            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,

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

@@ -96,6 +96,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 optional, off-by-default server mission-script manager. */
 void initialize() noexcept;
 /** Writes the authored controller path, `<name>/<name>.lua`, for one 1-based SDK activity row. */

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

@@ -34,6 +34,54 @@ find_combatant_damage(RuntimeInstance& instance, const host::SenseObservationKey
     return spare;
 }
 
+/**
+ * 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
 
 /**
@@ -67,41 +115,44 @@ 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 share the Sense watch table; they have no object tag, so the slot type stands in its
+ * place in the row key.
+ */
+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_combatant_damage(instance, 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

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

@@ -350,6 +350,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;

+ 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;
 }

+ 15 - 8
Sunrise/src/server/gameplay/actor_command_policy_session.h

@@ -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];

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

@@ -306,7 +306,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");
     }