Browse Source

Merge pull request #122 from Fozkais/sdk-scene-keys-and-directive-elements

SDK: record scene event keys and counter directive elements, carry directive progress
stan 2 days ago
parent
commit
3a8cb6a2c4
32 changed files with 788 additions and 142 deletions
  1. 12 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h
  2. 182 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp
  3. 20 1
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h
  4. 25 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp
  5. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h
  6. 3 2
      Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp
  7. 15 1
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  8. 36 2
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  9. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  10. 2 1
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  11. 128 29
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  12. 7 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp
  13. 1 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.h
  14. 22 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  15. 1 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_validate.cpp
  16. 16 11
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  17. 4 0
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  18. 8 8
      Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp
  19. 15 0
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  20. 5 0
      Sunrise/src/server/activity/activity_sdk_mission_runtime.h
  21. 27 2
      Sunrise/src/server/activity/mission/mission_script_lua_presentation_api.cpp
  22. 1 0
      Sunrise/src/server/activity/mission/mission_script_sdk_bridge.cpp
  23. 2 0
      Sunrise/src/server/activity/mission/mission_script_vm.h
  24. 80 61
      Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp
  25. 25 0
      Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp
  26. 1 0
      Sunrise/src/state/activity_sdk/activity_sdk_loader.cpp
  27. 62 11
      Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp
  28. 16 5
      Sunrise/src/state/activity_sdk/activity_sdk_structure_validation.cpp
  29. 62 6
      Sunrise/src/state/activity_sdk/format.h
  30. 2 0
      Sunrise/src/state/activity_sdk/generation/activity_sdk_pack_writer.cpp
  31. 1 0
      Sunrise/src/state/activity_sdk/generation/pack_writer.h
  32. 5 0
      Sunrise/src/state/activity_sdk/runtime.h

+ 12 - 0
Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h

@@ -95,6 +95,11 @@ is_performance_descriptor(const topology::Snapshot& topology,
     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. */
 [[nodiscard]] inline auto task_natural(const TaskTarget& row) noexcept {
     return std::tie(row.taskSlotIndex, row.configTag, row.descriptorOffset);
@@ -176,6 +181,13 @@ schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex
                                const squad::DescriptorFact& descriptor,
                                Text& output) noexcept;
 
+/** 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,

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

@@ -32,6 +32,25 @@ bool resource_id(const topology::Snapshot& topology,
                        static_cast<unsigned>(slot.slotType));
 }
 
+/** 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,
@@ -219,6 +238,59 @@ void log_scene_resource(const squad::DescriptorFact& descriptor,
     }
 }
 
+/** 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,
@@ -312,6 +384,94 @@ void log_scene_resource(const squad::DescriptorFact& descriptor,
     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. */
@@ -525,6 +685,16 @@ 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;
+                    }
                 }
             }
 
@@ -635,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(),
@@ -644,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) {

+ 20 - 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,6 +118,8 @@ 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{};

+ 25 - 0
Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp

@@ -227,6 +227,31 @@ bool validate(const topology::Snapshot& topology,
             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 =

+ 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",

+ 15 - 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)) {
@@ -314,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.
@@ -360,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
@@ -408,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

+ 36 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp

@@ -394,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);
@@ -583,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 = ");
@@ -597,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");
         }
     }

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

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

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

@@ -212,7 +212,8 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             storage.actorSequenceBindings,
             worldSources,
             storage.combatObjectiveGroups,
-            storage.actorAbilities};
+            storage.actorAbilities,
+            storage.authoredSceneEventKeys};
 }
 
 } // namespace

+ 128 - 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;
                         }
-                        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.description)) {
+                            log_directive_element(descriptor.slotIndex,
+                                                  nameHash,
+                                                  element,
+                                                  candidate.flags,
+                                                  "description");
+                            continue;
+                        }
+                        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) {

+ 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

@@ -631,6 +631,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];
@@ -694,8 +711,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)();

+ 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

+ 8 - 8
Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp

@@ -624,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;
-    }
-    return matches == 1 ? SceneStatus::ready : SceneStatus::invalidSlot;
+    // 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 SceneStatus::invalidSlot;
 }
 
 /** Resolves one exact type-3 objective sensor without changing transport state. */

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

@@ -178,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,

+ 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,

+ 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. */

+ 80 - 61
Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp

@@ -528,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) {
@@ -538,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) {

+ 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

@@ -21,6 +21,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();
@@ -128,14 +162,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;
 }
@@ -582,13 +631,15 @@ bool relations(const Catalog& catalog) {
         bool (*run)(const Catalog&) noexcept;
     };
     // Every relation check a catalog must pass, named so a refusal reports which one failed.
-    static constexpr std::array<Check, 7> kChecks{{{"task_targets", &task_targets},
-                                                   {"authored_text", &authored_text},
-                                                   {"behavior_edges", &behavior_edges},
-                                                   {"actor_semantics", &actor_semantics},
-                                                   {"runtime_semantics", &runtime_semantics},
-                                                   {"sobject_semantics", &sobject_semantics},
-                                                   {"entity_types", &entity_types}}};
+    static constexpr std::array<Check, 8> kChecks{
+        {{"authored_scene_event_keys", &authored_scene_event_keys},
+         {"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}}};
     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

+ 62 - 6
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{
@@ -115,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;
@@ -273,6 +286,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. */
@@ -387,7 +421,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;
@@ -411,10 +445,11 @@ inline constexpr std::size_t kSquadSize = 52;
 inline constexpr std::size_t kSquadMemberSize = 44;
 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;
@@ -727,9 +762,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 {
@@ -1146,6 +1183,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{};
@@ -1221,7 +1272,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{};
@@ -1234,6 +1285,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. */
@@ -1528,6 +1583,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,