Pārlūkot izejas kodu

Emit rule-less squads and the attachment capability in the activity SDK

Confetti3 1 mēnesi atpakaļ
vecāks
revīzija
1f9ec53c08

+ 3 - 2
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -249,13 +248,13 @@
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
     <ClCompile Include="src\client\hooks\world_objects\world_object_registry.cpp" />
+    <ClCompile Include="src\client\memory\current_process_memory.cpp" />
     <ClCompile Include="src\client\hooks\world_objects\world_object_identity_report.cpp" />
     <ClCompile Include="src\client\hooks\world_objects\world_object_purge_policy.cpp" />
     <ClCompile Include="src\client\hooking\detour.cpp" />
     <ClCompile Include="src\client\hooking\detour\protected_detour_uninstall.cpp" />
     <ClCompile Include="src\client\hooking\detour\transaction\detour_thread_transaction.cpp" />
     <ClCompile Include="src\client\executable\image.cpp" />
-    <ClCompile Include="src\client\memory\current_process_memory.cpp" />
     <ClCompile Include="src\client\hooks\package_trust\package_trust_bypass.cpp" />
     <ClCompile Include="src\client\hooks\feature_flags\feature_flags.cpp" />
     <ClCompile Include="src\client\hooks\cine_probe\cine_probe.cpp" />
@@ -2072,6 +2071,8 @@
     <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\state\activity_sdk\attachment_compatibility.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" />

+ 9 - 2
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -137,8 +137,11 @@ void report(ProgressProbe probe, void* context, Phase phase) noexcept {
 }
 
 /** Resolves one exact actor definition tag against the validated sorted actor section. */
-[[nodiscard]] bool
-resolve_actor(void* opaque, std::uint32_t definitionTag, std::uint32_t& output) noexcept {
+[[nodiscard]] bool resolve_actor(void* opaque,
+                                 std::uint32_t definitionTag,
+                                 std::uint32_t& output,
+                                 std::array<std::int8_t, 4>& profile) noexcept {
+    profile = {};
     output = format::kAbsentIndex;
     if (opaque == nullptr) {
         return false;
@@ -154,7 +157,11 @@ resolve_actor(void* opaque, std::uint32_t definitionTag, std::uint32_t& output)
         || static_cast<std::size_t>(found - actors.begin()) >= format::kAbsentIndex) {
         return false;
     }
+    if (!format::valid_authored_spawn_profile(found->authoredSpawnProfile)) {
+        return false;
+    }
     output = static_cast<std::uint32_t>(found - actors.begin());
+    profile = found->authoredSpawnProfile;
     return true;
 }
 

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

@@ -593,6 +593,7 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
         target.flags = input.flags;
         target.candidateCounts = input.candidateCounts;
         target.defaultCount = input.defaultCount;
+        target.authoredSpawnProfile = input.authoredSpawnProfile;
         if (!link_string(linker, input.id, target.id)) {
             return false;
         }

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

@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::content::activity::sdk_generation::squad_inventory {
+
+enum class SpawnReferenceState : std::uint8_t {
+    invalid,
+    absent,
+    present,
+};
+
+/** Build86657 native unset tests are typeFF or indexFFFF; malformed is not 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;
+}
+
+/** Source proof only: the selected rule and its occurrence still need independent validation. */
+[[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

@@ -71,6 +71,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
@@ -152,6 +153,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

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

+ 6 - 1
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 {
@@ -25,7 +26,8 @@ using TagReader = bool (*)(void* context,
 /** The actor inventory may close one exact definition tag to its final row index. */
 using ActorResolver = bool (*)(void* context,
                                std::uint32_t definitionTag,
-                               std::uint32_t& actorClassIndex) noexcept;
+                               std::uint32_t& actorClassIndex,
+                               std::array<std::int8_t, 4>& authoredSpawnProfile) noexcept;
 
 enum class CandidateState : std::uint8_t {
     nullPlacement,
@@ -84,6 +86,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{};
@@ -214,6 +218,7 @@ struct SquadMember final {
     std::uint32_t flags{};
     std::array<std::uint16_t, format::kSquadCandidateCountLaneCount> candidateCounts{};
     std::int32_t defaultCount{-1};
+    std::array<std::int8_t, 4> authoredSpawnProfile{};
     ActorLink actorLink{ActorLink::absent};
 };
 

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

@@ -239,6 +239,183 @@ struct ConfigOccurrenceCountKeyHash final {
                       value.row.spawnRuleConfigTag);
 }
 
+// Squads whose spawner names no rule: emitted without a rule or anchors, placeable only with a
+// selected type-66 rule slot. Shares the linker's private identity and member helpers.
+
+// Match the full descriptor tuple; no schema guesses based on slot type alone.
+[[nodiscard]] bool unbound_descriptor_exact(
+    const topology::Snapshot& topology,
+    const std::unordered_map<std::uint32_t, const GraphSlotSchemaProvenance*>& schemas,
+    const GraphDescriptor& d) {
+    if (!d.complete || d.slotIndex >= topology.slots.size()
+        || d.objectIndex >= topology.objects.size()) {
+        return false;
+    }
+    const auto& slot = topology.slots[d.slotIndex];
+    if (slot.objectIndex != d.objectIndex || slot.slotType != format::kSquadSlotType) {
+        return false;
+    }
+    const auto found = schemas.find(d.slotIndex);
+    const auto* match = found == schemas.end() ? nullptr : found->second;
+    return match != nullptr && match->exact && match->componentClass == d.componentClass
+           && match->senseSchema == d.senseSchema && match->authSchema == d.authSchema;
+}
+
+// A repeated authored config path is ambiguity, even when all paths have equal coordinates.
+[[nodiscard]] bool unbound_occurrence_exact(const topology::Snapshot& topology,
+                                            const std::vector<const GraphConfigContext*>& contexts,
+                                            const GraphDescriptor& d,
+                                            std::uint32_t occurrenceIndex) {
+    if (occurrenceIndex >= topology.occurrences.size()) {
+        return false;
+    }
+    const auto& o = topology.occurrences[occurrenceIndex];
+    if (o.objectIndex != d.objectIndex) {
+        return false;
+    }
+    std::uint32_t count = 0;
+    bool exact = false;
+    for (const auto* cp : contexts) {
+        const auto& c = *cp;
+        if (c.configTag != d.configTag || c.occurrenceIndex != occurrenceIndex) {
+            continue;
+        }
+        ++count;
+        exact = c.complete && c.objectIndex == d.objectIndex && c.scenarioIndex == o.scenarioIndex;
+    }
+    return count == 1 && exact;
+}
+
+// Append independently owned sources. Never construct or modify a GraphEdge.
+[[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 i = 0; i < graph.spawners.size(); ++i) {
+        if (!spawnersByConfig.emplace(graph.spawners[i].configTag, i).second) {
+            return false;
+        }
+    }
+    for (const auto& c : graph.configContexts) {
+        contextsByConfig[c.configTag].push_back(&c);
+    }
+    for (const auto& d : graph.descriptors) {
+        const auto foundSource = spawnersByConfig.find(d.configTag);
+        if (foundSource == spawnersByConfig.end()) {
+            continue;
+        }
+        const auto i = foundSource->second;
+        if (i >= facts.spawners.size()) {
+            return false;
+        }
+        const GraphSpawner& spawner = graph.spawners[i];
+        const SpawnerFact& source = facts.spawners[i];
+        if (!spawner.requiresSelectedRule) {
+            continue;
+        }
+        if (spawner.configTag != source.configTag) {
+            return false;
+        }
+        if (d.componentClass != format::kSquadComponentClass
+            || d.senseSchema != format::kSquadSenseSchema
+            || d.authSchema != format::kSquadAuthSchema
+            || !unbound_descriptor_exact(topology, schemas, d)) {
+            continue;
+        }
+        const auto foundContexts = contextsByConfig.find(d.configTag);
+        if (foundContexts == contextsByConfig.end()) {
+            continue;
+        }
+        std::vector<std::uint32_t> occurrenceIndexes{};
+        for (const auto* c : foundContexts->second) {
+            occurrenceIndexes.push_back(c->occurrenceIndex);
+        }
+        std::sort(occurrenceIndexes.begin(), occurrenceIndexes.end());
+        occurrenceIndexes.erase(std::unique(occurrenceIndexes.begin(), occurrenceIndexes.end()),
+                                occurrenceIndexes.end());
+        for (const auto oi : occurrenceIndexes) {
+            if (!unbound_occurrence_exact(topology, foundContexts->second, d, oi)) {
+                continue;
+            }
+            const auto& occurrence = topology.occurrences[oi];
+            std::string_view occurrenceId{};
+            if (!text_view(occurrence.id, occurrenceId)) {
+                return false;
+            }
+            const std::array<std::string_view, 2> parts{d.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 = d.objectIndex;
+            squad.row.slotIndex = d.slotIndex;
+            squad.row.spawnerConfigTag = d.configTag;
+            squad.row.spawnRuleConfigTag = format::kAbsentIndex;
+            squad.row.occurrenceIndex = oi;
+            squad.row.flags = format::kSquadRequiresSelectedRule
+                              | format::kSquadSourceDescriptorExact
+                              | format::kSquadScenarioOccurrenceExact;
+            bool sourceActorLinksComplete = true;
+            for (std::uint32_t mi = 0; mi < source.members.size(); ++mi) {
+                SquadMember member{};
+                if (!detail::build_member(source.members[mi],
+                                          digest,
+                                          mi,
+                                          actorResolver,
+                                          actorContext,
+                                          member,
+                                          sourceActorLinksComplete)) {
+                    return false;
+                }
+                squad.members.push_back(std::move(member));
+            }
+            // An unresolved unbound source must not disable the authored bound 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& m) {
+                        return (m.flags & format::kSquadMemberInvariantReadyMask)
+                                   == format::kSquadMemberInvariantReadyMask
+                               && m.defaultCount > 0;
+                    })) {
+                squad.row.flags |= format::kSquadCandidateCountsInvariantComplete;
+            }
+            std::vector<std::uint32_t> keys{};
+            for (const auto& m : squad.members) {
+                keys.push_back(m.memberKey);
+            }
+            std::sort(keys.begin(), keys.end());
+            if (std::adjacent_find(keys.begin(), keys.end()) != keys.end()) {
+                return false;
+            }
+            // Empty anchors and an absent rule are intentional; kSquadRunnableMask cannot pass.
+            pending.push_back(std::move(squad));
+        }
+    }
+    return true;
+}
+
 } // namespace
 
 /** Normalizes the authored graph once, then derives the narrower runnable squad projection. */
@@ -564,6 +741,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) {

+ 31 - 5
Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link_support.cpp

@@ -377,6 +377,35 @@ template <typename... Values>
     if (noNull) {
         output.flags |= format::kSquadMemberNoNullCandidates;
     }
+    bool commonProfileExact = allActorsEligible && actorResolver != nullptr;
+    bool foundProfile = false;
+    std::uint32_t singleActorIndex = format::kAbsentIndex;
+    std::array<std::int8_t, 4> commonProfile{};
+    if (commonProfileExact) {
+        for (const std::uint32_t tag : actorTags) {
+            std::uint32_t actorIndex = format::kAbsentIndex;
+            std::array<std::int8_t, 4> profile{};
+            if (!actorResolver(actorContext, tag, actorIndex, profile)
+                || actorIndex == format::kAbsentIndex
+                || !format::valid_authored_spawn_profile(profile)) {
+                commonProfileExact = false;
+                actorLinksComplete = false;
+                continue;
+            }
+            if (actorTags.size() == 1) {
+                singleActorIndex = actorIndex;
+            }
+            if (foundProfile && profile != commonProfile) {
+                commonProfileExact = false;
+            }
+            commonProfile = profile;
+            foundProfile = true;
+        }
+    }
+    if (commonProfileExact && foundProfile) {
+        output.flags |= format::kSquadMemberAuthoredProfileExact;
+        output.authoredSpawnProfile = commonProfile;
+    }
     if (allActorsEligible && actorTags.size() == 1) {
         output.actorDefinitionTag = *actorTags.begin();
         if (!format_text(output.actorDefinitionId,
@@ -384,11 +413,8 @@ template <typename... Values>
                          static_cast<unsigned>(output.actorDefinitionTag))) {
             return false;
         }
-        std::uint32_t actorIndex = format::kAbsentIndex;
-        if (actorResolver != nullptr
-            && actorResolver(actorContext, output.actorDefinitionTag, actorIndex)
-            && actorIndex != format::kAbsentIndex) {
-            output.actorClassIndex = actorIndex;
+        if (singleActorIndex != format::kAbsentIndex) {
+            output.actorClassIndex = singleActorIndex;
             output.actorLink = ActorLink::exactReciprocal;
             output.flags |= format::kSquadMemberActorClassExact;
         } else {

+ 21 - 8
Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp

@@ -11,6 +11,15 @@ namespace {
 
 std::atomic<Snapshot> g_snapshot{};
 std::atomic<Status> g_status{Status::notReady};
+SRWLOCK g_publicationLock = SRWLOCK_INIT;
+
+/** Loading/validation happens before this short swap; no game or Host lock is taken here. */
+void publish_snapshot(Snapshot pending, Status status) noexcept {
+    AcquireSRWLockExclusive(&g_publicationLock);
+    g_snapshot.store(std::move(pending), std::memory_order_release);
+    g_status.store(status, std::memory_order_release);
+    ReleaseSRWLockExclusive(&g_publicationLock);
+}
 
 template <typename Value>
 [[nodiscard]] std::span<const Value>
@@ -317,12 +326,10 @@ void initialize(void* module, const ExpectedIdentity& expected) noexcept {
     std::shared_ptr<Catalog> pending;
     Status result = Status::catalogInvalid;
     if (load(module, expected, pending, result)) {
-        g_snapshot.store(Snapshot(std::move(pending)), std::memory_order_release);
-        g_status.store(Status::ready, std::memory_order_release);
+        publish_snapshot(Snapshot(std::move(pending)), Status::ready);
         return;
     }
-    g_snapshot.store({}, std::memory_order_release);
-    g_status.store(result, std::memory_order_release);
+    publish_snapshot({}, result);
 }
 
 /** Keeps the published mapping when a catalog-authorized replacement cannot be authenticated. */
@@ -332,8 +339,7 @@ bool reload(void* module, const ExpectedIdentity& expected) noexcept {
     if (!load(module, expected, pending, result)) {
         return false;
     }
-    g_snapshot.store(Snapshot(std::move(pending)), std::memory_order_release);
-    g_status.store(Status::ready, std::memory_order_release);
+    publish_snapshot(Snapshot(std::move(pending)), Status::ready);
     return true;
 }
 
@@ -358,8 +364,7 @@ bool reload(void* module) noexcept {
 #endif
 
 void shutdown() noexcept {
-    g_snapshot.store({}, std::memory_order_release);
-    g_status.store(Status::notReady, std::memory_order_release);
+    publish_snapshot({}, Status::notReady);
 }
 
 Status status() noexcept {
@@ -927,4 +932,12 @@ const format::Slot* task_linked_objective_slot(const Catalog& catalog,
                : nullptr;
 }
 
+CatalogPublicationGuard::CatalogPublicationGuard() noexcept {
+    AcquireSRWLockShared(&g_publicationLock);
+}
+
+CatalogPublicationGuard::~CatalogPublicationGuard() noexcept {
+    ReleaseSRWLockShared(&g_publicationLock);
+}
+
 } // namespace sunrise::state::activity_sdk

+ 44 - 7
Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp

@@ -21,6 +21,41 @@ constexpr std::uint32_t kAbilityAuthSchema = 0x80807DA1U;
 constexpr std::uint32_t kAbilityTargetSlotType = 58U;
 constexpr std::uint32_t kAbilityTargetComponentClass = 0x80807D9BU;
 
+/** Validates common profile evidence without manufacturing a single actor identity. */
+[[nodiscard]] bool squad_member_profiles(const Catalog& catalog) noexcept {
+    const auto actors = catalog.actor_classes();
+    for (const format::SquadMember& member : catalog.squad_members()) {
+        const bool exactActor = (member.flags & format::kSquadMemberActorClassExact) != 0;
+        const bool exactProfile = (member.flags & format::kSquadMemberAuthoredProfileExact) != 0;
+        if ((member.flags & ~format::kSquadMemberFlagMask) != 0
+            || member.squadIndex >= catalog.squads().size()
+            || (exactActor ? member.actorClassIndex >= actors.size()
+                           : member.actorClassIndex != format::kAbsentIndex)) {
+            return false;
+        }
+        if (!exactProfile) {
+            if (member.authoredSpawnProfile != std::array<std::int8_t, 4>{} || exactActor) {
+                return false;
+            }
+            continue;
+        }
+        constexpr auto required =
+            format::kSquadMemberCandidateCountsComplete | format::kSquadMemberNoNullCandidates;
+        bool anyCandidate = false;
+        for (const auto count : member.candidateCounts) {
+            anyCandidate = anyCandidate || count != 0;
+        }
+        if ((member.flags & required) != required || !anyCandidate
+            || !format::valid_authored_spawn_profile(member.authoredSpawnProfile)
+            || (exactActor
+                && member.authoredSpawnProfile
+                       != actors[member.actorClassIndex].authoredSpawnProfile)) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** @return True when every task target names slots and objectives the catalog holds. */
 [[nodiscard]] bool task_targets(const Catalog& catalog) noexcept {
     const auto targets = catalog.task_targets();
@@ -582,13 +617,15 @@ bool relations(const Catalog& catalog) {
         bool (*run)(const Catalog&) noexcept;
     };
     // Every relation check a catalog must pass, named so a refusal reports which one failed.
-    static constexpr std::array<Check, 7> kChecks{{{"task_targets", &task_targets},
-                                                   {"authored_text", &authored_text},
-                                                   {"behavior_edges", &behavior_edges},
-                                                   {"actor_semantics", &actor_semantics},
-                                                   {"runtime_semantics", &runtime_semantics},
-                                                   {"sobject_semantics", &sobject_semantics},
-                                                   {"entity_types", &entity_types}}};
+    static constexpr std::array<Check, 8> kChecks{
+        {{"task_targets", &task_targets},
+         {"squad_member_profiles", &squad_member_profiles},
+         {"authored_text", &authored_text},
+         {"behavior_edges", &behavior_edges},
+         {"actor_semantics", &actor_semantics},
+         {"runtime_semantics", &runtime_semantics},
+         {"sobject_semantics", &sobject_semantics},
+         {"entity_types", &entity_types}}};
     for (const Check& check : kChecks) {
         if (!check.run(catalog)) {
             remember(check.name);

+ 23 - 0
Sunrise/src/state/activity_sdk/attachment_compatibility.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "format.h"
+
+namespace sunrise::state::activity_sdk::attachment_compatibility {
+
+static_assert(format::kVersion == 41,
+              "Re-audit attachment compatibility when the SDK format changes");
+
+/**
+ * Capability compatibility for the audited attachment pairs.
+ * The generated SDK digest is only known at runtime, so no audited-payload digest is pinned here.
+ * The type-26/type-1 same-owner checks in the slot API and attachments::admits still validate
+ * every attachment; pin the digest here once a catalog digest has been logged and audited.
+ */
+[[nodiscard]] inline bool supports(std::span<const std::byte> payloadSha256) noexcept {
+    return !payloadSha256.empty();
+}
+
+} // namespace sunrise::state::activity_sdk::attachment_compatibility

+ 31 - 3
Sunrise/src/state/activity_sdk/format.h

@@ -10,7 +10,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 = 40;
+/** Version 41 adds independently verified common squad-member profiles. */
+inline constexpr std::uint32_t kVersion = 41;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
 inline constexpr std::uint32_t kSectionCount = 50;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
@@ -240,12 +241,35 @@ 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);
+/** The mask one squad row must satisfy before it can be placed. */
+[[nodiscard]] constexpr std::uint32_t squad_required_mask(std::uint32_t flags) noexcept {
+    return (flags & kSquadRequiresSelectedRule) != 0 ? kSquadSourcePrerequisiteMask
+                                                     : kSquadRunnableMask;
+}
 /** Member flags separate actor resolution from count-array completeness. */
 inline constexpr std::uint32_t kSquadMemberActorClassExact = 0x1U;
 inline constexpr std::uint32_t kSquadMemberCandidateCountsComplete = 0x2U;
 inline constexpr std::uint32_t kSquadMemberCandidateCountsInvariant = 0x4U;
 inline constexpr std::uint32_t kSquadMemberNoNullCandidates = 0x8U;
-inline constexpr std::uint32_t kSquadMemberFlagMask = 0xFU;
+inline constexpr std::uint32_t kSquadMemberAuthoredProfileExact = 0x10U;
+inline constexpr std::uint32_t kSquadMemberFlagMask = 0x1FU;
+
+/** Auth field 5 lanes 1..4 retain the validated package logical ranges. */
+[[nodiscard]] constexpr bool
+valid_authored_spawn_profile(const std::array<std::int8_t, 4>& profile) noexcept {
+    constexpr std::array<std::int8_t, 4> maximum{2, 6, 2, 6};
+    for (std::size_t index = 0; index < profile.size(); ++index) {
+        if (profile[index] < 0 || profile[index] > maximum[index]) {
+            return false;
+        }
+    }
+    return true;
+}
 inline constexpr std::uint32_t kSquadMemberInvariantReadyMask =
     kSquadMemberCandidateCountsComplete | kSquadMemberCandidateCountsInvariant
     | kSquadMemberNoNullCandidates;
@@ -388,7 +412,7 @@ inline constexpr std::size_t kRsatDescriptorSize = 92;
 inline constexpr std::size_t kRsatSchemaSize = 64;
 inline constexpr std::size_t kRsatFieldSize = 40;
 inline constexpr std::size_t kSquadSize = 52;
-inline constexpr std::size_t kSquadMemberSize = 44;
+inline constexpr std::size_t kSquadMemberSize = 48;
 inline constexpr std::size_t kSquadAnchorSize = 48;
 inline constexpr std::size_t kAuthoredSceneResourceSize = 40;
 inline constexpr std::size_t kAuthoredSceneSquadEdgeSize = 40;
@@ -611,6 +635,7 @@ inline constexpr std::size_t kSquadMemberActorClassIndex = 20;
 inline constexpr std::size_t kSquadMemberFlags = 24;
 inline constexpr std::size_t kSquadMemberCandidateCounts = 28;
 inline constexpr std::size_t kSquadMemberDefaultCount = 40;
+inline constexpr std::size_t kSquadMemberAuthoredSpawnProfile = 44;
 inline constexpr std::size_t kSquadAnchorId = 0;
 inline constexpr std::size_t kSquadAnchorSquadIndex = 8;
 inline constexpr std::size_t kSquadAnchorPointOrdinal = 12;
@@ -1099,6 +1124,7 @@ struct SquadMember final {
     std::uint32_t flags{};
     std::array<std::uint16_t, kSquadCandidateCountLaneCount> candidateCounts{};
     std::int32_t defaultCount{-1};
+    std::array<std::int8_t, 4> authoredSpawnProfile{};
 };
 
 /** One exact authored anchor retains its placed-entry identity and raw position bits. */
@@ -1781,6 +1807,8 @@ static_assert(offsetof(SquadMember, actorClassIndex) == offset::kSquadMemberActo
 static_assert(offsetof(SquadMember, flags) == offset::kSquadMemberFlags);
 static_assert(offsetof(SquadMember, candidateCounts) == offset::kSquadMemberCandidateCounts);
 static_assert(offsetof(SquadMember, defaultCount) == offset::kSquadMemberDefaultCount);
+static_assert(offsetof(SquadMember, authoredSpawnProfile)
+              == offset::kSquadMemberAuthoredSpawnProfile);
 static_assert(offsetof(SquadAnchor, id) == offset::kSquadAnchorId);
 static_assert(offsetof(SquadAnchor, squadIndex) == offset::kSquadAnchorSquadIndex);
 static_assert(offsetof(SquadAnchor, pointOrdinal) == offset::kSquadAnchorPointOrdinal);

+ 11 - 0
Sunrise/src/state/activity_sdk/runtime.h

@@ -315,6 +315,17 @@ void shutdown() noexcept;
 /** @return Stable diagnostic text for one authored-scene seed result. */
 [[nodiscard]] const char* status_name(AuthoredSceneSeedStatus value) noexcept;
 /** @return Shared ownership of the current validated catalog. */
+/** Keeps catalog publication stable through one transport transaction.
+ * Ordinary snapshot reads do not acquire this lock. Do not reload or nest this guard while held.
+ */
+class CatalogPublicationGuard final {
+public:
+    CatalogPublicationGuard() noexcept;
+    ~CatalogPublicationGuard() noexcept;
+    CatalogPublicationGuard(const CatalogPublicationGuard&) = delete;
+    CatalogPublicationGuard& operator=(const CatalogPublicationGuard&) = delete;
+};
+
 [[nodiscard]] Snapshot snapshot() noexcept;
 /** Resolves one route only from the exact loaded SDK registry. */
 [[nodiscard]] bool executable_communication_route(