Просмотр исходного кода

Merge upstream master into strange-terrain-pr

Both sides raised the SDK pack version to 41 for different layouts, so the merged reader takes 42 and rejects packs generated by either.
Confetti3 3 недель назад
Родитель
Сommit
cc94cdde7a
36 измененных файлов с 900 добавлено и 326 удалено
  1. 3 2
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h
  2. 126 61
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp
  3. 5 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h
  4. 32 7
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp
  5. 2 2
      Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp
  6. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  7. 3 0
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  8. 5 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h
  9. 3 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  10. 22 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  11. 2 0
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  12. 9 3
      Sunrise/src/client/content/vendors/layout.h
  13. 59 16
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  14. 73 6
      Sunrise/src/client/hooks/stall_probe/stall_probe.cpp
  15. 2 0
      Sunrise/src/server/activity/activity_sdk_mission_internal.h
  16. 7 5
      Sunrise/src/server/activity/activity_sdk_mission_prepare.cpp
  17. 7 0
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  18. 16 6
      Sunrise/src/server/activity/activity_sdk_scene_cast.cpp
  19. 34 17
      Sunrise/src/server/activity/activity_sdk_scene_dependencies.h
  20. 47 0
      Sunrise/src/server/activity/activity_sdk_scene_spawn.cpp
  21. 22 4
      Sunrise/src/server/activity/activity_sdk_scene_spawn.h
  22. 10 54
      Sunrise/src/server/ui/activity_host/activity_host_event_view.cpp
  23. 54 2
      Sunrise/src/server/ui/activity_host/activity_host_sdk_mission_view.cpp
  24. 3 8
      Sunrise/src/server/ui/activity_host/activity_host_sdk_view.cpp
  25. 21 26
      Sunrise/src/server/ui/activity_host/activity_host_tool_window.h
  26. 69 54
      Sunrise/src/server/web_service/web_service_vendor_actions.cpp
  27. 27 7
      Sunrise/src/state/activity_sdk/format.h
  28. 5 3
      Sunrise/src/state/build_data/abilities/definition.h
  29. 27 6
      Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp
  30. 18 5
      Sunrise/src/state/build_data/cache/records/format.h
  31. 52 7
      Sunrise/src/state/build_data/vendors/definition.h
  32. 7 2
      Sunrise/src/state/build_data/vendors/vendor_catalog.cpp
  33. 18 9
      Sunrise/src/state/runtime/runtime.h
  34. 34 14
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  35. 65 0
      Sunrise/src/state/runtime/state_account_profile_runtime.cpp
  36. 10 0
      Sunrise/src/state/runtime/state_account_transaction_helpers.h

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

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

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

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

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

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

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

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

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp

@@ -10,6 +10,7 @@
 #include <string_view>
 
 #include "activity_sdk_tree_publication.h"
+#include "core/runtime/host_environment.h"
 
 namespace sunrise::client::content::activity::sdk_generation::live_publication {
 namespace {
@@ -113,8 +114,7 @@ constexpr std::size_t kDrivePrefixLength = 3;
     if (ancestry != Ancestry::driveRoot) {
         return false;
     }
-    const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
-    return ntdll != nullptr && GetProcAddress(ntdll, "wine_get_version") != nullptr;
+    return core::runtime::is_wine();
 }
 
 /** Resolves one existing ordinary directory and rejects reparse points in every ancestor. */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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