Browse Source

Merge pull request #119 from Fozkais/sdk-scene-resources-and-participants

SDK: record unresourced authored scenes and read every scene participant
stan 2 days ago
parent
commit
f988af7fca

+ 3 - 2
Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h

@@ -92,7 +92,7 @@ is_performance_descriptor(const topology::Snapshot& topology,
 
 
 /** Sort key matching the final section-22 pack order. */
 /** Sort key matching the final section-22 pack order. */
 [[nodiscard]] inline auto edge_natural(const SquadEdge& row) noexcept {
 [[nodiscard]] inline auto edge_natural(const SquadEdge& row) noexcept {
-    return std::tie(row.sceneSlotIndex, row.configTag, row.descriptorOffset);
+    return std::tie(row.sceneSlotIndex, row.configTag, row.descriptorOffset, row.squadSlotIndex);
 }
 }
 
 
 /** Sort key matching the final task-target pack order. */
 /** Sort key matching the final task-target pack order. */
@@ -176,9 +176,10 @@ schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex
                                const squad::DescriptorFact& descriptor,
                                const squad::DescriptorFact& descriptor,
                                Text& output) noexcept;
                                Text& output) noexcept;
 
 
-/** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
+/** Formats one scene-to-squad edge ID from its descriptor tuple and the squad slot it names. */
 [[nodiscard]] bool edge_id(const topology::Snapshot& topology,
 [[nodiscard]] bool edge_id(const topology::Snapshot& topology,
                            const squad::DescriptorFact& descriptor,
                            const squad::DescriptorFact& descriptor,
+                           std::uint32_t squadSlotRow,
                            Text& output) noexcept;
                            Text& output) noexcept;
 
 
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */

+ 126 - 61
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp

@@ -32,23 +32,27 @@ bool resource_id(const topology::Snapshot& topology,
                        static_cast<unsigned>(slot.slotType));
                        static_cast<unsigned>(slot.slotType));
 }
 }
 
 
-/** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
+/** Formats one scene-to-squad edge ID from its descriptor tuple and the squad slot it names. */
 bool edge_id(const topology::Snapshot& topology,
 bool edge_id(const topology::Snapshot& topology,
              const squad::DescriptorFact& descriptor,
              const squad::DescriptorFact& descriptor,
+             std::uint32_t squadSlotRow,
              Text& output) noexcept {
              Text& output) noexcept {
     if (descriptor.objectIndex >= topology.objects.size()
     if (descriptor.objectIndex >= topology.objects.size()
-        || descriptor.slotIndex >= topology.slots.size()) {
+        || descriptor.slotIndex >= topology.slots.size() || squadSlotRow >= topology.slots.size()) {
         return false;
         return false;
     }
     }
     const topology::Object& object = topology.objects[descriptor.objectIndex];
     const topology::Object& object = topology.objects[descriptor.objectIndex];
     const topology::Slot& slot = topology.slots[descriptor.slotIndex];
     const topology::Slot& slot = topology.slots[descriptor.slotIndex];
+    const topology::Slot& squad = topology.slots[squadSlotRow];
     return format_text(output,
     return format_text(output,
-                       "authored-scene-squad-edge/%08x/%08x/%08x/%04x/%04x",
+                       "authored-scene-squad-edge/%08x/%08x/%08x/%04x/%04x/%04x/%04x",
                        static_cast<unsigned>(descriptor.configTag),
                        static_cast<unsigned>(descriptor.configTag),
                        static_cast<unsigned>(object.objectTag),
                        static_cast<unsigned>(object.objectTag),
                        static_cast<unsigned>(descriptor.descriptorOffset),
                        static_cast<unsigned>(descriptor.descriptorOffset),
                        static_cast<unsigned>(slot.slotIndex),
                        static_cast<unsigned>(slot.slotIndex),
-                       static_cast<unsigned>(slot.slotType));
+                       static_cast<unsigned>(slot.slotType),
+                       static_cast<unsigned>(squad.slotIndex),
+                       static_cast<unsigned>(squad.slotType));
 }
 }
 
 
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */
@@ -191,6 +195,30 @@ void log_performance_edge(const squad::DescriptorFact& descriptor, const char* r
     }
     }
 }
 }
 
 
+/** Logs a scene descriptor that produced no resource row, and why. */
+void log_scene_resource(const squad::DescriptorFact& descriptor,
+                        std::uint32_t resourceTag,
+                        const char* result,
+                        core::log::Level level) noexcept {
+    std::array<char, 176> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=activity_sdk_scene_resource result=%s config=0x%08X offset=0x%X "
+                      "slot_row=%u resource=0x%08X",
+                      result,
+                      static_cast<unsigned>(descriptor.configTag),
+                      static_cast<unsigned>(descriptor.descriptorOffset),
+                      static_cast<unsigned>(descriptor.slotIndex),
+                      static_cast<unsigned>(resourceTag));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            level,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
 /** Reads one tag once and retains the physical class beside its bytes. */
 /** Reads one tag once and retains the physical class beside its bytes. */
 [[nodiscard]] bool package_row(squad::TagReader reader,
 [[nodiscard]] bool package_row(squad::TagReader reader,
                                void* readerContext,
                                void* readerContext,
@@ -435,7 +463,7 @@ bool build(const topology::Snapshot& topology,
                     continue;
                     continue;
                 }
                 }
                 SquadEdge row{};
                 SquadEdge row{};
-                if (!edge_id(topology, descriptor, row.id)) {
+                if (!edge_id(topology, descriptor, linkedSlot, row.id)) {
                     log_performance_edge(descriptor, "identity");
                     log_performance_edge(descriptor, "identity");
                     continue;
                     continue;
                 }
                 }
@@ -474,11 +502,18 @@ bool build(const topology::Snapshot& topology,
             if (!read_value(blob, resourceField, resourceTag)) {
             if (!read_value(blob, resourceField, resourceTag)) {
                 continue;
                 continue;
             }
             }
-            if (resourceTag != 0 && resourceTag != format::kAbsentIndex) {
+            if (resourceTag == 0 || resourceTag == format::kAbsentIndex) {
+                log_scene_resource(descriptor, resourceTag, "unresourced", core::log::Level::debug);
+                pending.unresourcedSlots.push_back(descriptor.slotIndex);
+            } else {
                 const PackageRow* resourcePackage = nullptr;
                 const PackageRow* resourcePackage = nullptr;
-                if (package_row(reader, readerContext, resourceTag, cache, resourcePackage)
-                    && resourcePackage != nullptr
-                    && resourcePackage->classId == format::kAuthoredSceneResourceClass) {
+                if (!package_row(reader, readerContext, resourceTag, cache, resourcePackage)
+                    || resourcePackage == nullptr) {
+                    log_scene_resource(
+                        descriptor, resourceTag, "unreadable", core::log::Level::warn);
+                } else if (resourcePackage->classId != format::kAuthoredSceneResourceClass) {
+                    log_scene_resource(descriptor, resourceTag, "class", core::log::Level::warn);
+                } else {
                     Resource row{};
                     Resource row{};
                     if (resource_id(topology, descriptor, row.id)) {
                     if (resource_id(topology, descriptor, row.id)) {
                         row.slotIndex = descriptor.slotIndex;
                         row.slotIndex = descriptor.slotIndex;
@@ -493,61 +528,87 @@ bool build(const topology::Snapshot& topology,
                 }
                 }
             }
             }
 
 
-            const std::size_t blockClassField =
-                static_cast<std::size_t>(descriptor.descriptorOffset)
-                + format::kAuthoredSceneSquadBlockClassRelativeOffset;
-            std::uint32_t blockClass = 0;
-            if (!read_value(blob, blockClassField, blockClass)) {
-                continue;
-            }
-            if (blockClass != format::kAuthoredSceneSquadBlockClass) {
+            // Every squad in the participant table is an edge; the other participant kinds
+            // (point sets, objects) are not.
+            const std::size_t descriptorOffset = descriptor.descriptorOffset;
+            std::uint64_t participantCount = 0;
+            std::uint32_t tableClass = 0;
+            if (!read_value(blob,
+                            descriptorOffset + format::kAuthoredSceneParticipantCountRelativeOffset,
+                            participantCount)
+                || !read_value(blob,
+                               descriptorOffset
+                                   + format::kAuthoredSceneParticipantTableClassRelativeOffset,
+                               tableClass)
+                || tableClass != format::kAuthoredSceneParticipantTableClass
+                || participantCount > format::kAuthoredSceneParticipantCapacity) {
                 continue;
                 continue;
             }
             }
-            const std::size_t referenceField = static_cast<std::size_t>(descriptor.descriptorOffset)
-                                               + format::kAuthoredSceneSquadReferenceRelativeOffset;
-            std::uint32_t targetObjectKey = 0;
-            std::uint16_t targetSlotType = 0;
-            std::uint16_t targetSlotIndex = 0;
-            if (!read_value(blob, referenceField, targetObjectKey)
-                || !read_value(blob, referenceField + kTargetSlotTypeRelativeOffset, targetSlotType)
-                || !read_value(
-                    blob, referenceField + kTargetSlotIndexRelativeOffset, targetSlotIndex)) {
-                continue;
-            }
-            if (targetSlotType != format::kSquadSlotType) {
-                continue;
-            }
-            if (descriptor.objectIndex >= topology.objects.size()
-                || targetObjectKey != topology.objects[descriptor.objectIndex].objectKey) {
-                continue;
-            }
-            std::uint32_t linkedSlot = format::kAbsentIndex;
-            if (!same_object_slot(
-                    topology, descriptor, targetSlotType, targetSlotIndex, linkedSlot)) {
-                return false;
-            }
-            if (linkedSlot == format::kAbsentIndex
-                || !slot_shape(topology,
-                               schemas,
-                               linkedSlot,
-                               format::kSquadSlotType,
-                               format::kSquadComponentClass,
-                               format::kSquadSenseSchema,
-                               format::kSquadAuthSchema)) {
-                continue;
-            }
-            SquadEdge row{};
-            if (!edge_id(topology, descriptor, row.id)) {
-                continue;
+            for (std::uint64_t entry = 0; entry < participantCount; ++entry) {
+                const std::size_t pointerField =
+                    descriptorOffset + format::kAuthoredSceneParticipantTableRelativeOffset
+                    + static_cast<std::size_t>(entry)
+                          * format::kAuthoredSceneParticipantPointerSize;
+                std::uint64_t pointer = 0;
+                if (!read_value(blob, pointerField, pointer)
+                    || pointer > blob.size() - pointerField) {
+                    break;
+                }
+                const std::size_t payload = pointerField + static_cast<std::size_t>(pointer);
+                std::uint32_t blockClass = 0;
+                if (payload < format::kAuthoredSceneParticipantClassSize
+                    || !read_value(
+                        blob, payload - format::kAuthoredSceneParticipantClassSize, blockClass)) {
+                    break;
+                }
+                if (blockClass != format::kAuthoredSceneSquadBlockClass) {
+                    continue;
+                }
+                const std::size_t referenceField =
+                    payload + format::kAuthoredSceneSquadPayloadReferenceOffset;
+                std::uint32_t targetObjectKey = 0;
+                std::uint16_t targetSlotType = 0;
+                std::uint16_t targetSlotIndex = 0;
+                if (!read_value(blob, referenceField, targetObjectKey)
+                    || !read_value(
+                        blob, referenceField + kTargetSlotTypeRelativeOffset, targetSlotType)
+                    || !read_value(
+                        blob, referenceField + kTargetSlotIndexRelativeOffset, targetSlotIndex)) {
+                    break;
+                }
+                if (targetSlotType != format::kSquadSlotType
+                    || descriptor.objectIndex >= topology.objects.size()
+                    || targetObjectKey != topology.objects[descriptor.objectIndex].objectKey) {
+                    continue;
+                }
+                std::uint32_t linkedSlot = format::kAbsentIndex;
+                if (!same_object_slot(
+                        topology, descriptor, targetSlotType, targetSlotIndex, linkedSlot)) {
+                    return false;
+                }
+                if (linkedSlot == format::kAbsentIndex
+                    || !slot_shape(topology,
+                                   schemas,
+                                   linkedSlot,
+                                   format::kSquadSlotType,
+                                   format::kSquadComponentClass,
+                                   format::kSquadSenseSchema,
+                                   format::kSquadAuthSchema)) {
+                    continue;
+                }
+                SquadEdge row{};
+                if (!edge_id(topology, descriptor, linkedSlot, row.id)) {
+                    continue;
+                }
+                row.sceneSlotIndex = descriptor.slotIndex;
+                row.squadSlotIndex = linkedSlot;
+                row.configTag = descriptor.configTag;
+                row.descriptorOffset = descriptor.descriptorOffset;
+                row.referenceFieldOffset = static_cast<std::uint32_t>(referenceField);
+                row.targetObjectKey = targetObjectKey;
+                row.flags = format::kAuthoredSceneSquadSameObjectExact;
+                pending.squadEdges.push_back(row);
             }
             }
-            row.sceneSlotIndex = descriptor.slotIndex;
-            row.squadSlotIndex = linkedSlot;
-            row.configTag = descriptor.configTag;
-            row.descriptorOffset = descriptor.descriptorOffset;
-            row.referenceFieldOffset = static_cast<std::uint32_t>(referenceField);
-            row.targetObjectKey = targetObjectKey;
-            row.flags = format::kAuthoredSceneSquadSameObjectExact;
-            pending.squadEdges.push_back(row);
         }
         }
         // A sensor with no descriptor fact logs nothing above, so count both sides here.
         // A sensor with no descriptor fact logs nothing above, so count both sides here.
         {
         {
@@ -595,6 +656,10 @@ bool build(const topology::Snapshot& topology,
                                                   return task_natural(left) == task_natural(right);
                                                   return task_natural(left) == task_natural(right);
                                               }),
                                               }),
                                   pending.taskTargets.end());
                                   pending.taskTargets.end());
+        std::sort(pending.unresourcedSlots.begin(), pending.unresourcedSlots.end());
+        pending.unresourcedSlots.erase(
+            std::unique(pending.unresourcedSlots.begin(), pending.unresourcedSlots.end()),
+            pending.unresourcedSlots.end());
         pending.complete = true;
         pending.complete = true;
         output = std::move(pending);
         output = std::move(pending);
         return true;
         return true;

+ 5 - 0
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h

@@ -107,6 +107,11 @@ struct Snapshot final {
     std::vector<format::DialogueCue> dialogueCues{};
     std::vector<format::DialogueCue> dialogueCues{};
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::vector<DirectiveElement> directiveElements{};
     std::vector<DirectiveElement> directiveElements{};
+    /**
+     * Scene slots whose package config references no resource, as topology rows, ascending
+     * and unique. The server seeds nothing for them instead of refusing the whole object.
+     */
+    std::vector<std::uint32_t> unresourcedSlots{};
     bool complete{};
     bool complete{};
 };
 };
 
 

+ 32 - 7
Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp

@@ -1,3 +1,4 @@
+#include <algorithm>
 #include <array>
 #include <array>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
@@ -86,7 +87,7 @@ constexpr std::size_t kHashCombineConstant = 0x9E3779B9U;
                 || descriptor.objectIndex >= topology.objects.size()
                 || descriptor.objectIndex >= topology.objects.size()
                 || descriptor.slotIndex >= topology.slots.size()
                 || descriptor.slotIndex >= topology.slots.size()
                 || descriptor.descriptorOffset
                 || descriptor.descriptorOffset
-                       > format::kAbsentIndex - format::kAuthoredSceneSquadReferenceRelativeOffset
+                       > format::kAbsentIndex - format::kAuthoredSceneParticipantTableRelativeOffset
                 || !descriptor_id(topology, descriptor, expected) || descriptor.id != expected
                 || !descriptor_id(topology, descriptor, expected) || descriptor.id != expected
                 || !ids.emplace(descriptor.id).second) {
                 || !ids.emplace(descriptor.id).second) {
                 return false;
                 return false;
@@ -207,16 +208,40 @@ bool validate(const topology::Snapshot& topology,
             return false;
             return false;
         }
         }
     }
     }
+    for (std::size_t index = 0; index < snapshot.unresourcedSlots.size(); ++index) {
+        const std::uint32_t slotRow = snapshot.unresourcedSlots[index];
+        // A slot is unresourced or resourced, never both: one descriptor per scene slot.
+        const bool resourced =
+            std::any_of(snapshot.resources.begin(),
+                        snapshot.resources.end(),
+                        [slotRow](const Resource& row) { return row.slotIndex == slotRow; });
+        if (resourced
+            || !slot_shape(topology,
+                           schemas,
+                           slotRow,
+                           format::kAuthoredSceneSlotType,
+                           format::kAuthoredSceneComponentClass,
+                           format::kAuthoredSceneSenseSchema,
+                           format::kAuthoredSceneAuthSchema)
+            || (index != 0 && snapshot.unresourcedSlots[index - 1] >= slotRow)) {
+            return false;
+        }
+    }
     for (std::size_t index = 0; index < snapshot.squadEdges.size(); ++index) {
     for (std::size_t index = 0; index < snapshot.squadEdges.size(); ++index) {
         const SquadEdge& row = snapshot.squadEdges[index];
         const SquadEdge& row = snapshot.squadEdges[index];
         const squad::DescriptorFact* descriptor =
         const squad::DescriptorFact* descriptor =
             find_descriptor(descriptors, row.sceneSlotIndex, row.configTag, row.descriptorOffset);
             find_descriptor(descriptors, row.sceneSlotIndex, row.configTag, row.descriptorOffset);
         Text expectedId{};
         Text expectedId{};
-        // A row is a type-43 scene edge or a type-42 performance edge; the flag says which.
+        // A row is a type-43 scene edge or a type-42 performance edge; the flag says which. A
+        // performance names its squad at a fixed field; a scene names it in a block its
+        // participant table points at, so only the table's position bounds that field here.
         const bool performance = row.flags == format::kAuthoredSceneSquadPerformanceTargetExact;
         const bool performance = row.flags == format::kAuthoredSceneSquadPerformanceTargetExact;
         const std::uint32_t referenceOffset =
         const std::uint32_t referenceOffset =
             performance ? format::kPerformanceSquadReferenceRelativeOffset
             performance ? format::kPerformanceSquadReferenceRelativeOffset
-                        : format::kAuthoredSceneSquadReferenceRelativeOffset;
+                        : format::kAuthoredSceneParticipantTableRelativeOffset;
+        const bool referencePlaced =
+            performance ? row.referenceFieldOffset == row.descriptorOffset + referenceOffset
+                        : row.referenceFieldOffset > row.descriptorOffset + referenceOffset;
         const bool sourceShape = performance ? slot_shape(topology,
         const bool sourceShape = performance ? slot_shape(topology,
                                                           schemas,
                                                           schemas,
                                                           row.sceneSlotIndex,
                                                           row.sceneSlotIndex,
@@ -236,8 +261,9 @@ bool validate(const topology::Snapshot& topology,
             || topology.slots[row.squadSlotIndex].objectIndex != descriptor->objectIndex
             || topology.slots[row.squadSlotIndex].objectIndex != descriptor->objectIndex
             || !(performance ? is_performance_descriptor(topology, *descriptor)
             || !(performance ? is_performance_descriptor(topology, *descriptor)
                              : is_scene_descriptor(topology, *descriptor))
                              : is_scene_descriptor(topology, *descriptor))
-            || !edge_id(topology, *descriptor, expectedId) || expectedId.value != row.id.value
-            || expectedId.length != row.id.length || !sourceShape
+            || !edge_id(topology, *descriptor, row.squadSlotIndex, expectedId)
+            || expectedId.value != row.id.value || expectedId.length != row.id.length
+            || !sourceShape
             || !slot_shape(topology,
             || !slot_shape(topology,
                            schemas,
                            schemas,
                            row.squadSlotIndex,
                            row.squadSlotIndex,
@@ -245,8 +271,7 @@ bool validate(const topology::Snapshot& topology,
                            format::kSquadComponentClass,
                            format::kSquadComponentClass,
                            format::kSquadSenseSchema,
                            format::kSquadSenseSchema,
                            format::kSquadAuthSchema)
                            format::kSquadAuthSchema)
-            || row.descriptorOffset > format::kAbsentIndex - referenceOffset
-            || row.referenceFieldOffset != row.descriptorOffset + referenceOffset
+            || row.descriptorOffset > format::kAbsentIndex - referenceOffset || !referencePlaced
             || row.targetObjectKey != topology.objects[descriptor->objectIndex].objectKey
             || row.targetObjectKey != topology.objects[descriptor->objectIndex].objectKey
             || (row.flags != format::kAuthoredSceneSquadSameObjectExact && !performance)
             || (row.flags != format::kAuthoredSceneSquadSameObjectExact && !performance)
             || row.reserved != 0
             || row.reserved != 0

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

@@ -245,6 +245,7 @@ local EventKind = {
 ---@field auth_component_offset integer|nil
 ---@field auth_component_offset integer|nil
 ---@field auth_dynamic boolean|nil
 ---@field auth_dynamic boolean|nil
 ---@field auth_writable boolean|nil
 ---@field auth_writable boolean|nil
+---@field unresourced boolean|nil
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
 ---@field applied fun(self: SunriseSlot, args: {channel: any}): boolean
 ---@field applied fun(self: SunriseSlot, args: {channel: any}): boolean
 ---@field run_atoms fun(self: SunriseSlot, )lua"
 ---@field run_atoms fun(self: SunriseSlot, )lua"

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp

@@ -223,6 +223,9 @@ bool render_mission(const Source& source,
         append_uint(output, slot.senseSchema);
         append_uint(output, slot.senseSchema);
         output.append(", auth_schema = ");
         output.append(", auth_schema = ");
         append_uint(output, slot.authSchema);
         append_uint(output, slot.authSchema);
+        if ((slot.flags & format::kSlotAuthoredSceneUnresourced) != 0) {
+            output.append(", unresourced = true");
+        }
         if (auth != nullptr) {
         if (auth != nullptr) {
             output.append(", auth_type = ");
             output.append(", auth_type = ");
             append_string(output, auth->name);
             append_string(output, auth->name);

+ 5 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h

@@ -64,4 +64,9 @@ struct PackageContext final {
                                               topology_enrichment::Snapshot& enrichment,
                                               topology_enrichment::Snapshot& enrichment,
                                               authored_scene::Snapshot& authored);
                                               authored_scene::Snapshot& authored);
 
 
+/** Marks the SDK slot row of every type-43 scene whose descriptor names no resource. */
+[[nodiscard]] bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
+                                             const authored_scene::Snapshot& scenes,
+                                             topology_enrichment::Snapshot& enrichment) noexcept;
+
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -371,6 +371,9 @@ Status stage(const wchar_t* sdkDirectory,
             return cancelled(cancel, cancelContext) ? Status::cancelled
             return cancelled(cancel, cancelContext) ? Status::cancelled
                                                     : Status::authoredSceneLinks;
                                                     : Status::authoredSceneLinks;
         }
         }
+        if (!attach_unresourced_scenes(topology, sceneRows, topologyDetails)) {
+            return Status::authoredSceneLinks;
+        }
         report(progress, progressContext, Phase::dialogueCues);
         report(progress, progressContext, Phase::dialogueCues);
         if (!attach_dialogue_cue_counts(
         if (!attach_dialogue_cue_counts(
                 topology, squadFacts, packageContext, topologyDetails, sceneRows)) {
                 topology, squadFacts, packageContext, topologyDetails, sceneRows)) {

+ 22 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -562,4 +562,26 @@ bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
     }
     }
 }
 }
 
 
+/** Flags the slot rows of the scenes the inventory found without a resource. */
+bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
+                               const authored_scene::Snapshot& scenes,
+                               topology_enrichment::Snapshot& enrichment) noexcept {
+    if (enrichment.slots.size() != topology.slots.size()) {
+        return false;
+    }
+    for (const std::uint32_t slotRow : scenes.unresourcedSlots) {
+        if (slotRow >= topology.slots.size()
+            || topology.slots[slotRow].slotType != format::kAuthoredSceneSlotType) {
+            return false;
+        }
+        topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
+        if (enriched.componentClass != format::kAuthoredSceneComponentClass
+            || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
+            return false;
+        }
+        enriched.flags |= format::kSlotAuthoredSceneUnresourced;
+    }
+    return true;
+}
+
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

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

@@ -16,6 +16,8 @@ struct PreparedScene final {
     state::build_data::scenarios::RosterGroup rosterGroup{};
     state::build_data::scenarios::RosterGroup rosterGroup{};
     middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies
     middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies
         sceneDependencies{};
         sceneDependencies{};
+    /** Squads the resolved cast draws from; more than the wire carries leaves the set empty. */
+    std::size_t castCount{};
     std::uint64_t activityClientGeneration{};
     std::uint64_t activityClientGeneration{};
     std::uint32_t scenarioRow{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t scenarioRow{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t stateRow{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t stateRow{state::activity_sdk::format::kAbsentIndex};

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

@@ -7,7 +7,7 @@
 #include "../../state/activity/runtime.h"
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_mission_internal.h"
-#include "activity_sdk_scene_dependencies.h"
+#include "activity_sdk_scene_spawn.h"
 #include "activity_sdk_scriptable_route.h"
 #include "activity_sdk_scriptable_route.h"
 
 
 namespace sunrise::server::activity::activity_sdk_mission::detail {
 namespace sunrise::server::activity::activity_sdk_mission::detail {
@@ -353,11 +353,13 @@ namespace {
         return SceneStatus::missingResource;
         return SceneStatus::missingResource;
     }
     }
 
 
-    const SceneStatus dependencies =
-        scene_dependencies(catalog, slot, resource, output.sceneDependencies);
-    if (dependencies != SceneStatus::ready) {
-        return dependencies;
+    SceneSpawnPlan cast{};
+    const SceneStatus collected = resolve_scene_spawn_plan(view, occurrenceRow, slotRow, cast);
+    if (collected != SceneStatus::ready) {
+        return collected;
     }
     }
+    output.castCount = cast.count;
+    static_cast<void>(scene_dependencies(cast, output.sceneDependencies));
 
 
     const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
     const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
     if (lease != SceneStatus::ready) {
     if (lease != SceneStatus::ready) {

+ 7 - 0
Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp

@@ -9,6 +9,7 @@
 #include "../../state/activity/runtime.h"
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_mission_internal.h"
+#include "activity_sdk_scene_spawn.h"
 #include "activity_sdk_scriptable_route.h"
 #include "activity_sdk_scriptable_route.h"
 #include "host_runtime.h"
 #include "host_runtime.h"
 
 
@@ -126,6 +127,9 @@ SceneStatus activate_authored_scene(const sdk::BoundView& view,
     if (status != SceneStatus::ready) {
     if (status != SceneStatus::ready) {
         return status;
         return status;
     }
     }
+    if (prepared.sceneDependencies.count == 0 && prepared.castCount != 0) {
+        log_omitted_scene_dependencies(*view.catalog, slotRow, prepared.castCount);
+    }
     if (server::bap::request_activity_state_local_authored_scene_override(
     if (server::bap::request_activity_state_local_authored_scene_override(
             view.binding,
             view.binding,
             prepared.target,
             prepared.target,
@@ -752,6 +756,9 @@ SceneStatus activate_authored_scene_reserved(const sdk::BoundView& view,
     if (status != SceneStatus::ready) {
     if (status != SceneStatus::ready) {
         return status;
         return status;
     }
     }
+    if (prepared.sceneDependencies.count == 0 && prepared.castCount != 0) {
+        log_omitted_scene_dependencies(*view.catalog, slotRow, prepared.castCount);
+    }
     if (server::bap::request_activity_state_local_authored_scene_override(
     if (server::bap::request_activity_state_local_authored_scene_override(
             view.binding,
             view.binding,
             prepared.target,
             prepared.target,

+ 16 - 6
Sunrise/src/server/activity/activity_sdk_scene_cast.cpp

@@ -70,12 +70,17 @@ using Source = state::gameplay::entity_identity::ActorSourceReference;
 
 
 /**
 /**
  * Resolves the complete cast before exposing any pair.
  * Resolves the complete cast before exposing any pair.
+ *
+ * A squad participant joins the cast only when one exact actor control in the scene's own
+ * scenario state resolves to it. The client binds every role from its content and waits for
+ * the squads nobody supplies, so a participant without an actor control is left to it rather
+ * than refused.
  * @param catalog Authenticated SDK data.
  * @param catalog Authenticated SDK data.
  * @param world Exact generated package references.
  * @param world Exact generated package references.
  * @param occurrenceRow Selected scene occurrence.
  * @param occurrenceRow Selected scene occurrence.
  * @param sceneSlotRow Type 43 slot owned by that occurrence.
  * @param sceneSlotRow Type 43 slot owned by that occurrence.
  * @param output Receives the complete plan; cleared on failure.
  * @param output Receives the complete plan; cleared on failure.
- * @return Ready only when every dependency has one exact actor control.
+ * @return Ready only when every cast squad has exactly one actor control.
  */
  */
 SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
 SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
                                      const state::build_data::scriptables::Snapshot& world,
                                      const state::build_data::scriptables::Snapshot& world,
@@ -101,14 +106,14 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
     if (resources.size() != 1) {
     if (resources.size() != 1) {
         return SceneStatus::ambiguousResource;
         return SceneStatus::ambiguousResource;
     }
     }
-    middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies dependencies{};
-    const auto resolved = scene_dependencies(catalog, scene, resources.front(), dependencies);
+    SceneSquadParticipants participants{};
+    const auto resolved = scene_squad_participants(catalog, scene, resources.front(), participants);
     if (resolved != SceneStatus::ready) {
     if (resolved != SceneStatus::ready) {
         return resolved;
         return resolved;
     }
     }
     SceneSpawnPlan candidate{};
     SceneSpawnPlan candidate{};
-    for (std::size_t index = 0; index < dependencies.count; ++index) {
-        const auto& reference = dependencies.references[index];
+    for (std::size_t index = 0; index < participants.count; ++index) {
+        const auto& reference = participants.rows[index].reference;
         const Source source{reference.rosterKey,
         const Source source{reference.rosterKey,
                             static_cast<std::uint16_t>(reference.slotIndex),
                             static_cast<std::uint16_t>(reference.slotIndex),
                             static_cast<std::uint8_t>(reference.slotType),
                             static_cast<std::uint8_t>(reference.slotType),
@@ -116,6 +121,10 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
                             true};
                             true};
         auto& pair = candidate.pairs[candidate.count];
         auto& pair = candidate.pairs[candidate.count];
         const auto parent = source_squad(catalog, occurrenceRow, source, pair.squadRow);
         const auto parent = source_squad(catalog, occurrenceRow, source, pair.squadRow);
+        if (parent == SceneStatus::targetUnavailable) {
+            pair = {};
+            continue;
+        }
         if (parent != SceneStatus::ready) {
         if (parent != SceneStatus::ready) {
             return parent;
             return parent;
         }
         }
@@ -154,7 +163,8 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
             }
             }
         }
         }
         if (pair.actorSlotRow == sdk::format::kAbsentIndex) {
         if (pair.actorSlotRow == sdk::format::kAbsentIndex) {
-            return SceneStatus::targetUnavailable;
+            pair = {};
+            continue;
         }
         }
         pair.actorTarget = physical_target(catalog, pair.actorSlotRow);
         pair.actorTarget = physical_target(catalog, pair.actorSlotRow);
         pair.sourceTarget = physical_target(catalog, catalog.squads()[pair.squadRow].slotIndex);
         pair.sourceTarget = physical_target(catalog, catalog.squads()[pair.squadRow].slotIndex);

+ 34 - 17
Sunrise/src/server/activity/activity_sdk_scene_dependencies.h

@@ -1,5 +1,7 @@
 #pragma once
 #pragma once
 
 
+#include <array>
+#include <cstddef>
 #include <limits>
 #include <limits>
 
 
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
@@ -7,24 +9,40 @@
 
 
 namespace sunrise::server::activity::activity_sdk_mission {
 namespace sunrise::server::activity::activity_sdk_mission {
 
 
+/** One squad participant of a scene: the authored reference and the squad slot it names. */
+struct SceneSquadParticipant final {
+    middleware::bap::activity_message::sensor_message::ClientReference reference{};
+    std::uint32_t squadSlotRow{state::activity_sdk::format::kAbsentIndex};
+};
+
+/** Every squad participant of one scene descriptor; the package bounds the table. */
+struct SceneSquadParticipants final {
+    std::array<SceneSquadParticipant,
+               state::activity_sdk::format::kAuthoredSceneParticipantCapacity>
+        rows{};
+    std::size_t count{};
+};
+
 /**
 /**
- * Collects only the selected scene descriptor's exact squad edges.
+ * Collects the selected scene descriptor's exact squad edges.
+ *
+ * The client binds every role from the participant table in its own content; these rows only
+ * tell the server which squads the scene may draw actors from. The wire dependency set is
+ * narrower (see scene_dependencies in activity_sdk_scene_spawn.h).
  * @param catalog Authenticated SDK catalog.
  * @param catalog Authenticated SDK catalog.
  * @param sceneSlot Owned scene slot.
  * @param sceneSlot Owned scene slot.
  * @param resource Selected resource descriptor for that slot.
  * @param resource Selected resource descriptor for that slot.
- * @param output Receives the complete dependency set; cleared on failure.
- * @return Ready for an exact bounded set, including an empty set.
+ * @param output Receives the complete participant set; cleared on failure.
+ * @return Ready for an exact set, including an empty set.
  */
  */
 [[nodiscard]] inline SceneStatus
 [[nodiscard]] inline SceneStatus
-scene_dependencies(const state::activity_sdk::Catalog& catalog,
-                   const state::activity_sdk::format::Slot& sceneSlot,
-                   const state::activity_sdk::format::AuthoredSceneResource& resource,
-                   middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies&
-                       output) noexcept {
+scene_squad_participants(const state::activity_sdk::Catalog& catalog,
+                         const state::activity_sdk::format::Slot& sceneSlot,
+                         const state::activity_sdk::format::AuthoredSceneResource& resource,
+                         SceneSquadParticipants& output) noexcept {
     namespace sdk = state::activity_sdk;
     namespace sdk = state::activity_sdk;
-    namespace message = middleware::bap::activity_message::sensor_auth_update;
     output = {};
     output = {};
-    message::AuthoredSceneDependencies candidate{};
+    SceneSquadParticipants candidate{};
     const auto slots = catalog.slots();
     const auto slots = catalog.slots();
     if (resource.slotIndex >= slots.size() || &slots[resource.slotIndex] != &sceneSlot
     if (resource.slotIndex >= slots.size() || &slots[resource.slotIndex] != &sceneSlot
         || sceneSlot.slotType != sdk::format::kAuthoredSceneSlotType
         || sceneSlot.slotType != sdk::format::kAuthoredSceneSlotType
@@ -35,7 +53,7 @@ scene_dependencies(const state::activity_sdk::Catalog& catalog,
         return SceneStatus::invalidSlot;
         return SceneStatus::invalidSlot;
     }
     }
     const auto edges = sdk::slot_authored_scene_squad_edges(catalog, sceneSlot);
     const auto edges = sdk::slot_authored_scene_squad_edges(catalog, sceneSlot);
-    if (edges.size() > candidate.references.size()) {
+    if (edges.size() > candidate.rows.size()) {
         return SceneStatus::refused;
         return SceneStatus::refused;
     }
     }
     for (const auto& edge : edges) {
     for (const auto& edge : edges) {
@@ -55,12 +73,11 @@ scene_dependencies(const state::activity_sdk::Catalog& catalog,
                    > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
                    > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
             return SceneStatus::ambiguousTarget;
             return SceneStatus::ambiguousTarget;
         }
         }
-        candidate.references[candidate.count++] = {edge.targetObjectKey,
-                                                   static_cast<std::int8_t>(target->slotType),
-                                                   static_cast<std::int16_t>(target->slotIndex)};
-    }
-    if (!message::valid_authored_scene_dependencies(candidate)) {
-        return SceneStatus::ambiguousTarget;
+        auto& row = candidate.rows[candidate.count++];
+        row.reference = {edge.targetObjectKey,
+                         static_cast<std::int8_t>(target->slotType),
+                         static_cast<std::int16_t>(target->slotIndex)};
+        row.squadSlotRow = edge.squadSlotIndex;
     }
     }
     output = candidate;
     output = candidate;
     return SceneStatus::ready;
     return SceneStatus::ready;

+ 47 - 0
Sunrise/src/server/activity/activity_sdk_scene_spawn.cpp

@@ -1,7 +1,11 @@
 #include "activity_sdk_scene_spawn.h"
 #include "activity_sdk_scene_spawn.h"
 
 
 #include <algorithm>
 #include <algorithm>
+#include <array>
+#include <cstdio>
+#include <string_view>
 
 
+#include "../../core/logging/log.h"
 #include "../../state/activity_sdk/generated_world/runtime.h"
 #include "../../state/activity_sdk/generated_world/runtime.h"
 #include "activity_sdk_device_internal.h"
 #include "activity_sdk_device_internal.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_mission_internal.h"
@@ -114,6 +118,49 @@ prepare_pair(const sdk::BoundView& view, std::uint32_t sceneState, SceneSpawnPai
 
 
 } // namespace
 } // namespace
 
 
+/** Sends the cast's squads when the wire can carry them all, otherwise none. */
+bool scene_dependencies(
+    const SceneSpawnPlan& plan,
+    middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies&
+        output) noexcept {
+    output = {};
+    if (plan.count > output.references.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < plan.count; ++index) {
+        const host::ScriptableTarget& source = plan.pairs[index].sourceTarget;
+        output.references[output.count++] = {source.registryKey,
+                                             static_cast<std::int8_t>(source.slotType),
+                                             static_cast<std::int16_t>(source.slotIndex)};
+    }
+    return true;
+}
+
+/** Logged once per activation; an availability check resolves the same cast every frame. */
+void log_omitted_scene_dependencies(const sdk::Catalog& catalog,
+                                    std::uint32_t sceneSlotRow,
+                                    std::size_t castCount) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const std::string_view scene = sceneSlotRow < catalog.slots().size()
+                                       ? catalog.string(catalog.slots()[sceneSlotRow].id)
+                                       : std::string_view{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=scene_dependencies result=omitted scene=%.*s cast=%zu capacity=%zu",
+        static_cast<int>(scene.size()),
+        scene.data(),
+        castCount,
+        middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies{}
+            .references.size());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::server,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
 /** Returns immutable scene cast identities without consulting live output state. */
 /** Returns immutable scene cast identities without consulting live output state. */
 SceneStatus resolve_scene_spawn_plan(const sdk::BoundView& view,
 SceneStatus resolve_scene_spawn_plan(const sdk::BoundView& view,
                                      std::uint32_t occurrenceRow,
                                      std::uint32_t occurrenceRow,

+ 22 - 4
Sunrise/src/server/activity/activity_sdk_scene_spawn.h

@@ -21,15 +21,33 @@ struct SceneSpawnPair final {
     bool sourceReady{};
     bool sourceReady{};
 };
 };
 
 
-/** The scene wire format bounds the complete cast before any output is queued. */
+/** The package participant table bounds the complete cast before any output is queued. */
 struct SceneSpawnPlan final {
 struct SceneSpawnPlan final {
-    std::array<
-        SceneSpawnPair,
-        middleware::bap::activity_message::sensor_auth_update::kAuthoredSceneMaximumDependencyCount>
+    std::array<SceneSpawnPair, state::activity_sdk::format::kAuthoredSceneParticipantCapacity>
         pairs{};
         pairs{};
     std::size_t count{};
     std::size_t count{};
 };
 };
 
 
+/**
+ * Names the cast's source squads as the scene's wire dependencies.
+ *
+ * The client uses them for one thing: once the scene runs, it marks each squad's remaining
+ * spawn budget as consumed. It binds roles from its own content, so a scene plays without
+ * them. The schema carries at most eight; a wider cast sends none.
+ * @param plan Complete resolved cast.
+ * @param output Receives the bounded set; empty when the cast exceeds the wire capacity.
+ * @return False when the cast was omitted.
+ */
+[[nodiscard]] bool
+scene_dependencies(const SceneSpawnPlan& plan,
+                   middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies&
+                       output) noexcept;
+
+/** Logs, at warn, a cast the wire could not carry; for the activation paths only. */
+void log_omitted_scene_dependencies(const state::activity_sdk::Catalog& catalog,
+                                    std::uint32_t sceneSlotRow,
+                                    std::size_t castCount) noexcept;
+
 /** Resolves the complete cast from exact package edges without reading Host state. */
 /** Resolves the complete cast from exact package edges without reading Host state. */
 [[nodiscard]] SceneStatus
 [[nodiscard]] SceneStatus
 collect_scene_spawn_plan(const state::activity_sdk::Catalog& catalog,
 collect_scene_spawn_plan(const state::activity_sdk::Catalog& catalog,

+ 54 - 2
Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp

@@ -8,7 +8,9 @@
 #include <cstdio>
 #include <cstdio>
 #include <imgui.h>
 #include <imgui.h>
 #include <span>
 #include <span>
+#include <string>
 #include <string_view>
 #include <string_view>
+#include <utility>
 #include <vector>
 #include <vector>
 
 
 #include "../../../core/ui/components/section/ui_section_component.h"
 #include "../../../core/ui/components/section/ui_section_component.h"
@@ -50,6 +52,32 @@ struct SceneBrowserRow final {
 
 
 std::vector<SceneBrowserRow> g_visibleScenes{};
 std::vector<SceneBrowserRow> g_visibleScenes{};
 
 
+/**
+ * Everything the scene rows are computed from. Resolving one row prepares its whole cast, so
+ * the rows are rebuilt when this changes or after kSceneRefreshSeconds, not every frame.
+ */
+struct SceneBrowserKey final {
+    const sdk::Catalog* catalog{};
+    std::uint32_t scenarioRow{sdk::format::kAbsentIndex};
+    std::uint32_t stateRow{sdk::format::kAbsentIndex};
+    std::uint64_t activityClientGeneration{};
+    std::uint64_t revision{};
+    std::uint64_t publishedRevision{};
+    std::int32_t effectiveRegion{-1};
+    bool configured{};
+    bool publicationPending{};
+    bool regionArrivalPending{};
+    std::string query{};
+
+    [[nodiscard]] bool operator==(const SceneBrowserKey&) const noexcept = default;
+};
+
+/** Bounds how stale a row's availability can be when nothing observable changed. */
+constexpr double kSceneRefreshSeconds = 0.25;
+SceneBrowserKey g_visibleScenesKey{};
+double g_visibleScenesTime{-1.0};
+bool g_visibleScenesReady{};
+
 /** @return The global row of one borrowed slot or the absent marker. */
 /** @return The global row of one borrowed slot or the absent marker. */
 [[nodiscard]] std::uint32_t global_slot_row(const sdk::Catalog& catalog,
 [[nodiscard]] std::uint32_t global_slot_row(const sdk::Catalog& catalog,
                                             const sdk::format::Slot& slot) noexcept {
                                             const sdk::format::Slot& slot) noexcept {
@@ -146,8 +174,28 @@ std::vector<SceneBrowserRow> g_visibleScenes{};
 
 
 /** Builds type-43 rows from only the selected state-ordinal-0 occurrence set. */
 /** Builds type-43 rows from only the selected state-ordinal-0 occurrence set. */
 [[nodiscard]] bool materialize_visible_scenes(const sdk::BoundView& view,
 [[nodiscard]] bool materialize_visible_scenes(const sdk::BoundView& view,
-                                              std::uint32_t stateRow) noexcept {
+                                              const mission::Snapshot& snapshot) noexcept {
+    const std::uint32_t stateRow = snapshot.plan.stateRow;
     try {
     try {
+        SceneBrowserKey key{.catalog = view.catalog.get(),
+                            .scenarioRow = view.scenarioRow,
+                            .stateRow = stateRow,
+                            .activityClientGeneration = snapshot.activityClientGeneration,
+                            .revision = snapshot.revision,
+                            .publishedRevision = snapshot.publishedRevision,
+                            .effectiveRegion = snapshot.effectiveRegion,
+                            .configured = snapshot.configured,
+                            .publicationPending = snapshot.publicationPending,
+                            .regionArrivalPending = snapshot.regionArrivalPending,
+                            .query = std::string(search_text())};
+        const double now = ImGui::GetTime();
+        if (key == g_visibleScenesKey && g_visibleScenesTime >= 0.0
+            && now - g_visibleScenesTime < kSceneRefreshSeconds) {
+            return g_visibleScenesReady;
+        }
+        g_visibleScenesKey = std::move(key);
+        g_visibleScenesTime = now;
+        g_visibleScenesReady = false;
         g_visibleScenes.clear();
         g_visibleScenes.clear();
         if (view.catalog == nullptr || stateRow >= view.catalog->states().size()) {
         if (view.catalog == nullptr || stateRow >= view.catalog->states().size()) {
             return false;
             return false;
@@ -185,16 +233,19 @@ std::vector<SceneBrowserRow> g_visibleScenes{};
                 }
                 }
             }
             }
         }
         }
+        g_visibleScenesReady = true;
         return true;
         return true;
     } catch (...) {
     } catch (...) {
         g_visibleScenes.clear();
         g_visibleScenes.clear();
+        g_visibleScenesKey = {};
+        g_visibleScenesTime = -1.0;
         return false;
         return false;
     }
     }
 }
 }
 
 
 /** Draws searchable exact type-43 rows and their manual one-generation action. */
 /** Draws searchable exact type-43 rows and their manual one-generation action. */
 void draw_authored_scenes(const sdk::BoundView& view, const mission::Snapshot& snapshot) noexcept {
 void draw_authored_scenes(const sdk::BoundView& view, const mission::Snapshot& snapshot) noexcept {
-    if (!materialize_visible_scenes(view, snapshot.plan.stateRow)) {
+    if (!materialize_visible_scenes(view, snapshot)) {
         ImGui::TextDisabled("No scene in this state.");
         ImGui::TextDisabled("No scene in this state.");
         return;
         return;
     }
     }
@@ -328,6 +379,7 @@ void draw_authored_scenes(const sdk::BoundView& view, const mission::Snapshot& s
                 g_sceneActionStatus =
                 g_sceneActionStatus =
                     mission::activate_authored_scene(view, row.occurrenceRow, row.slotRow);
                     mission::activate_authored_scene(view, row.occurrenceRow, row.slotRow);
                 g_hasSceneActionStatus = true;
                 g_hasSceneActionStatus = true;
+                g_visibleScenesTime = -1.0;
             }
             }
             ImGui::EndDisabled();
             ImGui::EndDisabled();
             if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
             if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {

+ 26 - 6
Sunrise/src/state/activity_sdk/format.h

@@ -10,7 +10,7 @@ namespace sunrise::state::activity_sdk::format {
 /** Eight-byte identity at the start of every runtime SDK pack. */
 /** 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'};
 inline constexpr std::array<char, 8> kMagic{'S', 'R', 'S', 'D', 'K', 'P', '0', '1'};
 /** Runtime-pack schema version accepted by this reader. */
 /** Runtime-pack schema version accepted by this reader. */
-inline constexpr std::uint32_t kVersion = 40;
+inline constexpr std::uint32_t kVersion = 41;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
 inline constexpr std::uint32_t kSectionCount = 50;
 inline constexpr std::uint32_t kSectionCount = 50;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
@@ -80,9 +80,16 @@ inline constexpr std::uint32_t kSlotReaderVerified = 0x1U;
 inline constexpr std::uint32_t kSlotSchemaJoinExact = 0x2U;
 inline constexpr std::uint32_t kSlotSchemaJoinExact = 0x2U;
 /** A type-53 descriptor resolved an exact authored cue list and bounded cue count. */
 /** A type-53 descriptor resolved an exact authored cue list and bounded cue count. */
 inline constexpr std::uint32_t kSlotDialogueCuesExact = 0x4U;
 inline constexpr std::uint32_t kSlotDialogueCuesExact = 0x4U;
-/** Slot rows expose only reader, schema-join, and authored-dialogue facts. */
-inline constexpr std::uint32_t kSlotFlagMask =
-    kSlotReaderVerified | kSlotSchemaJoinExact | kSlotDialogueCuesExact;
+/**
+ * The scene slot's package config references no resource at all: the scene exists but was
+ * authored empty. A resource that is referenced but cannot be read is a generation error and
+ * gets no flag.
+ */
+inline constexpr std::uint32_t kSlotAuthoredSceneUnresourced = 0x8U;
+/** Slot rows expose only reader, schema-join, authored-dialogue, and unresourced-scene facts. */
+inline constexpr std::uint32_t kSlotFlagMask = kSlotReaderVerified | kSlotSchemaJoinExact
+                                               | kSlotDialogueCuesExact
+                                               | kSlotAuthoredSceneUnresourced;
 /** Exact generated slot tuples for authored sequence and cinematic actions. */
 /** Exact generated slot tuples for authored sequence and cinematic actions. */
 inline constexpr std::uint32_t kObjectSlotType = 4U;
 inline constexpr std::uint32_t kObjectSlotType = 4U;
 inline constexpr std::uint32_t kObjectComponentClass = 0x80809927U;
 inline constexpr std::uint32_t kObjectComponentClass = 0x80809927U;
@@ -293,9 +300,22 @@ inline constexpr std::uint32_t kActorSequenceLocalDefinitionClass = 0x8080815FU;
 inline constexpr std::uint32_t kActorSequenceGlobalArrayOffset = 152;
 inline constexpr std::uint32_t kActorSequenceGlobalArrayOffset = 152;
 inline constexpr std::uint32_t kActorSequenceLocalArrayOffset = 16;
 inline constexpr std::uint32_t kActorSequenceLocalArrayOffset = 16;
 inline constexpr std::uint32_t kActorSequenceOwnerSourceClass = 0x808082ECU;
 inline constexpr std::uint32_t kActorSequenceOwnerSourceClass = 0x808082ECU;
-inline constexpr std::uint32_t kAuthoredSceneSquadBlockClassRelativeOffset = 0xA4U;
+/**
+ * A type-43 config lists its participants as a table: a 64-bit count, the table class, then one
+ * 64-bit pointer per participant, each relative to its own field. A pointer lands on a block's
+ * payload; the block's class sits in the four bytes before it. Only squad blocks become edges.
+ */
+inline constexpr std::uint32_t kAuthoredSceneParticipantCountRelativeOffset = 0x88U;
+inline constexpr std::uint32_t kAuthoredSceneParticipantTableClassRelativeOffset = 0x90U;
+inline constexpr std::uint32_t kAuthoredSceneParticipantTableRelativeOffset = 0x98U;
+inline constexpr std::uint32_t kAuthoredSceneParticipantTableClass = 0x80806268U;
+inline constexpr std::uint32_t kAuthoredSceneParticipantPointerSize = 8U;
+inline constexpr std::uint32_t kAuthoredSceneParticipantClassSize = 4U;
+/** More participants than any installed scene declares; a larger count is a misread config. */
+inline constexpr std::uint32_t kAuthoredSceneParticipantCapacity = 64U;
 inline constexpr std::uint32_t kAuthoredSceneSquadBlockClass = 0x80806262U;
 inline constexpr std::uint32_t kAuthoredSceneSquadBlockClass = 0x80806262U;
-inline constexpr std::uint32_t kAuthoredSceneSquadReferenceRelativeOffset = 0xB0U;
+/** Squad reference inside a squad block's payload: object key, then slot type and index. */
+inline constexpr std::uint32_t kAuthoredSceneSquadPayloadReferenceOffset = 0x8U;
 /** Exact type-38 task edge to the authored type-3 objective component it mutates. */
 /** Exact type-38 task edge to the authored type-3 objective component it mutates. */
 inline constexpr std::uint32_t kTaskSlotType = 38U;
 inline constexpr std::uint32_t kTaskSlotType = 38U;
 inline constexpr std::uint32_t kTaskComponentClass = 0x80807D87U;
 inline constexpr std::uint32_t kTaskComponentClass = 0x80807D87U;