Browse Source

Merge remote-tracking branch 'origin/master'

# Conflicts:
#	Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp
#	Sunrise/src/state/activity_sdk/format.h
stan 2 days ago
parent
commit
921247ab6c
61 changed files with 1730 additions and 549 deletions
  1. 15 2
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h
  2. 308 61
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp
  3. 25 1
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h
  4. 57 7
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp
  5. 2 3
      Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp
  6. 25 49
      Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp
  7. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h
  8. 3 2
      Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp
  9. 16 1
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  10. 39 2
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  11. 2 1
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  12. 5 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h
  13. 5 1
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  14. 150 29
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  15. 3 6
      Sunrise/src/client/content/activity/activity_sdk_one_shot.cpp
  16. 3 7
      Sunrise/src/client/content/activity/activity_sdk_one_shot_export.cpp
  17. 7 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp
  18. 1 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.h
  19. 22 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  20. 1 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_validate.cpp
  21. 2 7
      Sunrise/src/client/content/activity/activity_sdk_shard_cleanup.cpp
  22. 11 13
      Sunrise/src/client/content/activity/activity_sdk_tree_publication.cpp
  23. 2 0
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  24. 9 3
      Sunrise/src/client/content/vendors/layout.h
  25. 59 16
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  26. 73 6
      Sunrise/src/client/hooks/stall_probe/stall_probe.cpp
  27. 16 11
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  28. 4 0
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  29. 2 0
      Sunrise/src/server/activity/activity_sdk_mission_internal.h
  30. 14 12
      Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp
  31. 22 0
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  32. 5 0
      Sunrise/src/server/activity/activity_sdk_mission_runtime.h
  33. 16 6
      Sunrise/src/server/activity/activity_sdk_scene_cast.cpp
  34. 34 17
      Sunrise/src/server/activity/activity_sdk_scene_dependencies.h
  35. 47 0
      Sunrise/src/server/activity/activity_sdk_scene_spawn.cpp
  36. 22 4
      Sunrise/src/server/activity/activity_sdk_scene_spawn.h
  37. 27 2
      Sunrise/src/server/activity/mission/mission_script_lua_presentation_api.cpp
  38. 1 0
      Sunrise/src/server/activity/mission/mission_script_sdk_bridge.cpp
  39. 2 0
      Sunrise/src/server/activity/mission/mission_script_vm.h
  40. 10 54
      Sunrise/src/server/ui/activity_host/activity_host_event_view.cpp
  41. 134 63
      Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp
  42. 3 8
      Sunrise/src/server/ui/activity_host/activity_host_sdk_view.cpp
  43. 21 26
      Sunrise/src/server/ui/activity_host/activity_host_tool_window.h
  44. 69 54
      Sunrise/src/server/web_service/web_service_vendor_actions.cpp
  45. 25 0
      Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp
  46. 1 0
      Sunrise/src/state/activity_sdk/activity_sdk_loader.cpp
  47. 62 11
      Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp
  48. 16 5
      Sunrise/src/state/activity_sdk/activity_sdk_structure_validation.cpp
  49. 87 11
      Sunrise/src/state/activity_sdk/format.h
  50. 2 0
      Sunrise/src/state/activity_sdk/generation/activity_sdk_pack_writer.cpp
  51. 1 0
      Sunrise/src/state/activity_sdk/generation/pack_writer.h
  52. 5 0
      Sunrise/src/state/activity_sdk/runtime.h
  53. 5 3
      Sunrise/src/state/build_data/abilities/definition.h
  54. 27 6
      Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp
  55. 18 5
      Sunrise/src/state/build_data/cache/records/format.h
  56. 52 7
      Sunrise/src/state/build_data/vendors/definition.h
  57. 7 2
      Sunrise/src/state/build_data/vendors/vendor_catalog.cpp
  58. 18 9
      Sunrise/src/state/runtime/runtime.h
  59. 34 14
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  60. 65 0
      Sunrise/src/state/runtime/state_account_profile_runtime.cpp
  61. 10 0
      Sunrise/src/state/runtime/state_account_transaction_helpers.h

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

@@ -92,7 +92,12 @@ is_performance_descriptor(const topology::Snapshot& topology,
 
 /** Sort key matching the final section-22 pack order. */
 [[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 event-key pack order: the scene, then the gate. */
+[[nodiscard]] inline auto event_key_natural(const EventKey& row) noexcept {
+    return std::tie(row.slotIndex, row.gateOffset);
 }
 
 /** Sort key matching the final task-target pack order. */
@@ -176,9 +181,17 @@ schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex
                                const squad::DescriptorFact& descriptor,
                                Text& output) noexcept;
 
-/** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
+/** Formats one event-key ID from the resource, its graph, the scene slot and the gate. */
+[[nodiscard]] bool event_key_id(const topology::Snapshot& topology,
+                                const squad::DescriptorFact& descriptor,
+                                std::uint32_t graphTag,
+                                std::uint32_t gateOffset,
+                                Text& output) noexcept;
+
+/** 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,
                            const squad::DescriptorFact& descriptor,
+                           std::uint32_t squadSlotRow,
                            Text& output) noexcept;
 
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */

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

@@ -32,23 +32,46 @@ bool resource_id(const topology::Snapshot& topology,
                        static_cast<unsigned>(slot.slotType));
 }
 
-/** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
+/** Formats one event-key ID from the resource, its graph, the scene slot and the gate. */
+bool event_key_id(const topology::Snapshot& topology,
+                  const squad::DescriptorFact& descriptor,
+                  std::uint32_t graphTag,
+                  std::uint32_t gateOffset,
+                  Text& output) noexcept {
+    if (descriptor.slotIndex >= topology.slots.size()) {
+        return false;
+    }
+    const topology::Slot& slot = topology.slots[descriptor.slotIndex];
+    return format_text(output,
+                       "authored-scene-event-key/%08x/%08x/%04x/%04x/%08x",
+                       static_cast<unsigned>(descriptor.configTag),
+                       static_cast<unsigned>(graphTag),
+                       static_cast<unsigned>(slot.slotIndex),
+                       static_cast<unsigned>(slot.slotType),
+                       static_cast<unsigned>(gateOffset));
+}
+
+/** Formats one scene-to-squad edge ID from its descriptor tuple and the squad slot it names. */
 bool edge_id(const topology::Snapshot& topology,
              const squad::DescriptorFact& descriptor,
+             std::uint32_t squadSlotRow,
              Text& output) noexcept {
     if (descriptor.objectIndex >= topology.objects.size()
-        || descriptor.slotIndex >= topology.slots.size()) {
+        || descriptor.slotIndex >= topology.slots.size() || squadSlotRow >= topology.slots.size()) {
         return false;
     }
     const topology::Object& object = topology.objects[descriptor.objectIndex];
     const topology::Slot& slot = topology.slots[descriptor.slotIndex];
+    const topology::Slot& squad = topology.slots[squadSlotRow];
     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>(object.objectTag),
                        static_cast<unsigned>(descriptor.descriptorOffset),
                        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. */
@@ -191,6 +214,83 @@ 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)});
+    }
+}
+
+/** Logs a scene graph that yielded no event keys, and why. */
+void log_scene_graph(const squad::DescriptorFact& descriptor,
+                     std::uint32_t graphTag,
+                     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_graph result=%s config=0x%08X "
+                                      "slot_row=%u graph=0x%08X",
+                                      result,
+                                      static_cast<unsigned>(descriptor.configTag),
+                                      static_cast<unsigned>(descriptor.slotIndex),
+                                      static_cast<unsigned>(graphTag));
+    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 the gate table field of a scene graph.
+ * @param blob Graph bytes.
+ * @param rows Receives the offset of the first table row.
+ * @param count Receives the row count.
+ * @return False when the field does not hold a table of gate rows that fits the graph.
+ */
+[[nodiscard]] bool
+read_gate_table(std::span<const std::byte> blob, std::size_t& rows, std::uint64_t& count) noexcept {
+    std::int64_t relative = 0;
+    std::uint32_t marker = 0;
+    std::uint64_t repeated = 0;
+    std::uint32_t rowClass = 0;
+    const std::size_t pointer = format::kAuthoredSceneGateTableOffset + 8U;
+    if (!read_value(blob, format::kAuthoredSceneGateTableOffset, count)
+        || !read_value(blob, pointer, relative) || relative <= 0
+        || static_cast<std::uint64_t>(relative) > blob.size() - pointer) {
+        return false;
+    }
+    // The pointer is relative to its own field and lands on the count of the array header.
+    const std::size_t header = pointer + static_cast<std::size_t>(relative);
+    if (header < 4U || !read_value(blob, header - 4U, marker)
+        || marker != format::kPackageArrayMarker || !read_value(blob, header, repeated)
+        || repeated != count || !read_value(blob, header + 8U, rowClass)
+        || rowClass != format::kAuthoredSceneGateRowClass) {
+        return false;
+    }
+    rows = header + 16U;
+    return rows <= blob.size() && count <= (blob.size() - rows) / format::kAuthoredSceneGateRowSize;
+}
+
 /** Reads one tag once and retains the physical class beside its bytes. */
 [[nodiscard]] bool package_row(squad::TagReader reader,
                                void* readerContext,
@@ -284,6 +384,94 @@ void log_performance_edge(const squad::DescriptorFact& descriptor, const char* r
     return true;
 }
 
+/**
+ * Follows a scene resource to its event graph and appends one row per gate.
+ * A graph that cannot be read, or one gate that is not a gate element, leaves the scene with no
+ * keys and is logged. Only a topology inconsistency fails the build.
+ */
+[[nodiscard]] bool collect_event_keys(const topology::Snapshot& topology,
+                                      const squad::DescriptorFact& descriptor,
+                                      squad::TagReader reader,
+                                      void* readerContext,
+                                      PackageCache& cache,
+                                      std::uint32_t resourceTag,
+                                      std::span<const std::byte> resource,
+                                      std::vector<EventKey>& output) {
+    const std::size_t start = output.size();
+    std::uint32_t graphTag = 0;
+    if (!read_value(resource, format::kAuthoredSceneGraphRelativeOffset, graphTag) || graphTag == 0
+        || graphTag == format::kAbsentIndex) {
+        log_scene_graph(descriptor, graphTag, "unreferenced", core::log::Level::debug);
+        return true;
+    }
+    const PackageRow* graph = nullptr;
+    if (!package_row(reader, readerContext, graphTag, cache, graph) || graph == nullptr) {
+        log_scene_graph(descriptor, graphTag, "unreadable", core::log::Level::warn);
+        return true;
+    }
+    if (graph->classId != format::kAuthoredSceneGraphClass) {
+        log_scene_graph(descriptor, graphTag, "graph_class", core::log::Level::warn);
+        return true;
+    }
+    const auto blob = std::span(graph->bytes);
+    std::size_t rows = 0;
+    std::uint64_t count = 0;
+    if (!read_gate_table(blob, rows, count)) {
+        log_scene_graph(descriptor, graphTag, "gate_table", core::log::Level::warn);
+        return true;
+    }
+    if (count > format::kAuthoredSceneGateCapacity) {
+        log_scene_graph(descriptor, graphTag, "capacity", core::log::Level::warn);
+        return true;
+    }
+    for (std::uint64_t gate = 0; gate < count; ++gate) {
+        const std::size_t tableRow =
+            rows + static_cast<std::size_t>(gate) * format::kAuthoredSceneGateRowSize;
+        std::uint32_t rowOwner = 0;
+        std::uint32_t rowClass = 0;
+        std::uint64_t body = 0;
+        // A row names its body by absolute offset; the body names the row class back.
+        if (!read_value(blob, tableRow, rowOwner)
+            || !read_value(blob, tableRow + format::kAuthoredSceneGateClassOffset, rowClass)
+            || !read_value(blob, tableRow + format::kAuthoredSceneGateBodyOffset, body)
+            || rowOwner != graphTag || rowClass != format::kAuthoredSceneGateBodyClass
+            || body > blob.size() - format::kAuthoredSceneGateSize) {
+            output.resize(start);
+            log_scene_graph(descriptor, graphTag, "gate_row", core::log::Level::warn);
+            return true;
+        }
+        const auto element = static_cast<std::size_t>(body);
+        std::uint32_t owner = 0;
+        std::uint32_t bodyClass = 0;
+        std::uint32_t key = 0;
+        std::int32_t ordinal = 0;
+        if (!read_value(blob, element, owner)
+            || !read_value(blob, element + format::kAuthoredSceneGateClassOffset, bodyClass)
+            || !read_value(blob, element + format::kAuthoredSceneGateKeyOffset, key)
+            || !read_value(blob, element + format::kAuthoredSceneGateOrdinalOffset, ordinal)
+            || owner != graphTag || bodyClass != format::kAuthoredSceneGateRowClass || key == 0
+            || key == format::kAbsentIndex) {
+            output.resize(start);
+            log_scene_graph(descriptor, graphTag, "gate", core::log::Level::warn);
+            return true;
+        }
+        EventKey row{};
+        if (!event_key_id(
+                topology, descriptor, graphTag, static_cast<std::uint32_t>(element), row.id)) {
+            return false;
+        }
+        row.slotIndex = descriptor.slotIndex;
+        row.resourceTag = resourceTag;
+        row.graphTag = graphTag;
+        row.gateOffset = static_cast<std::uint32_t>(element);
+        row.ordinal = ordinal;
+        row.key = key;
+        row.flags = format::kAuthoredSceneEventKeyExact;
+        output.push_back(row);
+    }
+    return true;
+}
+
 } // namespace
 
 /** Builds both authored-scene sections from complete topology and package facts. */
@@ -435,7 +623,7 @@ bool build(const topology::Snapshot& topology,
                     continue;
                 }
                 SquadEdge row{};
-                if (!edge_id(topology, descriptor, row.id)) {
+                if (!edge_id(topology, descriptor, linkedSlot, row.id)) {
                     log_performance_edge(descriptor, "identity");
                     continue;
                 }
@@ -474,11 +662,18 @@ bool build(const topology::Snapshot& topology,
             if (!read_value(blob, resourceField, resourceTag)) {
                 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;
-                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{};
                     if (resource_id(topology, descriptor, row.id)) {
                         row.slotIndex = descriptor.slotIndex;
@@ -490,64 +685,100 @@ bool build(const topology::Snapshot& topology,
                         row.flags = format::kAuthoredSceneResourceExact;
                         pending.resources.push_back(row);
                     }
+                    if (!collect_event_keys(topology,
+                                            descriptor,
+                                            reader,
+                                            readerContext,
+                                            cache,
+                                            resourceTag,
+                                            std::span(resourcePackage->bytes),
+                                            pending.eventKeys)) {
+                        return false;
+                    }
                 }
             }
 
-            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) {
-                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) {
+            // 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;
             }
-            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.
         {
@@ -574,6 +805,11 @@ bool build(const topology::Snapshot& topology,
             }
         }
         std::sort(pending.resources.begin(), pending.resources.end(), resource_less);
+        std::sort(pending.eventKeys.begin(),
+                  pending.eventKeys.end(),
+                  [](const EventKey& left, const EventKey& right) {
+                      return event_key_natural(left) < event_key_natural(right);
+                  });
         std::sort(pending.squadEdges.begin(), pending.squadEdges.end(), edge_less);
         std::sort(pending.taskTargets.begin(), pending.taskTargets.end(), task_less);
         pending.resources.erase(std::unique(pending.resources.begin(),
@@ -583,6 +819,13 @@ bool build(const topology::Snapshot& topology,
                                                        == resource_natural(right);
                                             }),
                                 pending.resources.end());
+        pending.eventKeys.erase(std::unique(pending.eventKeys.begin(),
+                                            pending.eventKeys.end(),
+                                            [](const auto& left, const auto& right) {
+                                                return event_key_natural(left)
+                                                       == event_key_natural(right);
+                                            }),
+                                pending.eventKeys.end());
         pending.squadEdges.erase(std::unique(pending.squadEdges.begin(),
                                              pending.squadEdges.end(),
                                              [](const auto& left, const auto& right) {
@@ -595,6 +838,10 @@ bool build(const topology::Snapshot& topology,
                                                   return task_natural(left) == task_natural(right);
                                               }),
                                   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;
         output = std::move(pending);
         return true;

+ 25 - 1
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h

@@ -37,6 +37,19 @@ struct Resource final {
     std::uint32_t reserved{};
 };
 
+/** One event gate of a scene's graph before its ID is linked. */
+struct EventKey final {
+    Text id{};
+    std::uint32_t slotIndex{};
+    std::uint32_t resourceTag{};
+    std::uint32_t graphTag{};
+    std::uint32_t gateOffset{};
+    std::int32_t ordinal{};
+    std::uint32_t key{};
+    std::uint32_t flags{};
+    std::uint32_t reserved{};
+};
+
 /** One exact format-v12 same-object scene-to-squad edge before its ID is linked. */
 struct SquadEdge final {
     Text id{};
@@ -75,11 +88,12 @@ struct DialogueCueText final {
     std::uint32_t stringHash{};
 };
 
-/** One exact type-68 HUD element and its two authored localized fields. */
+/** One exact type-68 HUD element and its authored localized fields; absent ones stay empty. */
 struct DirectiveElement final {
     Text id{};
     Text title{};
     Text description{};
+    Text progress{};
     std::uint32_t slotIndex{};
     std::uint32_t nameHash{};
     std::int32_t elementIndex{};
@@ -88,6 +102,9 @@ struct DirectiveElement final {
     std::uint32_t titleStringHash{};
     std::uint32_t descriptionContainerTag{};
     std::uint32_t descriptionStringHash{};
+    std::uint32_t progressContainerTag{};
+    std::uint32_t progressStringHash{};
+    std::uint32_t flags{};
 };
 
 /** Scene-owned descriptor facts retain a complete source universe per selected object. */
@@ -101,12 +118,19 @@ struct Facts final {
 /** Canonical section-19 and section-22 rows with unresolved string references. */
 struct Snapshot final {
     std::vector<Resource> resources{};
+    /** Every gate of every resourced scene, in slot then gate order. */
+    std::vector<EventKey> eventKeys{};
     std::vector<SquadEdge> squadEdges{};
     std::vector<TaskTarget> taskTargets{};
     std::vector<DialogueCueText> dialogueCueTexts{};
     std::vector<format::DialogueCue> dialogueCues{};
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     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{};
 };
 

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

@@ -1,3 +1,4 @@
+#include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -86,7 +87,7 @@ constexpr std::size_t kHashCombineConstant = 0x9E3779B9U;
                 || descriptor.objectIndex >= topology.objects.size()
                 || descriptor.slotIndex >= topology.slots.size()
                 || descriptor.descriptorOffset
-                       > format::kAbsentIndex - format::kAuthoredSceneSquadReferenceRelativeOffset
+                       > format::kAbsentIndex - format::kAuthoredSceneParticipantTableRelativeOffset
                 || !descriptor_id(topology, descriptor, expected) || descriptor.id != expected
                 || !ids.emplace(descriptor.id).second) {
                 return false;
@@ -207,16 +208,65 @@ bool validate(const topology::Snapshot& topology,
             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.eventKeys.size(); ++index) {
+        const EventKey& row = snapshot.eventKeys[index];
+        // A key belongs to a resourced scene and names the resource its graph hangs off.
+        const auto resource = std::find_if(
+            snapshot.resources.begin(),
+            snapshot.resources.end(),
+            [&row](const Resource& candidate) { return candidate.slotIndex == row.slotIndex; });
+        const squad::DescriptorFact* descriptor = resource == snapshot.resources.end()
+                                                      ? nullptr
+                                                      : find_descriptor(descriptors,
+                                                                        resource->slotIndex,
+                                                                        resource->configTag,
+                                                                        resource->descriptorOffset);
+        Text expectedId{};
+        if (descriptor == nullptr || resource->resourceTag != row.resourceTag || row.graphTag == 0
+            || row.graphTag == format::kAbsentIndex || row.key == 0
+            || row.key == format::kAbsentIndex || row.flags != format::kAuthoredSceneEventKeyExact
+            || row.reserved != 0
+            || !event_key_id(topology, *descriptor, row.graphTag, row.gateOffset, expectedId)
+            || expectedId.value != row.id.value || expectedId.length != row.id.length
+            || (index != 0
+                && event_key_natural(snapshot.eventKeys[index - 1]) >= event_key_natural(row))) {
+            return false;
+        }
+    }
     for (std::size_t index = 0; index < snapshot.squadEdges.size(); ++index) {
         const SquadEdge& row = snapshot.squadEdges[index];
         const squad::DescriptorFact* descriptor =
             find_descriptor(descriptors, row.sceneSlotIndex, row.configTag, row.descriptorOffset);
         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 std::uint32_t referenceOffset =
             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,
                                                           schemas,
                                                           row.sceneSlotIndex,
@@ -236,8 +286,9 @@ bool validate(const topology::Snapshot& topology,
             || topology.slots[row.squadSlotIndex].objectIndex != descriptor->objectIndex
             || !(performance ? is_performance_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,
                            schemas,
                            row.squadSlotIndex,
@@ -245,8 +296,7 @@ bool validate(const topology::Snapshot& topology,
                            format::kSquadComponentClass,
                            format::kSquadSenseSchema,
                            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.flags != format::kAuthoredSceneSquadSameObjectExact && !performance)
             || row.reserved != 0

+ 2 - 3
Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp

@@ -163,7 +163,7 @@ void log_stage_duration(std::string_view stage,
     return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
-/** Accepts only a missing or empty ordinary output root for the internal offline pass. */
+/** Accepts only a missing or empty output root for the internal offline pass. */
 [[nodiscard]] bool isolated_output_ready(std::wstring_view path) noexcept {
     if (path.empty()) {
         return false;
@@ -175,8 +175,7 @@ void log_stage_duration(std::string_view stage,
             const DWORD error = GetLastError();
             return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
         }
-        if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-            || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+        if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
             return false;
         }
         WIN32_FIND_DATAW entry{};

+ 25 - 49
Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp

@@ -42,7 +42,7 @@ struct Marker final {
     std::array<std::uint8_t, 3> reserved{};
 };
 
-[[nodiscard]] bool ordinary_directory(const wchar_t* path) noexcept;
+[[nodiscard]] bool is_directory(const wchar_t* path) noexcept;
 
 /** Resolves one null-terminated lexical path into normalized absolute storage. */
 [[nodiscard]] bool full_path(const wchar_t* input, std::wstring& output) noexcept {
@@ -72,54 +72,33 @@ struct Marker final {
     }
 }
 
-/** Which drive-path component, if any, is not an ordinary directory. */
-enum class Ancestry : std::uint8_t {
-    ready,
-    pathShape,
-    driveRoot,
-    ancestor,
-};
-
-/** A drive path is at least a letter, a colon and a separator. */
-constexpr std::size_t kDrivePrefixLength = 3;
-
-/** Reports the first drive-path component which is not an ordinary directory. */
-[[nodiscard]] Ancestry ordinary_ancestry(const std::wstring& directory) noexcept {
-    if (directory.size() < kDrivePrefixLength || directory[1] != L':' || directory[2] != L'\\') {
-        return Ancestry::pathShape;
+/** Requires every existing drive-path directory component to be ordinary, never a reparse point. */
+[[nodiscard]] bool ordinary_ancestry(const std::wstring& directory) noexcept {
+    if (directory.size() < 3U || directory[1] != L':' || directory[2] != L'\\') {
+        return false;
     }
-    const bool rootOrdinary = ordinary_directory(directory.substr(0, kDrivePrefixLength).c_str());
-    std::size_t cursor = kDrivePrefixLength;
+    if (!is_directory(directory.substr(0, 3U).c_str())) {
+        return false;
+    }
+    std::size_t cursor = 3U;
     while (cursor < directory.size()) {
         const std::size_t separator = directory.find(L'\\', cursor);
         const std::size_t end = separator == std::wstring::npos ? directory.size() : separator;
         const std::wstring prefix = directory.substr(0, end);
-        if (!ordinary_directory(prefix.c_str())) {
-            return Ancestry::ancestor;
+        if (!is_directory(prefix.c_str())) {
+            return false;
         }
         if (separator == std::wstring::npos) {
             break;
         }
         cursor = separator + 1U;
     }
-    return rootOrdinary ? Ancestry::ready : Ancestry::driveRoot;
-}
-
-/** Wine maps its synthetic drive root as a reparse point; real path components stay checked. */
-[[nodiscard]] bool ordinary_ancestry_allowed(Ancestry ancestry) noexcept {
-    if (ancestry == Ancestry::ready) {
-        return true;
-    }
-    if (ancestry != Ancestry::driveRoot) {
-        return false;
-    }
-    const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
-    return ntdll != nullptr && GetProcAddress(ntdll, "wine_get_version") != nullptr;
+    return true;
 }
 
 /** Resolves one existing ordinary directory and rejects reparse points in every ancestor. */
 [[nodiscard]] bool canonical_directory(const wchar_t* input, std::wstring& output) noexcept {
-    return full_path(input, output) && ordinary_ancestry_allowed(ordinary_ancestry(output));
+    return full_path(input, output) && ordinary_ancestry(output);
 }
 
 /** Compares two complete Windows path components without locale-sensitive folding. */
@@ -162,24 +141,23 @@ split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf
     return MoveFileExW(source, target, MOVEFILE_WRITE_THROUGH) != FALSE;
 }
 
-/** Requires one ordinary directory and rejects a junction or symbolic-link leaf. */
-[[nodiscard]] bool ordinary_directory(const wchar_t* path) noexcept {
+/** Requires one ordinary directory. */
+[[nodiscard]] bool is_directory(const wchar_t* path) noexcept {
     if (path == nullptr || path[0] == L'\0') {
         return false;
     }
     const DWORD attributes = GetFileAttributesW(path);
-    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-           && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
 /** Requires one ordinary file and rejects a reparse-backed leaf. */
-[[nodiscard]] bool ordinary_file(const wchar_t* path) noexcept {
+[[nodiscard]] bool is_file(const wchar_t* path) noexcept {
     if (path == nullptr || path[0] == L'\0') {
         return false;
     }
     const DWORD attributes = GetFileAttributesW(path);
     return attributes != INVALID_FILE_ATTRIBUTES
-           && (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0;
+           && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
 }
 
 /** Accepts a missing final path or checks its exact expected ordinary kind. */
@@ -191,8 +169,7 @@ split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf
         return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
     }
     exists = true;
-    return (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0
-           && ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) == directory;
+    return ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) == directory;
 }
 
 /** Creates one empty ordinary directory or accepts an existing ordinary directory. */
@@ -200,7 +177,7 @@ split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf
     if (CreateDirectoryW(path.c_str(), nullptr) != FALSE) {
         return true;
     }
-    return GetLastError() == ERROR_ALREADY_EXISTS && ordinary_directory(path.c_str());
+    return GetLastError() == ERROR_ALREADY_EXISTS && is_directory(path.c_str());
 }
 
 /** Builds one collision-resistant sibling backup path without consulting global artifact state. */
@@ -237,7 +214,7 @@ split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf
 /** Reads one exact marker without accepting extra bytes or alternate state. */
 [[nodiscard]] bool read_marker(const std::wstring& path, Marker& output) noexcept {
     output = {};
-    if (!ordinary_file(path.c_str())) {
+    if (!is_file(path.c_str())) {
         return false;
     }
     const HANDLE file = CreateFileW(path.c_str(),
@@ -464,7 +441,6 @@ split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf
         }
         const std::wstring path = parent + L"\\" + std::wstring(name);
         if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-            || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0
             || !tree_publication::discard(path.c_str())) {
             complete = false;
         }
@@ -522,8 +498,8 @@ Status allocate(const wchar_t* finalPackPath, Stage& output) noexcept {
         }
         const std::wstring finalSdkDirectory = parentPath + L"\\sdk";
         const std::wstring finalCatalogPath = finalSdkDirectory + L"\\catalog.bin";
-        if (!ordinary_directory(parentPath.c_str())
-            || !ordinary_directory(finalSdkDirectory.c_str())
+        if (!is_directory(parentPath.c_str())
+            || !is_directory(finalSdkDirectory.c_str())
             || !recover(finalSdkDirectory.c_str(), normalizedPack.c_str(), finalCatalogPath.c_str())
             || !discard_stale_stages(parentPath)) {
             return Status::invalidInput;
@@ -600,8 +576,8 @@ Status publish(const Stage& stage,
         || !full_path(stage.catalogPath.c_str(), canonicalStageCatalog)
         || !full_path(finalPackPath, canonicalFinalPack)
         || !full_path(finalCatalogPath, canonicalFinalCatalog)
-        || !ordinary_file(canonicalStagePack.c_str())
-        || !ordinary_file(canonicalStageCatalog.c_str())) {
+        || !is_file(canonicalStagePack.c_str())
+        || !is_file(canonicalStageCatalog.c_str())) {
         return Status::invalidInput;
     }
     const std::wstring expectedStageSdk = canonicalStageRoot + L"\\sdk";

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h

@@ -79,6 +79,7 @@ struct Source final {
     std::span<const ScenarioWorldSource> scenarioWorldSources{};
     std::span<const format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::span<const format::ActorAbility> actorAbilities{};
+    std::span<const format::AuthoredSceneEventKey> authoredSceneEventKeys{};
 };
 
 /** One readable generated Lua module with a filesystem-safe canonical name. */

+ 3 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp

@@ -20,7 +20,7 @@ struct RowSpec final {
 };
 
 /** Every StringRef field declared by the row table below. */
-constexpr std::size_t kDecodedStringFieldCount = 54;
+constexpr std::size_t kDecodedStringFieldCount = 55;
 /** Section index of the activity-owned binding-tag rows, which have no direct collection. */
 constexpr std::uint32_t kBindingTagSectionIndex = 24;
 /** Section index of the binding-locator rows, projected both directly and per activity. */
@@ -175,7 +175,8 @@ constexpr std::array<RowSpec, 40> kRows{{
      "CatalogDirectiveElementView",
      "s:id;s:title;s:description;u32:slot_index;u32:name_hash;i32:element_index;"
      "u32:element_count;u32:title_container_tag;u32:title_string_hash;"
-     "u32:description_container_tag;u32:description_string_hash"},
+     "u32:description_container_tag;u32:description_string_hash;s:progress;"
+     "u32:progress_container_tag;u32:progress_string_hash;u32:flags"},
     {25,
      "activity_binding_locators",
      "activity_binding_locators",

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

@@ -49,6 +49,7 @@ bool render_contract_files(const Source& source, Bundle& output) noexcept {
                  {"actor_sequence_tables", number(source.actorSequenceTables.size())},
                  {"actor_sequence_entries", number(source.actorSequenceEntries.size())},
                  {"actor_sequence_bindings", number(source.actorSequenceBindings.size())},
+                 {"authored_scene_event_keys", number(source.authoredSceneEventKeys.size())},
              })},
         });
         if (!render_json(manifest, 0, output.manifestJson)) {
@@ -245,6 +246,7 @@ local EventKind = {
 ---@field auth_component_offset integer|nil
 ---@field auth_dynamic boolean|nil
 ---@field auth_writable boolean|nil
+---@field unresourced boolean|nil
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
 ---@field applied fun(self: SunriseSlot, args: {channel: any}): boolean
 ---@field run_atoms fun(self: SunriseSlot, )lua"
@@ -313,9 +315,20 @@ local EventKind = {
 ---@field transition any Generated device transition value.
 ---@field snap? boolean Jump to the end value instead of moving.
 
+---@class SunriseDirective
+---@field id string
+---@field slot_row integer
+---@field name_hash integer
+---@field element integer
+---@field title string
+---@field description string Empty when the element was authored without one.
+---@field progress string|nil Label shown before the counter, when authored.
+---@field counter boolean|nil True when the HUD shows the lane's progress values.
+
 ---@class SunriseDirectiveArguments
----@field directive table Generated mission directive declaration.
+---@field directive SunriseDirective Generated mission directive declaration.
 ---@field state? integer Defaults to 0, the native enter state.
+---@field progress? integer[] One to four lane values, current then maximum first; the element must declare a counter.
 ---@field audience? SunriseSlot Authored type-70 engagement sensor for the mission banner.
 ---@field navpoint? SunriseSlot Authored type-47 navigation marker.
 ---@field waypoint? SunriseSlot Authored type-60 volume; inside it the HUD marker hides.
@@ -359,6 +372,7 @@ local EventKind = {
 ---@field activate fun(self: SunriseScene, args: {spawn: boolean?}?): SunriseRequestKey
 ---@field stop fun(self: SunriseScene, args: table?): SunriseRequestKey
 ---@field send_event fun(self: SunriseScene, args: {key: integer}): SunriseRequestKey
+---@field event_keys integer[]|nil
 
 ---@class SunriseTaskTarget
 ---@field id string
@@ -407,6 +421,7 @@ local EventKind = {
 ---@field TaskGroup table<string, table<string, SunriseCombatTaskGroup>>
 ---@field ActorAbility table<string, table<string, table<string, SunriseActorAbility>>>
 ---@field TriggerVolume table<string, SunriseTriggerVolume>
+---@field Directive table<string, SunriseDirective>
 
 ---@class SunriseActivity
 ---@field client_teleport_reset integer

+ 39 - 2
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);
         output.append(", auth_schema = ");
         append_uint(output, slot.authSchema);
+        if ((slot.flags & format::kSlotAuthoredSceneUnresourced) != 0) {
+            output.append(", unresourced = true");
+        }
         if (auth != nullptr) {
             output.append(", auth_type = ");
             append_string(output, auth->name);
@@ -391,6 +394,27 @@ bool render_mission(const Source& source,
             append_hex(output, scene.configTag);
             output.append(", resource_tag = ");
             append_hex(output, scene.resourceTag);
+            // The graph's gates in order: what set_scene_events publishes to run the scene.
+            // The section is sorted by scene slot, so the slot's rows are one range.
+            const auto& gates = source.authoredSceneEventKeys;
+            const auto firstGate = std::lower_bound(
+                gates.begin(), gates.end(), slotRow, [](const auto& row, std::uint32_t index) {
+                    return row.sceneSlotIndex < index;
+                });
+            const auto lastGate = std::upper_bound(
+                firstGate, gates.end(), slotRow, [](std::uint32_t index, const auto& row) {
+                    return index < row.sceneSlotIndex;
+                });
+            if (firstGate != lastGate) {
+                output.append(", event_keys = { ");
+                for (auto gate = firstGate; gate != lastGate; ++gate) {
+                    if (gate != firstGate) {
+                        output.append(", ");
+                    }
+                    append_hex(output, gate->key);
+                }
+                output.append(" }");
+            }
             output.append(" },\n");
             sceneConstants.append("    ");
             sceneConstants.append(key);
@@ -580,8 +604,14 @@ bool render_mission(const Source& source,
         }
         for (const std::uint32_t rowIndex : found->second) {
             const format::DirectiveElement& row = source.directiveElements[rowIndex];
-            (void)append_unique_key(
-                directiveConstants, directiveKeys, text(source, row.title), row.nameHash);
+            const std::string_view progress = text(source, row.progress);
+            // Only a row with no description is new; a described row keeps the title key that
+            // shipped scripts use.
+            const bool labelKey = !progress.empty() && text(source, row.description).empty();
+            (void)append_unique_key(directiveConstants,
+                                    directiveKeys,
+                                    labelKey ? progress : text(source, row.title),
+                                    row.nameHash);
             directiveConstants.append("{ id = ");
             append_string(directiveConstants, text(source, row.id));
             directiveConstants.append(", slot_row = ");
@@ -594,6 +624,13 @@ bool render_mission(const Source& source,
             append_string(directiveConstants, text(source, row.title));
             directiveConstants.append(", description = ");
             append_string(directiveConstants, text(source, row.description));
+            if (!progress.empty()) {
+                directiveConstants.append(", progress = ");
+                append_string(directiveConstants, progress);
+            }
+            if ((row.flags & format::kDirectiveElementCounter) != 0) {
+                directiveConstants.append(", counter = true");
+            }
             directiveConstants.append(" },\n");
         }
     }

+ 2 - 1
Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp

@@ -158,7 +158,7 @@ private:
     }
     const DWORD attributes = GetFileAttributesW(path);
     if (attributes == INVALID_FILE_ATTRIBUTES
-        || (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) {
+        || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
         return false;
     }
     const HANDLE file = CreateFileW(path,
@@ -427,6 +427,7 @@ bool is_current(const wchar_t* sdkDirectory, const state::activity_sdk::Catalog&
         source.actorSequenceTables = catalog.actor_sequence_tables();
         source.actorSequenceEntries = catalog.actor_sequence_entries();
         source.actorSequenceBindings = catalog.actor_sequence_bindings();
+        source.authoredSceneEventKeys = catalog.authored_scene_event_keys();
         Bundle expected{};
         if (!internal::render_contract_files(source, expected)) {
             return false;

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

@@ -64,4 +64,9 @@ struct PackageContext final {
                                               topology_enrichment::Snapshot& enrichment,
                                               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

+ 5 - 1
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -213,7 +213,8 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             storage.actorSequenceBindings,
             worldSources,
             storage.combatObjectiveGroups,
-            storage.actorAbilities};
+            storage.actorAbilities,
+            storage.authoredSceneEventKeys};
 }
 
 } // namespace
@@ -372,6 +373,9 @@ Status stage(const wchar_t* sdkDirectory,
             return cancelled(cancel, cancelContext) ? Status::cancelled
                                                     : Status::authoredSceneLinks;
         }
+        if (!attach_unresourced_scenes(topology, sceneRows, topologyDetails)) {
+            return Status::authoredSceneLinks;
+        }
         report(progress, progressContext, Phase::dialogueCues);
         if (!attach_dialogue_cue_counts(
                 topology, squadFacts, packageContext, topologyDetails, sceneRows)) {

+ 150 - 29
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -2,6 +2,7 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <cstring>
 #include <limits>
 #include <map>
@@ -12,6 +13,7 @@
 #include <utility>
 #include <vector>
 
+#include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/activity_display_name_reader.h"
 #include "activity_sdk_dialogue_group_index.h"
 #include "activity_sdk_native_pack_internal.h"
@@ -53,6 +55,38 @@ add_relative(std::size_t member, std::int64_t relative, std::size_t& target) noe
     return true;
 }
 
+/** Logs one packed directive element the generator leaves out, and why. */
+void log_directive_element(std::uint32_t slotIndex,
+                           std::uint32_t nameHash,
+                           std::size_t element,
+                           std::uint32_t flags,
+                           const char* result) noexcept {
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity_sdk_directive_element result=%s slot_row=%u "
+                                      "name=0x%08X element=%zu flags=0x%08X",
+                                      result,
+                                      static_cast<unsigned>(slotIndex),
+                                      static_cast<unsigned>(nameHash),
+                                      element,
+                                      static_cast<unsigned>(flags));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::debug,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
+/** Reads one packed localized string reference: container tag, then string hash. */
+[[nodiscard]] bool read_reference(std::span<const std::byte> bytes,
+                                  std::size_t offset,
+                                  display::Reference& output) noexcept {
+    return read_value(bytes, offset, output.containerTag)
+           && read_value(bytes, offset + 4U, output.stringHash);
+}
+
 /** Reads one array field's data offset and count, checking its declared class and stride. */
 [[nodiscard]] bool read_array(std::span<const std::byte> bytes,
                               std::size_t field,
@@ -104,10 +138,12 @@ struct AuthoredTextCandidate final {
     std::uint32_t definitionHash{};
 };
 
-/** The two localized fields are one directive element, not two selectable directives. */
+/** The localized fields are one directive element, not several selectable directives. */
 struct AuthoredDirectiveCandidate final {
     display::Reference title{};
     display::Reference description{};
+    display::Reference progress{};
+    std::uint32_t flags{};
     std::uint32_t slotIndex{};
     std::uint32_t nameHash{};
     std::int32_t elementIndex{-1};
@@ -221,6 +257,15 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
         result = &cache.emplace(tag, std::move(row)).first->second;
         return true;
     };
+    // An absent reference has no container; a present one must name a string container.
+    auto string_container = [&](const display::Reference& reference) -> bool {
+        if (reference.containerTag == 0) {
+            return true;
+        }
+        const CachedTag* container = nullptr;
+        return package(reference.containerTag, container) && container != nullptr
+               && container->classId == display::kStringContainerClass;
+    };
     try {
         std::vector<AuthoredTextCandidate> dialogueCandidates{};
         std::vector<AuthoredDirectiveCandidate> directiveCandidates{};
@@ -297,55 +342,88 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                     }
                 }
             } else {
-                if (resource->classId != 0x80804F72U) {
+                if (resource->classId != format::kDirectiveTableClass) {
                     continue;
                 }
                 std::size_t entries = 0;
                 std::size_t entryCount = 0;
-                if (!read_array(bytes, 8U, 40U, 0x80804F74U, entries, entryCount)) {
+                if (!read_array(bytes,
+                                format::kDirectiveEntryArrayOffset,
+                                format::kDirectiveEntrySize,
+                                format::kDirectiveEntryClass,
+                                entries,
+                                entryCount)) {
                     continue;
                 }
                 for (std::size_t entry = 0; entry < entryCount; ++entry) {
-                    const std::size_t row = entries + entry * 40U;
+                    const std::size_t row = entries + entry * format::kDirectiveEntrySize;
+                    const std::size_t elementsField = row + format::kDirectiveEntryElementsOffset;
                     std::uint32_t nameHash = 0;
                     std::int64_t relative = 0;
                     std::size_t elements = 0;
                     std::uint64_t elementCount = 0;
                     std::uint32_t elementClass = 0;
-                    if (!read_value(bytes, row, nameHash) || !read_value(bytes, row + 24U, relative)
-                        || !add_relative(row + 24U, relative, elements)
+                    if (!read_value(bytes, row, nameHash)
+                        || !read_value(bytes, elementsField, relative)
+                        || !add_relative(elementsField, relative, elements)
                         || !read_value(bytes, elements, elementCount) || elementCount == 0
                         || elementCount > format::kAbsentIndex
                         || !read_value(bytes, elements + 8U, elementClass)
-                        || elementClass != 0x80804F76U) {
+                        || elementClass != format::kDirectiveElementClass) {
                         continue;
                     }
                     const std::size_t data = elements + 16U;
-                    if (data > bytes.size() || elementCount > (bytes.size() - data) / 36U) {
+                    if (data > bytes.size()
+                        || elementCount
+                               > (bytes.size() - data) / format::kDirectiveElementPackedSize) {
                         continue;
                     }
                     for (std::size_t element = 0; element < elementCount; ++element) {
-                        const std::size_t elementRow = data + element * 36U;
+                        const std::size_t elementRow =
+                            data + element * format::kDirectiveElementPackedSize;
                         AuthoredDirectiveCandidate candidate{};
                         candidate.slotIndex = descriptor.slotIndex;
                         candidate.nameHash = nameHash;
                         candidate.elementIndex = static_cast<std::int32_t>(element);
                         candidate.elementCount = static_cast<std::uint32_t>(elementCount);
-                        if (!read_value(bytes, elementRow, candidate.title.containerTag)
-                            || !read_value(bytes, elementRow + 4U, candidate.title.stringHash)
-                            || !read_value(
-                                bytes, elementRow + 8U, candidate.description.containerTag)
-                            || !read_value(
-                                bytes, elementRow + 12U, candidate.description.stringHash)) {
+                        if (!read_reference(bytes,
+                                            elementRow + format::kDirectiveElementTitleOffset,
+                                            candidate.title)
+                            || !read_reference(bytes,
+                                               elementRow
+                                                   + format::kDirectiveElementDescriptionOffset,
+                                               candidate.description)
+                            || !read_reference(bytes,
+                                               elementRow + format::kDirectiveElementProgressOffset,
+                                               candidate.progress)
+                            || !read_value(bytes,
+                                           elementRow + format::kDirectiveElementFlagsOffset,
+                                           candidate.flags)) {
+                            log_directive_element(
+                                descriptor.slotIndex, nameHash, element, candidate.flags, "read");
+                            continue;
+                        }
+                        // The title is always authored; the other two may be absent.
+                        if (candidate.title.containerTag == 0
+                            || !string_container(candidate.title)) {
+                            log_directive_element(
+                                descriptor.slotIndex, nameHash, element, candidate.flags, "title");
+                            continue;
+                        }
+                        if (!string_container(candidate.description)) {
+                            log_directive_element(descriptor.slotIndex,
+                                                  nameHash,
+                                                  element,
+                                                  candidate.flags,
+                                                  "description");
                             continue;
                         }
-                        const CachedTag* title = nullptr;
-                        const CachedTag* description = nullptr;
-                        if (!package(candidate.title.containerTag, title) || title == nullptr
-                            || title->classId != display::kStringContainerClass
-                            || !package(candidate.description.containerTag, description)
-                            || description == nullptr
-                            || description->classId != display::kStringContainerClass) {
+                        if (!string_container(candidate.progress)) {
+                            log_directive_element(descriptor.slotIndex,
+                                                  nameHash,
+                                                  element,
+                                                  candidate.flags,
+                                                  "progress");
                             continue;
                         }
                         directiveCandidates.push_back(candidate);
@@ -354,13 +432,14 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             }
         }
         std::vector<display::Reference> references{};
-        references.reserve(dialogueCandidates.size() + directiveCandidates.size() * 2U);
+        references.reserve(dialogueCandidates.size() + directiveCandidates.size() * 3U);
         for (const AuthoredTextCandidate& row : dialogueCandidates) {
             references.push_back(row.reference);
         }
         for (const AuthoredDirectiveCandidate& row : directiveCandidates) {
             references.push_back(row.title);
             references.push_back(row.description);
+            references.push_back(row.progress);
         }
         if (references.empty()) {
             return true;
@@ -402,8 +481,16 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
         for (const AuthoredDirectiveCandidate& candidate : directiveCandidates) {
             const display::Name& title = names.names[resolved++];
             const display::Name& description = names.names[resolved++];
-            if (title.authoredEmpty || title.length == 0 || description.authoredEmpty
-                || description.length == 0) {
+            const display::Name& progress = names.names[resolved++];
+            const bool hasDescription = !description.authoredEmpty && description.length != 0;
+            const bool hasProgress = !progress.authoredEmpty && progress.length != 0;
+            if (title.authoredEmpty || title.length == 0 || (!hasDescription && !hasProgress)) {
+                log_directive_element(candidate.slotIndex,
+                                      candidate.nameHash,
+                                      static_cast<std::size_t>(candidate.elementIndex),
+                                      candidate.flags,
+                                      title.authoredEmpty || title.length == 0 ? "no_title"
+                                                                               : "no_text");
                 continue;
             }
             char id[96]{};
@@ -417,8 +504,12 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             if (length <= 0 || static_cast<std::size_t>(length) >= sizeof id
                 || !copy_text(std::string_view(id, static_cast<std::size_t>(length)), row.id)
                 || !copy_text(std::string_view(title.value.data(), title.length), row.title)
-                || !copy_text(std::string_view(description.value.data(), description.length),
-                              row.description)) {
+                || (hasDescription
+                    && !copy_text(std::string_view(description.value.data(), description.length),
+                                  row.description))
+                || (hasProgress
+                    && !copy_text(std::string_view(progress.value.data(), progress.length),
+                                  row.progress))) {
                 continue;
             }
             row.slotIndex = candidate.slotIndex;
@@ -427,8 +518,16 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             row.elementCount = candidate.elementCount;
             row.titleContainerTag = candidate.title.containerTag;
             row.titleStringHash = candidate.title.stringHash;
-            row.descriptionContainerTag = candidate.description.containerTag;
-            row.descriptionStringHash = candidate.description.stringHash;
+            // An absent field keeps zero tags, so the row says which fields were authored.
+            if (hasDescription) {
+                row.descriptionContainerTag = candidate.description.containerTag;
+                row.descriptionStringHash = candidate.description.stringHash;
+            }
+            if (hasProgress) {
+                row.progressContainerTag = candidate.progress.containerTag;
+                row.progressStringHash = candidate.progress.stringHash;
+            }
+            row.flags = candidate.flags;
             output.directiveElements.push_back(row);
         }
         auto dialogueLess = [](const auto& first, const auto& second) {
@@ -562,4 +661,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

+ 3 - 6
Sunrise/src/client/content/activity/activity_sdk_one_shot.cpp

@@ -118,8 +118,7 @@ void report(OfflineProgressSink sink,
             const std::wstring prefix = directory.substr(0, end);
             const DWORD attributes = GetFileAttributesW(prefix.c_str());
             if (attributes == INVALID_FILE_ATTRIBUTES
-                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-                || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
                 return false;
             }
             if (separator == std::wstring::npos) {
@@ -132,15 +131,14 @@ void report(OfflineProgressSink sink,
     }
 }
 
-/** Canonicalizes one existing directory after rejecting reparse points in its ancestry. */
+/** Canonicalizes one existing directory. */
 [[nodiscard]] bool final_directory_path(std::wstring_view input, std::wstring& output) noexcept {
     std::wstring lexical;
     if (!full_path(input, lexical) || !ordinary_ancestry(lexical)) {
         return false;
     }
     const DWORD attributes = GetFileAttributesW(lexical.c_str());
-    if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-        || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+    if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
         return false;
     }
     const HANDLE directory = CreateFileW(lexical.c_str(),
@@ -191,7 +189,6 @@ void report(OfflineProgressSink sink,
     const DWORD attributes = GetFileAttributesW(lexical.c_str());
     if (attributes != INVALID_FILE_ATTRIBUTES) {
         return (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-               && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0
                && final_directory_path(lexical, output);
     }
     const std::size_t separator = lexical.find_last_of(L"\\/");

+ 3 - 7
Sunrise/src/client/content/activity/activity_sdk_one_shot_export.cpp

@@ -120,8 +120,7 @@ void copy_result(Status status, const Result& source, public_abi::Result& output
             const std::wstring prefix = directory.substr(0, end);
             const DWORD attributes = GetFileAttributesW(prefix.c_str());
             if (attributes == INVALID_FILE_ATTRIBUTES
-                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-                || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
                 return false;
             }
             if (separator == std::wstring::npos) {
@@ -155,10 +154,8 @@ void copy_result(Status status, const Result& source, public_abi::Result& output
     const DWORD estateAttributes = GetFileAttributesW(output.c_str());
     return ordinary_ancestry(output) && artifactAttributes != INVALID_FILE_ATTRIBUTES
            && (artifactAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-           && (artifactAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0
            && estateAttributes != INVALID_FILE_ATTRIBUTES
-           && (estateAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-           && (estateAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
+           && (estateAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
 /**
@@ -197,8 +194,7 @@ void copy_result(Status status, const Result& source, public_abi::Result& output
         }
         const DWORD attributes = GetFileAttributesW(requestedPath.c_str());
         return attributes == INVALID_FILE_ATTRIBUTES
-               || ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-                   && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0);
+               || ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0);
     } catch (...) {
         return false;
     }

+ 7 - 1
Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp

@@ -696,6 +696,11 @@ link_text(const StringLinker& linker, const Text& input, format::StringRef& outp
             return false;
         }
     }
+    for (const authored_scene_inventory::EventKey& row : authoredScenes.eventKeys) {
+        if (!keep_legacy(linker, row.id)) {
+            return false;
+        }
+    }
     for (const authored_scene_inventory::SquadEdge& row : authoredScenes.squadEdges) {
         if (!keep_legacy(linker, row.id)) {
             return false;
@@ -713,7 +718,7 @@ link_text(const StringLinker& linker, const Text& input, format::StringRef& outp
     }
     for (const authored_scene_inventory::DirectiveElement& row : authoredScenes.directiveElements) {
         if (!keep_legacy(linker, row.id) || !keep_legacy(linker, row.title)
-            || !keep_legacy(linker, row.description)) {
+            || !keep_legacy(linker, row.description) || !keep_legacy(linker, row.progress)) {
             return false;
         }
     }
@@ -775,6 +780,7 @@ pack::Tables Storage::tables() const noexcept {
         combatObjectiveGroups,
         actorAbilities,
         actorAbilityTargets,
+        authoredSceneEventKeys,
     };
 }
 

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer.h

@@ -113,6 +113,7 @@ struct Storage final {
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::vector<format::ActorAbility> actorAbilities{};
     std::vector<format::ActorAbilityTarget> actorAbilityTargets{};
+    std::vector<format::AuthoredSceneEventKey> authoredSceneEventKeys{};
 
     /** @return Borrowed sections in exact current format order. */
     [[nodiscard]] pack::Tables tables() const noexcept;

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

@@ -632,6 +632,23 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
         }
     }
 
+    output.authoredSceneEventKeys.resize(authoredScenes.eventKeys.size());
+    for (std::size_t index = 0; index < authoredScenes.eventKeys.size(); ++index) {
+        const authored_scene_inventory::EventKey& input = authoredScenes.eventKeys[index];
+        format::AuthoredSceneEventKey& target = output.authoredSceneEventKeys[index];
+        target.sceneSlotIndex = input.slotIndex;
+        target.resourceTag = input.resourceTag;
+        target.graphTag = input.graphTag;
+        target.gateOffset = input.gateOffset;
+        target.ordinal = input.ordinal;
+        target.key = input.key;
+        target.flags = input.flags;
+        target.reserved = input.reserved;
+        if (!link_text(linker, input.id, target.id)) {
+            return false;
+        }
+    }
+
     output.authoredSceneSquadEdges.resize(authoredScenes.squadEdges.size());
     for (std::size_t index = 0; index < authoredScenes.squadEdges.size(); ++index) {
         const authored_scene_inventory::SquadEdge& input = authoredScenes.squadEdges[index];
@@ -695,8 +712,12 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
         target.titleStringHash = input.titleStringHash;
         target.descriptionContainerTag = input.descriptionContainerTag;
         target.descriptionStringHash = input.descriptionStringHash;
+        target.progressContainerTag = input.progressContainerTag;
+        target.progressStringHash = input.progressStringHash;
+        target.flags = input.flags;
         if (!link_text(linker, input.id, target.id) || !link_text(linker, input.title, target.title)
-            || !link_text(linker, input.description, target.description)) {
+            || !link_text(linker, input.description, target.description)
+            || !link_text(linker, input.progress, target.progress)) {
             return false;
         }
     }

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

@@ -61,6 +61,7 @@ bool validate_storage(const Inputs&,
         value.combatObjectiveGroups.size(),
         value.actorAbilities.size(),
         value.actorAbilityTargets.size(),
+        value.authoredSceneEventKeys.size(),
     };
     const bool bounded = std::all_of(counts.begin(), counts.end(), [](std::size_t count) {
         return count <= (std::numeric_limits<std::uint32_t>::max)();

+ 2 - 7
Sunrise/src/client/content/activity/activity_sdk_shard_cleanup.cpp

@@ -13,7 +13,7 @@ namespace {
 namespace generated = state::activity_sdk::generated_world;
 namespace manifest = state::activity_sdk::generated_world::manifest;
 
-/** Resolves one directory and rejects every reparse-backed ancestor component. */
+/** Resolves one directory. */
 [[nodiscard]] bool ordinary_directory_path(const std::wstring& input,
                                            std::wstring& output) noexcept {
     output.clear();
@@ -40,8 +40,7 @@ namespace manifest = state::activity_sdk::generated_world::manifest;
             const std::wstring prefix = output.substr(0, end);
             const DWORD attributes = GetFileAttributesW(prefix.c_str());
             if (attributes == INVALID_FILE_ATTRIBUTES
-                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
-                || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+                || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
                 output.clear();
                 return false;
             }
@@ -148,10 +147,6 @@ bool clean_stale_shards(const std::wstring& directory,
         std::uint32_t scenarioTag = 0;
         generated::Digest digest{};
         if (parse_shard_leaf(leaf, scenarioTag, digest)) {
-            if ((found.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
-                complete = false;
-                continue;
-            }
             const auto record =
                 std::lower_bound(active.begin(),
                                  active.end(),

+ 11 - 13
Sunrise/src/client/content/activity/activity_sdk_tree_publication.cpp

@@ -53,31 +53,30 @@ struct Marker final {
     }
 }
 
-/** Requires one existing ordinary directory and rejects a reparse-backed leaf. */
-[[nodiscard]] bool ordinary_directory(const wchar_t* path) noexcept {
+/** Requires one existing ordinary directory. */
+[[nodiscard]] bool is_directory(const wchar_t* path) noexcept {
     const DWORD attributes = GetFileAttributesW(path);
-    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-           && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
-/** Requires one existing ordinary file and rejects a reparse-backed leaf. */
-[[nodiscard]] bool ordinary_file(const wchar_t* path) noexcept {
+/** Requires one existing ordinary file. */
+[[nodiscard]] bool is_file(const wchar_t* path) noexcept {
     const DWORD attributes = GetFileAttributesW(path);
     return attributes != INVALID_FILE_ATTRIBUTES
-           && (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0;
+           && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
 }
 
 /** Requires every existing drive-path directory component to be ordinary. */
 [[nodiscard]] bool ordinary_ancestry(const std::wstring& directory) noexcept {
     if (directory.size() < 3U || directory[1] != L':' || directory[2] != L'\\'
-        || !ordinary_directory(directory.substr(0, 3U).c_str())) {
+        || !is_directory(directory.substr(0, 3U).c_str())) {
         return false;
     }
     std::size_t cursor = 3U;
     while (cursor < directory.size()) {
         const std::size_t separator = directory.find(L'\\', cursor);
         const std::size_t end = separator == std::wstring::npos ? directory.size() : separator;
-        if (!ordinary_directory(directory.substr(0, end).c_str())) {
+        if (!is_directory(directory.substr(0, end).c_str())) {
             return false;
         }
         if (separator == std::wstring::npos) {
@@ -192,7 +191,7 @@ sibling_path(std::wstring_view output, std::wstring_view suffix, std::wstring& s
 /** Reads one exact marker without accepting trailing bytes or noncanonical fields. */
 [[nodiscard]] bool read_marker(const std::wstring& path, Marker& output) noexcept {
     output = {};
-    if (!ordinary_file(path.c_str())) {
+    if (!is_file(path.c_str())) {
         return false;
     }
     const HANDLE file = CreateFileW(path.c_str(),
@@ -225,8 +224,7 @@ sibling_path(std::wstring_view output, std::wstring_view suffix, std::wstring& s
         return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
     }
     exists = true;
-    return (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0
-           && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
+    return (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
 /** Restores or finalizes one interrupted transaction before any new publication begins. */
@@ -324,7 +322,7 @@ Status publish(const wchar_t* stage,
     } catch (...) {
         return Status::invalidInput;
     }
-    if (!ordinary_ancestry(parent) || !ordinary_directory(canonicalStage.c_str())) {
+    if (!ordinary_ancestry(parent) || !is_directory(canonicalStage.c_str())) {
         return Status::invalidInput;
     }
     std::wstring backup;

+ 2 - 0
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -124,6 +124,8 @@ bool build_character_abilities(
                              std::uint16_t socketEntryListIndex,
                              const domain::Selection& selection) noexcept {
         if (count >= output.size()) {
+            // A dropped row leaves that subclass and selection with no published abilities.
+            report_ability_failure("capacity", character, socketEntryListIndex, output.size());
             return;
         }
         domain::Definition row{};

+ 9 - 3
Sunrise/src/client/content/vendors/layout.h

@@ -19,12 +19,18 @@ inline constexpr std::size_t kResetIntervalOffset = 20;
 /** Raw reset phase, paired with the interval. */
 inline constexpr std::size_t kResetPhaseOffset = 24;
 
-/** Sale row price-override array descriptor, which is what the row charges. */
+/** Sale row cost array descriptor, which is what the row charges. */
 inline constexpr std::size_t kSaleCostArrayDescriptor = 32;
-/** Cost item-definition index inside one price-override row. */
+/** Cost entry item-definition index. */
 inline constexpr std::size_t kSaleCostItemIndexOffset = 0;
-/** Units the price-override row charges. */
+/** Cost entry static quantity. */
 inline constexpr std::size_t kSaleCostQuantityOffset = 4;
+/** Cost entry descriptor of its first expression array. An entry carrying one is not static. */
+inline constexpr std::size_t kSaleCostFirstProgramDescriptor = 8;
+/** Cost entry descriptor of its second expression array. An entry carrying one is not static. */
+inline constexpr std::size_t kSaleCostSecondProgramDescriptor = 24;
+/** Cost entry trailing word, which every expression-free entry carries as `kPlainCostWord`. */
+inline constexpr std::size_t kSaleCostWordOffset = 40;
 /** Sale row main item-definition index. */
 inline constexpr std::size_t kSaleItemIndexOffset = 70;
 /** Sale row vendor category index. */

+ 59 - 16
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -31,6 +31,10 @@ struct Storage {
     std::size_t definitionCount{};
     std::size_t saleRowCount{};
     std::size_t installedRowCount{};
+    /** Sale rows whose price depends on an expression, kept but never charged. */
+    std::size_t conditionalRows{};
+    /** Sale rows whose cost array did not read, kept but never charged. */
+    std::size_t unreadableRows{};
 };
 
 /** One array a definition or a sale row declares, reduced to what the catalog stores. */
@@ -89,27 +93,51 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 
 /**
- * Reads what one sale row charges, from the first row of its price-override array.
- * A row charging nothing declares no override, which is data rather than a malformed row.
+ * Reads what one sale row charges: every entry of its cost array, and whether those entries are
+ * the price. An entry is static only when both of its expression arrays are empty and its
+ * trailing word is the plain value. One carrying an expression, or another word, makes the row
+ * conditional: its price then depends on state this build does not evaluate, so the entries are
+ * kept for the record and the row is never charged.
  * @param blob Whole definition blob.
  * @param at Sale row offset inside the blob.
- * @param value Receives the cost item and quantity, or the absent cost.
- * @return True when the array is absent, or resolves and ends inside the blob.
+ * @param value Receives the entries and the row's price state.
+ * @return True when the array is absent, or resolves, fits, and ends inside the blob. Otherwise
+ *         the row is left unreadable, with no entries.
  */
 [[nodiscard]] bool
 read_sale_cost(std::span<const std::byte> blob, std::size_t at, domain::SaleRow& value) noexcept {
-    value.costItemIndex = domain::kAbsentCostItem;
-    value.costQuantity = 0;
+    value.costs = {};
+    value.costCount = 0;
+    value.priceState = domain::PriceState::unreadable;
     ArrayView cost{};
     if (!read_array(blob, at + kSaleCostArrayDescriptor, domain::kSaleCostRowStride, cost)) {
         return false;
     }
-    if (cost.count == 0) {
-        return true;
+    if (cost.count != 0
+        && (cost.classId != domain::kSaleCostRowClass || cost.count > value.costs.size())) {
+        return false;
+    }
+    bool conditional = false;
+    for (std::size_t entry = 0; entry < cost.count; ++entry) {
+        const std::size_t entryAt = cost.base + (entry * domain::kSaleCostRowStride);
+        domain::SaleCost& output = value.costs[entry];
+        std::uint64_t firstProgram = 0;
+        std::uint64_t secondProgram = 0;
+        std::uint32_t word = 0;
+        if (!read(blob, entryAt + kSaleCostItemIndexOffset, output.itemIndex)
+            || !read(blob, entryAt + kSaleCostQuantityOffset, output.quantity)
+            || !read(blob, entryAt + kSaleCostFirstProgramDescriptor, firstProgram)
+            || !read(blob, entryAt + kSaleCostSecondProgramDescriptor, secondProgram)
+            || !read(blob, entryAt + kSaleCostWordOffset, word)) {
+            value.costs = {};
+            return false;
+        }
+        conditional = conditional || firstProgram != 0 || secondProgram != 0
+                      || word != domain::kPlainCostWord;
     }
-    return cost.classId == domain::kSaleCostRowClass
-           && read(blob, cost.base + kSaleCostItemIndexOffset, value.costItemIndex)
-           && read(blob, cost.base + kSaleCostQuantityOffset, value.costQuantity);
+    value.costCount = static_cast<std::uint8_t>(cost.count);
+    value.priceState = conditional ? domain::PriceState::conditional : domain::PriceState::plain;
+    return true;
 }
 
 /**
@@ -146,6 +174,8 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
 
 /**
  * Reads every sale row of one definition into the flat bank.
+ * A row whose cost array does not read costs that row its price, never the vendor: a purchase
+ * names the row by ordinal, so the row stays in place and refuses to be bought.
  * @param blob Whole definition blob.
  * @param definition Definition whose sale array was already resolved.
  * @param storage Pass storage receiving the rows.
@@ -163,10 +193,20 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
         value = {};
         if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
             || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
-            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)
-            || !read_sale_cost(blob, at, value)) {
+            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)) {
             return false;
         }
+        if (!read_sale_cost(blob, at, value)) {
+            ++storage.unreadableRows;
+            core::log::writef(core::log::Channel::state,
+                              core::log::Level::warn,
+                              "ev=build_data stage=vendors result=unreadable_cost hash=0x%08X "
+                              "row=%zu",
+                              definition.definitionHash,
+                              row);
+        } else if (value.priceState == domain::PriceState::conditional) {
+            ++storage.conditionalRows;
+        }
     }
     storage.saleRowCount += definition.saleCount;
     return true;
@@ -271,17 +311,20 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=build_data stage=vendors index=%zu definitions=%zu "
-                                      "sale=%zu installed=%zu skipped=%zu result=%s",
+                                      "sale=%zu conditional=%zu unreadable=%zu installed=%zu "
+                                      "skipped=%zu result=%s",
                                       storage.indexCount,
                                       storage.definitionCount,
                                       storage.saleRowCount,
+                                      storage.conditionalRows,
+                                      storage.unreadableRows,
                                       storage.installedRowCount,
                                       skipped,
                                       result);
     if (written > 0) {
+        const bool clean = storage.indexCount != 0 && skipped == 0 && storage.unreadableRows == 0;
         core::log::write(core::log::Channel::state,
-                         storage.indexCount != 0 && skipped == 0 ? core::log::Level::info
-                                                                 : core::log::Level::warn,
+                         clean ? core::log::Level::info : core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
 }

+ 73 - 6
Sunrise/src/client/hooks/stall_probe/stall_probe.cpp

@@ -39,6 +39,8 @@ constexpr std::size_t kFrameLimit = 16;
 constexpr std::size_t kFramesPerLine = 8;
 /** Time the stopping side waits for the watcher to leave its loop. */
 constexpr DWORD kJoinMs = 2'000;
+/** No image maps below the first 64 KiB; a stack slot holding less is data, not a return. */
+constexpr std::uint64_t kLowestCodeAddress = 0x10000;
 
 HANDLE g_thread{};
 HANDLE g_stop{};
@@ -57,7 +59,53 @@ void resolve_module_ranges() noexcept {
     (void)diagnostics::module_range(own, g_ownRange);
 }
 
-/** Formats one code address as a module-relative token, or raw hex outside both images. */
+/**
+ * Names the loaded module holding one address, as "<basename>+0x<rva>".
+ * Runs only after every suspended thread has resumed: the loader lock may be held by one of
+ * them, and this takes it.
+ * @return True when a module owns the address and the token fit.
+ */
+[[nodiscard]] bool
+format_module_address(std::uint64_t value, char* out, std::size_t size) noexcept {
+    HMODULE module = nullptr;
+    diagnostics::ModuleRange range{};
+    if (value < kLowestCodeAddress
+        || GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
+                                  | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+                              reinterpret_cast<LPCWSTR>(static_cast<std::uintptr_t>(value)),
+                              &module)
+               == 0
+        || !diagnostics::module_range(module, range) || !diagnostics::contains(range, value)) {
+        return false;
+    }
+    std::array<wchar_t, MAX_PATH> path{};
+    const DWORD length = GetModuleFileNameW(module, path.data(), static_cast<DWORD>(path.size()));
+    if (length == 0 || length >= path.size()) {
+        return false;
+    }
+    std::size_t start = length;
+    while (start > 0 && path[start - 1] != L'\\' && path[start - 1] != L'/') {
+        --start;
+    }
+    // The extension carries nothing the module name does not, and the line has a budget.
+    std::size_t stop = length;
+    if (stop > start + 4 && path[stop - 4] == L'.') {
+        stop -= 4;
+    }
+    const int written = std::snprintf(
+        out,
+        size,
+        "%.*ls+0x%llX",
+        static_cast<int>(stop - start),
+        path.data() + start,
+        static_cast<unsigned long long>(value - reinterpret_cast<std::uintptr_t>(module)));
+    return written > 0 && static_cast<std::size_t>(written) < size;
+}
+
+/**
+ * Formats one code address as a module-relative token: the game image, this DLL, any other
+ * loaded module by name, or raw hex when nothing owns it.
+ */
 void format_address(std::uint64_t value, char* out, std::size_t size) noexcept {
     if (diagnostics::contains(g_gameRange, value)) {
         std::snprintf(
@@ -69,9 +117,29 @@ void format_address(std::uint64_t value, char* out, std::size_t size) noexcept {
             out, size, "own+0x%llX", static_cast<unsigned long long>(value - g_ownRange.base));
         return;
     }
+    if (format_module_address(value, out, size)) {
+        return;
+    }
     std::snprintf(out, size, "0x%llX", static_cast<unsigned long long>(value));
 }
 
+/** @return True when some loaded image, not only the two known ones, holds the address. */
+[[nodiscard]] bool in_any_module(std::uint64_t value) noexcept {
+    if (diagnostics::contains(g_gameRange, value) || diagnostics::contains(g_ownRange, value)) {
+        return true;
+    }
+    HMODULE module = nullptr;
+    diagnostics::ModuleRange range{};
+    // Wine answers the main image for a null address, so the range check is what decides.
+    return value >= kLowestCodeAddress
+           && GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
+                                     | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+                                 reinterpret_cast<LPCWSTR>(static_cast<std::uintptr_t>(value)),
+                                 &module)
+                  != 0
+           && diagnostics::module_range(module, range) && diagnostics::contains(range, value);
+}
+
 /**
  * Suspends one thread, copies its control registers and a stack window, then resumes it.
  * Only memory is taken while the thread is suspended; all logging happens after the resume.
@@ -105,7 +173,7 @@ void format_address(std::uint64_t value, char* out, std::size_t size) noexcept {
     return captured;
 }
 
-/** Logs one thread's rip, rsp and every in-image return address on its stack. */
+/** Logs one thread's rip, rsp and every module-owned return address on its stack. */
 void report_thread(std::uint32_t tid) noexcept {
     alignas(16) CONTEXT context{};
     std::array<std::byte, kStackWindowBytes> stack{};
@@ -113,7 +181,7 @@ void report_thread(std::uint32_t tid) noexcept {
     if (!capture_thread(tid, context, stack.data(), stackBytes)) {
         return;
     }
-    std::array<char, 32> ripText{};
+    std::array<char, 64> ripText{};
     format_address(context.Rip, ripText.data(), ripText.size());
     std::array<char, core::log::kLineCapacity> line{};
     int written = std::snprintf(line.data(),
@@ -135,8 +203,7 @@ void report_thread(std::uint32_t tid) noexcept {
     for (std::size_t index = 0; index * 8 + 8 <= stackBytes && frames < kFrameLimit; ++index) {
         std::uint64_t value = 0;
         std::memcpy(&value, stack.data() + index * 8, sizeof value);
-        if (!diagnostics::contains(g_gameRange, value)
-            && !diagnostics::contains(g_ownRange, value)) {
+        if (!in_any_module(value)) {
             continue;
         }
         if (onLine == 0) {
@@ -144,7 +211,7 @@ void report_thread(std::uint32_t tid) noexcept {
                 line.data(), line.size(), "ev=probe stage=stall set=frames tid=0x%08X", tid);
             offset = prefix > 0 ? static_cast<std::size_t>(prefix) : 0;
         }
-        std::array<char, 32> text{};
+        std::array<char, 64> text{};
         format_address(value, text.data(), text.size());
         const int piece = std::snprintf(
             line.data() + offset, line.size() - offset, " f%zu=%s", frames, text.data());

+ 16 - 11
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp

@@ -3,6 +3,7 @@
 #include <bit>
 #include <cmath>
 #include <limits>
+#include <span>
 
 #include "scriptable_auth_internal.h"
 
@@ -74,22 +75,25 @@ constexpr std::uint32_t kWaypointMarkerMode = 2;
 }
 
 /** Writes one of the three complete type-68 state lanes. */
-[[nodiscard]] bool write_directive_entry(bits::Writer& writer,
-                                         std::uint32_t nameHash,
-                                         std::int32_t elementIndex,
-                                         std::int8_t state,
-                                         const Type2LaneClientRef& target = {},
-                                         std::uint32_t targetNameHash = kClientRefAbsentKey,
-                                         std::uint32_t targetBubbleHash = 0,
-                                         const Type2LaneClientRef& waypoint = {}) noexcept {
+[[nodiscard]] bool
+write_directive_entry(bits::Writer& writer,
+                      std::uint32_t nameHash,
+                      std::int32_t elementIndex,
+                      std::int8_t state,
+                      const Type2LaneClientRef& target = {},
+                      std::uint32_t targetNameHash = kClientRefAbsentKey,
+                      std::uint32_t targetBubbleHash = 0,
+                      const Type2LaneClientRef& waypoint = {},
+                      std::span<const std::int32_t, kType68ProgressCount> progress =
+                          std::array<std::int32_t, kType68ProgressCount>{}) noexcept {
     if (!writer.write(nameHash, 32)
         || !writer.write(std::bit_cast<std::uint32_t>(elementIndex) + kSigned32Bias, 32)
         || !writer.write(static_cast<std::uint32_t>(state) + 1U, kDirectiveStateWidth)
         || !write_neutral_timed_state(writer)) {
         return false;
     }
-    for (std::size_t index = 0; index < 4; ++index) {
-        if (!writer.write(kSigned32Bias, 32)) {
+    for (const std::int32_t value : progress) {
+        if (!writer.write(std::bit_cast<std::uint32_t>(value) + kSigned32Bias, 32)) {
             return false;
         }
     }
@@ -388,7 +392,8 @@ bool encode_type68(const Type68Preset& preset,
                                               preset.navpoint,
                                               preset.navpointNameHash,
                                               preset.navpointBubbleHash,
-                                              preset.waypoint)
+                                              preset.waypoint,
+                                              preset.progress)
                       : write_directive_entry(writer, kClientRefAbsentKey, 0, -1);
     }
     encoded = encoded && writer.write(preset.visible ? 1U : 0U, kDirectiveActiveIndexWidth);

+ 4 - 0
Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h

@@ -501,6 +501,8 @@ inline constexpr std::size_t kType53ByteCount = (kType53BitCount + 7) / 8;
 inline constexpr std::uint8_t kType68SlotType = 68;
 inline constexpr std::uint32_t kType68Schema = 0x80804F67;
 inline constexpr std::size_t kType68EntryCount = 3;
+/** Signed progress values each lane carries; a counter element shows the first two. */
+inline constexpr std::size_t kType68ProgressCount = 4;
 inline constexpr std::size_t kType68BitCount = 4'802;
 inline constexpr std::size_t kType68ByteCount = (kType68BitCount + 7) / 8;
 /** ClientRef slot type of an authored navigation marker a directive may point at. */
@@ -553,6 +555,8 @@ struct Type68Preset final {
     /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
     std::int8_t state{};
     bool visible{true};
+    /** Lane progress values, current then maximum first; the HUD reads them as a counter. */
+    std::array<std::int32_t, kType68ProgressCount> progress{};
     /** Authored type-47 destination; absent removes the explicit guidance marker. */
     Type2LaneClientRef navpoint{};
     /** The navpoint's slot name hash; the client resolves it to a position when the object is

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

@@ -16,6 +16,8 @@ struct PreparedScene final {
     state::build_data::scenarios::RosterGroup rosterGroup{};
     middleware::bap::activity_message::sensor_auth_update::AuthoredSceneDependencies
         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::uint32_t scenarioRow{state::activity_sdk::format::kAbsentIndex};
     std::uint32_t stateRow{state::activity_sdk::format::kAbsentIndex};

+ 14 - 12
Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp

@@ -7,7 +7,7 @@
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_behavior_scope.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"
 
 namespace sunrise::server::activity::activity_sdk_mission::detail {
@@ -353,11 +353,13 @@ namespace {
         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);
     if (lease != SceneStatus::ready) {
@@ -622,14 +624,14 @@ namespace {
     if (status != SceneStatus::ready) {
         return status;
     }
-    std::size_t matches = 0;
-    for (const sdk::format::DirectiveElement& row : view.catalog->directive_elements()) {
-        matches +=
-            row.slotIndex == slotRow && row.nameHash == nameHash && row.elementIndex == elementIndex
-                ? 1U
-                : 0U;
+    // Validation keeps one row per (slot, name hash, element), so the slot's range decides.
+    for (const sdk::format::DirectiveElement& row :
+         sdk::slot_directive_elements(*view.catalog, view.catalog->slots()[slotRow])) {
+        if (row.nameHash == nameHash && row.elementIndex == elementIndex) {
+            return SceneStatus::ready;
+        }
     }
-    return matches == 1 ? SceneStatus::ready : SceneStatus::invalidSlot;
+    return SceneStatus::invalidSlot;
 }
 
 /** Resolves one exact type-3 objective sensor without changing transport state. */

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

@@ -9,6 +9,7 @@
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_behavior_scope.h"
 #include "activity_sdk_mission_internal.h"
+#include "activity_sdk_scene_spawn.h"
 #include "activity_sdk_scriptable_route.h"
 #include "host_runtime.h"
 
@@ -126,6 +127,9 @@ SceneStatus activate_authored_scene(const sdk::BoundView& view,
     if (status != SceneStatus::ready) {
         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(
             view.binding,
             prepared.target,
@@ -174,6 +178,21 @@ SceneStatus play_dialogue_cue(const sdk::BoundView& view,
     return SceneStatus::refused;
 }
 
+/** Checks one exact type-68 slot; the answer holds for every element the slot carries. */
+SceneStatus directives_availability(const sdk::BoundView& view,
+                                    std::uint32_t occurrenceRow,
+                                    std::uint32_t slotRow) noexcept {
+    PreparedScene prepared{};
+    return prepare_typed_behavior(view,
+                                  occurrenceRow,
+                                  slotRow,
+                                  sdk::format::kDirectiveSlotType,
+                                  sdk::format::kDirectiveComponentClass,
+                                  sdk::format::kDirectiveAuthSchema,
+                                  false,
+                                  prepared);
+}
+
 SceneStatus directive_availability(const sdk::BoundView& view,
                                    std::uint32_t occurrenceRow,
                                    std::uint32_t slotRow,
@@ -752,6 +771,9 @@ SceneStatus activate_authored_scene_reserved(const sdk::BoundView& view,
     if (status != SceneStatus::ready) {
         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(
             view.binding,
             prepared.target,

+ 5 - 0
Sunrise/src/server/activity/activity_sdk_mission_runtime.h

@@ -103,6 +103,11 @@ select_state(const state::activity_sdk::BoundView& view,
                                             std::uint32_t slotRow,
                                             std::uint16_t cueIndex) noexcept;
 
+/** Checks one exact generated type-68 slot; the answer holds for every element it carries. */
+[[nodiscard]] SceneStatus directives_availability(const state::activity_sdk::BoundView& view,
+                                                  std::uint32_t occurrenceRow,
+                                                  std::uint32_t slotRow) noexcept;
+
 /** Checks one exact generated type-68 HUD directive element. */
 [[nodiscard]] SceneStatus directive_availability(const state::activity_sdk::BoundView& view,
                                                  std::uint32_t occurrenceRow,

+ 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.
+ *
+ * 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 world Exact generated package references.
  * @param occurrenceRow Selected scene occurrence.
  * @param sceneSlotRow Type 43 slot owned by that occurrence.
  * @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,
                                      const state::build_data::scriptables::Snapshot& world,
@@ -101,14 +106,14 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
     if (resources.size() != 1) {
         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) {
         return resolved;
     }
     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,
                             static_cast<std::uint16_t>(reference.slotIndex),
                             static_cast<std::uint8_t>(reference.slotType),
@@ -116,6 +121,10 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
                             true};
         auto& pair = candidate.pairs[candidate.count];
         const auto parent = source_squad(catalog, occurrenceRow, source, pair.squadRow);
+        if (parent == SceneStatus::targetUnavailable) {
+            pair = {};
+            continue;
+        }
         if (parent != SceneStatus::ready) {
             return parent;
         }
@@ -154,7 +163,8 @@ SceneStatus collect_scene_spawn_plan(const sdk::Catalog& catalog,
             }
         }
         if (pair.actorSlotRow == sdk::format::kAbsentIndex) {
-            return SceneStatus::targetUnavailable;
+            pair = {};
+            continue;
         }
         pair.actorTarget = physical_target(catalog, pair.actorSlotRow);
         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
 
+#include <array>
+#include <cstddef>
 #include <limits>
 
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
@@ -7,24 +9,40 @@
 
 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 sceneSlot Owned scene 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
-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 message = middleware::bap::activity_message::sensor_auth_update;
     output = {};
-    message::AuthoredSceneDependencies candidate{};
+    SceneSquadParticipants candidate{};
     const auto slots = catalog.slots();
     if (resource.slotIndex >= slots.size() || &slots[resource.slotIndex] != &sceneSlot
         || sceneSlot.slotType != sdk::format::kAuthoredSceneSlotType
@@ -35,7 +53,7 @@ scene_dependencies(const state::activity_sdk::Catalog& catalog,
         return SceneStatus::invalidSlot;
     }
     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;
     }
     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)())) {
             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;
     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 <algorithm>
+#include <array>
+#include <cstdio>
+#include <string_view>
 
+#include "../../core/logging/log.h"
 #include "../../state/activity_sdk/generated_world/runtime.h"
 #include "activity_sdk_device_internal.h"
 #include "activity_sdk_mission_internal.h"
@@ -114,6 +118,49 @@ prepare_pair(const sdk::BoundView& view, std::uint32_t sceneState, SceneSpawnPai
 
 } // 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. */
 SceneStatus resolve_scene_spawn_plan(const sdk::BoundView& view,
                                      std::uint32_t occurrenceRow,

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

@@ -21,15 +21,33 @@ struct SceneSpawnPair final {
     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 {
-    std::array<
-        SceneSpawnPair,
-        middleware::bap::activity_message::sensor_auth_update::kAuthoredSceneMaximumDependencyCount>
+    std::array<SceneSpawnPair, state::activity_sdk::format::kAuthoredSceneParticipantCapacity>
         pairs{};
     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. */
 [[nodiscard]] SceneStatus
 collect_scene_spawn_plan(const state::activity_sdk::Catalog& catalog,

+ 27 - 2
Sunrise/src/server/activity/mission/mission_script_lua_presentation_api.cpp

@@ -84,8 +84,8 @@ namespace {
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
     // Named arguments this call accepts. Any other key is refused.
-    static constexpr std::array<std::string_view, 5> kDeclared{
-        "directive", "state", "navpoint", "audience", "waypoint"};
+    static constexpr std::array<std::string_view, 6> kDeclared{
+        "directive", "state", "navpoint", "audience", "waypoint", "progress"};
     refuse_unknown_arguments(state, kDeclared);
     SlotDefinition slot{};
     if (!current_slot(state, *handle, slot)) {
@@ -122,6 +122,31 @@ namespace {
                                          .elementIndex = resolved.elementIndex,
                                          .state = static_cast<std::int8_t>(directiveState),
                                          .visible = true};
+    // Progress rides only on an element that declares a counter; the HUD ignores it otherwise.
+    if (push_argument(state, "progress") != LUA_TNIL) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const std::size_t count = lua_rawlen(state, -1);
+        if ((resolved.flags & format::kDirectiveElementCounter) == 0) {
+            return luaL_error(state, "directive progress requires an element with a counter");
+        }
+        if (count == 0 || count > preset.progress.size()) {
+            return luaL_error(state, "directive progress holds one to four integers");
+        }
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            if (!lua_isinteger(state, -1)) {
+                return luaL_error(state, "directive progress values must be integers");
+            }
+            const lua_Integer value = lua_tointeger(state, -1);
+            if (value < (std::numeric_limits<std::int32_t>::min)()
+                || value > (std::numeric_limits<std::int32_t>::max)()) {
+                return luaL_error(state, "directive progress is outside its native field width");
+            }
+            preset.progress[index - 1] = static_cast<std::int32_t>(value);
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
     if (!optional_slot_reference(
             state, "audience", scriptable_auth::kType70SlotType, preset.audience)) {
         return luaL_error(state,

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

@@ -421,6 +421,7 @@ resolve_slot_id(const void* context, std::string_view id, lua_vm::SlotDefinition
     output.nameHash = row.nameHash;
     output.elementIndex = row.elementIndex;
     output.elementCount = row.elementCount;
+    output.flags = row.flags;
     return !output.id.empty();
 }
 

+ 2 - 0
Sunrise/src/server/activity/mission/mission_script_vm.h

@@ -124,6 +124,8 @@ struct DirectiveElementDefinition final {
     std::int32_t elementIndex{};
     /** Rows that sub-array holds, so the bound is the array's, never the wire lane's width. */
     std::uint32_t elementCount{};
+    /** Authored flag word; the counter bit says the HUD shows the lane's progress values. */
+    std::uint32_t flags{};
 };
 
 /** One state name a type-42 performance sensor may start on the actor it drives. */

+ 10 - 54
Sunrise/src/server/ui/activity_host/activity_host_event_view.cpp

@@ -8,7 +8,6 @@
 #include <imgui.h>
 #include <string_view>
 
-#include "../../../core/ui/components/card/ui_card_component.h"
 #include "../../../core/ui/components/section/ui_section_component.h"
 #include "../../../middleware/bap/activity_message/wire_schema/activity_wire_schema.h"
 #include "../../activity/host_runtime.h"
@@ -20,7 +19,6 @@ namespace sunrise::server::ui::activity_host::event_view {
 namespace {
 
 namespace host = server::activity::host;
-namespace card = core::ui::components::card;
 namespace section = core::ui::components::section;
 namespace wire_schema = middleware::bap::activity_message::wire_schema;
 namespace sense_update = middleware::bap::activity_message::sense_update;
@@ -499,39 +497,6 @@ void draw_client_messages(const state::activity::SessionBinding* selected,
     }
 }
 
-/** Counts ingress rows after the current generation, type, and epoch filters. */
-[[nodiscard]] std::size_t
-visible_client_message_count(const state::activity::SessionBinding* selected,
-                             const host::DiagnosticsSnapshot& snapshot) noexcept {
-    std::size_t count = 0;
-    bool hasPatchEpoch = false;
-    for (std::size_t index = 0; index < snapshot.clientMessageCount; ++index) {
-        const host::ClientMessageRecord& record = snapshot.clientMessages[index];
-        if (!client_message_visible(record, selected)) {
-            continue;
-        }
-        if (g_coalescePatchEpoch && record.messageType == 52) {
-            hasPatchEpoch = true;
-        } else {
-            ++count;
-        }
-    }
-    return count + (hasPatchEpoch ? 1U : 0U);
-}
-
-/** Counts host transitions for the selected exact generation. */
-[[nodiscard]] std::size_t visible_event_count(const state::activity::SessionBinding* selected,
-                                              const host::DiagnosticsSnapshot& snapshot) noexcept {
-    if (selected == nullptr) {
-        return 0;
-    }
-    std::size_t count = 0;
-    for (std::size_t index = 0; index < snapshot.eventCount; ++index) {
-        count += same_binding(snapshot.events[index].binding, *selected) ? 1U : 0U;
-    }
-    return count;
-}
-
 /** Draws recent events for the selected exact activity generation. */
 void draw_events(const state::activity::SessionBinding& selected,
                  const host::DiagnosticsSnapshot& snapshot) noexcept {
@@ -595,26 +560,17 @@ void draw(bool& open,
     }
     const state::activity::SessionBinding* const selected =
         instance != nullptr ? &instance->binding : nullptr;
-    const std::size_t clientRows = visible_client_message_count(selected, snapshot);
-    const std::size_t eventRows = visible_event_count(selected, snapshot);
-    const float rows = table_layout::authored_rows_height(clientRows, 16)
-                       + table_layout::authored_rows_height(eventRows, 16);
-    const float baseHeight = selected != nullptr ? 250.0F : 190.0F;
-    const ImVec2 windowSize{820.0F, (std::clamp)(baseHeight + rows, 240.0F, 680.0F)};
-    const bool visible = tool_window::begin(
-        "Activity Host - Packets###activity_host_events", open, {320.0F, 320.0F}, windowSize);
+    const bool visible =
+        tool_window::begin("Packets - Activity Host###activity_host_events", open, {0.72F, 0.46F});
     if (visible) {
-        const card::Scope surface("##activity_host_events_card");
-        if (surface.visible()) {
-            draw_client_messages(selected, snapshot);
-            if (instance != nullptr) {
-                ImGui::Spacing();
-                draw_events(instance->binding, snapshot);
-                ImGui::Spacing();
-                incident_editor::draw(instance->binding, *instance, snapshot);
-            } else {
-                ImGui::TextDisabled("No activity selected. Showing every packet.");
-            }
+        draw_client_messages(selected, snapshot);
+        if (instance != nullptr) {
+            ImGui::Spacing();
+            draw_events(instance->binding, snapshot);
+            ImGui::Spacing();
+            incident_editor::draw(instance->binding, *instance, snapshot);
+        } else {
+            ImGui::TextDisabled("No activity selected. Showing every packet.");
         }
     }
     ImGui::End();

+ 134 - 63
Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp

@@ -8,7 +8,9 @@
 #include <cstdio>
 #include <imgui.h>
 #include <span>
+#include <string>
 #include <string_view>
+#include <utility>
 #include <vector>
 
 #include "../../../core/ui/components/section/ui_section_component.h"
@@ -50,6 +52,32 @@ struct SceneBrowserRow final {
 
 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. */
 [[nodiscard]] std::uint32_t global_slot_row(const sdk::Catalog& catalog,
                                             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. */
 [[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 {
+        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();
         if (view.catalog == nullptr || stateRow >= view.catalog->states().size()) {
             return false;
@@ -185,16 +233,19 @@ std::vector<SceneBrowserRow> g_visibleScenes{};
                 }
             }
         }
+        g_visibleScenesReady = true;
         return true;
     } catch (...) {
         g_visibleScenes.clear();
+        g_visibleScenesKey = {};
+        g_visibleScenesTime = -1.0;
         return false;
     }
 }
 
 /** 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 {
-    if (!materialize_visible_scenes(view, snapshot.plan.stateRow)) {
+    if (!materialize_visible_scenes(view, snapshot)) {
         ImGui::TextDisabled("No scene in this state.");
         return;
     }
@@ -328,6 +379,7 @@ void draw_authored_scenes(const sdk::BoundView& view, const mission::Snapshot& s
                 g_sceneActionStatus =
                     mission::activate_authored_scene(view, row.occurrenceRow, row.slotRow);
                 g_hasSceneActionStatus = true;
+                g_visibleScenesTime = -1.0;
             }
             ImGui::EndDisabled();
             if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
@@ -476,7 +528,6 @@ void draw_directives(const sdk::BoundView& view, const mission::Snapshot& snapsh
     const sdk::Catalog& catalog = *view.catalog;
     const auto occurrences = catalog.occurrences();
     const auto objects = catalog.objects();
-    const auto slots = catalog.slots();
     const std::string_view query = search_text();
     std::size_t rows = 0;
     for (std::uint32_t occurrenceRow = 0; occurrenceRow < occurrences.size(); ++occurrenceRow) {
@@ -486,73 +537,93 @@ void draw_directives(const sdk::BoundView& view, const mission::Snapshot& snapsh
             || occurrence.objectIndex >= objects.size()) {
             continue;
         }
-        for (const sdk::format::DirectiveElement& directive : catalog.directive_elements()) {
-            if (directive.slotIndex >= slots.size()
-                || slots[directive.slotIndex].objectIndex != occurrence.objectIndex) {
+        for (const sdk::format::Slot& slot :
+             sdk::object_slots(catalog, objects[occurrence.objectIndex])) {
+            if (slot.slotType != sdk::format::kDirectiveSlotType) {
+                continue;
+            }
+            const auto elements = sdk::slot_directive_elements(catalog, slot);
+            if (elements.empty()) {
                 continue;
             }
-            const std::string_view title = display_text(catalog, directive.title);
-            const std::string_view description = display_text(catalog, directive.description);
-            const std::string_view id = display_text(catalog, directive.id);
-            if (!query.empty() && !contains_folded(title, query)
-                && !contains_folded(description, query) && !contains_folded(id, query)) {
+            const std::uint32_t slotRow = global_slot_row(catalog, slot);
+            if (slotRow == sdk::format::kAbsentIndex) {
                 continue;
             }
-            ++rows;
+            // The slot resolves once; validation keeps every element row unique inside it.
             const mission::SceneStatus available =
-                mission::directive_availability(view,
-                                                occurrenceRow,
-                                                directive.slotIndex,
-                                                directive.nameHash,
-                                                directive.elementIndex);
-            ImGui::PushID(static_cast<int>(occurrenceRow));
-            ImGui::PushID(static_cast<int>(directive.slotIndex));
-            ImGui::PushID(static_cast<int>(directive.nameHash));
-            ImGui::PushID(directive.elementIndex);
-            ImGui::Text("%.*s", print_length(title), title.data());
-            ImGui::TextWrapped("%.*s", print_length(description), description.data());
-            ImGui::TextDisabled("hash %08X  element %d  slot %u",
-                                static_cast<unsigned>(directive.nameHash),
-                                directive.elementIndex,
-                                static_cast<unsigned>(directive.slotIndex));
-            ImGui::BeginDisabled(available != mission::SceneStatus::ready);
-            const auto apply = [&](const char* label, std::int8_t state, bool visible) {
-                if (ImGui::Button(label)) {
-                    g_directiveActionOccurrence = occurrenceRow;
-                    g_directiveActionSlot = directive.slotIndex;
-                    g_directiveActionHash = directive.nameHash;
-                    g_directiveActionElement = directive.elementIndex;
-                    g_directiveActionStatus = mission::set_directive(view,
-                                                                     occurrenceRow,
-                                                                     directive.slotIndex,
-                                                                     directive.nameHash,
-                                                                     directive.elementIndex,
-                                                                     state,
-                                                                     visible);
-                    g_hasDirectiveActionStatus = true;
+                mission::directives_availability(view, occurrenceRow, slotRow);
+            for (const sdk::format::DirectiveElement& directive : elements) {
+                const std::string_view title = display_text(catalog, directive.title);
+                const std::string_view description = catalog.string(directive.description);
+                const std::string_view progress = catalog.string(directive.progress);
+                const std::string_view id = display_text(catalog, directive.id);
+                if (!query.empty() && !contains_folded(title, query)
+                    && !contains_folded(description, query) && !contains_folded(progress, query)
+                    && !contains_folded(id, query)) {
+                    continue;
                 }
-            };
-            apply("Show", 0, true);
-            ImGui::SameLine();
-            apply("Complete", 1, true);
-            ImGui::SameLine();
-            apply("Alternate exit", 2, true);
-            ImGui::SameLine();
-            apply("Hide", 0, false);
-            ImGui::EndDisabled();
-            ImGui::SameLine();
-            ImGui::TextDisabled("%s", mission::status_name(available));
-            if (g_hasDirectiveActionStatus && g_directiveActionOccurrence == occurrenceRow
-                && g_directiveActionSlot == directive.slotIndex
-                && g_directiveActionHash == directive.nameHash
-                && g_directiveActionElement == directive.elementIndex) {
-                ImGui::TextDisabled("last: %s", mission::status_name(g_directiveActionStatus));
+                ++rows;
+                ImGui::PushID(static_cast<int>(occurrenceRow));
+                ImGui::PushID(static_cast<int>(slotRow));
+                ImGui::PushID(static_cast<int>(directive.nameHash));
+                ImGui::PushID(directive.elementIndex);
+                ImGui::Text("%.*s", print_length(title), title.data());
+                if (!description.empty()) {
+                    ImGui::TextWrapped("%.*s", print_length(description), description.data());
+                }
+                if (!progress.empty()) {
+                    ImGui::TextWrapped("%.*s%s",
+                                       print_length(progress),
+                                       progress.data(),
+                                       (directive.flags & sdk::format::kDirectiveElementCounter)
+                                               != 0
+                                           ? " (counter)"
+                                           : "");
+                }
+                ImGui::TextDisabled("hash %08X  element %d  slot %u",
+                                    static_cast<unsigned>(directive.nameHash),
+                                    directive.elementIndex,
+                                    static_cast<unsigned>(slotRow));
+                ImGui::BeginDisabled(available != mission::SceneStatus::ready);
+                const auto apply = [&](const char* label, std::int8_t state, bool visible) {
+                    if (ImGui::Button(label)) {
+                        g_directiveActionOccurrence = occurrenceRow;
+                        g_directiveActionSlot = slotRow;
+                        g_directiveActionHash = directive.nameHash;
+                        g_directiveActionElement = directive.elementIndex;
+                        g_directiveActionStatus = mission::set_directive(view,
+                                                                         occurrenceRow,
+                                                                         slotRow,
+                                                                         directive.nameHash,
+                                                                         directive.elementIndex,
+                                                                         state,
+                                                                         visible);
+                        g_hasDirectiveActionStatus = true;
+                    }
+                };
+                apply("Show", 0, true);
+                ImGui::SameLine();
+                apply("Complete", 1, true);
+                ImGui::SameLine();
+                apply("Alternate exit", 2, true);
+                ImGui::SameLine();
+                apply("Hide", 0, false);
+                ImGui::EndDisabled();
+                ImGui::SameLine();
+                ImGui::TextDisabled("%s", mission::status_name(available));
+                if (g_hasDirectiveActionStatus && g_directiveActionOccurrence == occurrenceRow
+                    && g_directiveActionSlot == slotRow
+                    && g_directiveActionHash == directive.nameHash
+                    && g_directiveActionElement == directive.elementIndex) {
+                    ImGui::TextDisabled("last: %s", mission::status_name(g_directiveActionStatus));
+                }
+                ImGui::Separator();
+                ImGui::PopID();
+                ImGui::PopID();
+                ImGui::PopID();
+                ImGui::PopID();
             }
-            ImGui::Separator();
-            ImGui::PopID();
-            ImGui::PopID();
-            ImGui::PopID();
-            ImGui::PopID();
         }
     }
     if (rows == 0) {

+ 3 - 8
Sunrise/src/server/ui/activity_host/activity_host_sdk_view.cpp

@@ -8,7 +8,6 @@
 #include <utility>
 
 #include "../../../client/ui/activity/authored_placement_marker.h"
-#include "../../../core/ui/components/card/ui_card_component.h"
 #include "../../../core/ui/scaling/dpi/ui_dpi_scaling.h"
 #include "../../../state/activity_sdk/generation/runtime.h"
 #include "../../../state/activity_sdk/runtime.h"
@@ -25,7 +24,6 @@
 namespace sunrise::server::ui::activity_host::sdk_view {
 namespace {
 
-namespace card = core::ui::components::card;
 namespace format = state::activity_sdk::format;
 namespace generation = state::activity_sdk::generation;
 namespace host = server::activity::host;
@@ -386,13 +384,10 @@ void draw(bool& open, const host::InstanceSnapshot* instance) noexcept {
         marker::set_world_page(marker::WorldPage::none);
         return;
     }
-    const bool visible = tool_window::begin(
-        "Activity Host - World###activity_host_sdk", open, {32.0F, 40.0F}, {940.0F, 680.0F});
+    const bool visible =
+        tool_window::begin("World - Activity Host###activity_host_sdk", open, {0.72F, 0.68F});
     if (visible) {
-        const card::Scope surface("##activity_host_sdk_card");
-        if (surface.visible()) {
-            draw_content(instance);
-        }
+        draw_content(instance);
     }
     ImGui::End();
     if (!open) {

+ 21 - 26
Sunrise/src/server/ui/activity_host/activity_host_tool_window.h

@@ -7,51 +7,46 @@
 
 namespace sunrise::server::ui::activity_host::tool_window {
 
-/** The transparent parent moves and resizes while the card owns content scrolling. */
+/** A titled window that moves, resizes and scrolls its own content. */
 constexpr ImGuiWindowFlags kWindowFlags =
-    ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings
-    | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
+    ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings;
 
-/** Applies a bounded content size and a first-use position. */
-inline void set_next(const ImVec2& authoredPosition, const ImVec2& authoredSize) noexcept {
+/** Applies bounded size constraints and a centered first-use size given as work-area fractions. */
+inline void set_next(const ImVec2& workFraction) noexcept {
     namespace scaling = core::ui::scaling::dpi;
-    ImVec2 size = scaling::pixels(authoredSize);
     const ImGuiViewport* viewport = ImGui::GetMainViewport();
     if (viewport == nullptr) {
-        ImGui::SetNextWindowSize(size, ImGuiCond_FirstUseEver);
         return;
     }
     const float margin = scaling::pixels(16.0F);
     const ImVec2 maximum{(std::max)(viewport->WorkSize.x - (margin * 2.0F), 1.0F),
                          (std::max)(viewport->WorkSize.y - (margin * 2.0F), 1.0F)};
-    size.x = (std::min)(size.x, maximum.x);
-    size.y = (std::min)(size.y, maximum.y);
     const ImVec2 requestedMinimum = scaling::pixels({320.0F, 120.0F});
     const ImVec2 minimum{(std::min)(requestedMinimum.x, maximum.x),
                          (std::min)(requestedMinimum.y, maximum.y)};
+    const ImVec2 size{std::clamp(viewport->WorkSize.x * workFraction.x, minimum.x, maximum.x),
+                      std::clamp(viewport->WorkSize.y * workFraction.y, minimum.y, maximum.y)};
     ImGui::SetNextWindowSizeConstraints(minimum, maximum);
     ImGui::SetNextWindowSize(size, ImGuiCond_FirstUseEver);
-
-    const ImVec2 requested = scaling::pixels(authoredPosition);
-    const ImVec2 available{(std::max)(viewport->WorkSize.x - size.x, 0.0F),
-                           (std::max)(viewport->WorkSize.y - size.y, 0.0F)};
-    const ImVec2 position{viewport->WorkPos.x + std::clamp(requested.x, 0.0F, available.x),
-                          viewport->WorkPos.y + std::clamp(requested.y, 0.0F, available.y)};
-    ImGui::SetNextWindowPos(position, ImGuiCond_FirstUseEver);
+    ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_FirstUseEver, {0.5F, 0.5F});
 }
 
-/** Begins a transparent movable parent whose entire visible surface is the child card. */
-[[nodiscard]] inline bool begin(const char* id,
-                                bool& open,
-                                const ImVec2& authoredPosition,
-                                const ImVec2& authoredSize) noexcept {
-    set_next(authoredPosition, authoredSize);
-    ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4{});
-    ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0F);
-    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2{});
+/** Begins a movable, resizable window whose title bar reads as a raised Sunrise panel. */
+[[nodiscard]] inline bool begin(const char* id, bool& open, const ImVec2& workFraction) noexcept {
+    namespace scaling = core::ui::scaling::dpi;
+    set_next(workFraction);
+    // Dear ImGui reads these only while Begin draws the frame, so the body keeps the theme.
+    // The same colour whether focused or not, so focus never lights the bar up.
+    const ImGuiStyle& style = ImGui::GetStyle();
+    const ImVec4 titleBackground = style.Colors[ImGuiCol_ChildBg];
+    ImGui::PushStyleColor(ImGuiCol_TitleBg, titleBackground);
+    ImGui::PushStyleColor(ImGuiCol_TitleBgActive, titleBackground);
+    ImGui::PushStyleVar(ImGuiStyleVar_FramePadding,
+                        ImVec2{style.FramePadding.x, scaling::pixels(8.0F)});
+    ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, style.WindowBorderSize);
     const bool visible = ImGui::Begin(id, &open, kWindowFlags);
     ImGui::PopStyleVar(2);
-    ImGui::PopStyleColor();
+    ImGui::PopStyleColor(2);
     return visible;
 }
 

+ 69 - 54
Sunrise/src/server/web_service/web_service_vendor_actions.cpp

@@ -5,6 +5,7 @@
 #include <cstdio>
 #include <cstring>
 #include <limits>
+#include <optional>
 #include <span>
 #include <string_view>
 
@@ -298,26 +299,33 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
     // A sale row holds its cost as u32; the mutation charges an i32, so a wider row is refused.
     constexpr auto kQuantityLimit =
         static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
+    // An exchange charges exactly one static stack, so a row that is not plainly priced, or
+    // declares any number of entries but one, is not one.
+    const std::span<const vendor_domain::SaleCost> price = vendor_domain::cost_entries(row);
+    const vendor_domain::SaleCost single = price.size() == 1 ? price[0] : vendor_domain::SaleCost{};
     state::build_data::items::Definition cost{};
     // A recycle row owns its purchase from here, refused or not: falling through would grant the
     // placeholder, which is the failure this path exists to avoid.
-    if (row.costItemIndex == vendor_domain::kAbsentCostItem || row.costQuantity == 0
-        || row.costQuantity > kQuantityLimit
-        || !state::build_data::find_item_definition_index(row.costItemIndex, cost)) {
+    if (row.priceState != vendor_domain::PriceState::plain || price.size() != 1
+        || single.quantity == 0 || single.quantity > kQuantityLimit
+        || !state::build_data::find_item_definition_index(single.itemIndex, cost)) {
         core::log::writef(core::log::Channel::server,
                           core::log::Level::warn,
                           "ev=vendor_exchange stage=apply result=fail reason=cost vendor=%d "
-                          "hash=0x%08X row=%d cost_item=%u quantity=%u",
+                          "hash=0x%08X row=%d price_state=%u entries=%zu cost_item=%u "
+                          "quantity=%u",
                           vendorIndex,
                           entry.definitionHash,
                           rowIndex,
-                          static_cast<unsigned>(row.costItemIndex),
-                          static_cast<unsigned>(row.costQuantity));
+                          static_cast<unsigned>(row.priceState),
+                          price.size(),
+                          static_cast<unsigned>(single.itemIndex),
+                          static_cast<unsigned>(single.quantity));
         return true;
     }
     const bool applied = state::prepare_vendor_exchange(
         cost.definitionHash,
-        static_cast<std::int32_t>(row.costQuantity),
+        static_cast<std::int32_t>(single.quantity),
         std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount},
         mutation);
     core::log::writef(core::log::Channel::server,
@@ -329,7 +337,7 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
                       entry.definitionHash,
                       rowIndex,
                       cost.definitionHash,
-                      static_cast<unsigned>(row.costQuantity),
+                      static_cast<unsigned>(single.quantity),
                       payoutCount);
     return true;
 }
@@ -340,13 +348,17 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
  * @param message Request being answered, for the log line.
  * @param collectibleIndex Collectible that owns the item.
  * @param itemDefinitionIndex Item to grant.
+ * @param price A vendor row's cost entries, spent in place of the collectible's material set;
+ *        absent for a Collections pull, which pays with the collectible's materials.
  * @param outcome Receives the prepared mutation on success.
  * @return True when a mutation is prepared. A pursuit already held prepares none.
  */
-bool grant_item_definition(const middleware::web_service::Message& message,
-                           std::uint16_t collectibleIndex,
-                           std::uint16_t itemDefinitionIndex,
-                           Outcome& outcome) noexcept {
+bool grant_item_definition(
+    const middleware::web_service::Message& message,
+    std::uint16_t collectibleIndex,
+    std::uint16_t itemDefinitionIndex,
+    std::optional<std::span<const state::build_data::vendors::SaleCost>> price,
+    Outcome& outcome) noexcept {
     state::build_data::items::Definition definition{};
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
         report_item_acquisition(
@@ -404,7 +416,7 @@ bool grant_item_definition(const middleware::web_service::Message& message,
             return false;
         }
         if (!state::prepare_profile_item_acquisition(
-                collectibleIndex, definition.definitionHash, *mutation)) {
+                collectibleIndex, definition.definitionHash, price, *mutation)) {
             clear_mutation(outcome);
             report_item_acquisition(message,
                                     "profile_state",
@@ -436,7 +448,8 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                 0);
         return false;
     }
-    if (!state::prepare_item_acquisition(collectibleIndex, definition.definitionHash, *mutation)) {
+    if (!state::prepare_item_acquisition(
+            collectibleIndex, definition.definitionHash, price, *mutation)) {
         clear_mutation(outcome);
         report_item_acquisition(
             message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
@@ -478,24 +491,24 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
             message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
         return;
     }
-    (void)grant_item_definition(message, collectibleIndex, itemDefinitionIndex, outcome);
+    (void)grant_item_definition(
+        message, collectibleIndex, itemDefinitionIndex, std::nullopt, outcome);
 }
 
 /**
  * Resolves one vendor row to the item it sells. Shared by 901 and 904, which name a row alike.
  * @param vendorIndex Vendor table row.
  * @param rowIndex Sale row within that vendor.
- * @param itemDefinitionIndex Receives the item the row sells.
- * @param categoryIndex Receives the row's category, which is its sale row plus 100.
+ * @param row Receives the sale row: its item, category and price.
  * @param reason Receives the step that failed, when one does.
  * @return True when the row resolved.
  */
 [[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
                                       std::int32_t rowIndex,
-                                      std::uint16_t& itemDefinitionIndex,
-                                      std::int32_t& categoryIndex,
+                                      state::build_data::vendors::SaleRow& row,
                                       const char*& reason) noexcept {
     namespace vendor_domain = state::build_data::vendors;
+    row = {};
     if (vendorIndex < 0 || rowIndex < 0) {
         reason = "negative_index";
         return false;
@@ -506,13 +519,10 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
         reason = "vendor";
         return false;
     }
-    vendor_domain::SaleRow row{};
     if (!vendor_domain::sale_row(definition, static_cast<std::size_t>(rowIndex), row)) {
         reason = "sale_row";
         return false;
     }
-    itemDefinitionIndex = row.itemIndex;
-    categoryIndex = row.categoryIndex;
     return true;
 }
 
@@ -634,17 +644,33 @@ constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U;
  * @param opcode Opcode to report under.
  * @param vendorIndex Vendor the request names.
  * @param rowIndex Sale row the request names.
- * @param categoryIndex Category of that row, from sale row +100.
- * @param itemDefinitionIndex Item the row names.
+ * @param row The resolved sale row. Its price is spent by a grant and by a bounty roll, and an
+ *        exchange spends it as the stack it recycles.
  * @param outcome Receives whatever mutation the row prepared.
  */
 void settle_vendor_row(const middleware::web_service::Message& message,
                        std::uint16_t opcode,
                        std::int32_t vendorIndex,
                        std::int32_t rowIndex,
-                       std::int32_t categoryIndex,
-                       std::uint16_t itemDefinitionIndex,
+                       const state::build_data::vendors::SaleRow& row,
                        Outcome& outcome) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    const std::int32_t categoryIndex = row.categoryIndex;
+    const std::uint16_t itemDefinitionIndex = row.itemIndex;
+    // A row is sold only while its static entries are its price. A conditional row's price
+    // depends on state this build does not evaluate, and an unreadable row's is unknown.
+    if (row.priceState != vendor_domain::PriceState::plain) {
+        report_purchase(opcode,
+                        "fail",
+                        row.priceState == vendor_domain::PriceState::conditional
+                            ? "conditional_price"
+                            : "unreadable_price",
+                        vendorIndex,
+                        rowIndex,
+                        itemDefinitionIndex);
+        return;
+    }
+    const std::span<const vendor_domain::SaleCost> price = vendor_domain::cost_entries(row);
     std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
     if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
         report_purchase(opcode,
@@ -657,7 +683,9 @@ void settle_vendor_row(const middleware::web_service::Message& message,
         if (rolledBounty != kUnavailableDefinitionIndex) {
             std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
             (void)find_collectible_for_item(rolledBounty, rolledCollectible);
-            (void)grant_item_definition(message, rolledCollectible, rolledBounty, outcome);
+            // The tile prices whatever it rolls: only its placeholder item is withheld, never
+            // its price. A tile the character cannot pay for grants nothing.
+            (void)grant_item_definition(message, rolledCollectible, rolledBounty, price, outcome);
         }
         return;
     }
@@ -696,7 +724,7 @@ void settle_vendor_row(const middleware::web_service::Message& message,
                     vendorIndex,
                     rowIndex,
                     granted);
-    (void)grant_item_definition(message, collectibleIndex, granted, outcome);
+    (void)grant_item_definition(message, collectibleIndex, granted, price, outcome);
 }
 
 /**
@@ -722,17 +750,17 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         return;
     }
     const std::int32_t row = request.saleIndex;
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
     // A row of -1 says the tile is not a sale row at all, so the installed array answers it.
     // Reading the slot as a sale row here would grant whatever sits at that row.
     const bool rowless = row < 0;
-    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row.
-    std::int32_t questCategoryIndex = -1;
+    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row:
+    // it has no category and no price.
+    state::build_data::vendors::SaleRow sale{};
+    sale.categoryIndex = state::build_data::vendors::kAbsentCategoryIndex;
     const bool located =
-        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, itemDefinitionIndex)
-                : resolve_vendor_row(
-                      request.vendorIndex, row, itemDefinitionIndex, questCategoryIndex, reason);
+        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, sale.itemIndex)
+                : resolve_vendor_row(request.vendorIndex, row, sale, reason);
     if (!located) {
         report_purchase(quest::kOpcode,
                         "fail",
@@ -746,19 +774,13 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         }
         return;
     }
-    settle_vendor_row(message,
-                      quest::kOpcode,
-                      request.vendorIndex,
-                      row,
-                      questCategoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome);
 }
 
 /**
  * Prepares one opcode-901 vendor purchase, for any Tower vendor.
- * The sale row names an item-definition index, so this hands over to the Collections grant.
- * Only a recycle row charges: an ordinary row's cost is read but not yet spent.
+ * The sale row names an item-definition index, so this hands over to the Collections grant, and
+ * the row's own cost is charged with it: a purchase that cannot pay grants nothing.
  */
 void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
     namespace purchase = middleware::web_service::messages::opcode901;
@@ -767,11 +789,9 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
         report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
         return;
     }
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
-    std::int32_t categoryIndex = -1;
-    if (!resolve_vendor_row(
-            request.vendorIndex, request.saleIndex, itemDefinitionIndex, categoryIndex, reason)) {
+    state::build_data::vendors::SaleRow sale{};
+    if (!resolve_vendor_row(request.vendorIndex, request.saleIndex, sale, reason)) {
         report_purchase(purchase::kOpcode,
                         "fail",
                         reason,
@@ -780,13 +800,8 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
                         kUnavailableDefinitionIndex);
         return;
     }
-    settle_vendor_row(message,
-                      purchase::kOpcode,
-                      request.vendorIndex,
-                      request.saleIndex,
-                      categoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(
+        message, purchase::kOpcode, request.vendorIndex, request.saleIndex, sale, outcome);
 }
 
 } // namespace sunrise::server::web_service

+ 25 - 0
Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp

@@ -307,6 +307,10 @@ std::span<const format::ActorSequenceBinding> Catalog::actor_sequence_bindings()
     return rows<format::ActorSequenceBinding>(
         header_, view_, format::SectionIndex::actorSequenceBindings);
 }
+std::span<const format::AuthoredSceneEventKey> Catalog::authored_scene_event_keys() const noexcept {
+    return rows<format::AuthoredSceneEventKey>(
+        header_, view_, format::SectionIndex::authoredSceneEventKeys);
+}
 
 std::span<const format::ActorStateName> Catalog::actor_state_names() const noexcept {
     return rows<format::ActorStateName>(header_, view_, format::SectionIndex::actorStateNames);
@@ -789,6 +793,27 @@ slot_authored_scene_resources(const Catalog& catalog, const format::Slot& slot)
                           static_cast<std::size_t>(last - first));
 }
 
+/** Relies on slot ordering to return one contiguous zero-copy element range. */
+std::span<const format::DirectiveElement>
+slot_directive_elements(const Catalog& catalog, const format::Slot& slot) noexcept {
+    const auto slots = catalog.slots();
+    if (!owns(slots, slot)) {
+        return {};
+    }
+    const auto values = catalog.directive_elements();
+    const std::uint32_t slotIndex = static_cast<std::uint32_t>(&slot - slots.data());
+    const auto first =
+        std::lower_bound(values.begin(), values.end(), slotIndex, [](const auto& row, auto index) {
+            return row.slotIndex < index;
+        });
+    const auto last =
+        std::upper_bound(first, values.end(), slotIndex, [](auto index, const auto& row) {
+            return index < row.slotIndex;
+        });
+    return values.subspan(static_cast<std::size_t>(first - values.begin()),
+                          static_cast<std::size_t>(last - first));
+}
+
 /** Relies on scene-slot ordering to return one contiguous zero-copy edge range. */
 std::span<const format::AuthoredSceneSquadEdge>
 slot_authored_scene_squad_edges(const Catalog& catalog, const format::Slot& slot) noexcept {

+ 1 - 0
Sunrise/src/state/activity_sdk/activity_sdk_loader.cpp

@@ -66,6 +66,7 @@ constexpr std::array<std::uint32_t, format::kSectionCount> kExpectedStrides{
     sizeof(format::CombatObjectiveGroup),
     sizeof(format::ActorAbility),
     sizeof(format::ActorAbilityTarget),
+    sizeof(format::AuthoredSceneEventKey),
 };
 
 /** Checks all section bounds before any typed row pointer is formed. */

+ 62 - 11
Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp

@@ -38,6 +38,40 @@ constexpr std::uint32_t kAbilityAuthSchema = 0x80807DA1U;
 constexpr std::uint32_t kAbilityTargetSlotType = 58U;
 constexpr std::uint32_t kAbilityTargetComponentClass = 0x80807D9BU;
 
+/**
+ * @return True when every scene event key belongs to a resourced type-43 slot, names that
+ *         slot's resource, and the rows come in slot then gate order without repeats.
+ */
+[[nodiscard]] bool authored_scene_event_keys(const Catalog& catalog) noexcept {
+    const auto keys = catalog.authored_scene_event_keys();
+    const auto slots = catalog.slots();
+    for (std::size_t index = 0; index < keys.size(); ++index) {
+        const format::AuthoredSceneEventKey& row = keys[index];
+        if (row.sceneSlotIndex >= slots.size() || row.flags != format::kAuthoredSceneEventKeyExact
+            || row.reserved != 0 || row.key == 0 || row.key == format::kAbsentIndex
+            || row.graphTag == 0 || row.graphTag == format::kAbsentIndex) {
+            return false;
+        }
+        const format::Slot& slot = slots[row.sceneSlotIndex];
+        if (slot.slotType != format::kAuthoredSceneSlotType) {
+            return false;
+        }
+        const auto resources = slot_authored_scene_resources(catalog, slot);
+        if (resources.size() != 1 || resources.front().resourceTag != row.resourceTag) {
+            return false;
+        }
+        if (index != 0) {
+            const format::AuthoredSceneEventKey& previous = keys[index - 1];
+            if (previous.sceneSlotIndex > row.sceneSlotIndex
+                || (previous.sceneSlotIndex == row.sceneSlotIndex
+                    && previous.gateOffset >= row.gateOffset)) {
+                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();
@@ -145,14 +179,29 @@ constexpr std::uint32_t kAbilityTargetComponentClass = 0x80807D9BU;
             return false;
         }
     }
+    const format::DirectiveElement* priorElement = nullptr;
     for (const format::DirectiveElement& row : catalog.directive_elements()) {
-        if (row.slotIndex >= slots.size() || slots[row.slotIndex].slotType != 68U
-            || row.elementIndex < 0
+        // An authored field has both source tags; an absent one has neither.
+        const bool descriptionPaired =
+            (row.descriptionContainerTag == 0) == (row.descriptionStringHash == 0);
+        const bool progressPaired =
+            (row.progressContainerTag == 0) == (row.progressStringHash == 0);
+        // Slot, name hash then element order, without repeats, backs the per-slot range lookup
+        // and makes every (slot, name hash, element) identity unique.
+        if (row.slotIndex >= slots.size()
+            || slots[row.slotIndex].slotType != format::kDirectiveSlotType || row.elementIndex < 0
             || static_cast<std::uint32_t>(row.elementIndex) >= row.elementCount
-            || row.titleContainerTag == 0 || row.titleStringHash == 0
-            || row.descriptionContainerTag == 0 || row.descriptionStringHash == 0) {
+            || row.titleContainerTag == 0 || row.titleStringHash == 0 || !descriptionPaired
+            || !progressPaired
+            || (row.descriptionContainerTag == 0 && row.progressContainerTag == 0)
+            || (priorElement != nullptr
+                && std::tie(row.slotIndex, row.nameHash, row.elementIndex)
+                       <= std::tie(priorElement->slotIndex,
+                                   priorElement->nameHash,
+                                   priorElement->elementIndex))) {
             return false;
         }
+        priorElement = &row;
     }
     return true;
 }
@@ -599,14 +648,16 @@ 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, 8> kChecks{{{"squad_profiles", &squad_profiles},
+    static constexpr std::array<Check, 8> kChecks{
+        {{"authored_scene_event_keys", &authored_scene_event_keys},
+         {"squad_profiles", &squad_profiles},
                                                    {"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}}};
+         {"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);

+ 16 - 5
Sunrise/src/state/activity_sdk/activity_sdk_structure_validation.cpp

@@ -21,6 +21,20 @@ template <typename Row, typename Id>
     return true;
 }
 
+/** @return True when every directive element has a valid description or progress label. */
+[[nodiscard]] bool directive_texts(const Catalog& catalog) noexcept {
+    for (const format::DirectiveElement& row : catalog.directive_elements()) {
+        const std::string_view description = catalog.string(row.description);
+        const std::string_view progress = catalog.string(row.progress);
+        if ((description.empty() && progress.empty())
+            || description.find('\0') != std::string_view::npos
+            || progress.find('\0') != std::string_view::npos) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** @return True when every activity's row ranges stay inside the sections they index. */
 [[nodiscard]] bool activity_ranges(const Catalog& catalog) noexcept {
     for (const format::Activity& row : catalog.activities()) {
@@ -131,11 +145,8 @@ bool structure(const Catalog& catalog) {
                catalog.directive_elements(), catalog, [](const auto& row) { return row.id; })
            && required_ids(
                catalog.directive_elements(), catalog, [](const auto& row) { return row.title; })
-           && required_ids(catalog.directive_elements(),
-                           catalog,
-                           [](const auto& row) { return row.description; })
-           && activity_ranges(catalog) && topology_ranges(catalog) && behavior_ranges(catalog)
-           && runtime_ranges(catalog);
+           && directive_texts(catalog) && activity_ranges(catalog) && topology_ranges(catalog)
+           && behavior_ranges(catalog) && runtime_ranges(catalog);
 }
 
 } // namespace sunrise::state::activity_sdk::validation

+ 87 - 11
Sunrise/src/state/activity_sdk/format.h

@@ -10,9 +10,9 @@ 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 = 41;
+inline constexpr std::uint32_t kVersion = 42;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
-inline constexpr std::uint32_t kSectionCount = 50;
+inline constexpr std::uint32_t kSectionCount = 51;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
 /** The runtime accepts only the checked generated SDK build. Each pin is a SHA-256 digest. */
 inline constexpr std::array<std::byte, 32> kExpectedSdkBuildDigest{
@@ -80,9 +80,16 @@ inline constexpr std::uint32_t kSlotReaderVerified = 0x1U;
 inline constexpr std::uint32_t kSlotSchemaJoinExact = 0x2U;
 /** A type-53 descriptor resolved an exact authored cue list and bounded cue count. */
 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. */
 inline constexpr std::uint32_t kObjectSlotType = 4U;
 inline constexpr std::uint32_t kObjectComponentClass = 0x80809927U;
@@ -108,6 +115,19 @@ inline constexpr std::uint32_t kOccupancyAuthSchema = 0x80809532U;
 inline constexpr std::uint32_t kDirectiveSlotType = 68U;
 inline constexpr std::uint32_t kDirectiveComponentClass = 0x80804F53U;
 inline constexpr std::uint32_t kDirectiveAuthSchema = 0x80804F67U;
+/** Packed directive table layout; only the counter bit of an element's flag word is named. */
+inline constexpr std::uint32_t kDirectiveTableClass = 0x80804F72U;
+inline constexpr std::uint32_t kDirectiveEntryArrayOffset = 0x8U;
+inline constexpr std::uint32_t kDirectiveEntryClass = 0x80804F74U;
+inline constexpr std::uint32_t kDirectiveEntrySize = 0x28U;
+inline constexpr std::uint32_t kDirectiveEntryElementsOffset = 0x18U;
+inline constexpr std::uint32_t kDirectiveElementClass = 0x80804F76U;
+inline constexpr std::uint32_t kDirectiveElementPackedSize = 0x24U;
+inline constexpr std::uint32_t kDirectiveElementTitleOffset = 0x0U;
+inline constexpr std::uint32_t kDirectiveElementDescriptionOffset = 0x8U;
+inline constexpr std::uint32_t kDirectiveElementProgressOffset = 0x10U;
+inline constexpr std::uint32_t kDirectiveElementFlagsOffset = 0x20U;
+inline constexpr std::uint32_t kDirectiveElementCounter = 0x1U;
 /** Exact generated slot tuple and descriptor field for authored dialogue. */
 inline constexpr std::uint32_t kDialogueSlotType = 53U;
 inline constexpr std::uint32_t kDialogueComponentClass = 0x80804F4BU;
@@ -268,6 +288,27 @@ inline constexpr std::uint32_t kAuthoredSceneSenseSchema = 0x8080626AU;
 inline constexpr std::uint32_t kAuthoredSceneAuthSchema = 0x8080626BU;
 inline constexpr std::uint32_t kAuthoredSceneResourceRelativeOffset = 0x60U;
 inline constexpr std::uint32_t kAuthoredSceneResourceClass = 0x80809C0FU;
+/**
+ * The resource names its graph at a fixed field. The graph's gate table is an array field whose
+ * rows each name one gate body by absolute offset. A body opens with the graph's tag and the row
+ * class, then carries the event key and the gate's ordinal (-1 opens every scene).
+ */
+inline constexpr std::uint32_t kAuthoredSceneGraphRelativeOffset = 0xC0U;
+inline constexpr std::uint32_t kAuthoredSceneGraphClass = 0x80809C36U;
+inline constexpr std::uint32_t kPackageArrayMarker = 0x80809FBDU;
+inline constexpr std::uint32_t kAuthoredSceneGateTableOffset = 0xE8U;
+inline constexpr std::uint32_t kAuthoredSceneGateRowClass = 0x8080637DU;
+inline constexpr std::uint32_t kAuthoredSceneGateRowSize = 0xC0U;
+inline constexpr std::uint32_t kAuthoredSceneGateBodyClass = 0x8080638AU;
+inline constexpr std::uint32_t kAuthoredSceneGateBodyOffset = 0x8U;
+inline constexpr std::uint32_t kAuthoredSceneGateSize = 0x60U;
+inline constexpr std::uint32_t kAuthoredSceneGateClassOffset = 0x4U;
+inline constexpr std::uint32_t kAuthoredSceneGateKeyOffset = 0x10U;
+inline constexpr std::uint32_t kAuthoredSceneGateOrdinalOffset = 0x58U;
+/** More gates than any installed scene declares; a larger count is a misread graph. */
+inline constexpr std::uint32_t kAuthoredSceneGateCapacity = 64U;
+inline constexpr std::uint32_t kAuthoredSceneEventKeyExact = 0x1U;
+inline constexpr std::uint32_t kAuthoredSceneEventKeyFlagMask = kAuthoredSceneEventKeyExact;
 /** Scene-to-squad rows retain one exact same-object package reference. */
 inline constexpr std::uint32_t kAuthoredSceneSquadSameObjectExact = 0x1U;
 /** A type-42 performance sensor names the same-object squad it drives at descriptor +0x58. */
@@ -295,9 +336,22 @@ inline constexpr std::uint32_t kActorSequenceLocalDefinitionClass = 0x8080815FU;
 inline constexpr std::uint32_t kActorSequenceGlobalArrayOffset = 152;
 inline constexpr std::uint32_t kActorSequenceLocalArrayOffset = 16;
 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 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. */
 inline constexpr std::uint32_t kTaskSlotType = 38U;
 inline constexpr std::uint32_t kTaskComponentClass = 0x80807D87U;
@@ -369,7 +423,7 @@ inline constexpr std::int64_t kAbsentSignedValue = (-0x7FFFFFFFFFFFFFFFLL - 1);
 
 /** Fixed packed byte sizes make producer and consumer ABI drift fail at compile time. */
 inline constexpr std::size_t kSectionSize = 16;
-inline constexpr std::size_t kHeaderSize = 960;
+inline constexpr std::size_t kHeaderSize = 976;
 inline constexpr std::size_t kStringRefSize = 8;
 inline constexpr std::size_t kRangeSize = 8;
 inline constexpr std::size_t kActivitySize = 124;
@@ -393,10 +447,11 @@ inline constexpr std::size_t kSquadSize = 52;
 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 kAuthoredSceneEventKeySize = 40;
 inline constexpr std::size_t kAuthoredSceneSquadEdgeSize = 40;
 inline constexpr std::size_t kTaskTargetSize = 44;
 inline constexpr std::size_t kDialogueCueTextSize = 36;
-inline constexpr std::size_t kDirectiveElementSize = 56;
+inline constexpr std::size_t kDirectiveElementSize = 76;
 inline constexpr std::size_t kBehaviorProgramSize = 28;
 inline constexpr std::size_t kBehaviorInputSize = 36;
 inline constexpr std::size_t kBehaviorChannelWriteSize = 16;
@@ -710,9 +765,11 @@ enum class SectionIndex : std::uint32_t {
     combatObjectiveGroups,
     actorAbilities,
     actorAbilityTargets,
+    authoredSceneEventKeys,
 };
 
-static_assert(static_cast<std::uint32_t>(SectionIndex::actorAbilityTargets) + 1 == kSectionCount);
+static_assert(static_cast<std::uint32_t>(SectionIndex::authoredSceneEventKeys) + 1
+              == kSectionCount);
 
 /** Exact activity-name/root join result retained for every activity row. */
 enum class ActivityJoinStatus : std::uint32_t {
@@ -1130,6 +1187,20 @@ struct AuthoredSceneResource final {
     std::uint32_t reserved{};
 };
 
+/** One event gate of a type-43 scene's graph, in the scene's gate order. */
+struct AuthoredSceneEventKey final {
+    StringRef id{};
+    std::uint32_t sceneSlotIndex{};
+    std::uint32_t resourceTag{};
+    std::uint32_t graphTag{};
+    /** Offset of the gate element inside the graph, the row's provenance. */
+    std::uint32_t gateOffset{};
+    std::int32_t ordinal{};
+    std::uint32_t key{};
+    std::uint32_t flags{};
+    std::uint32_t reserved{};
+};
+
 /** One type-43 slot retains an exact same-object reference to one type-1 squad slot. */
 struct AuthoredSceneSquadEdge final {
     StringRef id{};
@@ -1205,7 +1276,7 @@ struct DialogueCueText final {
     std::uint32_t stringHash{};
 };
 
-/** One bounded authored type-68 HUD element with its exact title and description fields. */
+/** One bounded authored type-68 HUD element; an absent field is empty with zero source tags. */
 struct DirectiveElement final {
     StringRef id{};
     StringRef title{};
@@ -1218,6 +1289,10 @@ struct DirectiveElement final {
     std::uint32_t titleStringHash{};
     std::uint32_t descriptionContainerTag{};
     std::uint32_t descriptionStringHash{};
+    StringRef progress{};
+    std::uint32_t progressContainerTag{};
+    std::uint32_t progressStringHash{};
+    std::uint32_t flags{};
 };
 
 /** One installed compiled behavior root and its complete local channel-edge ranges. */
@@ -1512,6 +1587,7 @@ static_assert(sizeof(Squad) == kSquadSize);
 static_assert(sizeof(SquadMember) == kSquadMemberSize);
 static_assert(sizeof(SquadAnchor) == kSquadAnchorSize);
 static_assert(sizeof(AuthoredSceneResource) == kAuthoredSceneResourceSize);
+static_assert(sizeof(AuthoredSceneEventKey) == kAuthoredSceneEventKeySize);
 static_assert(sizeof(AuthoredSceneSquadEdge) == kAuthoredSceneSquadEdgeSize);
 static_assert(sizeof(TaskTarget) == kTaskTargetSize);
 static_assert(sizeof(DialogueCueText) == kDialogueCueTextSize);

+ 2 - 0
Sunrise/src/state/activity_sdk/generation/activity_sdk_pack_writer.cpp

@@ -71,6 +71,7 @@ constexpr std::array<std::uint32_t, format::kSectionCount> kSectionStrides{
     sizeof(format::CombatObjectiveGroup),
     sizeof(format::ActorAbility),
     sizeof(format::ActorAbilityTarget),
+    sizeof(format::AuthoredSceneEventKey),
 };
 /** Writer-owned siblings use the cleanup service's final.process.thread.sequence.tmp shape. */
 constexpr std::wstring_view kTemporarySuffix = L".%08lX.%08lX.%08lX.tmp";
@@ -182,6 +183,7 @@ raw_sections(const Tables& tables) noexcept {
         rows(tables.combatObjectiveGroups),
         rows(tables.actorAbilities),
         rows(tables.actorAbilityTargets),
+        rows(tables.authoredSceneEventKeys),
     };
 }
 

+ 1 - 0
Sunrise/src/state/activity_sdk/generation/pack_writer.h

@@ -72,6 +72,7 @@ struct Tables final {
     std::span<const format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::span<const format::ActorAbility> actorAbilities{};
     std::span<const format::ActorAbilityTarget> actorAbilityTargets{};
+    std::span<const format::AuthoredSceneEventKey> authoredSceneEventKeys{};
 };
 
 /** Canonical pack build and publication outcomes. */

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

@@ -248,6 +248,8 @@ public:
     actor_sequence_entries() const noexcept;
     [[nodiscard]] std::span<const format::ActorSequenceBinding>
     actor_sequence_bindings() const noexcept;
+    [[nodiscard]] std::span<const format::AuthoredSceneEventKey>
+    authored_scene_event_keys() const noexcept;
 
 private:
     friend bool load(void* module,
@@ -509,6 +511,9 @@ slot_authored_scene_resources(const Catalog& catalog, const format::Slot& slot)
 /** Returns all exact same-object squad edges owned by one type-43 slot. */
 [[nodiscard]] std::span<const format::AuthoredSceneSquadEdge>
 slot_authored_scene_squad_edges(const Catalog& catalog, const format::Slot& slot) noexcept;
+/** Returns every HUD element of one type-68 slot, in name hash then element order. */
+[[nodiscard]] std::span<const format::DirectiveElement>
+slot_directive_elements(const Catalog& catalog, const format::Slot& slot) noexcept;
 /** Resolves one validated scene edge to its exact type-1 target slot. */
 [[nodiscard]] const format::Slot*
 authored_scene_linked_squad_slot(const Catalog& catalog,

+ 5 - 3
Sunrise/src/state/build_data/abilities/definition.h

@@ -14,10 +14,12 @@ inline constexpr std::size_t kBucketHashCapacity = 16;
 inline constexpr std::size_t kOverflowCapacity = 32;
 /**
  * One row per distinct subclass and ability selection the configured characters use. Each
- * character publishes a row for every subclass it owns at the default selection, plus one for the
- * equipped subclass's real picks when those differ: 3 characters by up to 4 rows.
+ * character publishes a row for every subclass it owns at the default selection, plus one for
+ * every one of those subclasses whose remembered picks differ, since the picks are kept per
+ * subclass item rather than only for the equipped one: 3 characters by 3 owned subclasses by 2
+ * rows.
  */
-inline constexpr std::size_t kDefinitionCapacity = 12;
+inline constexpr std::size_t kDefinitionCapacity = 18;
 /** All bits set marks a bucket no entry claimed. */
 inline constexpr std::uint8_t kEmptyBucketKind = 0xFF;
 

+ 27 - 6
Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp

@@ -74,28 +74,49 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no
     return true;
 }
 
-/** Encodes one vendor sale row. */
+/** Encodes one vendor sale row. Unused cost entries stay zero so the packed row always matches. */
 bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept {
     record = {};
+    if (value.costCount > value.costs.size()) {
+        return false;
+    }
     record.itemIndex = value.itemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.categoryIndex = value.categoryIndex;
-    record.costQuantity = value.costQuantity;
-    record.costItemIndex = value.costItemIndex;
+    record.costCount = value.costCount;
+    record.priceState = static_cast<std::uint8_t>(value.priceState);
+    for (std::size_t cost = 0; cost < value.costCount; ++cost) {
+        record.costs[cost].itemIndex = value.costs[cost].itemIndex;
+        record.costs[cost].quantity = value.costs[cost].quantity;
+    }
     return true;
 }
 
 /** Decodes one vendor sale row. */
 bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept {
     value = {};
-    if (record.reserved != decltype(record.reserved){}) {
+    if (record.reserved != decltype(record.reserved){} || record.costCount > record.costs.size()
+        || record.priceState > static_cast<std::uint8_t>(vendors::PriceState::unreadable)) {
         return false;
     }
+    for (std::size_t cost = 0; cost < record.costs.size(); ++cost) {
+        const VendorSaleCostRecord& stored = record.costs[cost];
+        if (stored.reserved != 0) {
+            return false;
+        }
+        if (cost >= record.costCount) {
+            if (stored.itemIndex != 0 || stored.quantity != 0) {
+                return false;
+            }
+            continue;
+        }
+        value.costs[cost] = {stored.itemIndex, stored.quantity};
+    }
     value.itemIndex = record.itemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.categoryIndex = record.categoryIndex;
-    value.costQuantity = record.costQuantity;
-    value.costItemIndex = record.costItemIndex;
+    value.costCount = record.costCount;
+    value.priceState = static_cast<vendors::PriceState>(record.priceState);
     return true;
 }
 

+ 18 - 5
Sunrise/src/state/build_data/cache/records/format.h

@@ -31,7 +31,7 @@ namespace sunrise::state::build_data::cache::records {
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 /** Bump when stored layouts or extracted values change; other versions are rebuilt. */
-inline constexpr std::uint32_t kCacheFormatVersion = 65;
+inline constexpr std::uint32_t kCacheFormatVersion = 66;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -546,15 +546,25 @@ struct VendorDefinitionRecord {
     std::uint16_t thirdCount{};
 };
 
+/** Disk form of one cost entry of a vendor sale row. */
+struct VendorSaleCostRecord {
+    std::uint32_t quantity{};
+    std::uint16_t itemIndex{};
+    /** Must be zero, so the packed cost entry always matches. */
+    std::uint16_t reserved{};
+};
+
 /** Disk form of one vendor sale row. */
 struct VendorSaleRowRecord {
     std::int32_t categoryIndex{};
-    std::uint32_t costQuantity{};
+    std::array<VendorSaleCostRecord, vendors::kSaleCostCapacity> costs{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
-    std::uint16_t costItemIndex{};
+    std::uint8_t costCount{};
+    /** `vendors::PriceState`, stored as its underlying value. */
+    std::uint8_t priceState{};
     /** Must be zero, so the packed sale row always matches. */
-    std::uint16_t reserved{};
+    std::array<std::uint8_t, 2> reserved{};
 };
 
 /** Disk form of one vendor category row. */
@@ -588,7 +598,10 @@ static_assert(sizeof(SpawnPointRecord)
 static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(VendorDefinitionRecord)
               == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));
-static_assert(sizeof(VendorSaleRowRecord) == 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t));
+static_assert(sizeof(VendorSaleCostRecord) == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
+static_assert(sizeof(VendorSaleRowRecord)
+              == sizeof(std::int32_t) + vendors::kSaleCostCapacity * sizeof(VendorSaleCostRecord)
+                     + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
 static_assert(sizeof(VendorInstalledRowRecord) == sizeof(std::uint32_t));
 static_assert(sizeof(HashNameRecord)
               == hash_names::kNameLength + sizeof(std::uint32_t) + 4 * sizeof(std::uint8_t));

+ 52 - 7
Sunrise/src/state/build_data/vendors/definition.h

@@ -1,7 +1,9 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
+#include <span>
 
 namespace sunrise::state::build_data::vendors {
 
@@ -22,9 +24,9 @@ inline constexpr std::size_t kSaleRowStride = 184;
 inline constexpr std::size_t kInstalledRowStride = 24;
 /** One interaction row is 80 bytes. */
 inline constexpr std::size_t kThirdRowStride = 80;
-/** One price-override row is 48 bytes: the cost item index, then the units it charges. */
+/** One cost entry is 48 bytes. `SaleCost` documents its layout. */
 inline constexpr std::size_t kSaleCostRowStride = 48;
-/** Element class of a sale row's price-override array. */
+/** Element class of a sale row's cost array. */
 inline constexpr std::uint32_t kSaleCostRowClass = 0x80807865U;
 
 /** Wrapper class of the vendor index blob. */
@@ -83,23 +85,66 @@ struct Definition {
     std::uint16_t thirdCount{};
 };
 
-/** A sale row charging nothing carries this instead of a cost item. */
+/** A cost entry naming no item carries this. */
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
+/** Cost entries one sale row may declare. The widest row in the installed catalog declares four. */
+inline constexpr std::size_t kSaleCostCapacity = 4;
+/**
+ * Cost entry +40 in every expression-free entry of the installed catalog. Its role is not
+ * decoded, so an entry carrying anything else is conditional rather than priced statically.
+ */
+inline constexpr std::uint32_t kPlainCostWord = 100'000U;
+
+/**
+ * One cost entry of a sale row (row +32 array, `kSaleCostRowClass`, 48 bytes), reduced to its
+ * static item and quantity. The entry also carries two expression arrays, at +8 and +24, and a
+ * word at +40; those decide whether the static quantity is the price at all, and the answer is
+ * kept on the row as its `PriceState`. On Xûr's definition every entry is static: item 128 with
+ * 29, 23, 97 and 9 units, the Legendary Shard prices of his weapons, armour, Fated Engram and
+ * Invitation of the Nine.
+ */
+struct SaleCost {
+    /** Entry +0. Cost item-definition index. */
+    std::uint16_t itemIndex{kAbsentCostItem};
+    /** Entry +4. Units charged. */
+    std::uint32_t quantity{};
+};
+
+/** Whether a sale row's static cost entries are its price. */
+enum class PriceState : std::uint8_t {
+    /** Every entry is expression-free with the plain word: the static quantities are the price. */
+    plain,
+    /**
+     * An entry carries an expression, or a word other than `kPlainCostWord`. Its price depends
+     * on state this build does not evaluate, so the row cannot be bought.
+     */
+    conditional,
+    /** The row's cost array did not read. The row keeps its item and category and cannot be bought.
+     */
+    unreadable,
+};
 
 /** One sale row of one vendor definition. */
 struct SaleRow {
     /** Row +100. The row's vendor category. The catalog bounds it by the category count. */
     std::int32_t categoryIndex{};
-    /** First price-override row's charged units. Zero when the row charges nothing. */
-    std::uint32_t costQuantity{};
     /** Row +70. Main sale item-definition index. */
     std::uint16_t itemIndex{};
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     std::uint16_t secondaryItemIndex{};
-    /** First price-override row's item, or `kAbsentCostItem` when the row charges nothing. */
-    std::uint16_t costItemIndex{kAbsentCostItem};
+    /** Every cost entry, in declared order. A plain row charges all of them together. */
+    std::array<SaleCost, kSaleCostCapacity> costs{};
+    /** Entries of `costs` in use. Zero when the row charges nothing. */
+    std::uint8_t costCount{};
+    /** Whether `costs` is the price. Only a plain row is charged. */
+    PriceState priceState{PriceState::plain};
 };
 
+/** @return The static cost entries of one sale row, which are its price only while it is plain. */
+[[nodiscard]] inline std::span<const SaleCost> cost_entries(const SaleRow& row) noexcept {
+    return {row.costs.data(), row.costCount > row.costs.size() ? std::size_t{0} : row.costCount};
+}
+
 /** One category row, reduced to the definition hash a rowless request resolves through. */
 struct InstalledRow {
     std::uint32_t definitionHash{};

+ 7 - 2
Sunrise/src/state/build_data/vendors/vendor_catalog.cpp

@@ -84,7 +84,8 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
  * Checks the sale rows one definition owns.
  * @param definition Owning definition.
  * @param saleRows Complete flat sale bank.
- * @return True when every row names a category of this definition, or none at all.
+ * @return True when every row names a category of this definition, or none at all, and its cost
+ *         entries fit their state: an unreadable row has none.
  */
 [[nodiscard]] bool canonical_sale_rows(const Definition& definition,
                                        std::span<const SaleRow> saleRows) noexcept {
@@ -94,7 +95,11 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
         const bool selects =
             value.categoryIndex == kAbsentCategoryIndex
             || (value.categoryIndex >= 0 && value.categoryIndex < definition.installedCount);
-        if (!selects) {
+        const bool priced =
+            value.costCount <= value.costs.size()
+            && (value.priceState == PriceState::plain || value.priceState == PriceState::conditional
+                || (value.priceState == PriceState::unreadable && value.costCount == 0));
+        if (!selects || !priced) {
             return false;
         }
     }

+ 18 - 9
Sunrise/src/state/runtime/runtime.h

@@ -3,11 +3,13 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <optional>
 #include <span>
 #include <variant>
 
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
+#include "../build_data/vendors/definition.h"
 #include "state.h"
 
 namespace sunrise::state::account::settings {
@@ -520,14 +522,18 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool
  * Native-default sockets, a unique runtime SOID, and the selected character's current item level
  * are used. Full loadout resolution is the authoritative bucket-capacity check.
  *
- * @param collectibleIndex Collections row the Client pulled from.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
  * @param definitionHash Installed item definition requested by the Client.
+ * @param price A vendor sale row's cost entries, spent in place of the collectible's material
+ *        set; absent for a Collections pull, which pays with the collectible's materials.
  * @param mutation Gets a checked after-image without changing account State.
  * @return True when the item and every existing loadout row resolve with one free native row.
  */
-[[nodiscard]] bool prepare_item_acquisition(std::uint16_t collectibleIndex,
-                                            std::uint32_t definitionHash,
-                                            PendingItemAcquisition& mutation) noexcept;
+[[nodiscard]] bool
+prepare_item_acquisition(std::uint16_t collectibleIndex,
+                         std::uint32_t definitionHash,
+                         std::optional<std::span<const build_data::vendors::SaleCost>> price,
+                         PendingItemAcquisition& mutation) noexcept;
 
 /** Prepares a direct character-item grant without a Collections charge. */
 [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
@@ -591,15 +597,18 @@ reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexce
  * An existing non-full stack is incremented. Otherwise a new dense State entry is appended only
  * when the installed profile bucket still owns a free native row.
  *
- * @param collectibleIndex Collections row the Client pulled from.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
  * @param definitionHash Installed stackable definition requested by the Client.
+ * @param price A vendor sale row's cost entries, spent in place of the collectible's material
+ *        set; absent for a Collections pull, which pays with the collectible's materials.
  * @param mutation Gets the checked profile before/after images without changing account State.
  * @return True when the definition belongs to the main profile array and one unit fits.
  */
-[[nodiscard]] bool
-prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                 std::uint32_t definitionHash,
-                                 PendingProfileItemAcquisition& mutation) noexcept;
+[[nodiscard]] bool prepare_profile_item_acquisition(
+    std::uint16_t collectibleIndex,
+    std::uint32_t definitionHash,
+    std::optional<std::span<const build_data::vendors::SaleCost>> price,
+    PendingProfileItemAcquisition& mutation) noexcept;
 
 /** Prepares a direct profile-stack grant without a Collections charge. */
 [[nodiscard]] bool

+ 34 - 14
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -165,18 +165,20 @@ using Quest = build_data::items::QuestInitialization;
  * Inventory and quest state must come from the same locked save view.
  * @param collectibleIndex Collections row, or kNoCollectibleIndex for an item-only grant.
  * @param definitionHash Item definition to grant.
+ * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
  * @param mutation Receives a pending grant; prepared is set only on success.
  * @return False when identity, costs, capacity, or saved state prevent the grant.
  */
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
                               std::uint32_t definitionHash,
+                              std::optional<std::span<const build_data::vendors::SaleCost>> price,
                               PendingItemAcquisition& mutation) noexcept {
     const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::items::Definition grantedDefinition{};
-    // A vendor purchase names an item, not a collectible, so the collectible steps are skipped
+    // A vendor row may name an item with no collectible, so the collectible steps are skipped
     // rather than faked. The item is still validated, just by its own hash.
     const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
@@ -199,13 +201,19 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 
     AccountState chargedAccount = account;
     bool profileChanged = false;
-    // Nothing is charged without a collectible: the cost lives on the collectible's material
-    // requirements, and a sale row's own cost fields are still role-open.
-    if (hasCollectible
-        && !apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
+    // A vendor row pays its own price, whatever its collectible would charge from Collections;
+    // a Collections pull pays with the collectible's materials, and an item-only grant is free.
+    const bool paid = price.has_value()
+                          ? apply_sale_price(account, *price, chargedAccount, profileChanged)
+                          : !hasCollectible
+                                || apply_collection_materials(
+                                    account, collectible, chargedAccount, profileChanged);
+    if (!paid) {
         return false;
     }
 
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those; a sale
+    // price is proven only by its before/after profile images.
     return finalize_item_acquisition(
         account,
         chargedAccount,
@@ -711,10 +719,15 @@ finalize_profile_item_acquisition(const AccountState& account,
 
 } // namespace runtime::detail
 
-/** Prepares one checked profile-stack increment or append for a Collections pull. */
-bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                      std::uint32_t definitionHash,
-                                      PendingProfileItemAcquisition& mutation) noexcept {
+/**
+ * Prepares one checked profile-stack increment or append.
+ * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
+ */
+bool prepare_profile_item_acquisition(
+    std::uint16_t collectibleIndex,
+    std::uint32_t definitionHash,
+    std::optional<std::span<const build_data::vendors::SaleCost>> price,
+    PendingProfileItemAcquisition& mutation) noexcept {
     mutation = {};
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
@@ -736,16 +749,22 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     }
     AccountState chargedAccount = account;
     bool materialsChanged = false;
-    // Nothing is charged without a collectible: the cost lives on the collectible's material
-    // requirements, and a sale row's own cost fields are still role-open.
-    if (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
-        && !apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
+    // A vendor row pays its own price, whatever its collectible would charge from Collections;
+    // a Collections pull pays with the collectible's materials, and an item-only grant is free.
+    const bool paid = price.has_value()
+                          ? apply_sale_price(account, *price, chargedAccount, materialsChanged)
+                          : collectibleIndex == build_data::collectibles::kNoCollectibleIndex
+                                || apply_collection_materials(
+                                    account, collectible, chargedAccount, materialsChanged);
+    if (!paid) {
         return false;
     }
     (void)materialsChanged;
     const bool actionSource =
         build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
 
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those; a sale
+    // price is proven only by its before/after profile images.
     return finalize_profile_item_acquisition(
         account,
         chargedAccount,
@@ -792,7 +811,8 @@ bool preview_profile_item_acquisition(const PendingProfileItemAcquisition& mutat
     return materialize_profile_acquisition(current, mutation, after);
 }
 
-/** Commits one profile-stack after-image only while its exact prepare-time view remains current. */
+/** Commits one profile-stack after-image only while its exact prepare-time view remains
+ * current. */
 bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcept {
     const PendingProfileItemAcquisition& prepared = mutation;
     const PendingConsumption consume{mutation};

+ 65 - 0
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -9,6 +9,7 @@
 #include <string_view>
 #include <utility>
 
+#include "../../core/logging/log.h"
 #include "../build_data/runtime.h"
 #include "runtime.h"
 #include "state_account_transaction_helpers.h"
@@ -294,6 +295,70 @@ apply_collection_materials(const AccountState& before,
         changed);
 }
 
+/**
+ * Writes what a refused price asked for, entry by entry, against what the profile holds.
+ * A held count of -1 names an entry whose item is not installed, which the engine cannot debit.
+ */
+static void report_price_refusal(const AccountState& account,
+                                 std::span<const build_data::vendors::SaleCost> price) noexcept {
+    for (const build_data::vendors::SaleCost& entry : price) {
+        build_data::items::Definition item{};
+        std::int64_t held = -1;
+        if (build_data::find_item_definition_index(entry.itemIndex, item)) {
+            held = 0;
+            for (std::size_t index = 0; index < account.profileItemCount; ++index) {
+                if (account.profileItems[index].definitionHash == item.definitionHash) {
+                    held += account.profileItems[index].quantity;
+                }
+            }
+        }
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=vendor stage=price result=fail item=%u quantity=%u held=%lld",
+                          static_cast<unsigned>(entry.itemIndex),
+                          entry.quantity,
+                          static_cast<long long>(held));
+    }
+}
+
+/**
+ * Spends one vendor sale row's price through the engine that spends Collections materials, so a
+ * cost item is validated and debited by the same rules: a stackable profile item, every stack
+ * summed, refused when short, then debited and compacted.
+ * @param price The row's cost entries; empty charges nothing.
+ * @param after Receives the charged account, or `before` again when refused.
+ * @return True when every entry was paid, or the price was empty.
+ */
+[[nodiscard]] bool apply_sale_price(const AccountState& before,
+                                    std::span<const build_data::vendors::SaleCost> price,
+                                    AccountState& after,
+                                    bool& changed) noexcept {
+    namespace materials = build_data::material_requirements;
+    after = before;
+    changed = false;
+    if (price.size() > build_data::vendors::kSaleCostCapacity) {
+        return false;
+    }
+    // Every entry is consumed on purchase; nothing in a sale row is a held-only requirement.
+    std::array<materials::Requirement, build_data::vendors::kSaleCostCapacity> requirements{};
+    for (std::size_t index = 0; index < price.size(); ++index) {
+        requirements[index] = {.quantity = price[index].quantity,
+                               .itemDefinitionIndex = price[index].itemIndex,
+                               .deleteOnAction = true};
+    }
+    if (apply_material_requirements(
+            before,
+            std::span<const materials::Requirement>{requirements.data(), price.size()},
+            after,
+            changed)) {
+        return true;
+    }
+    after = before;
+    changed = false;
+    report_price_refusal(before, price);
+    return false;
+}
+
 /** @return True when the account holds the requested socket-action source. */
 [[nodiscard]] bool holds_plug_source(const AccountState& account,
                                      std::uint32_t definitionHash) noexcept {

+ 10 - 0
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -4,6 +4,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <memory>
+#include <span>
 #include <string_view>
 #include <type_traits>
 
@@ -99,6 +100,15 @@ apply_collection_materials(const AccountState& before,
                            const build_data::collectibles::Definition& collectible,
                            AccountState& after,
                            bool& changed) noexcept;
+/**
+ * Spends one vendor sale row's price through the same engine Collections materials use.
+ * @param price The row's cost entries; empty charges nothing.
+ * @return True when every entry was paid, or the price was empty.
+ */
+[[nodiscard]] bool apply_sale_price(const AccountState& before,
+                                    std::span<const build_data::vendors::SaleCost> price,
+                                    AccountState& after,
+                                    bool& changed) noexcept;
 [[nodiscard]] bool
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept;
 [[nodiscard]] bool materialize_profile_acquisition(const AccountState& current,