Explorar el Código

Merge pull request #123 from Fozkais/sdk-dialogue-definitions

SDK: read authored dialogue lists, publish cue definitions, raise dialogue cue events
stan hace 4 días
padre
commit
ec2df8bd0f
Se han modificado 24 ficheros con 869 adiciones y 317 borrados
  1. 2 0
      Sunrise/Sunrise.vcxproj
  2. 18 2
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h
  3. 0 67
      Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp
  4. 0 17
      Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.h
  5. 300 0
      Sunrise/src/client/content/activity/activity_sdk_dialogue_list.cpp
  6. 67 0
      Sunrise/src/client/content/activity/activity_sdk_dialogue_list.h
  7. 3 0
      Sunrise/src/client/content/activity/activity_sdk_lua_activities.cpp
  8. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h
  9. 13 4
      Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp
  10. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  11. 20 12
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  12. 2 0
      Sunrise/src/client/content/activity/activity_sdk_lua_missions_internal.h
  13. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  14. 2 2
      Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h
  15. 27 4
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  16. 186 71
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  17. 5 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp
  18. 19 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  19. 19 20
      Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp
  20. 80 89
      Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp
  21. 61 19
      Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp
  22. 1 0
      Sunrise/src/state/activity_sdk/activity_sdk_structure_validation.cpp
  23. 30 7
      Sunrise/src/state/activity_sdk/format.h
  24. 11 2
      Sunrise/src/state/activity_sdk/runtime.h

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -819,6 +819,7 @@
     <ClCompile Include="src\client\content\activity\activity_sdk_native_pack_pipeline.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_dialogue_group_index.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_actor_ability_inventory.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_dialogue_list.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer_rows.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer_validate.cpp" />
@@ -2048,6 +2049,7 @@
     <ClInclude Include="src\client\content\activity\activity_sdk_native_pack_pipeline.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_dialogue_group_index.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_actor_ability_inventory.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_dialogue_list.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_pack_composer.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_pack_composer_internal.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_policy_input_adapter.h" />

+ 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{};
+    float authoredWindowSeconds{};
+    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{};
     /**

+ 0 - 67
Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp

@@ -1,6 +1,5 @@
 #include "activity_sdk_dialogue_group_index.h"
 
-#include <algorithm>
 #include <cmath>
 #include <cstring>
 #include <limits>
@@ -10,8 +9,6 @@
 namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index {
 namespace {
 
-// One dialogue group row is 16 bytes in the blob.
-constexpr std::size_t kGroupStride = 16U;
 // The bank stores a counted definition array at +8 with an eight-byte row stride.
 constexpr std::size_t kDefinitionsField = 8U;
 constexpr std::size_t kDefinitionStride = 8U;
@@ -96,68 +93,4 @@ bool definitions(std::span<const std::byte> bytes, std::vector<Definition>& outp
     }
 }
 
-/** Builds the sorted group index over one dialogue blob. @return False when it is malformed. */
-bool build(std::span<const std::byte> bytes,
-           std::size_t groupRows,
-           std::size_t groupCount,
-           std::vector<Span>& output) noexcept {
-    output.clear();
-    if (groupRows > bytes.size() || groupCount > (bytes.size() - groupRows) / kGroupStride) {
-        return false;
-    }
-    try {
-        output.reserve(groupCount);
-        for (std::size_t index = 0; index < groupCount; ++index) {
-            const std::size_t row = groupRows + index * kGroupStride;
-            Span group{};
-            std::int64_t relative = 0;
-            if (!read_value(bytes, row, group.definitionHash)
-                || !read_value(bytes, row + 8U, relative)
-                || !add_relative(row + 8U, relative, group.begin) || group.begin > bytes.size()) {
-                output.clear();
-                return false;
-            }
-            output.push_back(group);
-        }
-        for (Span& group : output) {
-            group.end = bytes.size();
-            for (const Span& candidate : output) {
-                if (candidate.begin > group.begin && candidate.begin < group.end) {
-                    group.end = candidate.begin;
-                }
-            }
-        }
-        std::sort(output.begin(), output.end(), [](const Span& first, const Span& second) {
-            return first.definitionHash < second.definitionHash;
-        });
-        if (std::adjacent_find(output.begin(),
-                               output.end(),
-                               [](const Span& first, const Span& second) {
-                                   return first.definitionHash == second.definitionHash;
-                               })
-            != output.end()) {
-            output.clear();
-            return false;
-        }
-        return true;
-    } catch (...) {
-        output.clear();
-        return false;
-    }
-}
-
-/** Finds one group by definition hash in the sorted index. */
-bool find(std::span<const Span> groups, std::uint32_t definitionHash, Span& output) noexcept {
-    output = {};
-    const auto row = std::lower_bound(
-        groups.begin(), groups.end(), definitionHash, [](const Span& group, std::uint32_t hash) {
-            return group.definitionHash < hash;
-        });
-    if (row == groups.end() || row->definitionHash != definitionHash) {
-        return false;
-    }
-    output = *row;
-    return true;
-}
-
 } // namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index

+ 0 - 17
Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.h

@@ -7,13 +7,6 @@
 
 namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index {
 
-/** One content range keyed by its authored dialogue definition hash. */
-struct Span final {
-    std::uint32_t definitionHash{};
-    std::size_t begin{};
-    std::size_t end{};
-};
-
 /** One cue definition's delayed-request expiration window, in seconds. */
 struct Definition final {
     std::uint32_t hash{};
@@ -26,14 +19,4 @@ struct Definition final {
 [[nodiscard]] bool definitions(std::span<const std::byte> bytes,
                                std::vector<Definition>& output) noexcept;
 
-/** Builds a hash-sorted index from the native 16-byte dialogue group rows. */
-[[nodiscard]] bool build(std::span<const std::byte> bytes,
-                         std::size_t groupRows,
-                         std::size_t groupCount,
-                         std::vector<Span>& output) noexcept;
-
-/** Finds the content range for one cue definition hash. */
-[[nodiscard]] bool
-find(std::span<const Span> groups, std::uint32_t definitionHash, Span& output) noexcept;
-
 } // namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index

+ 300 - 0
Sunrise/src/client/content/activity/activity_sdk_dialogue_list.cpp

@@ -0,0 +1,300 @@
+#include "activity_sdk_dialogue_list.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <limits>
+
+#include "../../../state/activity_sdk/format.h"
+#include "activity_sdk_dialogue_group_index.h"
+
+namespace sunrise::client::content::activity::sdk_generation::dialogue_list {
+namespace {
+
+namespace format = state::activity_sdk::format;
+
+// The cue definitions come in cue order; the content trees that follow are sorted by hash.
+constexpr std::size_t kTreeField = 0x18;
+constexpr std::size_t kTreeStride = 16;
+// Every tree object repeats the definition hash it belongs to, after a per-object id.
+constexpr std::size_t kObjectHashOffset = 4;
+// A sequence plays its children in order through an array of relative object pointers.
+constexpr std::uint32_t kSequenceClass = 0x80808D1DU;
+constexpr std::size_t kSequenceChildField = 0x18;
+constexpr std::uint32_t kPointerArrayClass = 0x80808D20U;
+constexpr std::size_t kPointerStride = 8;
+// A selection plays one entry; each entry holds its conditions and a pointer to its child.
+constexpr std::uint32_t kSelectionClass = 0x80808D1AU;
+constexpr std::size_t kSelectionEntryField = 0x20;
+constexpr std::uint32_t kSelectionEntryClass = 0x80808D1FU;
+constexpr std::size_t kSelectionEntryStride = 0x38;
+constexpr std::size_t kSelectionChildOffset = 0x30;
+// A line carries two takes of the same text.
+constexpr std::uint32_t kLineClass = 0x80808D23U;
+constexpr std::size_t kFirstTakeOffset = 0x18;
+constexpr std::size_t kSecondTakeOffset = 0x28;
+constexpr std::size_t kLineByteCount = 0x38;
+// Ceilings that stop a corrupt or cyclic tree from driving an unbounded walk.
+constexpr std::size_t kMaximumDepth = 16;
+constexpr std::size_t kMaximumObjects = 1024;
+constexpr std::size_t kMaximumArrayRows = 4096;
+// Authored totals are float sums of the take lengths, so they match within float rounding only.
+constexpr float kSecondsTolerance = 0.001F;
+
+template <typename Value>
+[[nodiscard]] bool
+read_value(std::span<const std::byte> bytes, std::size_t offset, Value& output) noexcept {
+    output = {};
+    if (offset > bytes.size() || sizeof output > bytes.size() - offset) {
+        return false;
+    }
+    std::memcpy(&output, bytes.data() + offset, sizeof output);
+    return true;
+}
+
+/** Follows one signed self-relative pointer stored at `member`. @return False outside the blob. */
+[[nodiscard]] bool
+follow(std::span<const std::byte> bytes, std::size_t member, std::size_t& target) noexcept {
+    std::int64_t relative = 0;
+    if (!read_value(bytes, member, relative)) {
+        return false;
+    }
+    if (relative >= 0) {
+        const auto distance = static_cast<std::uint64_t>(relative);
+        if (distance > bytes.size() - member) {
+            return false;
+        }
+        target = member + static_cast<std::size_t>(distance);
+        return true;
+    }
+    const auto distance = static_cast<std::uint64_t>(-(relative + 1)) + 1U;
+    if (distance > member) {
+        return false;
+    }
+    target = member - static_cast<std::size_t>(distance);
+    return true;
+}
+
+/** Reads one array field's data offset and count, checking its element class and stride. */
+[[nodiscard]] bool read_array(std::span<const std::byte> bytes,
+                              std::size_t field,
+                              std::size_t stride,
+                              std::uint32_t elementClass,
+                              std::size_t& data,
+                              std::size_t& count) noexcept {
+    data = 0;
+    count = 0;
+    std::uint64_t rawCount = 0;
+    std::size_t header = 0;
+    std::uint64_t repeated = 0;
+    std::uint32_t classId = 0;
+    std::uint32_t padding = 0;
+    if (!read_value(bytes, field, rawCount) || rawCount > kMaximumArrayRows) {
+        return false;
+    }
+    if (rawCount == 0) {
+        return true;
+    }
+    if (!follow(bytes, field + 8U, header) || !read_value(bytes, header, repeated)
+        || repeated != rawCount || !read_value(bytes, header + 8U, classId)
+        || classId != elementClass || !read_value(bytes, header + 12U, padding) || padding != 0) {
+        return false;
+    }
+    data = header + 16U;
+    count = static_cast<std::size_t>(rawCount);
+    return data <= bytes.size() && count * stride <= bytes.size() - data;
+}
+
+/** Reads one take stored at `offset`. */
+[[nodiscard]] bool
+read_take(std::span<const std::byte> bytes, std::size_t offset, Take& output) noexcept {
+    return read_value(bytes, offset, output.audioTag)
+           && read_value(bytes, offset + 4U, output.containerTag)
+           && read_value(bytes, offset + 8U, output.stringHash)
+           && read_value(bytes, offset + 12U, output.seconds) && std::isfinite(output.seconds)
+           && output.seconds >= 0.0F;
+}
+
+/** Walk state shared by one tree: its hash, its object budget, and the lines it collects. */
+struct Walk final {
+    std::uint32_t definitionHash{};
+    std::size_t objects{};
+    std::vector<Line> lines{};
+};
+
+/**
+ * Collects the lines below one tree object and computes how long it plays.
+ * @return False when the object or any descendant is malformed.
+ */
+[[nodiscard]] bool walk_object(std::span<const std::byte> bytes,
+                               std::size_t object,
+                               std::size_t depth,
+                               Walk& walk,
+                               float& seconds) {
+    seconds = 0.0F;
+    std::uint32_t classId = 0;
+    std::uint32_t hash = 0;
+    if (depth > kMaximumDepth || ++walk.objects > kMaximumObjects || object < sizeof classId
+        || !read_value(bytes, object - sizeof classId, classId)
+        || !read_value(bytes, object + kObjectHashOffset, hash) || hash != walk.definitionHash) {
+        return false;
+    }
+    if (classId == kLineClass) {
+        Line line{};
+        if (object + kLineByteCount > bytes.size()
+            || !read_take(bytes, object + kFirstTakeOffset, line.first)
+            || !read_take(bytes, object + kSecondTakeOffset, line.second)) {
+            return false;
+        }
+        seconds = (std::max)(line.first.seconds, line.second.seconds);
+        walk.lines.push_back(line);
+        return true;
+    }
+    std::size_t data = 0;
+    std::size_t count = 0;
+    if (classId == kSequenceClass) {
+        if (!read_array(bytes,
+                        object + kSequenceChildField,
+                        kPointerStride,
+                        kPointerArrayClass,
+                        data,
+                        count)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < count; ++index) {
+            std::size_t child = 0;
+            float childSeconds = 0.0F;
+            if (!follow(bytes, data + index * kPointerStride, child)
+                || !walk_object(bytes, child, depth + 1U, walk, childSeconds)) {
+                return false;
+            }
+            seconds += childSeconds;
+        }
+        return true;
+    }
+    if (classId == kSelectionClass) {
+        if (!read_array(bytes,
+                        object + kSelectionEntryField,
+                        kSelectionEntryStride,
+                        kSelectionEntryClass,
+                        data,
+                        count)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < count; ++index) {
+            std::size_t child = 0;
+            float childSeconds = 0.0F;
+            if (!follow(bytes, data + index * kSelectionEntryStride + kSelectionChildOffset, child)
+                || !walk_object(bytes, child, depth + 1U, walk, childSeconds)) {
+                return false;
+            }
+            seconds = (std::max)(seconds, childSeconds);
+        }
+        return true;
+    }
+    return false;
+}
+
+/** One tree root and its hash, in blob order. */
+struct Tree final {
+    std::uint32_t definitionHash{};
+    std::size_t root{};
+};
+
+} // namespace
+
+/**
+ * Matches each cue definition to the content trees that carry its hash.
+ * @param list Authored dialogue list blob.
+ * @param output Receives the cues, their lines and the shared hashes; reset first.
+ * @return False when the definition or tree arrays are malformed.
+ */
+bool read(std::span<const std::byte> list, Snapshot& output) {
+    output = {};
+    std::vector<dialogue_group_index::Definition> definitions{};
+    std::size_t treeRows = 0;
+    std::size_t treeCount = 0;
+    if (!dialogue_group_index::definitions(list, definitions)
+        || !read_array(
+            list, kTreeField, kTreeStride, format::kDialogueGroupArrayClass, treeRows, treeCount)) {
+        return false;
+    }
+    std::vector<Tree> trees(treeCount);
+    for (std::size_t index = 0; index < treeCount; ++index) {
+        const std::size_t row = treeRows + index * kTreeStride;
+        if (!read_value(list, row, trees[index].definitionHash)
+            || !follow(list, row + 8U, trees[index].root)) {
+            return false;
+        }
+    }
+    std::stable_sort(trees.begin(), trees.end(), [](const Tree& left, const Tree& right) {
+        return left.definitionHash < right.definitionHash;
+    });
+    for (auto first = trees.begin(); first != trees.end();) {
+        const auto last = std::find_if(first, trees.end(), [&](const Tree& tree) {
+            return tree.definitionHash != first->definitionHash;
+        });
+        if (last - first > 1) {
+            output.sharedHashes.push_back(
+                {first->definitionHash, static_cast<std::uint32_t>(last - first)});
+        }
+        first = last;
+    }
+
+    output.cues.resize(definitions.size());
+    for (std::size_t index = 0; index < definitions.size(); ++index) {
+        Cue& cue = output.cues[index];
+        cue.definitionHash = definitions[index].hash;
+        cue.windowSeconds = definitions[index].authoredWindowSeconds;
+        const auto range = std::equal_range(trees.begin(),
+                                            trees.end(),
+                                            Tree{cue.definitionHash, 0},
+                                            [](const Tree& left, const Tree& right) {
+                                                return left.definitionHash < right.definitionHash;
+                                            });
+        std::size_t matches = 0;
+        bool malformed = false;
+        std::vector<Line> matched{};
+        std::vector<Line> unmatched{};
+        for (auto tree = range.first; tree != range.second; ++tree) {
+            Walk walk{};
+            walk.definitionHash = cue.definitionHash;
+            float seconds = 0.0F;
+            if (!walk_object(list, tree->root, 0, walk, seconds)) {
+                malformed = true;
+                continue;
+            }
+            if (std::fabs(seconds - cue.windowSeconds) <= kSecondsTolerance) {
+                ++matches;
+                matched = std::move(walk.lines);
+            } else {
+                unmatched = std::move(walk.lines);
+            }
+        }
+        const std::size_t candidates = static_cast<std::size_t>(range.second - range.first);
+        if (candidates == 0) {
+            cue.status = CueStatus::missing;
+        } else if (matches == 1) {
+            cue.status = CueStatus::resolved;
+        } else if (malformed) {
+            cue.status = CueStatus::malformed;
+        } else if (candidates == 1) {
+            // A sole tree is the cue's content even when its length disagrees; keep its lines.
+            cue.status = CueStatus::mismatched;
+            matched = std::move(unmatched);
+        } else {
+            cue.status = CueStatus::ambiguous;
+        }
+        if ((cue.status != CueStatus::resolved && cue.status != CueStatus::mismatched)
+            || matched.size() > (std::numeric_limits<std::uint32_t>::max)()
+            || output.lines.size() > (std::numeric_limits<std::uint32_t>::max)() - matched.size()) {
+            continue;
+        }
+        cue.firstLine = static_cast<std::uint32_t>(output.lines.size());
+        cue.lineCount = static_cast<std::uint32_t>(matched.size());
+        output.lines.insert(output.lines.end(), matched.begin(), matched.end());
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::dialogue_list

+ 67 - 0
Sunrise/src/client/content/activity/activity_sdk_dialogue_list.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+namespace sunrise::client::content::activity::sdk_generation::dialogue_list {
+
+/** One recorded take of a line: its audio, its localized text, and its length. */
+struct Take final {
+    std::uint32_t audioTag{};
+    std::uint32_t containerTag{};
+    std::uint32_t stringHash{};
+    float seconds{};
+};
+
+/** One line. Both takes carry the same text; the line lasts as long as the longer take. */
+struct Line final {
+    Take first{};
+    Take second{};
+};
+
+/** How one cue definition was matched to the content tree the list authors for it. */
+enum class CueStatus : std::uint8_t {
+    /** Exactly one tree carrying the definition hash matches the authored duration. */
+    resolved,
+    /** Several trees share the definition hash and none or several match the authored duration. */
+    ambiguous,
+    /** No tree carries the definition hash. */
+    missing,
+    /** A tree carrying the definition hash is malformed. */
+    malformed,
+    /** The one tree carrying the definition hash differs from the authored duration. */
+    mismatched,
+};
+
+/** One cue in list order. Lines are present only for resolved and mismatched cues. */
+struct Cue final {
+    std::uint32_t definitionHash{};
+    /** Authored window from the definition row; a tree must last as long to resolve. */
+    float windowSeconds{};
+    std::uint32_t firstLine{};
+    std::uint32_t lineCount{};
+    CueStatus status{CueStatus::missing};
+};
+
+/** One definition hash carried by more than one content tree. */
+struct SharedHash final {
+    std::uint32_t definitionHash{};
+    std::uint32_t treeCount{};
+};
+
+/** Every cue of one dialogue list, its lines in cue order, and the hashes several trees share. */
+struct Snapshot final {
+    std::vector<Cue> cues{};
+    std::vector<Line> lines{};
+    std::vector<SharedHash> sharedHashes{};
+};
+
+/**
+ * Reads one dialogue list blob.
+ * @return False when the definition or tree arrays are malformed; a bad tree only marks its cues.
+ */
+[[nodiscard]] bool read(std::span<const std::byte> list, Snapshot& output);
+
+} // namespace sunrise::client::content::activity::sdk_generation::dialogue_list

+ 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 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h

@@ -80,6 +80,7 @@ struct Source final {
     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. */

+ 13 - 4
Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp

@@ -20,14 +20,14 @@ struct RowSpec final {
 };
 
 /** Every StringRef field declared by the row table below. */
-constexpr std::size_t kDecodedStringFieldCount = 55;
+constexpr std::size_t kDecodedStringFieldCount = 56;
 /** 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. */
 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",
+     "u32:slot_index;u32:cue_index;u32:definition_hash;u32:authored_window_seconds_bits;s:id;"
+     "u32:list_tag;u32:line_count;u32:flags;u32:reserved"},
 }};
 
 /** Splits the semicolon list into type:name pairs; false on a bad token or a refusing callback. */
@@ -411,12 +417,15 @@ append_raw_field(Value::Object& fields, std::string_view code, std::string_view
 
 /** Ledger entry for the strings section; the view field appears only in the projection ledger. */
 [[nodiscard]] Value strings_projection(bool includeView) {
+    std::string surface("context.sdk.catalog.<all ");
+    surface.append(std::to_string(kDecodedStringFieldCount));
+    surface.append(" StringRef value/offset/length fields>");
     Value::Object fields{
         {"projection_mode", string("authenticated_parent_bounded_string_refs")},
         {"section", string("strings")},
         {"section_index", number(0)},
         {"source_artifact", string("activity_sdk.runtime_pack")},
-        {"surface", string("context.sdk.catalog.<all 54 StringRef value/offset/length fields>")},
+        {"surface", string(surface)},
     };
     if (includeView) {
         fields.push_back({"view", string("decoded_utf8_string_or_nil_with_raw_StringRef")});

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

@@ -50,6 +50,7 @@ bool render_contract_files(const Source& source, Bundle& output) noexcept {
                  {"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)) {

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

@@ -13,6 +13,7 @@
 #include <vector>
 
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../state/activity_sdk/runtime.h"
 #include "../../../state/activity_sdk/scene_triggers.h"
 #include "activity_sdk_lua_missions_internal.h"
 
@@ -520,6 +521,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 +546,24 @@ 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);
+                // The authored window bounds a delayed cue; it is not a playback length.
+                dialogueDefinitionConstants.append(", window_ms = ");
+                append_uint(dialogueDefinitionConstants,
+                            state::activity_sdk::authored_milliseconds(row.authoredWindowSeconds));
+                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 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp

@@ -407,6 +407,7 @@ bool is_current(const wchar_t* sdkDirectory, const state::activity_sdk::Catalog&
         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,

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

@@ -214,11 +214,34 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             worldSources,
             storage.combatObjectiveGroups,
             storage.actorAbilities,
-            storage.authoredSceneEventKeys};
+            storage.authoredSceneEventKeys,
+            storage.dialogueCues};
 }
 
 } // namespace
 
+/** Flags the slot rows of the scenes the inventory found without a resource. */
+bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
+                               const authored_scene::Snapshot& scenes,
+                               topology_enrichment::Snapshot& enrichment) noexcept {
+    if (enrichment.slots.size() != topology.slots.size()) {
+        return false;
+    }
+    for (const std::uint32_t slotRow : scenes.unresourcedSlots) {
+        if (slotRow >= topology.slots.size()
+            || topology.slots[slotRow].slotType != format::kAuthoredSceneSlotType) {
+            return false;
+        }
+        topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
+        if (enriched.componentClass != format::kAuthoredSceneComponentClass
+            || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
+            return false;
+        }
+        enriched.flags |= format::kSlotAuthoredSceneUnresourced;
+    }
+    return true;
+}
+
 /** Returns the stable log token for each pipeline result. */
 const char* status_name(Status value) noexcept {
     switch (value) {
@@ -372,13 +395,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)) {

+ 186 - 71
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -10,12 +10,15 @@
 #include <string>
 #include <string_view>
 #include <unordered_map>
+#include <unordered_set>
 #include <utility>
 #include <vector>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/activity_display_name_reader.h"
+#include "../../../state/activity_sdk/runtime.h"
 #include "activity_sdk_dialogue_group_index.h"
+#include "activity_sdk_dialogue_list.h"
 #include "activity_sdk_native_pack_internal.h"
 
 namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline {
@@ -136,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. */
@@ -150,6 +157,51 @@ struct AuthoredDirectiveCandidate final {
     std::uint32_t elementCount{};
 };
 
+/** 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,
+                       std::uint32_t definitionHash,
+                       std::uint32_t count,
+                       core::log::Level level) noexcept {
+    std::array<char, 192> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=activity_sdk_dialogue_list result=%s config=0x%08X offset=0x%X "
+                      "slot_row=%u list=0x%08X definition=0x%08X count=%u",
+                      result,
+                      static_cast<unsigned>(descriptor.configTag),
+                      static_cast<unsigned>(descriptor.descriptorOffset),
+                      static_cast<unsigned>(descriptor.slotIndex),
+                      static_cast<unsigned>(listTag),
+                      static_cast<unsigned>(definitionHash),
+                      static_cast<unsigned>(count));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            level,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
+/** @return The log result name of one cue status. */
+const char* cue_status_name(dialogue_list::CueStatus status) noexcept {
+    switch (status) {
+    case dialogue_list::CueStatus::resolved:
+        return "cue_resolved";
+    case dialogue_list::CueStatus::ambiguous:
+        return "cue_ambiguous";
+    case dialogue_list::CueStatus::missing:
+        return "cue_missing";
+    case dialogue_list::CueStatus::malformed:
+        return "cue_malformed";
+    case dialogue_list::CueStatus::mismatched:
+        return "cue_window_mismatch";
+    }
+    return "cue_unknown";
+}
+
 } // namespace
 
 /**
@@ -234,8 +286,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) {
@@ -267,8 +320,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;
@@ -294,51 +354,114 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                 if (resource->classId != format::kDialogueAuthoredListClass) {
                     continue;
                 }
-                std::size_t groups = 0;
-                std::size_t groupCount = 0;
-                if (!read_array(
-                        bytes, 0x18U, 16U, format::kDialogueGroupArrayClass, groups, groupCount)) {
+                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;
                 }
-                std::size_t definitions = 0;
-                std::size_t definitionCount = 0;
-                if (!read_array(bytes,
-                                8U,
-                                8U,
-                                format::kDialogueDefinitionArrayClass,
-                                definitions,
-                                definitionCount)) {
+                dialogue_list::Snapshot list{};
+                if (!dialogue_list::read(bytes, list)) {
+                    log_dialogue_list(
+                        descriptor, resourceTag, "malformed", 0, 0, core::log::Level::warn);
                     continue;
                 }
-                std::vector<dialogue_groups::Span> groupIndex{};
-                if (!dialogue_groups::build(bytes, groups, groupCount, groupIndex)) {
+                for (const dialogue_list::SharedHash& shared : list.sharedHashes) {
+                    log_dialogue_list(descriptor,
+                                      resourceTag,
+                                      "shared_hash",
+                                      shared.definitionHash,
+                                      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;
                 }
-                for (std::size_t cue = 0; cue < definitionCount; ++cue) {
-                    std::uint32_t definitionHash = 0;
-                    if (!read_value(bytes, definitions + cue * 8U, definitionHash)
-                        || definitionHash == 0 || definitionHash == 0x811C9DC5U) {
-                        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;
                     }
-                    dialogue_groups::Span group{};
-                    if (!dialogue_groups::find(groupIndex, definitionHash, group)) {
+                    cueRow.slotIndex = descriptor.slotIndex;
+                    cueRow.cueIndex = static_cast<std::uint32_t>(cue);
+                    cueRow.listTag = resourceTag;
+                    cueRow.definitionHash = row.definitionHash;
+                    cueRow.authoredWindowSeconds = row.windowSeconds;
+                    const bool mismatched = row.status == dialogue_list::CueStatus::mismatched;
+                    const bool hasLines =
+                        mismatched || row.status == dialogue_list::CueStatus::resolved;
+                    if (hasLines) {
+                        cueRow.lineCount = row.lineCount;
+                        cueRow.flags = format::kDialogueCueLinesExact
+                                       | (mismatched ? format::kDialogueCueWindowMismatch : 0U);
+                    }
+                    output.dialogueCues.push_back(cueRow);
+                    if (row.status != dialogue_list::CueStatus::resolved) {
+                        log_dialogue_list(descriptor,
+                                          resourceTag,
+                                          cue_status_name(row.status),
+                                          row.definitionHash,
+                                          static_cast<std::uint32_t>(cue),
+                                          core::log::Level::warn);
+                    }
+                    if (!hasLines) {
                         continue;
                     }
-                    for (std::size_t offset = group.begin; offset + 8U <= group.end; offset += 4U) {
-                        std::uint32_t containerTag = 0;
-                        std::uint32_t stringHash = 0;
-                        const CachedTag* container = nullptr;
-                        if (!read_value(bytes, offset, containerTag)
-                            || !read_value(bytes, offset + 4U, stringHash)
-                            || containerTag < 0x80A12000U || containerTag >= 0xC0000000U
-                            || !package(containerTag, container) || container == nullptr
-                            || container->classId != display::kStringContainerClass) {
-                            continue;
+                    // 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
+                                || container->classId != display::kStringContainerClass) {
+                                continue;
+                            }
+                            dialogueCandidates.push_back(
+                                {{take.containerTag, take.stringHash},
+                                 descriptor.slotIndex,
+                                 static_cast<std::uint32_t>(cue),
+                                 row.definitionHash,
+                                 lineIndex,
+                                 takeIndex,
+                                 take.audioTag,
+                                 state::activity_sdk::authored_milliseconds(take.seconds)});
                         }
-                        dialogueCandidates.push_back({{containerTag, stringHash},
-                                                      descriptor.slotIndex,
-                                                      static_cast<std::uint32_t>(cue),
-                                                      definitionHash});
                     }
                 }
             } else {
@@ -441,6 +564,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;
         }
@@ -454,16 +589,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)
@@ -475,6 +613,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();
@@ -530,10 +672,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)
@@ -565,8 +708,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;
     }
@@ -648,11 +790,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;
@@ -661,26 +798,4 @@ bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
     }
 }
 
-/** Flags the slot rows of the scenes the inventory found without a resource. */
-bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
-                               const authored_scene::Snapshot& scenes,
-                               topology_enrichment::Snapshot& enrichment) noexcept {
-    if (enrichment.slots.size() != topology.slots.size()) {
-        return false;
-    }
-    for (const std::uint32_t slotRow : scenes.unresourcedSlots) {
-        if (slotRow >= topology.slots.size()
-            || topology.slots[slotRow].slotType != format::kAuthoredSceneSlotType) {
-            return false;
-        }
-        topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
-        if (enriched.componentClass != format::kAuthoredSceneComponentClass
-            || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
-            return false;
-        }
-        enriched.flags |= format::kSlotAuthoredSceneUnresourced;
-    }
-    return true;
-}
-
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

+ 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.authoredWindowSeconds = input.authoredWindowSeconds;
+        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;

+ 19 - 20
Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp

@@ -461,12 +461,15 @@ 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);
+                // The check reads the cue only against the slot's cue count, which every cue
+                // drawn here satisfies, so one resolution per sensor answers for all of them.
+                const mission::SceneStatus available =
+                    mission::dialogue_cue_availability(view, occurrenceRow, slotRow, 0);
                 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);
                     ImGui::PushID(static_cast<int>(cueRow));
-                    const mission::SceneStatus available =
-                        mission::dialogue_cue_availability(view, occurrenceRow, slotRow, cue);
                     ImGui::BeginDisabled(available != mission::SceneStatus::ready);
                     if (ImGui::Button("Play cue")) {
                         g_dialogueActionOccurrence = occurrenceRow;
@@ -478,33 +481,29 @@ 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  window %.3f s  %u lines",
+                                    static_cast<unsigned>(cue),
+                                    static_cast<unsigned>(definition.definitionHash),
+                                    static_cast<double>(definition.authoredWindowSeconds),
+                                    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();
+                    const auto dialogueRows = sdk::cue_dialogue_texts(catalog, slot, cueRow);
                     for (std::size_t rowIndex = 0; rowIndex < dialogueRows.size(); ++rowIndex) {
-                        const sdk::format::DialogueCueText& row = dialogueRows[rowIndex];
-                        if (row.slotIndex != slotRow || row.cueIndex != cue) {
-                            continue;
-                        }
-                        const std::string_view candidate = catalog.string(row.text);
+                        const std::string_view candidate =
+                            catalog.string(dialogueRows[rowIndex].text);
                         if (candidate.empty()) {
                             continue;
                         }
+                        // The two takes of a line usually share their text; show it once.
                         bool duplicate = false;
                         for (std::size_t previous = 0; previous < rowIndex; ++previous) {
-                            const sdk::format::DialogueCueText& prior = dialogueRows[previous];
-                            if (prior.slotIndex == slotRow && prior.cueIndex == cue
-                                && catalog.string(prior.text) == candidate) {
+                            if (catalog.string(dialogueRows[previous].text) == candidate) {
                                 duplicate = true;
                                 break;
                             }

+ 80 - 89
Sunrise/src/state/activity_sdk/activity_sdk_catalog.cpp

@@ -2,6 +2,9 @@
 
 #include <algorithm>
 #include <atomic>
+#include <cmath>
+#include <limits>
+#include <utility>
 
 #include "internal.h"
 #include "runtime.h"
@@ -36,6 +39,31 @@ template <typename Parent, typename Child>
     return values.subspan(first, count);
 }
 
+/** Returns the contiguous rows whose projected key equals `key`; rows are sorted by that key. */
+template <typename Row, typename Key, typename Project>
+[[nodiscard]] std::span<const Row>
+key_rows(std::span<const Row> values, const Key& key, Project project) noexcept {
+    const auto first = std::partition_point(
+        values.begin(), values.end(), [&](const Row& row) { return project(row) < key; });
+    const auto last = std::partition_point(
+        first, values.end(), [&](const Row& row) { return !(key < project(row)); });
+    return values.subspan(static_cast<std::size_t>(first - values.begin()),
+                          static_cast<std::size_t>(last - first));
+}
+
+/** Returns the rows keyed by one slot of this catalog, or none for a foreign slot. */
+template <typename Row, typename Project>
+[[nodiscard]] std::span<const Row> slot_rows(const Catalog& catalog,
+                                             const format::Slot& slot,
+                                             std::span<const Row> values,
+                                             Project project) noexcept {
+    const auto slots = catalog.slots();
+    if (!owns(slots, slot)) {
+        return {};
+    }
+    return key_rows(values, static_cast<std::uint32_t>(&slot - slots.data()), project);
+}
+
 } // namespace
 
 /** Releases the mapped view after the last immutable snapshot owner leaves. */
@@ -180,14 +208,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);
 }
@@ -555,18 +583,10 @@ std::span<const format::Squad> scenario_squads(const Catalog& catalog,
     if (!owns(scenarios, scenario)) {
         return {};
     }
-    const auto values = catalog.squads();
     const std::uint32_t scenarioIndex = static_cast<std::uint32_t>(&scenario - scenarios.data());
-    const auto first = std::lower_bound(
-        values.begin(), values.end(), scenarioIndex, [](const format::Squad& row, auto index) {
-            return row.scenarioIndex < index;
-        });
-    const auto last = std::upper_bound(
-        first, values.end(), scenarioIndex, [](auto index, const format::Squad& row) {
-            return index < row.scenarioIndex;
-        });
-    const std::size_t offset = static_cast<std::size_t>(first - values.begin());
-    return values.subspan(offset, static_cast<std::size_t>(last - first));
+    return key_rows(catalog.squads(), scenarioIndex, [](const format::Squad& row) {
+        return row.scenarioIndex;
+    });
 }
 
 std::span<const format::State> bubble_states(const Catalog& catalog,
@@ -772,67 +792,60 @@ std::span<const format::SquadAnchor> squad_anchors(const Catalog& catalog,
     return children(catalog.squads(), squad, catalog.squad_anchors(), squad.anchors);
 }
 
-/** 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 {
+/** 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 {
+    return slot_rows(
+        catalog, slot, catalog.dialogue_cues(), [](const auto& row) { return row.slotIndex; });
+}
+
+/** Relies on slot, cue, line then take ordering to return one contiguous zero-copy line range. */
+std::span<const format::DialogueCueText> cue_dialogue_texts(const Catalog& catalog,
+                                                            const format::Slot& slot,
+                                                            std::uint32_t cueIndex) noexcept {
     const auto slots = catalog.slots();
     if (!owns(slots, slot)) {
         return {};
     }
-    const auto values = catalog.authored_scene_resources();
-    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));
+    const auto key = std::make_pair(static_cast<std::uint32_t>(&slot - slots.data()), cueIndex);
+    return key_rows(catalog.dialogue_cue_texts(), key, [](const auto& row) {
+        return std::make_pair(row.slotIndex, row.cueIndex);
+    });
+}
+
+/** @return Zero for a negative or NaN input; saturates at the 32-bit maximum. */
+std::uint32_t authored_milliseconds(float seconds) noexcept {
+    constexpr double kMillisecondsPerSecond = 1000.0;
+    const double value = std::round(static_cast<double>(seconds) * kMillisecondsPerSecond);
+    if (!(value > 0.0)) {
+        return 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);
+}
+
+/** 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 {
+    return slot_rows(catalog, slot, catalog.authored_scene_resources(), [](const auto& row) {
+        return row.slotIndex;
+    });
 }
 
 /** 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));
+    return slot_rows(
+        catalog, slot, catalog.directive_elements(), [](const auto& row) { return row.slotIndex; });
 }
 
 /** 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 {
-    const auto slots = catalog.slots();
-    if (!owns(slots, slot)) {
-        return {};
-    }
-    const auto values = catalog.authored_scene_squad_edges();
-    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.sceneSlotIndex < index;
-        });
-    const auto last =
-        std::upper_bound(first, values.end(), slotIndex, [](auto index, const auto& row) {
-            return index < row.sceneSlotIndex;
-        });
-    return values.subspan(static_cast<std::size_t>(first - values.begin()),
-                          static_cast<std::size_t>(last - first));
+    return slot_rows(catalog, slot, catalog.authored_scene_squad_edges(), [](const auto& row) {
+        return row.sceneSlotIndex;
+    });
 }
 
 const format::Slot*
@@ -848,17 +861,9 @@ authored_scene_linked_squad_slot(const Catalog& catalog,
 /** Relies on actor-class ordering to return one contiguous zero-copy state-name range. */
 std::span<const format::ActorStateName>
 actor_class_state_names(const Catalog& catalog, std::uint32_t actorClassIndex) noexcept {
-    const auto values = catalog.actor_state_names();
-    const auto first = std::lower_bound(
-        values.begin(), values.end(), actorClassIndex, [](const auto& row, auto index) {
-            return row.actorClassIndex < index;
-        });
-    const auto last =
-        std::upper_bound(first, values.end(), actorClassIndex, [](auto index, const auto& row) {
-            return index < row.actorClassIndex;
-        });
-    return values.subspan(static_cast<std::size_t>(first - values.begin()),
-                          static_cast<std::size_t>(last - first));
+    return key_rows(catalog.actor_state_names(), actorClassIndex, [](const auto& row) {
+        return row.actorClassIndex;
+    });
 }
 
 /** Copies one performance slot's state name hashes. @return How many were written. */
@@ -917,22 +922,8 @@ std::size_t performance_state_names(const Catalog& catalog,
 /** Relies on task-slot ordering to return one contiguous zero-copy target range. */
 std::span<const format::TaskTarget> slot_task_targets(const Catalog& catalog,
                                                       const format::Slot& slot) noexcept {
-    const auto slots = catalog.slots();
-    if (!owns(slots, slot)) {
-        return {};
-    }
-    const auto values = catalog.task_targets();
-    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.taskSlotIndex < index;
-        });
-    const auto last =
-        std::upper_bound(first, values.end(), slotIndex, [](auto index, const auto& row) {
-            return index < row.taskSlotIndex;
-        });
-    return values.subspan(static_cast<std::size_t>(first - values.begin()),
-                          static_cast<std::size_t>(last - first));
+    return slot_rows(
+        catalog, slot, catalog.task_targets(), [](const auto& row) { return row.taskSlotIndex; });
 }
 
 /** @return The objective slot one task target names, or null when it names none. */

+ 61 - 19
Sunrise/src/state/activity_sdk/activity_sdk_relation_validation.cpp

@@ -107,6 +107,47 @@ 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 == kAbsentDefinitionHash
+            || !std::isfinite(row.authoredWindowSeconds) || row.authoredWindowSeconds < 0.0F
+            || 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 +199,26 @@ 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)))) {
+    const format::DialogueCueText* priorText = nullptr;
+    for (const format::DialogueCueText& row : catalog.dialogue_cue_texts()) {
+        // Slot, cue, line then take order, without repeats, backs the per-cue range lookup.
+        if (row.slotIndex >= slots.size() || row.containerTag == 0 || row.stringHash == 0
+            || row.takeIndex >= format::kDialogueTakeCount
+            || (priorText != nullptr
+                && std::tie(row.slotIndex, row.cueIndex, row.lineIndex, row.takeIndex)
+                       <= std::tie(priorText->slotIndex,
+                                   priorText->cueIndex,
+                                   priorText->lineIndex,
+                                   priorText->takeIndex))) {
             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) {
+        priorText = &row;
+        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,10 +691,11 @@ 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{
+    static constexpr std::array<Check, 10> kChecks{
         {{"authored_scene_event_keys", &authored_scene_event_keys},
          {"squad_profiles", &squad_profiles},
          {"task_targets", &task_targets},
+         {"dialogue_cues", &dialogue_cues},
          {"authored_text", &authored_text},
          {"behavior_edges", &behavior_edges},
          {"actor_semantics", &actor_semantics},

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

+ 30 - 7
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,14 @@ 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 one content tree carrying its definition hash. */
+inline constexpr std::uint32_t kDialogueCueLinesExact = 0x1U;
+/** That tree's length differs from the authored window; its lines are still kept. */
+inline constexpr std::uint32_t kDialogueCueWindowMismatch = 0x2U;
+inline constexpr std::uint32_t kDialogueCueFlagMask =
+    kDialogueCueLinesExact | kDialogueCueWindowMismatch;
 /** 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,7 +491,8 @@ 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 kDialogueCueSize = 40;
+inline constexpr std::size_t kDialogueCueTextSize = 52;
 inline constexpr std::size_t kDirectiveElementSize = 76;
 inline constexpr std::size_t kBehaviorProgramSize = 28;
 inline constexpr std::size_t kBehaviorInputSize = 36;
@@ -1288,17 +1297,25 @@ struct CombatObjectiveGroup final {
     bool operator==(const CombatObjectiveGroup&) const = default;
 };
 
-/** A delayed cue expires after this authored window; it is not a playback duration. */
+/**
+ * A delayed cue expires after this authored window; it is not a playback duration.
+ * One row per cue of a type-53 slot, in slot then cue order; lines are present only when
+ * kDialogueCueLinesExact is set.
+ */
 struct DialogueCue final {
     std::uint32_t slotIndex{};
     std::uint32_t cueIndex{};
     std::uint32_t definitionHash{};
     float authoredWindowSeconds{};
-
-    bool operator==(const DialogueCue&) const = default;
+    StringRef id{};
+    /** Dialogue list the cue was read from, the row's provenance. */
+    std::uint32_t listTag{};
+    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 +1324,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 +1645,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);

+ 11 - 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,14 @@ 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 every take of every line of one cue of a type-53 slot, in play order. */
+[[nodiscard]] std::span<const format::DialogueCueText> cue_dialogue_texts(
+    const Catalog& catalog, const format::Slot& slot, std::uint32_t cueIndex) noexcept;
+/** Rounds authored seconds to the nearest millisecond, saturating at 32 bits. */
+[[nodiscard]] std::uint32_t authored_milliseconds(float seconds) 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;