Przeglądaj źródła

Publish authored dialogue definitions in the SDK pack and mission Lua

Every cue of an authored type-53 list now has a row in a new dialogue cue
section: its definition hash, its authored duration in milliseconds, its line
count, and the list it was read from. The duration is present even when the
cue's content tree could not be matched; lines are only present when it was.
Line text rows carry their line and take index, audio tag and take duration,
and keep the order the lines play in.

Relation validation requires one row per cue for a slot that has any, and
text rows that name a line of their own cue. The generated mission Lua
publishes DialogueDefinition[cue] as { hash, duration_ms, lines }, and the host
dialogue view shows each cue's duration. Format version 39.
Fozkais 1 miesiąc temu
rodzic
commit
72677efe14

+ 18 - 2
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h

@@ -77,7 +77,19 @@ struct TaskTarget final {
     std::uint32_t reserved{};
 };
 
-/** One localized candidate found below an authored type-53 cue. */
+/** One type-53 cue read from the list its slot's descriptor references. */
+struct DialogueCue final {
+    Text id{};
+    std::uint32_t slotIndex{};
+    std::uint32_t cueIndex{};
+    std::uint32_t listTag{};
+    std::uint32_t definitionHash{};
+    std::uint32_t durationMs{};
+    std::uint32_t lineCount{};
+    std::uint32_t flags{};
+};
+
+/** One localized take of one line of a type-53 cue. */
 struct DialogueCueText final {
     Text id{};
     Text text{};
@@ -86,6 +98,10 @@ struct DialogueCueText final {
     std::uint32_t definitionHash{};
     std::uint32_t containerTag{};
     std::uint32_t stringHash{};
+    std::uint32_t lineIndex{};
+    std::uint32_t takeIndex{};
+    std::uint32_t audioTag{};
+    std::uint32_t durationMs{};
 };
 
 /** One exact type-68 HUD element and its authored localized fields; absent ones stay empty. */
@@ -122,8 +138,8 @@ struct Snapshot final {
     std::vector<EventKey> eventKeys{};
     std::vector<SquadEdge> squadEdges{};
     std::vector<TaskTarget> taskTargets{};
+    std::vector<DialogueCue> dialogueCues{};
     std::vector<DialogueCueText> dialogueCueTexts{};
-    std::vector<format::DialogueCue> dialogueCues{};
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::vector<DirectiveElement> directiveElements{};
     /**

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

@@ -147,6 +147,9 @@ namespace {
     for (std::uint32_t index = 0; index < source.taskTargets.size(); ++index) {
         output.tasksBySlot[source.taskTargets[index].taskSlotIndex].push_back(index);
     }
+    for (std::uint32_t index = 0; index < source.dialogueCues.size(); ++index) {
+        output.dialogueCuesBySlot.emplace(source.dialogueCues[index].slotIndex, index);
+    }
     for (std::uint32_t index = 0; index < source.dialogueCueTexts.size(); ++index) {
         output.dialogueBySlot[source.dialogueCueTexts[index].slotIndex].push_back(index);
     }

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

@@ -79,7 +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{};
+    std::span<const format::DialogueCue> dialogueCues{};
 };
 
 /** One readable generated Lua module with a filesystem-safe canonical name. */

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

@@ -27,7 +27,7 @@ constexpr std::uint32_t kBindingTagSectionIndex = 24;
 constexpr std::uint32_t kBindingLocatorSectionIndex = 25;
 
 /** Compact type:name tokens are the single native inventory for all raw pack rows. */
-constexpr std::array<RowSpec, 40> kRows{{
+constexpr std::array<RowSpec, 41> kRows{{
     {1,
      "activities",
      "activities",
@@ -168,7 +168,7 @@ constexpr std::array<RowSpec, 40> kRows{{
      "dialogue_cue_texts",
      "CatalogDialogueCueTextView",
      "s:id;s:text;u32:slot_index;u32:cue_index;u32:definition_hash;u32:container_tag;"
-     "u32:string_hash"},
+     "u32:string_hash;u32:line_index;u32:take_index;u32:audio_tag;u32:duration_ms"},
     {23,
      "directive_elements",
      "directive_elements",
@@ -288,6 +288,12 @@ constexpr std::array<RowSpec, 40> kRows{{
      "s:name;u32:codec_families;u32:type_code;u32:decoded_size;u32:fixed_bits;"
      "u32:minimum_bits;u32:maximum_bits;u32:flags;u32:reserved;"
      "u64:writer_evidence_address;u64:reader_evidence_address"},
+    {46,
+     "dialogue_cues",
+     "dialogue_cues",
+     "CatalogDialogueCueView",
+     "s:id;u32:slot_index;u32:cue_index;u32:list_tag;u32:definition_hash;u32:duration_ms;"
+     "u32:line_count;u32:flags;u32:reserved"},
 }};
 
 /** Splits the semicolon list into type:name pairs; false on a bad token or a refusing callback. */

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

@@ -49,7 +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())},
+                 {"dialogue_cues", number(source.dialogueCues.size())},
              })},
         });
         if (!render_json(manifest, 0, output.manifestJson)) {

+ 17 - 12
Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp

@@ -520,6 +520,7 @@ bool render_mission(const Source& source,
     std::string dialogueDefinitionConstants = "mission.DialogueDefinition = {\n";
     for (const std::uint32_t slot : slots) {
         const auto found = index.dialogueBySlot.find(slot);
+        const auto cues = index.dialogueCuesBySlot.find(slot);
         const auto slotKey = slotKeyByRow.find(slot);
         if (slotKey == slotKeyByRow.end() || slot >= source.slots.size()) {
             continue;
@@ -544,18 +545,22 @@ bool render_mission(const Source& source,
             append_uint(dialogueCueConstants, cue);
             dialogueCueConstants.append(",\n");
 
-            if (found != index.dialogueBySlot.end()) {
-                for (const std::uint32_t rowIndex : found->second) {
-                    const format::DialogueCueText& row = source.dialogueCueTexts[rowIndex];
-                    if (row.cueIndex == cue) {
-                        dialogueDefinitionConstants.append("        [");
-                        append_uint(dialogueDefinitionConstants, cue);
-                        dialogueDefinitionConstants.append("] = ");
-                        append_hex(dialogueDefinitionConstants, row.definitionHash);
-                        dialogueDefinitionConstants.append(",\n");
-                        break;
-                    }
-                }
+            // Cue rows are contiguous per slot and indexed by cue, so the cue offsets the slot's
+            // first row.
+            if (cues != index.dialogueCuesBySlot.end()
+                && cues->second + cue < source.dialogueCues.size()
+                && source.dialogueCues[cues->second + cue].slotIndex == slot
+                && source.dialogueCues[cues->second + cue].cueIndex == cue) {
+                const format::DialogueCue& row = source.dialogueCues[cues->second + cue];
+                dialogueDefinitionConstants.append("        [");
+                append_uint(dialogueDefinitionConstants, cue);
+                dialogueDefinitionConstants.append("] = { hash = ");
+                append_hex(dialogueDefinitionConstants, row.definitionHash);
+                dialogueDefinitionConstants.append(", duration_ms = ");
+                append_uint(dialogueDefinitionConstants, row.durationMs);
+                dialogueDefinitionConstants.append(", lines = ");
+                append_uint(dialogueDefinitionConstants, row.lineCount);
+                dialogueDefinitionConstants.append(" },\n");
             }
 
             dialogueCueTextConstants.append("        [");

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_missions_internal.h

@@ -17,6 +17,8 @@ struct RenderIndex final {
     std::vector<std::vector<std::uint32_t>> squadsByScenario{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> scenesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> tasksBySlot{};
+    /** First cue row of each type-53 slot; a slot's cue rows are contiguous and cue-indexed. */
+    std::unordered_map<std::uint32_t, std::uint32_t> dialogueCuesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> dialogueBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> directivesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> combatGroupsBySlot{};

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

@@ -406,7 +406,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();
+        source.dialogueCues = catalog.dialogue_cues();
         Bundle expected{};
         if (!internal::render_contract_files(source, expected)) {
             return false;

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h

@@ -47,6 +47,7 @@ struct PackageContext final {
 
 /** Extracts localized dialogue aliases and safe authored directive elements. */
 [[nodiscard]] bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                                        const topology_enrichment::Snapshot& enrichment,
                                         const squads::Facts& facts,
                                         PackageContext& packageContext,
                                         authored_scene::Snapshot& output);
@@ -61,8 +62,7 @@ struct PackageContext final {
 [[nodiscard]] bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
                                               const squads::Facts& facts,
                                               PackageContext& packageContext,
-                                              topology_enrichment::Snapshot& enrichment,
-                                              authored_scene::Snapshot& authored);
+                                              topology_enrichment::Snapshot& enrichment);
 
 /** 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,

+ 4 - 4
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -214,7 +214,7 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             worldSources,
             storage.combatObjectiveGroups,
             storage.actorAbilities,
-            storage.authoredSceneEventKeys};
+            storage.dialogueCues};
 }
 
 } // namespace
@@ -372,13 +372,13 @@ Status stage(const wchar_t* sdkDirectory,
             return Status::authoredSceneLinks;
         }
         report(progress, progressContext, Phase::dialogueCues);
-        if (!attach_dialogue_cue_counts(
-                topology, squadFacts, packageContext, topologyDetails, sceneRows)) {
+        if (!attach_dialogue_cue_counts(topology, squadFacts, packageContext, topologyDetails)) {
             return cancelled(cancel, cancelContext) ? Status::cancelled : Status::dialogueCues;
         }
         report(progress, progressContext, Phase::authoredText);
         if (!attach_combat_objective_groups(topology, squadFacts, packageContext, sceneRows)
-            || !attach_authored_text(topology, squadFacts, packageContext, sceneRows)) {
+            || !attach_authored_text(
+                topology, topologyDetails, squadFacts, packageContext, sceneRows)) {
             return cancelled(cancel, cancelContext) ? Status::cancelled : Status::authoredText;
         }
         if (cancelled(cancel, cancelContext)) {

+ 120 - 25
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -1,5 +1,6 @@
 #include <algorithm>
 #include <array>
+#include <cmath>
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
@@ -10,6 +11,7 @@
 #include <string>
 #include <string_view>
 #include <unordered_map>
+#include <unordered_set>
 #include <utility>
 #include <vector>
 
@@ -137,6 +139,10 @@ struct AuthoredTextCandidate final {
     std::uint32_t slotIndex{};
     std::uint32_t cueIndex{format::kAbsentIndex};
     std::uint32_t definitionHash{};
+    std::uint32_t lineIndex{};
+    std::uint32_t takeIndex{};
+    std::uint32_t audioTag{};
+    std::uint32_t durationMs{};
 };
 
 /** The localized fields are one directive element, not several selectable directives. */
@@ -151,7 +157,7 @@ struct AuthoredDirectiveCandidate final {
     std::uint32_t elementCount{};
 };
 
-/** Logs one dialogue list fact that changes which cues carry lines, and its cue context. */
+/** Logs one dialogue list fact that changes which cues carry lines, with its context. */
 void log_dialogue_list(const squads::DescriptorFact& descriptor,
                        std::uint32_t listTag,
                        const char* result,
@@ -179,6 +185,14 @@ void log_dialogue_list(const squads::DescriptorFact& descriptor,
     }
 }
 
+/** Rounds seconds to the nearest millisecond, saturating at the row's width. */
+[[nodiscard]] std::uint32_t milliseconds(float seconds) noexcept {
+    const double value = std::round(static_cast<double>(seconds) * 1000.0);
+    return value >= static_cast<double>((std::numeric_limits<std::uint32_t>::max)())
+               ? (std::numeric_limits<std::uint32_t>::max)()
+               : static_cast<std::uint32_t>(value);
+}
+
 } // namespace
 
 /**
@@ -263,8 +277,9 @@ bool attach_combat_objective_groups(const topology_inventory::Snapshot& topology
     }
 }
 
-/** Extracts localized dialogue aliases and safe authored directive elements. */
+/** Extracts dialogue cues, their localized lines, and safe authored directive elements. */
 bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                          const topology_enrichment::Snapshot& enrichment,
                           const squads::Facts& facts,
                           PackageContext& packageContext,
                           authored_scene::Snapshot& output) {
@@ -296,8 +311,15 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                && container->classId == display::kStringContainerClass;
     };
     try {
+        if (enrichment.slots.size() != topology.slots.size()) {
+            return false;
+        }
         std::vector<AuthoredTextCandidate> dialogueCandidates{};
         std::vector<AuthoredDirectiveCandidate> directiveCandidates{};
+        // The list each type-53 slot's cues were taken from; a second descriptor of the slot that
+        // references another list makes the slot's cues unknowable, so the slot keeps none.
+        std::unordered_map<std::uint32_t, std::uint32_t> dialogueListBySlot{};
+        std::unordered_set<std::uint32_t> conflictingDialogueSlots{};
         for (const squads::DescriptorFact& descriptor : facts.descriptors) {
             if (descriptor.slotIndex >= topology.slots.size()) {
                 continue;
@@ -323,6 +345,20 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                 if (resource->classId != format::kDialogueAuthoredListClass) {
                     continue;
                 }
+                const auto [known, firstList] =
+                    dialogueListBySlot.emplace(descriptor.slotIndex, resourceTag);
+                if (!firstList) {
+                    if (known->second != resourceTag) {
+                        conflictingDialogueSlots.insert(descriptor.slotIndex);
+                        log_dialogue_list(descriptor,
+                                          resourceTag,
+                                          "list_conflict",
+                                          known->second,
+                                          0,
+                                          core::log::Level::warn);
+                    }
+                    continue;
+                }
                 dialogue_list::Snapshot list{};
                 if (!dialogue_list::read(bytes, list)) {
                     log_dialogue_list(
@@ -337,8 +373,47 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                                       shared.treeCount,
                                       core::log::Level::info);
                 }
+                // An empty list authors no cue, so its slot never gets an exact cue count.
+                if (list.cues.empty()) {
+                    continue;
+                }
+                // Cue rows follow the slot's cue count, which the same list gave.
+                const topology_enrichment::Slot& enriched = enrichment.slots[descriptor.slotIndex];
+                if ((enriched.flags & format::kSlotDialogueCuesExact) == 0
+                    || enriched.dialogueCueCount != list.cues.size()) {
+                    log_dialogue_list(descriptor,
+                                      resourceTag,
+                                      "count_mismatch",
+                                      0,
+                                      static_cast<std::uint32_t>(list.cues.size()),
+                                      core::log::Level::warn);
+                    continue;
+                }
                 for (std::size_t cue = 0; cue < list.cues.size(); ++cue) {
                     const dialogue_list::Cue& row = list.cues[cue];
+                    authored_scene::DialogueCue cueRow{};
+                    char cueId[64]{};
+                    const int cueIdLength = std::snprintf(cueId,
+                                                          sizeof cueId,
+                                                          "dialogue_cue/%08x/%u",
+                                                          descriptor.slotIndex,
+                                                          static_cast<unsigned>(cue));
+                    if (cueIdLength <= 0 || static_cast<std::size_t>(cueIdLength) >= sizeof cueId
+                        || !copy_text(
+                            std::string_view(cueId, static_cast<std::size_t>(cueIdLength)),
+                            cueRow.id)) {
+                        return false;
+                    }
+                    cueRow.slotIndex = descriptor.slotIndex;
+                    cueRow.cueIndex = static_cast<std::uint32_t>(cue);
+                    cueRow.listTag = resourceTag;
+                    cueRow.definitionHash = row.definitionHash;
+                    cueRow.durationMs = milliseconds(row.seconds);
+                    if (row.status == dialogue_list::CueStatus::resolved) {
+                        cueRow.lineCount = row.lineCount;
+                        cueRow.flags = format::kDialogueCueLinesExact;
+                    }
+                    output.dialogueCues.push_back(cueRow);
                     if (row.status == dialogue_list::CueStatus::ambiguous
                         || row.status == dialogue_list::CueStatus::missing
                         || row.status == dialogue_list::CueStatus::malformed) {
@@ -353,22 +428,28 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                             core::log::Level::warn);
                         continue;
                     }
-                    // Both takes of a line normally reference the same text; each distinct
-                    // reference is still a candidate, and the Lua publisher folds equal texts.
-                    for (std::uint32_t offset = 0; offset < row.lineCount; ++offset) {
-                        const dialogue_list::Line& line = list.lines[row.firstLine + offset];
-                        for (const dialogue_list::Take* take : {&line.first, &line.second}) {
+                    // Both takes of a line normally reference the same text; each is still its
+                    // own row, and the Lua publisher folds equal texts.
+                    for (std::uint32_t lineIndex = 0; lineIndex < row.lineCount; ++lineIndex) {
+                        const dialogue_list::Line& line = list.lines[row.firstLine + lineIndex];
+                        const std::array<const dialogue_list::Take*, format::kDialogueTakeCount>
+                            takes{&line.first, &line.second};
+                        for (std::uint32_t takeIndex = 0; takeIndex < takes.size(); ++takeIndex) {
+                            const dialogue_list::Take& take = *takes[takeIndex];
                             const CachedTag* container = nullptr;
-                            if (take->containerTag == 0
-                                || take->containerTag == format::kAbsentIndex
-                                || !package(take->containerTag, container) || container == nullptr
+                            if (take.containerTag == 0 || take.containerTag == format::kAbsentIndex
+                                || !package(take.containerTag, container) || container == nullptr
                                 || container->classId != display::kStringContainerClass) {
                                 continue;
                             }
-                            dialogueCandidates.push_back({{take->containerTag, take->stringHash},
+                            dialogueCandidates.push_back({{take.containerTag, take.stringHash},
                                                           descriptor.slotIndex,
                                                           static_cast<std::uint32_t>(cue),
-                                                          row.definitionHash});
+                                                          row.definitionHash,
+                                                          lineIndex,
+                                                          takeIndex,
+                                                          take.audioTag,
+                                                          milliseconds(take.seconds)});
                         }
                     }
                 }
@@ -472,6 +553,18 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             references.push_back(row.description);
             references.push_back(row.progress);
         }
+        const auto conflicting = [&](const auto& row) {
+            return conflictingDialogueSlots.contains(row.slotIndex);
+        };
+        output.dialogueCues.erase(
+            std::remove_if(output.dialogueCues.begin(), output.dialogueCues.end(), conflicting),
+            output.dialogueCues.end());
+        std::sort(output.dialogueCues.begin(),
+                  output.dialogueCues.end(),
+                  [](const auto& first, const auto& second) {
+                      return std::tie(first.slotIndex, first.cueIndex)
+                             < std::tie(second.slotIndex, second.cueIndex);
+                  });
         if (references.empty()) {
             return true;
         }
@@ -485,16 +578,19 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             if (name.authoredEmpty || name.length == 0) {
                 continue;
             }
+            const AuthoredTextCandidate& candidate = dialogueCandidates[index];
+            if (conflictingDialogueSlots.contains(candidate.slotIndex)) {
+                continue;
+            }
             const std::string_view text(name.value.data(), name.length);
             char id[96]{};
-            const AuthoredTextCandidate& candidate = dialogueCandidates[index];
             const int length = std::snprintf(id,
                                              sizeof id,
-                                             "dialogue/%08x/%u/%08x/%08x",
+                                             "dialogue/%08x/%u/%u/%u",
                                              candidate.slotIndex,
                                              candidate.cueIndex,
-                                             candidate.definitionHash,
-                                             candidate.reference.stringHash);
+                                             candidate.lineIndex,
+                                             candidate.takeIndex);
             authored_scene::DialogueCueText row{};
             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)
@@ -506,6 +602,10 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             row.definitionHash = candidate.definitionHash;
             row.containerTag = candidate.reference.containerTag;
             row.stringHash = candidate.reference.stringHash;
+            row.lineIndex = candidate.lineIndex;
+            row.takeIndex = candidate.takeIndex;
+            row.audioTag = candidate.audioTag;
+            row.durationMs = candidate.durationMs;
             output.dialogueCueTexts.push_back(row);
         }
         std::size_t resolved = dialogueCandidates.size();
@@ -561,10 +661,11 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             row.flags = candidate.flags;
             output.directiveElements.push_back(row);
         }
+        // Lines keep their play order inside a cue.
         auto dialogueLess = [](const auto& first, const auto& second) {
-            return std::tie(first.slotIndex, first.cueIndex, first.definitionHash, first.stringHash)
+            return std::tie(first.slotIndex, first.cueIndex, first.lineIndex, first.takeIndex)
                    < std::tie(
-                       second.slotIndex, second.cueIndex, second.definitionHash, second.stringHash);
+                       second.slotIndex, second.cueIndex, second.lineIndex, second.takeIndex);
         };
         auto directiveLess = [](const auto& first, const auto& second) {
             return std::tie(first.slotIndex, first.nameHash, first.elementIndex)
@@ -596,8 +697,7 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
 bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
                                 const squads::Facts& facts,
                                 PackageContext& packageContext,
-                                topology_enrichment::Snapshot& enrichment,
-                                authored_scene::Snapshot& authoredRows) {
+                                topology_enrichment::Snapshot& enrichment) {
     if (topology.slots.size() != enrichment.slots.size()) {
         return false;
     }
@@ -679,11 +779,6 @@ bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
             if (resolved && sawDescriptor && agreedCount != 0) {
                 enriched.dialogueCueCount = static_cast<std::uint32_t>(agreedCount);
                 enriched.flags |= format::kSlotDialogueCuesExact;
-                for (std::uint32_t cue = 0; cue < agreedDefinitions.size(); ++cue) {
-                    const auto& definition = agreedDefinitions[cue];
-                    authoredRows.dialogueCues.push_back(
-                        {slotRow, cue, definition.hash, definition.authoredWindowSeconds});
-                }
             }
         }
         return true;

+ 5 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp

@@ -654,6 +654,11 @@ template <typename Text> [[nodiscard]] bool keep_legacy(StringLinker& linker, co
             return false;
         }
     }
+    for (const authored_scene_inventory::DialogueCue& row : authoredScenes.dialogueCues) {
+        if (!keep_legacy(linker, row.id)) {
+            return false;
+        }
+    }
     for (const authored_scene_inventory::DialogueCueText& row : authoredScenes.dialogueCueTexts) {
         if (!keep_legacy(linker, row.id) || !keep_legacy(linker, row.text)) {
             return false;

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

@@ -649,8 +649,22 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
             return false;
         }
     }
-    output.dialogueCues = authoredScenes.dialogueCues;
     output.combatObjectiveGroups = authoredScenes.combatObjectiveGroups;
+    output.dialogueCues.resize(authoredScenes.dialogueCues.size());
+    for (std::size_t index = 0; index < authoredScenes.dialogueCues.size(); ++index) {
+        const authored_scene_inventory::DialogueCue& input = authoredScenes.dialogueCues[index];
+        format::DialogueCue& target = output.dialogueCues[index];
+        target.slotIndex = input.slotIndex;
+        target.cueIndex = input.cueIndex;
+        target.listTag = input.listTag;
+        target.definitionHash = input.definitionHash;
+        target.durationMs = input.durationMs;
+        target.lineCount = input.lineCount;
+        target.flags = input.flags;
+        if (!link_text(linker, input.id, target.id)) {
+            return false;
+        }
+    }
     output.dialogueCueTexts.resize(authoredScenes.dialogueCueTexts.size());
     for (std::size_t index = 0; index < authoredScenes.dialogueCueTexts.size(); ++index) {
         const authored_scene_inventory::DialogueCueText& input =
@@ -661,6 +675,10 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
         target.definitionHash = input.definitionHash;
         target.containerTag = input.containerTag;
         target.stringHash = input.stringHash;
+        target.lineIndex = input.lineIndex;
+        target.takeIndex = input.takeIndex;
+        target.audioTag = input.audioTag;
+        target.durationMs = input.durationMs;
         if (!link_text(linker, input.id, target.id)
             || !link_text(linker, input.text, target.text)) {
             return false;

+ 10 - 9
Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp

@@ -461,6 +461,7 @@ void draw_dialogues(const sdk::BoundView& view, const mission::Snapshot& snapsho
                                     display_text(catalog, object.id).data(),
                                     static_cast<unsigned>(occurrenceRow),
                                     static_cast<unsigned>(slot.slotIndex));
+                const auto cueRows = sdk::slot_dialogue_cues(catalog, slot);
                 for (std::uint32_t cueRow = 0; cueRow < slot.reserved; ++cueRow) {
                     // The cue index is a 16-bit wire field, so the row is narrowed once here.
                     const auto cue = static_cast<std::uint16_t>(cueRow);
@@ -478,16 +479,16 @@ void draw_dialogues(const sdk::BoundView& view, const mission::Snapshot& snapsho
                     }
                     ImGui::EndDisabled();
                     ImGui::SameLine();
-                    std::uint32_t definitionHash = 0;
-                    for (const sdk::format::DialogueCueText& row : catalog.dialogue_cue_texts()) {
-                        if (row.slotIndex == slotRow && row.cueIndex == cue) {
-                            definitionHash = row.definitionHash;
-                            break;
-                        }
+                    if (cueRow < cueRows.size()) {
+                        const sdk::format::DialogueCue& definition = cueRows[cueRow];
+                        ImGui::Text("Cue %u  definition %08X  %u ms  %u lines",
+                                    static_cast<unsigned>(cue),
+                                    static_cast<unsigned>(definition.definitionHash),
+                                    static_cast<unsigned>(definition.durationMs),
+                                    static_cast<unsigned>(definition.lineCount));
+                    } else {
+                        ImGui::Text("Cue %u  no definition", static_cast<unsigned>(cue));
                     }
-                    ImGui::Text("Cue %u  definition %08X",
-                                static_cast<unsigned>(cue),
-                                static_cast<unsigned>(definitionHash));
                     ImGui::Indent();
                     bool hasCandidate = false;
                     const auto dialogueRows = catalog.dialogue_cue_texts();

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

@@ -180,14 +180,14 @@ std::span<const format::TaskTarget> Catalog::task_targets() const noexcept {
     return rows<format::TaskTarget>(header_, view_, format::SectionIndex::taskTargets);
 }
 
-std::span<const format::DialogueCueText> Catalog::dialogue_cue_texts() const noexcept {
-    return rows<format::DialogueCueText>(header_, view_, format::SectionIndex::dialogueCueTexts);
-}
-
 std::span<const format::DialogueCue> Catalog::dialogue_cues() const noexcept {
     return rows<format::DialogueCue>(header_, view_, format::SectionIndex::dialogueCues);
 }
 
+std::span<const format::DialogueCueText> Catalog::dialogue_cue_texts() const noexcept {
+    return rows<format::DialogueCueText>(header_, view_, format::SectionIndex::dialogueCueTexts);
+}
+
 std::span<const format::ActorAbility> Catalog::actor_abilities() const noexcept {
     return rows<format::ActorAbility>(header_, view_, format::SectionIndex::actorAbilities);
 }
@@ -772,6 +772,27 @@ std::span<const format::SquadAnchor> squad_anchors(const Catalog& catalog,
     return children(catalog.squads(), squad, catalog.squad_anchors(), squad.anchors);
 }
 
+/** Relies on slot then cue ordering to return one contiguous zero-copy cue range. */
+std::span<const format::DialogueCue> slot_dialogue_cues(const Catalog& catalog,
+                                                        const format::Slot& slot) noexcept {
+    const auto slots = catalog.slots();
+    if (!owns(slots, slot)) {
+        return {};
+    }
+    const auto values = catalog.dialogue_cues();
+    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 slot-index ordering to return one contiguous zero-copy resource range. */
 std::span<const format::AuthoredSceneResource>
 slot_authored_scene_resources(const Catalog& catalog, const format::Slot& slot) noexcept {

+ 56 - 28
Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp

@@ -107,6 +107,45 @@ constexpr std::uint32_t kAbilityTargetComponentClass = 0x80807D9BU;
     return true;
 }
 
+/**
+ * @return True when every type-53 slot with cue rows has exactly one row per cue, in
+ *         slot then cue order, and lines only on cues whose content tree was matched.
+ */
+[[nodiscard]] bool dialogue_cues(const Catalog& catalog) noexcept {
+    const auto cues = catalog.dialogue_cues();
+    const auto slots = catalog.slots();
+    for (std::size_t index = 0; index < cues.size(); ++index) {
+        const format::DialogueCue& row = cues[index];
+        if (row.slotIndex >= slots.size() || (row.flags & ~format::kDialogueCueFlagMask) != 0
+            || row.reserved != 0 || row.definitionHash == 0 || row.definitionHash == 0x811C9DC5U
+            || row.listTag == 0 || row.listTag == format::kAbsentIndex
+            || (row.lineCount != 0 && (row.flags & format::kDialogueCueLinesExact) == 0)) {
+            return false;
+        }
+        const format::Slot& slot = slots[row.slotIndex];
+        if (slot.slotType != format::kDialogueSlotType
+            || (slot.flags & format::kSlotDialogueCuesExact) == 0
+            || row.cueIndex >= slot.reserved) {
+            return false;
+        }
+        // The rows of one slot start at cue zero and step by one up to the slot's cue count.
+        const bool opensSlot = index == 0 || cues[index - 1].slotIndex != row.slotIndex;
+        if (opensSlot) {
+            if (row.cueIndex != 0 || (index != 0 && cues[index - 1].slotIndex > row.slotIndex)) {
+                return false;
+            }
+        } else if (row.cueIndex != cues[index - 1].cueIndex + 1) {
+            return false;
+        }
+        const bool closesSlot =
+            index + 1 == cues.size() || cues[index + 1].slotIndex != row.slotIndex;
+        if (closesSlot && row.cueIndex + 1 != slot.reserved) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** @return True when authored controls belong to their exact slots. */
 [[nodiscard]] bool authored_text(const Catalog& catalog) noexcept {
     const auto slots = catalog.slots();
@@ -158,26 +197,17 @@ constexpr std::uint32_t kAbilityTargetComponentClass = 0x80807D9BU;
         }
         priorGroup = &row;
     }
-    const format::DialogueCue* previous = nullptr;
-    for (const format::DialogueCue& row : catalog.dialogue_cues()) {
-        if (row.slotIndex >= slots.size() || row.cueIndex >= slots[row.slotIndex].reserved
-            || slots[row.slotIndex].slotType != format::kDialogueSlotType
-            || (slots[row.slotIndex].flags & format::kSlotDialogueCuesExact) == 0
-            || row.definitionHash == 0 || row.definitionHash == kAbsentDefinitionHash
-            || !std::isfinite(row.authoredWindowSeconds) || row.authoredWindowSeconds < 0.0F
-            || (previous != nullptr
-                && (row.slotIndex < previous->slotIndex
-                    || (row.slotIndex == previous->slotIndex
-                        && row.cueIndex <= previous->cueIndex)))) {
+    for (const format::DialogueCueText& row : catalog.dialogue_cue_texts()) {
+        if (row.slotIndex >= slots.size() || row.containerTag == 0 || row.stringHash == 0
+            || row.takeIndex >= format::kDialogueTakeCount) {
             return false;
         }
-        previous = &row;
-    }
-    for (const format::DialogueCueText& row : catalog.dialogue_cue_texts()) {
-        if (row.slotIndex >= slots.size() || row.cueIndex >= slots[row.slotIndex].reserved
-            || slots[row.slotIndex].slotType != format::kDialogueSlotType || row.definitionHash == 0
-            || row.definitionHash == kAbsentDefinitionHash || row.containerTag == 0
-            || row.stringHash == 0) {
+        const auto cues = slot_dialogue_cues(catalog, slots[row.slotIndex]);
+        if (row.cueIndex >= cues.size()) {
+            return false;
+        }
+        const format::DialogueCue& cue = cues[row.cueIndex];
+        if (cue.definitionHash != row.definitionHash || row.lineIndex >= cue.lineCount) {
             return false;
         }
     }
@@ -650,16 +680,14 @@ 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, 9> 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}}};
+    static constexpr std::array<Check, 8> kChecks{{{"task_targets", &task_targets},
+                                                   {"dialogue_cues", &dialogue_cues},
+                                                   {"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);

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

@@ -137,6 +137,7 @@ bool structure(const Catalog& catalog) {
            && required_ids(
                catalog.entity_type_definitions(), catalog, [](const auto& row) { return row.name; })
            && required_ids(catalog.task_targets(), catalog, [](const auto& row) { return row.id; })
+           && required_ids(catalog.dialogue_cues(), catalog, [](const auto& row) { return row.id; })
            && required_ids(
                catalog.dialogue_cue_texts(), catalog, [](const auto& row) { return row.id; })
            && required_ids(

+ 29 - 9
Sunrise/src/state/activity_sdk/format.h

@@ -9,8 +9,8 @@ namespace sunrise::state::activity_sdk::format {
 
 /** Eight-byte identity at the start of every runtime SDK pack. */
 inline constexpr std::array<char, 8> kMagic{'S', 'R', 'S', 'D', 'K', 'P', '0', '1'};
-/** Runtime-pack schema version accepted by this reader. 44 adds rule-less squads. */
-inline constexpr std::uint32_t kVersion = 44;
+/** Runtime-pack schema version accepted by this reader. 45 adds authored dialogue cue rows. */
+inline constexpr std::uint32_t kVersion = 45;
 /** The ABI contains only activity identity, topology, placement, and panel metadata. */
 inline constexpr std::uint32_t kSectionCount = 51;
 #if defined(SUNRISE_ACTIVITY_SDK_TESTING)
@@ -137,6 +137,11 @@ inline constexpr std::uint32_t kDialogueAuthoredListClass = 0x80808D54U;
 inline constexpr std::uint32_t kDialogueDefinitionArrayClass = 0x80808D18U;
 inline constexpr std::uint32_t kDialogueGroupArrayClass = 0x80808D19U;
 inline constexpr std::uint32_t kDialogueMaximumCueCount = 128U;
+/** Every dialogue line holds two takes of the same text. */
+inline constexpr std::uint32_t kDialogueTakeCount = 2U;
+/** A cue row's lines were read from the one content tree matching its authored duration. */
+inline constexpr std::uint32_t kDialogueCueLinesExact = 0x1U;
+inline constexpr std::uint32_t kDialogueCueFlagMask = kDialogueCueLinesExact;
 /** Inspect exposure is read-only and may be rendered by diagnostics. */
 inline constexpr std::uint32_t kInspectExposure = 0x1U;
 /** Panel-test exposure marks a candidate that still passes runtime gates. */
@@ -483,8 +488,9 @@ 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 = 76;
+inline constexpr std::size_t kDialogueCueSize = 40;
+inline constexpr std::size_t kDialogueCueTextSize = 52;
+inline constexpr std::size_t kDirectiveElementSize = 56;
 inline constexpr std::size_t kBehaviorProgramSize = 28;
 inline constexpr std::size_t kBehaviorInputSize = 36;
 inline constexpr std::size_t kBehaviorChannelWriteSize = 16;
@@ -1288,17 +1294,25 @@ struct CombatObjectiveGroup final {
     bool operator==(const CombatObjectiveGroup&) const = default;
 };
 
-/** A delayed cue expires after this authored window; it is not a playback duration. */
+/**
+ * One type-53 cue, in slot then cue order, with one row for every cue of the slot.
+ * Lines are present only when kDialogueCueLinesExact is set.
+ */
 struct DialogueCue final {
+    StringRef id{};
     std::uint32_t slotIndex{};
     std::uint32_t cueIndex{};
+    /** Dialogue list the cue was read from, the row's provenance. */
+    std::uint32_t listTag{};
     std::uint32_t definitionHash{};
-    float authoredWindowSeconds{};
-
-    bool operator==(const DialogueCue&) const = default;
+    /** Authored play time rounded to the nearest millisecond, whether or not lines were read. */
+    std::uint32_t durationMs{};
+    std::uint32_t lineCount{};
+    std::uint32_t flags{};
+    std::uint32_t reserved{};
 };
 
-/** One localized variant belongs to one exact authored type-53 cue definition. */
+/** One localized take of one line of a cue, in slot, cue, line, then take order. */
 struct DialogueCueText final {
     StringRef id{};
     StringRef text{};
@@ -1307,6 +1321,11 @@ struct DialogueCueText final {
     std::uint32_t definitionHash{};
     std::uint32_t containerTag{};
     std::uint32_t stringHash{};
+    /** Play order of the line inside its cue. */
+    std::uint32_t lineIndex{};
+    std::uint32_t takeIndex{};
+    std::uint32_t audioTag{};
+    std::uint32_t durationMs{};
 };
 
 /** One bounded authored type-68 HUD element; an absent field is empty with zero source tags. */
@@ -1623,6 +1642,7 @@ static_assert(sizeof(AuthoredSceneResource) == kAuthoredSceneResourceSize);
 static_assert(sizeof(AuthoredSceneEventKey) == kAuthoredSceneEventKeySize);
 static_assert(sizeof(AuthoredSceneSquadEdge) == kAuthoredSceneSquadEdgeSize);
 static_assert(sizeof(TaskTarget) == kTaskTargetSize);
+static_assert(sizeof(DialogueCue) == kDialogueCueSize);
 static_assert(sizeof(DialogueCueText) == kDialogueCueTextSize);
 static_assert(sizeof(DirectiveElement) == kDirectiveElementSize);
 static_assert(sizeof(BehaviorProgram) == kBehaviorProgramSize);

+ 6 - 2
Sunrise/src/state/activity_sdk/runtime.h

@@ -182,9 +182,10 @@ public:
     authored_scene_squad_edges() const noexcept;
     /** @return Every exact type-38 task target. */
     [[nodiscard]] std::span<const format::TaskTarget> task_targets() const noexcept;
-    /** @return Every localized authored dialogue-line alias. */
-    [[nodiscard]] std::span<const format::DialogueCueText> dialogue_cue_texts() const noexcept;
+    /** @return Every type-53 cue in slot then cue order. */
     [[nodiscard]] std::span<const format::DialogueCue> dialogue_cues() const noexcept;
+    /** @return Every localized take of a dialogue line. */
+    [[nodiscard]] std::span<const format::DialogueCueText> dialogue_cue_texts() const noexcept;
     [[nodiscard]] std::span<const format::ActorAbility> actor_abilities() const noexcept;
     [[nodiscard]] std::span<const format::ActorAbilityTarget>
     actor_ability_targets() const noexcept;
@@ -508,6 +509,9 @@ squad_members(const Catalog& catalog, const format::Squad& squad) noexcept;
 /** Returns exact anchors owned by one squad. */
 [[nodiscard]] std::span<const format::SquadAnchor>
 squad_anchors(const Catalog& catalog, const format::Squad& squad) noexcept;
+/** Returns every cue of one type-53 slot, indexed by cue. */
+[[nodiscard]] std::span<const format::DialogueCue>
+slot_dialogue_cues(const Catalog& catalog, const format::Slot& slot) noexcept;
 /** Returns all exact authored resources retained for one type-43 slot. */
 [[nodiscard]] std::span<const format::AuthoredSceneResource>
 slot_authored_scene_resources(const Catalog& catalog, const format::Slot& slot) noexcept;