Ver Fonte

feat(inventory): model authored account and character items

Expand the authored state model for dense character inventories, profile stacks, stable instance identities, mutation serials, equipped placement, and native bucket capacities.

Encode the account and selected-character Family 4 records from validated State, resolve complete socket/default loadouts, and include every referenced resident object in initial snapshots.

The stricter validation prevents dangling SOIDs, duplicate ownership, malformed bucket placement, and partial account graphs from reaching the client.
Thomas Shields há 3 semanas atrás
pai
commit
9bf40e0f93

+ 9 - 0
Sunrise/src/core/settings/parser.h

@@ -165,6 +165,15 @@ private:
      * @return True when every name is known and appears at most once.
      */
     [[nodiscard]] bool equipment(state::account::inventory::Equipment& output) noexcept;
+    /**
+     * Parses the optional unequipped character inventory array.
+     * @param output
+     * Receives items in authored bucket-placement order.
+     * @return True when every item is
+     * complete and the fixed array has room.
+     */
+    [[nodiscard]] bool
+    character_inventory(state::account::inventory::CharacterItems& output) noexcept;
     /**
      * Parses one item only when every required named field appears exactly once.
      * @param output Receives the complete item after checking.

+ 7 - 0
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -15,6 +15,7 @@ constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint
 /** Parses the authored account-wide item array. */
 bool Parser::profile_items(state::AccountState& output) noexcept {
     namespace inventory = state::account::inventory;
+    output.profileItems = {};
     output.profileItemCount = 0;
     if (!consume('[')) {
         return false;
@@ -115,6 +116,7 @@ bool Parser::character(state::CharacterState& output) noexcept {
     }
     bool hasSoid = false;
     bool hasEquipment = false;
+    bool hasInventory = false;
     if (consume('}')) {
         return false;
     }
@@ -202,6 +204,11 @@ bool Parser::character(state::CharacterState& output) noexcept {
                 return false;
             }
             hasEquipment = true;
+        } else if (key == "inventory") {
+            if (hasInventory || !character_inventory(output.inventory)) {
+                return false;
+            }
+            hasInventory = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 28 - 0
Sunrise/src/core/settings/state/inventory_parser.cpp

@@ -72,6 +72,34 @@ bool Parser::equipment(authored_inventory::Equipment& output) noexcept {
     }
 }
 
+/** Parses the optional unequipped character inventory array. */
+bool Parser::character_inventory(authored_inventory::CharacterItems& output) noexcept {
+    authored_inventory::CharacterItems parsed{};
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        output = parsed;
+        return true;
+    }
+    for (;;) {
+        if (parsed.count >= parsed.values.size() || !equipment_item(parsed.values[parsed.count])) {
+            return false;
+        }
+        ++parsed.count;
+        if (consume(']')) {
+            if (!authored_inventory::valid(parsed)) {
+                return false;
+            }
+            output = parsed;
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
 /** Parses one item only when every required named field appears exactly once. */
 bool Parser::equipment_item(authored_inventory::Item& output) noexcept {
     authored_inventory::Item parsed{};

+ 1 - 1
Sunrise/src/core/settings/state_settings.cpp

@@ -186,7 +186,7 @@ bool Parser::state_settings(Settings& output) noexcept {
             return false;
         }
         if (consume('}')) {
-            return state::account::valid(output.initialAccount)
+            return state::account::valid_authored(output.initialAccount)
                    && state::activity::defaults::valid(output.initialActivityDefaults);
         }
         if (!consume(',')) {

+ 16 - 1
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -38,12 +38,25 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
     // The dense item table already carries the bucket, so a profile item needs no detail record.
     // Only equipped items and their plugs have one.
     state::build_data::items::Definition definition{};
+    state::build_data::items::details::Definition detail{};
     state::build_data::inventory::buckets::Descriptor bucket{};
-    if (!state::build_data::find_item_definition_hash(item.definitionHash, definition)
+    if (item.quantity <= 0 || item.mutationSerial < 0
+        || !state::build_data::find_item_definition_hash(item.definitionHash, definition)
+        || !state::build_data::find_configured_item_detail(definition.definitionIndex, detail)
+        || detail.definitionIndex != definition.definitionIndex
+        || detail.definitionHash != definition.definitionHash
+        || detail.bucketId != definition.bucketId
+        || detail.instancedDefinitionState
+               != state::build_data::items::details::InstancedDefinitionState::stackable
         || !state::build_data::find_inventory_bucket_descriptor(definition.bucketId, bucket)
         || bucket.arraySelector != state::build_data::inventory::buckets::ArraySelector::profile) {
         return false;
     }
+    const bool actionSource = state::build_data::is_profile_action_source(
+        definition.definitionIndex, definition.bucketId);
+    if (actionSource != (item.instanceSoid != 0)) {
+        return false;
+    }
     const std::uint16_t used = taken[definition.bucketId];
     if (used >= bucket.slotCount) {
         return false;
@@ -54,7 +67,9 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
     }
     taken[definition.bucketId] = static_cast<std::uint16_t>(used + 1);
     rows[slot].definitionIndex = definition.definitionIndex;
+    rows[slot].instanceSoid = item.instanceSoid;
     rows[slot].quantity = item.quantity;
+    rows[slot].mutationSerial = item.mutationSerial;
     return true;
 }
 

+ 33 - 3
Sunrise/src/middleware/datagen/family4/account/layout.h

@@ -36,8 +36,10 @@ inline constexpr std::size_t kPreferencesBindingsPaddingSize = 607;
 inline constexpr std::size_t kBindingsProfilePaddingSize = 456;
 /** Native inventory counts are followed by 4 reserved alignment bytes. */
 inline constexpr std::size_t kInventoryCountPaddingSize = 4;
-/** 708 reserved bytes separate secondary items from progressions. */
-inline constexpr std::size_t kSecondaryProgressionPaddingSize = 708;
+/** The profile inventory observer reads 16 transient mutation descriptors. */
+inline constexpr std::size_t kProfileInventoryChangeRecordCapacity = 16;
+/** Opaque bytes after the profile mutation bank keep the progression bank at its native offset. */
+inline constexpr std::size_t kProfileInventoryProgressionPaddingSize = 512;
 /** The account acquired-flag bank holds one byte per flag. */
 inline constexpr std::size_t kAcquiredFlagCapacity = 12'300;
 /** The account objective-value bank holds one signed value per objective. */
@@ -78,6 +80,8 @@ inline constexpr std::size_t kProfileItemsOffset = 4'368;
 inline constexpr std::size_t kSecondaryItemCountOffset = 26'800;
 /** The secondary inventory rows follow their count and alignment bytes. */
 inline constexpr std::size_t kSecondaryItemsOffset = 26'808;
+/** The profile-inventory mutation bank follows the complete secondary inventory. */
+inline constexpr std::size_t kProfileInventoryChangesOffset = 27'000;
 /** The fixed progression bank precedes the account acquired-flag bank. */
 inline constexpr std::size_t kProgressionsOffset = 27'708;
 /** The account acquired-flag bank follows every fixed progression row. */
@@ -97,6 +101,26 @@ struct CharacterUnlockBlock {
     std::array<std::int32_t, kCharacterValueCapacity> values{};
 };
 
+/** One transient profile inventory mutation consumed by the native account-object observer. */
+struct ProfileInventoryChangeRecord {
+    std::uint16_t sequence{};
+    std::uint16_t reserved{};
+    /** Mutation serial of the profile inventory row this record describes. */
+    std::int32_t mutationSerial{};
+    /** Nonzero mutation kind. Kind 1 follows the ordinary acquisition path. */
+    std::uint8_t kind{};
+    std::uint8_t reservedKind{};
+    /** Native observer policy bits; 0 enables the ordinary acquisition path. */
+    std::uint16_t flags{};
+};
+
+/** Header and fixed record bank beginning at native account-object offset 0x6978. */
+struct ProfileInventoryChangeList {
+    std::uint16_t writeSlot{};
+    std::uint16_t nextSequence{};
+    std::array<ProfileInventoryChangeRecord, kProfileInventoryChangeRecordCapacity> records{};
+};
+
 /** Byte-exact Family-4 account object generated from State. */
 struct Object {
     std::uint64_t accountSoid{};
@@ -118,7 +142,9 @@ struct Object {
     std::uint32_t secondaryItemCount{};
     std::array<std::byte, kInventoryCountPaddingSize> secondaryCountPadding{};
     std::array<inventory::layout::Entry, kSecondaryItemCapacity> secondaryItems{};
-    std::array<std::byte, kSecondaryProgressionPaddingSize> secondaryProgressionPadding{};
+    ProfileInventoryChangeList profileInventoryChanges{};
+    std::array<std::byte, kProfileInventoryProgressionPaddingSize>
+        profileInventoryProgressionPadding{};
     std::array<progression::layout::Entry, kProgressionCapacity> progressions{};
     std::array<std::uint8_t, kAcquiredFlagCapacity> acquiredFlags{};
     std::array<std::int32_t, kObjectiveValueCapacity> objectiveValues{};
@@ -145,6 +171,7 @@ static_assert(offsetof(Object, profileItemCount) == kProfileItemCountOffset);
 static_assert(offsetof(Object, profileItems) == kProfileItemsOffset);
 static_assert(offsetof(Object, secondaryItemCount) == kSecondaryItemCountOffset);
 static_assert(offsetof(Object, secondaryItems) == kSecondaryItemsOffset);
+static_assert(offsetof(Object, profileInventoryChanges) == kProfileInventoryChangesOffset);
 static_assert(offsetof(Object, progressions) == kProgressionsOffset);
 static_assert(offsetof(Object, acquiredFlags) == kAcquiredFlagsOffset);
 static_assert(offsetof(Object, objectiveValues) == kObjectiveValuesOffset);
@@ -152,6 +179,9 @@ static_assert(offsetof(Object, characterUnlocks) == kCharacterUnlocksOffset);
 static_assert(offsetof(Object, profileUnlockFlags) == kProfileUnlockFlagsOffset);
 static_assert(sizeof(CharacterUnlockBlock)
               == kCharacterFlagCapacity + kCharacterValueCapacity * sizeof(std::int32_t));
+static_assert(sizeof(ProfileInventoryChangeRecord) == 12);
+static_assert(sizeof(ProfileInventoryChangeList)
+              == 4 + kProfileInventoryChangeRecordCapacity * sizeof(ProfileInventoryChangeRecord));
 static_assert(std::is_standard_layout_v<Object>);
 static_assert(std::is_trivially_copyable_v<Object>);
 

+ 11 - 0
Sunrise/src/middleware/datagen/family4/character/abi.h

@@ -15,6 +15,12 @@ inline constexpr std::size_t kIdentityOffset = 8;
 inline constexpr std::size_t kNextInventorySerialOffset = 48;
 /** The first fixed inventory row begins at native byte 56. */
 inline constexpr std::size_t kInventoryItemsOffset = 56;
+/** Four opaque bytes after inventory rows begin at native byte 11,256. */
+inline constexpr std::size_t kInventoryChangeUnknownOffset = 11256;
+/** The transient inventory-change header begins at native byte 11,260. */
+inline constexpr std::size_t kInventoryChangeHeaderOffset = 11260;
+/** The first 12-byte inventory-change record begins at native byte 11,264. */
+inline constexpr std::size_t kInventoryChangeRecordsOffset = 11264;
 /** Equipped instance SOIDs begin after all fixed inventory rows at native byte 11,456. */
 inline constexpr std::size_t kEquippedInstancesOffset = 11456;
 /** The stored equipment summary begins at native byte 11,616. */
@@ -49,6 +55,11 @@ static_assert(offsetof(layout::Object, characterSoid) == kCharacterSoidOffset);
 static_assert(offsetof(layout::Object, identity) == kIdentityOffset);
 static_assert(offsetof(layout::Object, nextInventorySerial) == kNextInventorySerialOffset);
 static_assert(offsetof(layout::Object, inventoryItems) == kInventoryItemsOffset);
+static_assert(offsetof(layout::Object, inventoryChangeUnknown) == kInventoryChangeUnknownOffset);
+static_assert(offsetof(layout::Object, inventoryChanges) == kInventoryChangeHeaderOffset);
+static_assert(offsetof(layout::InventoryChangeList, records)
+                  + offsetof(layout::Object, inventoryChanges)
+              == kInventoryChangeRecordsOffset);
 static_assert(offsetof(layout::Object, equippedInstanceSoids) == kEquippedInstancesOffset);
 static_assert(offsetof(layout::Object, equipmentSummary) == kEquipmentSummaryOffset);
 static_assert(offsetof(layout::Object, inventoryGateDefinitionIndex)

+ 25 - 6
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -54,7 +54,9 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
  */
 [[nodiscard]] bool valid(const loadout::ResolvedLoadout& resolvedLoadout) noexcept {
     if (resolvedLoadout.itemCount > resolvedLoadout.items.size()
-        || resolvedLoadout.nextInventorySerial != resolvedLoadout.itemCount) {
+        || resolvedLoadout.nextInventorySerial < resolvedLoadout.itemCount
+        || resolvedLoadout.nextInventorySerial
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
         return false;
     }
 
@@ -71,14 +73,20 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
             || itemInstance.bounds.itemDefinitionCount > instance::layout::kDefinitionIndexCapacity
             || itemInstance.baseDefinitionIndex == kEmptyDefinitionIndex
             || itemInstance.baseDefinitionIndex >= itemInstance.bounds.itemDefinitionCount
-            || occupiedRows[item.inventoryRow] || occupiedEquipmentSlots[item.equipmentSlot]
+            || item.mutationSerial < 0
+            || static_cast<std::uint32_t>(item.mutationSerial)
+                   >= resolvedLoadout.nextInventorySerial
+            || occupiedRows[item.inventoryRow]
+            || (item.equipped && occupiedEquipmentSlots[item.equipmentSlot])
             || std::find(instanceSoids.cbegin(), priorSoidsEnd, itemInstance.instanceSoid)
                    != priorSoidsEnd
             || (index != 0 && resolvedLoadout.items[index - 1].inventoryRow >= item.inventoryRow)) {
             return false;
         }
         occupiedRows[item.inventoryRow] = true;
-        occupiedEquipmentSlots[item.equipmentSlot] = true;
+        if (item.equipped) {
+            occupiedEquipmentSlots[item.equipmentSlot] = true;
+        }
         instanceSoids[index] = itemInstance.instanceSoid;
     }
     return true;
@@ -94,14 +102,21 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
 summary_matches_loadout(const loadout::ResolvedLoadout& resolvedLoadout,
                         const state::equipment::light::Evaluation& evaluation) noexcept {
     std::size_t summaryItemCount = 0;
+    for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
+        summaryItemCount += static_cast<std::size_t>(resolvedLoadout.items[index].equipped);
+    }
+    std::size_t scoreCount = 0;
     for (const std::optional<state::equipment::light::ItemScore>& score : evaluation.character) {
-        summaryItemCount += static_cast<std::size_t>(score.has_value());
+        scoreCount += static_cast<std::size_t>(score.has_value());
     }
-    if (summaryItemCount != resolvedLoadout.itemCount) {
+    if (summaryItemCount != scoreCount) {
         return false;
     }
     for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
         const loadout::ResolvedItem& item = resolvedLoadout.items[index];
+        if (!item.equipped) {
+            continue;
+        }
         const auto& score = evaluation.character[item.equipmentSlot];
         if (!score.has_value() || score->definitionIndex != item.instance.baseDefinitionIndex) {
             return false;
@@ -160,12 +175,16 @@ bool encode(const state::CharacterState& state,
         inventoryRow.definitionIndex = item.instance.baseDefinitionIndex;
         inventoryRow.instanceSoid = item.instance.instanceSoid;
         inventoryRow.quantity = item.quantity;
+        inventoryRow.mutationSerial = item.mutationSerial;
+        inventoryRow.flags = item.flags;
         // Both companion arrays are indexed by inventory row, not by equipment slot, and the
         // client's own producer marks every row it fills.
         object.newItemFlags[item.inventoryRow / kBitsPerFlagByte] |=
             std::byte{1U} << (item.inventoryRow % kBitsPerFlagByte);
         object.instanceProgressWatermarks[item.inventoryRow] = kOccupiedRowWatermark;
-        object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
+        if (item.equipped) {
+            object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
+        }
     }
 
     // Commit only after validation so callers never receive a partially initialized object.

+ 38 - 3
Sunrise/src/middleware/datagen/family4/character/layout.h

@@ -38,8 +38,10 @@ inline constexpr std::size_t kObjectiveValueCapacity = 768;
 inline constexpr std::size_t kCreationHeaderSize = 36;
 /** The next inventory serial is followed by 4 reserved alignment bytes. */
 inline constexpr std::size_t kInventorySerialPaddingSize = 4;
-/** 200 reserved bytes separate inventory rows from equipped instance keys. */
-inline constexpr std::size_t kInventoryEquipmentPaddingSize = 200;
+/** 4 opaque bytes precede the character inventory-change list. */
+inline constexpr std::size_t kInventoryChangeUnknownSize = 4;
+/** The character object carries at most 16 transient inventory-change records. */
+inline constexpr std::size_t kInventoryChangeRecordCapacity = 16;
 /** 52 reserved bytes separate the equipment summary from its validity gate. */
 inline constexpr std::size_t kSummaryGatePaddingSize = 52;
 /** 14 reserved bytes separate the two inventory validity gate fields. */
@@ -108,6 +110,29 @@ struct RosterMirrorEntry {
     std::array<std::byte, kRosterMirrorEntrySize> bytes{};
 };
 
+/** One transient inventory mutation consumed by the native character-object observer. */
+struct InventoryChangeRecord {
+    /** Rising record identity; the native observer prefers the greatest matching value. */
+    std::uint16_t sequence{};
+    std::uint16_t reserved{};
+    /** Mutation serial of the inventory row this record describes. */
+    std::int32_t mutationSerial{};
+    /** Nonzero mutation kind. Kind 1 follows the ordinary item-acquisition path. */
+    std::uint8_t kind{};
+    std::uint8_t reservedKind{};
+    /** Native observer policy bits; 0 enables the ordinary acquisition path. */
+    std::uint16_t flags{};
+};
+
+/** Header and fixed record bank occupying bytes 0x2BFC through 0x2CBF. */
+struct InventoryChangeList {
+    /** Ring slot the native producer will write next. */
+    std::uint16_t writeSlot{};
+    /** Sequence assigned to the next native record. */
+    std::uint16_t nextSequence{};
+    std::array<InventoryChangeRecord, kInventoryChangeRecordCapacity> records{};
+};
+
 /** Byte-exact selected-character Family-4 object generated from resolved loadout rows. */
 struct Object {
     std::uint64_t characterSoid{};
@@ -117,7 +142,8 @@ struct Object {
     std::uint32_t nextInventorySerial{};
     std::array<std::byte, kInventorySerialPaddingSize> inventorySerialPadding{};
     std::array<inventory::layout::Entry, kInventoryCapacity> inventoryItems{};
-    std::array<std::byte, kInventoryEquipmentPaddingSize> inventoryEquipmentPadding{};
+    std::array<std::byte, kInventoryChangeUnknownSize> inventoryChangeUnknown{};
+    InventoryChangeList inventoryChanges{};
     std::array<std::uint64_t, kEquipmentCapacity> equippedInstanceSoids{};
     EquipmentSummary equipmentSummary{};
     std::array<std::byte, kSummaryGatePaddingSize> summaryGatePadding{};
@@ -159,6 +185,15 @@ static_assert(sizeof(EquipmentSummary)
               == kSummaryArrayCount * kEquipmentCapacity * sizeof(EquipmentSummaryEntry)
                      + kSummaryIntegerCount * sizeof(std::int32_t)
                      + kSummaryScalarCount * sizeof(float));
+static_assert(sizeof(InventoryChangeRecord) == 12);
+static_assert(offsetof(InventoryChangeRecord, sequence) == 0);
+static_assert(offsetof(InventoryChangeRecord, mutationSerial) == 4);
+static_assert(offsetof(InventoryChangeRecord, kind) == 8);
+static_assert(offsetof(InventoryChangeRecord, flags) == 10);
+static_assert(sizeof(InventoryChangeList)
+              == sizeof(std::uint32_t)
+                     + kInventoryChangeRecordCapacity * sizeof(InventoryChangeRecord));
+static_assert(offsetof(InventoryChangeList, records) == 4);
 static_assert(sizeof(Object) == kObjectSize);
 static_assert(std::is_trivially_copyable_v<Object>);
 

+ 9 - 2
Sunrise/src/middleware/datagen/family4/loadout/definition.h

@@ -9,14 +9,21 @@
 
 namespace sunrise::middleware::datagen::family4::loadout {
 
-/** One selected item can occupy each of the 16 authored semantic equipment slots. */
-inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount;
+/** Equipped plus unequipped authored items one character may publish. */
+inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount
+                                             + state::account::inventory::kCharacterItemCapacity;
 
 /** One installed-build-resolved item ready for character and instance encoding. */
 struct ResolvedItem {
     std::uint16_t inventoryRow{};
     std::uint8_t equipmentSlot{};
+    /** Only equipped items publish their instance SOID into the native equipment array. */
+    bool equipped{};
     std::int32_t quantity{};
+    /** Authored runtime generation copied into the native inventory row. */
+    std::int32_t mutationSerial{};
+    /** Accumulated native item-state bits copied into the native inventory row. */
+    std::uint32_t flags{};
     instance::ResolvedInstance instance{};
 };
 

+ 2 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -153,6 +153,8 @@ bool resolve_item(const authored_inventory::Item& authored,
     Candidate candidate{};
     candidate.bucket = bucket;
     candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    candidate.item.mutationSerial = authored.mutationSerial;
+    candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)
         || !resolve_ordinary_sockets(authored.sockets,
                                      itemDetail,

+ 73 - 10
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp

@@ -74,12 +74,11 @@ namespace build_buckets = state::build_data::inventory::buckets;
     return false;
 }
 
-} // namespace
-
-/** Resolves the item instances one character owns, selected or not. */
-bool resolve_instances(const state::AccountState& account,
-                       std::size_t characterIndex,
-                       ResolvedInstances& output) noexcept {
+/** Resolves equipped items, and optionally unequipped inventory, for one character. */
+[[nodiscard]] bool resolve_character_instances(const state::AccountState& account,
+                                               std::size_t characterIndex,
+                                               bool includeInventory,
+                                               ResolvedInstances& output) noexcept {
     output = {};
     if (account.characterCount > account.characters.size()
         || characterIndex >= account.characterCount || !state::build_data::item_definitions_ready()
@@ -111,6 +110,21 @@ bool resolve_instances(const state::AccountState& account,
         }
         ++itemCount;
     }
+    if (includeInventory) {
+        for (std::size_t index = 0; index < character.inventory.count; ++index) {
+            Candidate candidate{};
+            if (itemCount >= resolved.size()
+                || !resolve_item(character.inventory.values[index],
+                                 character,
+                                 itemDefinitionCount,
+                                 socketEntryListCount,
+                                 candidate)
+                || !place_item(candidate, occupied, resolved[itemCount])) {
+                return false;
+            }
+            ++itemCount;
+        }
+    }
     std::sort(resolved.begin(),
               resolved.begin() + static_cast<std::ptrdiff_t>(itemCount),
               [](const ResolvedItem& first, const ResolvedItem& second) {
@@ -126,6 +140,22 @@ bool resolve_instances(const state::AccountState& account,
     return true;
 }
 
+} // namespace
+
+/** Resolves the equipped item instances one character owns, selected or not. */
+bool resolve_instances(const state::AccountState& account,
+                       std::size_t characterIndex,
+                       ResolvedInstances& output) noexcept {
+    return resolve_character_instances(account, characterIndex, false, output);
+}
+
+/** Resolves every equipped and unequipped item instance one character owns. */
+bool resolve_owned_instances(const state::AccountState& account,
+                             std::size_t characterIndex,
+                             ResolvedInstances& output) noexcept {
+    return resolve_character_instances(account, characterIndex, true, output);
+}
+
 /** Resolves one selected character's authored equipment through installed build data. */
 bool resolve(const state::AccountState& account,
              std::size_t selectedCharacterIndex,
@@ -149,8 +179,10 @@ bool resolve(const state::AccountState& account,
         semanticToNative{};
     std::array<std::optional<std::size_t>, state::build_data::items::details::kEquipmentSlotCount>
         nativeToSemantic{};
-    std::array<Candidate, kItemCapacity> selectedCandidates{};
-    std::array<bool, kItemCapacity> selectedPresent{};
+    std::array<Candidate, authored_inventory::kEquipmentSlotCount> selectedCandidates{};
+    std::array<bool, authored_inventory::kEquipmentSlotCount> selectedPresent{};
+    std::array<Candidate, authored_inventory::kCharacterItemCapacity> selectedInventory{};
+    std::size_t selectedInventoryCount = 0;
     std::array<std::uint64_t, state::kCharacterCapacity * kItemCapacity> instanceSoids{};
     std::size_t instanceSoidCount = 0;
     // Every character contributes to one stable semantic-to-native slot contract.
@@ -184,11 +216,35 @@ bool resolve(const state::AccountState& account,
                                           nativeToSemantic)) {
                 return false;
             }
+            candidate.item.equipped = true;
             if (characterIndex == selectedCharacterIndex) {
                 selectedCandidates[semanticIndex] = candidate;
                 selectedPresent[semanticIndex] = true;
             }
         }
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            const authored_inventory::Item& authored = character.inventory.values[itemIndex];
+            const auto instanceSoidEnd =
+                instanceSoids.cbegin() + static_cast<std::ptrdiff_t>(instanceSoidCount);
+            if (instanceSoidCount >= instanceSoids.size()
+                || std::find(instanceSoids.cbegin(), instanceSoidEnd, authored.instanceSoid)
+                       != instanceSoidEnd) {
+                return false;
+            }
+            instanceSoids[instanceSoidCount++] = authored.instanceSoid;
+            if (characterIndex != selectedCharacterIndex) {
+                continue;
+            }
+            if (selectedInventoryCount >= selectedInventory.size()
+                || !resolve_item(authored,
+                                 character,
+                                 itemDefinitionCount,
+                                 socketEntryListCount,
+                                 selectedInventory[selectedInventoryCount])) {
+                return false;
+            }
+            ++selectedInventoryCount;
+        }
     }
 
     ResolvedLoadout staged{};
@@ -205,13 +261,20 @@ bool resolve(const state::AccountState& account,
         }
         ++staged.itemCount;
     }
+    for (std::size_t index = 0; index < selectedInventoryCount; ++index) {
+        // Equipment claims the first bucket row; unequipped items fill the remaining rows.
+        if (staged.itemCount >= staged.items.size()
+            || !place_item(selectedInventory[index], occupied, staged.items[staged.itemCount])) {
+            return false;
+        }
+        ++staged.itemCount;
+    }
     std::sort(staged.items.begin(),
               staged.items.begin() + static_cast<std::ptrdiff_t>(staged.itemCount),
               [](const ResolvedItem& first, const ResolvedItem& second) {
                   return first.inventoryRow < second.inventoryRow;
               });
-    for (std::size_t index = 0; index < staged.itemCount; ++index) {}
-    staged.nextInventorySerial = static_cast<std::uint32_t>(staged.itemCount);
+    staged.nextInventorySerial = account.characters[selectedCharacterIndex].nextInventorySerial;
 
     // Counts are the publication-stability gate for the mappings resolved above.
     if (state::build_data::item_definition_count() != itemDefinitionCount

+ 9 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.h

@@ -31,4 +31,13 @@ namespace sunrise::middleware::datagen::family4::loadout {
                                      std::size_t characterIndex,
                                      ResolvedInstances& output) noexcept;
 
+/**
+ * Resolves every equipped and unequipped item instance one character owns.
+ * Family four must
+ * publish an instance object for every SOID referenced by the character inventory.
+ */
+[[nodiscard]] bool resolve_owned_instances(const state::AccountState& account,
+                                           std::size_t characterIndex,
+                                           ResolvedInstances& output) noexcept;
+
 } // namespace sunrise::middleware::datagen::family4::loadout

+ 98 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_object_staging.cpp

@@ -4,6 +4,7 @@
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/layout.h"
+#include "../../../../../state/build_data/runtime.h"
 #include "internal.h"
 
 namespace sunrise::server::bap::encrypted::push::snapshot {
@@ -75,4 +76,101 @@ bool append_items(Scratch& scratch,
     return true;
 }
 
+/** Resolves one source-backed profile stack into the shared Family-4 item-instance schema. */
+bool resolve_profile_item_instance(const state::account::inventory::ProfileItem& profileItem,
+                                   family4_datagen::instance::ResolvedInstance& output) noexcept {
+    output = {};
+    const std::size_t itemDefinitionCount = state::build_data::item_definition_count();
+    const std::size_t socketEntryListCount = state::build_data::socket_entry_list_count();
+    state::build_data::items::Definition item{};
+    state::build_data::items::details::Definition detail{};
+    state::build_data::inventory::buckets::Descriptor bucket{};
+    state::build_data::socket_entry_lists::Definition socketList{};
+    if (profileItem.instanceSoid == 0 || profileItem.quantity <= 0 || profileItem.mutationSerial < 0
+        || itemDefinitionCount == 0
+        || itemDefinitionCount > state::build_data::items::kDefinitionCapacity
+        || socketEntryListCount == 0
+        || socketEntryListCount > state::build_data::socket_entry_lists::kDefinitionCapacity
+        || !state::build_data::find_item_definition_hash(profileItem.definitionHash, item)
+        || item.definitionHash != profileItem.definitionHash
+        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+        || detail.definitionIndex != item.definitionIndex
+        || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+        || detail.equipmentSlot.has_value()
+        || detail.instancedDefinitionState
+               != state::build_data::items::details::InstancedDefinitionState::stackable
+        || detail.ordinarySocketState
+               != state::build_data::items::details::OrdinarySocketState::absent
+        || detail.ordinarySocketCount != 0
+        || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+        || bucket.arraySelector != state::build_data::inventory::buckets::ArraySelector::profile
+        || !state::build_data::is_profile_action_source(item.definitionIndex, item.bucketId)
+        || !state::build_data::find_socket_entry_list(detail.socketEntryListIndex, socketList)
+        || socketList.definitionIndex != detail.socketEntryListIndex || socketList.entryCount != 0
+        || static_cast<std::size_t>(item.definitionIndex) >= itemDefinitionCount
+        || static_cast<std::size_t>(socketList.definitionIndex) >= socketEntryListCount) {
+        return false;
+    }
+
+    family4_datagen::instance::ResolvedInstance candidate{};
+    candidate.instanceSoid = profileItem.instanceSoid;
+    candidate.bounds.itemDefinitionCount = static_cast<std::uint32_t>(itemDefinitionCount);
+    candidate.bounds.socketEntryListCount = static_cast<std::uint32_t>(socketEntryListCount);
+    candidate.baseDefinitionIndex = item.definitionIndex;
+    candidate.level = family4_datagen::instance::layout::kMinimumItemLevel;
+    candidate.curveSelector = family4_datagen::instance::layout::kInitialLevelCurveX;
+    candidate.capSelector = family4_datagen::instance::layout::kInitialLevelCapRow;
+    candidate.socketEntryListIndex = socketList.definitionIndex;
+    candidate.socketEntryCount = 0;
+    candidate.socketEntryContentsResolved = true;
+    candidate.ordinarySockets.state = family4_datagen::instance::OrdinarySocketBlockState::absent;
+    if (state::build_data::item_definition_count() != itemDefinitionCount
+        || state::build_data::socket_entry_list_count() != socketEntryListCount) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Appends every source-backed profile item after all character-owned item residents. */
+bool append_profile_items(Scratch& scratch,
+                          std::span<std::byte> rawStorage,
+                          std::uint32_t itemInstanceObjectId,
+                          const state::AccountState& account,
+                          std::size_t baseIndex,
+                          Prepared& staged,
+                          std::size_t& itemCursor,
+                          std::size_t& compressedExtent) noexcept {
+    if (account.profileItemCount > account.profileItems.size()
+        || family4_datagen::instance::layout::kObjectSize > rawStorage.size()) {
+        return false;
+    }
+    const auto encoded = rawStorage.first(family4_datagen::instance::layout::kObjectSize);
+    std::size_t appendedCount = 0;
+    for (std::size_t profileIndex = 0; profileIndex < account.profileItemCount; ++profileIndex) {
+        const state::account::inventory::ProfileItem& item = account.profileItems[profileIndex];
+        if (item.instanceSoid == 0) {
+            continue;
+        }
+        if (appendedCount >= state::account::inventory::kProfileActionSourceCapacity
+            || baseIndex + itemCursor >= staged.objects.size()) {
+            return false;
+        }
+        family4_datagen::instance::ResolvedInstance instance{};
+        if (!resolve_profile_item_instance(item, instance)
+            || !family4_datagen::instance::encode(instance, encoded)
+            || !append_object(scratch,
+                              encoded,
+                              itemInstanceObjectId,
+                              instance.instanceSoid,
+                              staged.objects[baseIndex + itemCursor],
+                              compressedExtent)) {
+            return false;
+        }
+        ++itemCursor;
+        ++appendedCount;
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 19 - 1
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -61,6 +61,11 @@ bool prepare(Scratch& scratch,
     if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
         return report_failure("account_storage");
     }
+    // Package extraction may have completed after State initialization on a first cache build.
+    // Canonicalize profile action-source identities immediately before the first Family-4 image.
+    if (!state::ensure_profile_item_identities()) {
+        return report_failure("profile_identities");
+    }
     const state::AccountState account = state::account_snapshot();
     if (!state::account::valid(account)) {
         return report_failure("account_state");
@@ -128,7 +133,8 @@ bool prepare(Scratch& scratch,
     for (std::size_t characterIndex = 0; characterIndex < account.characterCount;
          ++characterIndex) {
         family4_datagen::loadout::ResolvedInstances instances{};
-        if (!family4_datagen::loadout::resolve_instances(account, characterIndex, instances)) {
+        if (!family4_datagen::loadout::resolve_owned_instances(
+                account, characterIndex, instances)) {
             return report_failure("loadout");
         }
         if (instances.itemCount != 0
@@ -143,6 +149,18 @@ bool prepare(Scratch& scratch,
             return report_failure("items");
         }
     }
+    // Profile rows with nonzero SOIDs are native action sources.  Their item residents follow all
+    // character-owned instances so the full-snapshot manifest remains deterministic.
+    if (!append_profile_items(scratch,
+                              rawStorage,
+                              itemInstanceObjectId,
+                              account,
+                              itemBaseIndex,
+                              staged,
+                              itemCursor,
+                              compressedExtent)) {
+        return report_failure("profile_items");
+    }
 
     const std::size_t objectCount = itemBaseIndex + itemCursor;
     if (itemCursor != 0) {

+ 88 - 26
Sunrise/src/state/account/account_state.cpp

@@ -5,56 +5,118 @@
 #include <cmath>
 
 namespace sunrise::state::account {
+namespace {
 
-/**
- * Checks bounded ids and whole settings for every nonzero account.
- * @param state Account State to check.
- * @return True for empty State, or a whole account with unique ids.
- */
-bool valid(const AccountState& state) noexcept {
+/** Enough fixed storage for every account, character, profile-stack, and character-item key. */
+inline constexpr std::size_t kIdentityCapacity =
+    1 + inventory::kProfileItemCapacity
+    + kCharacterCapacity * (1 + inventory::kEquipmentSlotCount + inventory::kCharacterItemCapacity);
+
+/** @return True when a profile row carries no authored or runtime-owned value. */
+[[nodiscard]] bool empty_profile_item(const inventory::ProfileItem& item) noexcept {
+    return item.instanceSoid == 0 && item.definitionHash == 0 && item.quantity == 0
+           && item.mutationSerial == 0;
+}
+
+/** Adds one nonzero globally unique key to a bounded identity set. */
+[[nodiscard]] bool append_identity(std::array<std::uint64_t, kIdentityCapacity>& identities,
+                                   std::size_t& count,
+                                   std::uint64_t soid) noexcept {
+    if (soid == 0 || count >= identities.size()) {
+        return false;
+    }
+    const auto end = identities.cbegin() + static_cast<std::ptrdiff_t>(count);
+    if (std::find(identities.cbegin(), end, soid) != end) {
+        return false;
+    }
+    identities[count++] = soid;
+    return true;
+}
+
+/** Checks the complete authored/runtime structure without consulting installed build data. */
+[[nodiscard]] bool valid_impl(const AccountState& state) noexcept {
+    if (state.profileItemCount > state.profileItems.size()
+        || state.characterCount > state.characters.size()) {
+        return false;
+    }
     if (state.primarySoid == 0) {
-        // Null accounts cannot own characters or a detached settings object.
-        return state.characterCount == 0 && !state.settings.configured
-               && !state.settings.keyBindings.configured;
+        if (state.profileItemCount != 0 || state.characterCount != 0 || state.settings.configured
+            || state.settings.keyBindings.configured) {
+            return false;
+        }
+        return std::all_of(
+            state.profileItems.cbegin(), state.profileItems.cend(), empty_profile_item);
+    }
+    if (!settings::valid(state.settings)) {
+        return false;
     }
-    if (state.characterCount > state.characters.size() || !settings::valid(state.settings)) {
+
+    std::array<std::uint64_t, kIdentityCapacity> identities{};
+    std::size_t identityCount = 0;
+    if (!append_identity(identities, identityCount, state.primarySoid)) {
         return false;
     }
+    for (std::size_t index = 0; index < state.profileItems.size(); ++index) {
+        const inventory::ProfileItem& item = state.profileItems[index];
+        if (index >= state.profileItemCount) {
+            if (!empty_profile_item(item)) {
+                return false;
+            }
+            continue;
+        }
+        if (item.definitionHash == inventory::kNoDefinitionHash || item.quantity <= 0
+            || item.mutationSerial < 0
+            || (item.instanceSoid != 0
+                && !append_identity(identities, identityCount, item.instanceSoid))) {
+            return false;
+        }
+    }
+
     bool selected = false;
-    std::array<std::uint64_t, kCharacterCapacity * inventory::kEquipmentSlotCount> itemSoids{};
-    std::size_t itemSoidCount = 0;
     for (std::size_t index = 0; index < state.characterCount; ++index) {
         const CharacterState& character = state.characters[index];
-        if (character.soid == 0 || character.soid == state.primarySoid
+        if (!append_identity(identities, identityCount, character.soid)
             || (character.selected && selected) || character.race > CharacterRace::exo
             || character.gender > CharacterGender::female
             || character.characterClass > CharacterClass::warlock
-            || !std::isfinite(character.appearanceValue)
-            || !inventory::valid(character.equipment)) {
+            || !std::isfinite(character.appearanceValue) || !inventory::valid(character.equipment)
+            || !inventory::valid(character.inventory)) {
             return false;
         }
         selected = selected || character.selected;
-        // The fixed 3-slot size keeps this compare against earlier entries bounded and
-        // allocation-free.
-        for (std::size_t prior = 0; prior < index; ++prior) {
-            if (state.characters[prior].soid == character.soid) {
+        for (const std::optional<inventory::Item>& item : character.equipment.slots) {
+            if (item.has_value()
+                && !append_identity(identities, identityCount, item->instanceSoid)) {
                 return false;
             }
         }
-        for (const std::optional<inventory::Item>& item : character.equipment.slots) {
-            if (!item.has_value()) {
-                continue;
-            }
-            const auto end = itemSoids.cbegin() + static_cast<std::ptrdiff_t>(itemSoidCount);
-            if (std::find(itemSoids.cbegin(), end, item->instanceSoid) != end) {
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            if (!append_identity(identities,
+                                 identityCount,
+                                 character.inventory.values[itemIndex].instanceSoid)) {
                 return false;
             }
-            itemSoids[itemSoidCount++] = item->instanceSoid;
         }
     }
     return true;
 }
 
+} // namespace
+
+/**
+ * Checks bounded ids and whole settings for every nonzero account.
+ * @param state Account State to check.
+ * @return True for empty State, or a whole account with unique ids.
+ */
+bool valid(const AccountState& state) noexcept {
+    return valid_impl(state);
+}
+
+/** Checks settings-authored State before runtime-only profile stack identities are seeded. */
+bool valid_authored(const AccountState& state) noexcept {
+    return valid_impl(state);
+}
+
 /**
  * Finds the selected character without storing a second account-level key.
  * @param state Account snapshot read under the lock.

+ 7 - 0
Sunrise/src/state/account/account_state.h

@@ -91,6 +91,10 @@ struct CharacterState {
     std::uint8_t classAbilityEntry{kDefaultClassAbilityEntry};
     /** Authored loadout keyed only by stable semantic equipment slots. */
     account::inventory::Equipment equipment;
+    /** Unequipped items routed into their installed character-inventory bucket ranges. */
+    account::inventory::CharacterItems inventory;
+    /** Next row generation; equip transactions consume two values for the two moved items. */
+    std::uint32_t nextInventorySerial{};
 };
 
 /** Account identity shared by backend object families. */
@@ -109,6 +113,9 @@ namespace account {
 
 [[nodiscard]] bool valid(const AccountState& state) noexcept;
 
+/** Checks settings-authored State before runtime-only profile stack SOIDs are assigned. */
+[[nodiscard]] bool valid_authored(const AccountState& state) noexcept;
+
 [[nodiscard]] std::uint64_t selected_character_soid(const AccountState& state) noexcept;
 
 } // namespace account

+ 18 - 1
Sunrise/src/state/account/inventory/inventory_state.cpp

@@ -74,7 +74,7 @@ bool valid(const Sockets& sockets) noexcept {
 /** Checks one whole authored item without reading installed build data. */
 bool valid(const Item& item) noexcept {
     return item.instanceSoid != 0 && item.definitionHash != kNoDefinitionHash && item.level >= 0
-           && item.quantity > 0 && valid(item.sockets);
+           && item.quantity > 0 && item.mutationSerial >= 0 && valid(item.sockets);
 }
 
 /** Checks every item present in the fixed semantic equipment array. */
@@ -87,4 +87,21 @@ bool valid(const Equipment& equipment) noexcept {
     return true;
 }
 
+/** Checks the used prefix and empty tail of one character's unequipped item array. */
+bool valid(const CharacterItems& items) noexcept {
+    if (items.count > items.values.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < items.values.size(); ++index) {
+        if (index < items.count) {
+            if (!valid(items.values[index])) {
+                return false;
+            }
+        } else if (items.values[index].instanceSoid != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
 } // namespace sunrise::state::account::inventory

+ 29 - 2
Sunrise/src/state/account/inventory/inventory_state.h

@@ -49,13 +49,27 @@ struct Sockets {
     std::size_t plugCount{};
 };
 
-/** Account-wide profile items an account may declare. */
-inline constexpr std::size_t kProfileItemCapacity = 32;
+/** Account-wide stacks can occupy every row of the native 701-row profile inventory. */
+inline constexpr std::size_t kProfileItemCapacity = 701;
+/** The supported mod and shader profile bucket runs reserve 50 action-source rows each. */
+inline constexpr std::size_t kProfileActionSourceCapacity = 100;
+/** Runtime-owned SOIDs for profile stacks use a namespace separate from created item instances. */
+inline constexpr std::uint64_t kFirstProfileItemInstanceSoid = 0x5000000000000001ULL;
+/**
+ * The 16 supported character equipment buckets reserve 151 native rows in this build. One row
+
+ * * per semantic slot can be equipped, leaving at most 135 simultaneously unequipped instances.
+ */
+inline constexpr std::size_t kCharacterItemCapacity = 135;
 
 /** One authored account-wide item, placed by the inventory bucket its definition names. */
 struct ProfileItem {
+    /** Stable runtime identity required to materialize this row as an inventory action source. */
+    std::uint64_t instanceSoid{};
     std::uint32_t definitionHash{};
     std::int32_t quantity{};
+    /** Rising generation copied into the native row and matched by acquisition feedback. */
+    std::int32_t mutationSerial{};
 };
 
 /** One authored equipment item without native table or wire-layout fields. */
@@ -64,9 +78,19 @@ struct Item {
     std::uint32_t definitionHash{};
     std::int32_t level{};
     std::int32_t quantity{};
+    /** Rising per-character generation assigned whenever this item changes inventory rows. */
+    std::int32_t mutationSerial{};
+    /** Native accumulated item-state bits such as the finisher favorite marker. */
+    std::uint32_t flags{};
     Sockets sockets;
 };
 
+/** Ordered unequipped items placed into their native character-inventory bucket ranges. */
+struct CharacterItems {
+    std::array<Item, kCharacterItemCapacity> values{};
+    std::size_t count{};
+};
+
 /** One optional authored item for every semantic equipment slot. */
 struct Equipment {
     std::array<std::optional<Item>, kEquipmentSlotCount> slots{};
@@ -97,4 +121,7 @@ struct Equipment {
  */
 [[nodiscard]] bool valid(const Equipment& equipment) noexcept;
 
+/** Checks the used prefix and empty tail of one character's unequipped item array. */
+[[nodiscard]] bool valid(const CharacterItems& items) noexcept;
+
 } // namespace sunrise::state::account::inventory

+ 15 - 4
Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp

@@ -68,6 +68,14 @@ void mix_sockets(std::uint64_t& hash, const account::inventory::Sockets& sockets
     }
 }
 
+/** Mixes the installed-detail inputs shared by equipped and unequipped authored items. */
+void mix_item(std::uint64_t& hash, const account::inventory::Item& item) noexcept {
+    // SOIDs, quantity, gates and secrets stay outside build identity.
+    mix_value(hash, item.definitionHash);
+    mix_value(hash, static_cast<std::uint32_t>(item.level));
+    mix_sockets(hash, item.sockets);
+}
+
 /**
  * Mixes one character's 5 selected subclass entries.
  * The ability bucket rows are keyed by these, so a changed pick must rebuild.
@@ -98,10 +106,13 @@ std::uint64_t configured_hash(const AccountState& accountState) noexcept {
                 continue;
             }
             mix_byte(hash, kPresentItemMarker);
-            // SOIDs, quantity, gates and secrets stay outside build identity.
-            mix_value(hash, item->definitionHash);
-            mix_value(hash, static_cast<std::uint32_t>(item->level));
-            mix_sockets(hash, item->sockets);
+            mix_item(hash, *item);
+        }
+        static_assert(account::inventory::kCharacterItemCapacity
+                      <= (std::numeric_limits<std::uint8_t>::max)());
+        mix_byte(hash, static_cast<std::uint8_t>(character.inventory.count));
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            mix_item(hash, character.inventory.values[itemIndex]);
         }
     }
     return hash;

+ 141 - 7
Sunrise/src/state/runtime/state_runtime.cpp

@@ -50,6 +50,118 @@ template <std::size_t Size>
            >= 0;
 }
 
+/** @return True when any authored or already-seeded account identity owns one SOID. */
+[[nodiscard]] bool identity_uses_soid(const AccountState& accountState,
+                                      std::uint64_t soid) noexcept {
+    if (soid == 0 || accountState.primarySoid == soid) {
+        return true;
+    }
+    for (std::size_t index = 0; index < accountState.profileItemCount; ++index) {
+        if (accountState.profileItems[index].instanceSoid == soid) {
+            return true;
+        }
+    }
+    for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
+         ++characterIndex) {
+        const CharacterState& character = accountState.characters[characterIndex];
+        if (character.soid == soid) {
+            return true;
+        }
+        for (const std::optional<account::inventory::Item>& item : character.equipment.slots) {
+            if (item.has_value() && item->instanceSoid == soid) {
+                return true;
+            }
+        }
+        for (std::size_t index = 0; index < character.inventory.count; ++index) {
+            if (character.inventory.values[index].instanceSoid == soid) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+/** Seeds canonical character row generations before installed build data is needed. */
+[[nodiscard]] bool seed_inventory_runtime_fields(AccountState& accountState) noexcept {
+    if (!account::valid_authored(accountState)) {
+        return false;
+    }
+    for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
+         ++characterIndex) {
+        CharacterState& character = accountState.characters[characterIndex];
+        std::uint32_t next = 0;
+        for (std::optional<account::inventory::Item>& item : character.equipment.slots) {
+            if (item.has_value()) {
+                item->mutationSerial = static_cast<std::int32_t>(next++);
+            }
+        }
+        for (std::size_t index = 0; index < character.inventory.count; ++index) {
+            character.inventory.values[index].mutationSerial = static_cast<std::int32_t>(next++);
+        }
+        character.nextInventorySerial = next;
+    }
+    return account::valid(accountState);
+}
+
+/** Canonicalizes only profile rows which the installed socket UI materializes as action sources. */
+[[nodiscard]] bool canonicalize_profile_item_identities(AccountState& accountState) noexcept {
+    if (!account::valid(accountState) || !build_data::socket_plug_rules_ready()) {
+        return false;
+    }
+    std::array<bool, account::inventory::kProfileItemCapacity> actionSources{};
+    std::size_t actionSourceCount = 0;
+    for (std::size_t index = 0; index < accountState.profileItemCount; ++index) {
+        const account::inventory::ProfileItem& profileItem = accountState.profileItems[index];
+        build_data::items::Definition item{};
+        build_data::items::details::Definition detail{};
+        build_data::inventory::buckets::Descriptor bucket{};
+        if (!build_data::find_item_definition_hash(profileItem.definitionHash, item)
+            || item.definitionHash != profileItem.definitionHash
+            || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+            || detail.definitionIndex != item.definitionIndex
+            || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+            || detail.instancedDefinitionState
+                   != build_data::items::details::InstancedDefinitionState::stackable
+            || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+            || bucket.arraySelector != build_data::inventory::buckets::ArraySelector::profile) {
+            return false;
+        }
+        actionSources[index] =
+            build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
+        if (actionSources[index]
+            && ++actionSourceCount > account::inventory::kProfileActionSourceCapacity) {
+            return false;
+        }
+    }
+
+    // Currency, material, and consumable rows are native non-instanced stacks.  Clear any stale
+    // runtime key before allocating action-source identities so it cannot reserve the namespace.
+    for (std::size_t index = 0; index < accountState.profileItemCount; ++index) {
+        if (!actionSources[index]) {
+            accountState.profileItems[index].instanceSoid = 0;
+        }
+    }
+
+    std::uint64_t nextProfileSoid = account::inventory::kFirstProfileItemInstanceSoid;
+    for (std::size_t index = 0; index < accountState.profileItemCount; ++index) {
+        account::inventory::ProfileItem& item = accountState.profileItems[index];
+        if (!actionSources[index] || item.instanceSoid != 0) {
+            continue;
+        }
+        while (identity_uses_soid(accountState, nextProfileSoid)) {
+            if (nextProfileSoid == (std::numeric_limits<std::uint64_t>::max)()) {
+                return false;
+            }
+            ++nextProfileSoid;
+        }
+        item.instanceSoid = nextProfileSoid;
+        if (nextProfileSoid != (std::numeric_limits<std::uint64_t>::max)()) {
+            ++nextProfileSoid;
+        }
+    }
+    return account::valid(accountState);
+}
+
 } // namespace
 
 /**
@@ -72,10 +184,20 @@ bool initialize(void* module, const AccountState& initialAccount) noexcept {
 bool initialize(void* module,
                 const AccountState& initialAccount,
                 const activity::defaults::ActivityDefaults& activityDefaults) noexcept {
-    if (!account::valid(initialAccount) || !activity::defaults::valid(activityDefaults)) {
+    AccountState runtimeAccount = initialAccount;
+    if (!seed_inventory_runtime_fields(runtimeAccount)
+        || !activity::defaults::valid(activityDefaults)) {
         return false;
     }
-    if (!build_data::initialize(module, runtime::equipment::configured_hash(initialAccount))) {
+    if (!build_data::initialize(module, runtime::equipment::configured_hash(runtimeAccount))) {
+        return false;
+    }
+    // A cache hit already has the complete plug relation, so publish canonical profile identities
+    // in the first State image.  On a first cache build, snapshot preparation repeats this step
+    // after package extraction has published the relation.
+    if (build_data::socket_plug_rules_ready()
+        && !canonicalize_profile_item_identities(runtimeAccount)) {
+        build_data::shutdown();
         return false;
     }
     {
@@ -85,8 +207,8 @@ bool initialize(void* module,
             std::snprintf(line.data(),
                           line.size(),
                           "ev=account stage=identity primary=0x%016llX characters=%zu",
-                          static_cast<unsigned long long>(initialAccount.primarySoid),
-                          initialAccount.characterCount);
+                          static_cast<unsigned long long>(runtimeAccount.primarySoid),
+                          runtimeAccount.characterCount);
         if (written > 0) {
             core::log::write(core::log::Channel::state,
                              core::log::Level::info,
@@ -106,7 +228,7 @@ bool initialize(void* module,
     // The published relay port is the one the listener binds, so both move with one setting.
     initialized.signOn.relayPort = core::settings::get().server.bapPort;
     initialized.signOn.tokenLifetimeSeconds = kDefaultTokenLifetimeSeconds;
-    initialized.account = initialAccount;
+    initialized.account = runtimeAccount;
     initialized.activity.defaults = activityDefaults;
     initialized.investment.family5.objectSoid = kGlobalFamily5Soid;
     // Only the override lists come from settings. Identity and gate stay owned by State.
@@ -118,8 +240,8 @@ bool initialize(void* module,
     // The arm is account-wide and rides the first ws-503, which goes out before any pick. Nothing
     // is selected at boot, so it is armed when any authored character carries the bypass. The
     // per-character objB byte is the other half, and it still decides which character it opens.
-    for (std::size_t index = 0; index < initialAccount.characterCount; ++index) {
-        if (initialAccount.characters[index].contentBypass) {
+    for (std::size_t index = 0; index < runtimeAccount.characterCount; ++index) {
+        if (runtimeAccount.characters[index].contentBypass) {
             initialized.investment.family5.contentGateArm = true;
             break;
         }
@@ -146,6 +268,18 @@ const SignOnState& sign_on() noexcept {
     return runtime::storage::g_state.signOn;
 }
 
+/** Ensures every native profile action source has one unique runtime item-instance key. */
+bool ensure_profile_item_identities() noexcept {
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    const bool ready = canonicalize_profile_item_identities(candidate);
+    if (ready) {
+        runtime::storage::g_state.account = candidate;
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return ready;
+}
+
 /**
  * Publishes the bootstrap content-id token read from the installed client.
  * @param token Exactly 16 native bytes.