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

feat(queuez): publish inventory mutations atomically

Stage exact Family 4 revisions and bounded object batches for item acquisition, profile acquisition, dismantling, equipment placement, socket changes, and item-state updates.

Encode all response and Queuez frames before committing State, verify the peer resident manifest and before-image, then publish output, nonce, and session ladders only after the State transaction succeeds.

This keeps correlated status revisions, account/character objects, item upserts or releases, and profile material deductions synchronized under every failure path.
Thomas Shields 3 недель назад
Родитель
Сommit
50ac4977f6

+ 209 - 0
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -116,6 +116,215 @@ bool process(const ServiceRoute& route,
         }
         outcome.hasSubscription = webOutcome.hasSubscription;
         outcome.subscription = webOutcome.subscription;
+        if (webOutcome.hasEquipmentSwap) {
+            // Equip is an optimistic Character-screen action. Its status-pair value is the exact
+            // Family-4 revision whose following Queuez frame makes the action authoritative. Stage
+            // that revision before encoding the reply so the Client cannot complete the action
+            // against the old object store.
+            if (!queuez::stage_equipment_swap(queuezState,
+                                              webOutcome.equipmentSwap.characterSoid,
+                                              outcome.equipmentSwapUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws403 stage=queuez_preflight result=fail");
+                // Keep the already-encoded sentinel response and publish no mutation, matching
+                // the change-character failure contract instead of dropping the correlated task.
+                outcome.equipmentSwapUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.equipmentSwapUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=ws403 stage=response result=fail");
+                    return false;
+                }
+                web_service::report_equip_response(message, status.value, output.first(written));
+                outcome.hasEquipmentSwap = true;
+                outcome.equipmentSwap = webOutcome.equipmentSwap;
+            }
+        }
+        if (webOutcome.hasSocketPlug) {
+            // Opcode 903 completes at the exact Family-4 revision carrying the changed resident
+            // item instance. The resident manifest and character placement remain unchanged.
+            if (!queuez::stage_socket_plug(queuezState,
+                                           webOutcome.socketPlug.accountSoid,
+                                           webOutcome.socketPlug.characterSoid,
+                                           webOutcome.socketPlug.targetInstanceSoid,
+                                           webOutcome.socketPlug.profileChanged,
+                                           outcome.socketPlugUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=socket_plug stage=queuez_preflight result=fail");
+                outcome.socketPlugUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.socketPlugUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=socket_plug stage=response result=fail");
+                    return false;
+                }
+                web_service::report_socket_plug_response(message,
+                                                         status.value,
+                                                         webOutcome.socketPlug.targetInstanceSoid,
+                                                         webOutcome.socketPlug.socketLane,
+                                                         webOutcome.socketPlug.plugDefinitionIndex,
+                                                         output.first(written));
+                outcome.hasSocketPlug = true;
+                outcome.socketPlug = webOutcome.socketPlug;
+            }
+        }
+        if (webOutcome.hasItemState) {
+            // Opcode 406 completes at the exact Family-4 revision carrying the changed inventory
+            // row flags. Placement and every resident item-instance body remain unchanged.
+            if (!queuez::stage_equipment_swap(
+                    queuezState, webOutcome.itemState.characterSoid, outcome.itemStateUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=item_state stage=queuez_preflight result=fail");
+                outcome.itemStateUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.itemStateUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=item_state stage=response result=fail");
+                    return false;
+                }
+                outcome.hasItemState = true;
+                outcome.itemState = webOutcome.itemState;
+            }
+        }
+        if (webOutcome.hasItemAcquisition) {
+            // A Collections pull is complete only at the exact Family-4 revision that adds both
+            // the inventory row and its newly resident instance object. Stage that revision before
+            // re-encoding the correlated status pair, just like an equipment swap.
+            if (!queuez::stage_item_acquisition(queuezState,
+                                                webOutcome.itemAcquisition.accountSoid,
+                                                webOutcome.itemAcquisition.characterSoid,
+                                                webOutcome.itemAcquisition.acquiredInstanceSoid,
+                                                webOutcome.itemAcquisition.profileChanged,
+                                                outcome.itemAcquisitionUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=acquire stage=queuez_preflight result=fail");
+                outcome.itemAcquisitionUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.itemAcquisitionUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=acquire stage=response result=fail");
+                    return false;
+                }
+                web_service::report_item_acquisition_response(
+                    message,
+                    status.value,
+                    webOutcome.itemAcquisition.acquiredInstanceSoid,
+                    output.first(written));
+                outcome.hasItemAcquisition = true;
+                outcome.itemAcquisition = webOutcome.itemAcquisition;
+            }
+        }
+        if (webOutcome.hasProfileItemAcquisition) {
+            // Profile stacks live in the account body. Actionable shaders/modifications also name
+            // a Family-4 item resident: an existing stack must already own it, while a newly
+            // appended row adds it atomically at this exact +1 revision.
+            if (!queuez::stage_profile_item_acquisition(
+                    queuezState,
+                    webOutcome.profileItemAcquisition.accountSoid,
+                    webOutcome.profileItemAcquisition.acquiredInstanceSoid,
+                    webOutcome.profileItemAcquisition.actionSource,
+                    webOutcome.profileItemAcquisition.appended,
+                    outcome.profileItemAcquisitionUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=profile_acquire stage=queuez_preflight result=fail");
+                outcome.profileItemAcquisitionUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.profileItemAcquisitionUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=profile_acquire stage=response result=fail");
+                    return false;
+                }
+                web_service::report_profile_item_acquisition_response(
+                    message,
+                    status.value,
+                    webOutcome.profileItemAcquisition.acquiredDefinitionHash,
+                    webOutcome.profileItemAcquisition.acquiredQuantity,
+                    output.first(written));
+                outcome.hasProfileItemAcquisition = true;
+                outcome.profileItemAcquisition = webOutcome.profileItemAcquisition;
+            }
+        }
+        if (webOutcome.hasItemDismantle) {
+            // Dismantle is another optimistic Character-screen action. Promise only the exact
+            // Family-4 revision that carries both the character after-image and the empty
+            // item-instance release descriptor; otherwise retain the generic sentinel reply and
+            // publish no removal.
+            if (!queuez::stage_item_dismantle(queuezState,
+                                              webOutcome.itemDismantle.characterSoid,
+                                              webOutcome.itemDismantle.dismantledInstanceSoid,
+                                              outcome.itemDismantleUpdate)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=dismantle stage=queuez_preflight result=fail");
+                outcome.itemDismantleUpdate = {};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = outcome.itemDismantleUpdate.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=dismantle stage=response result=fail");
+                    return false;
+                }
+                web_service::report_item_dismantle_response(
+                    message,
+                    status.value,
+                    webOutcome.itemDismantle.dismantledInstanceSoid,
+                    output.first(written));
+                outcome.hasItemDismantle = true;
+                outcome.itemDismantle = webOutcome.itemDismantle;
+            }
+        }
         // A pick that names the resident character moves nothing, so staging refuses it and the
         // reply still stands on its own.
         if (webOutcome.hasSelectedCharacter

+ 127 - 0
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -1,6 +1,8 @@
 #include <Windows.h>
 
 #include <algorithm>
+#include <array>
+#include <cstdio>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/secure_channel/runtime.h"
@@ -177,6 +179,131 @@ bool consume(Session& session,
             }
             arm_repushes(session, queuezPublication);
             publish_connection_fields(session, publication, connection);
+            if (outcome.hasEquipmentSwap) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=equip stage=output_publish result=ok framed_bytes=%zu queuez_published=%u "
+                    "family_version=%d family0_version=%d family3_version=%d",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    session.queuez.family0Version,
+                    session.queuez.family3Version);
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (outcome.hasSocketPlug) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=socket_plug stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d family0_version=%d "
+                    "family3_version=%d instance=0x%llX lane=%u "
+                    "plug_definition=%u target_bucket=%u plug_bucket=%u",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    session.queuez.family0Version,
+                    session.queuez.family3Version,
+                    static_cast<unsigned long long>(outcome.socketPlug.targetInstanceSoid),
+                    static_cast<unsigned>(outcome.socketPlug.socketLane),
+                    static_cast<unsigned>(outcome.socketPlug.plugDefinitionIndex),
+                    static_cast<unsigned>(outcome.socketPlug.targetBucketId),
+                    static_cast<unsigned>(outcome.socketPlug.plugBucketId));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (outcome.hasItemState) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=item_state stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d instance=0x%llX flags=0x%X",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned long long>(outcome.itemState.targetInstanceSoid),
+                    outcome.itemState.afterFlags);
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (outcome.hasItemAcquisition) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=acquire stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    static_cast<unsigned long long>(
+                        outcome.itemAcquisitionUpdate.acquiredInstanceSoid));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (outcome.hasProfileItemAcquisition) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=profile_acquire stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u definition_hash=%u "
+                    "quantity=%d instance=0x%llX action_source=%u appended_row=%u "
+                    "appended_resident=%u",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    outcome.profileItemAcquisition.acquiredDefinitionHash,
+                    outcome.profileItemAcquisition.acquiredQuantity,
+                    static_cast<unsigned long long>(
+                        outcome.profileItemAcquisition.acquiredInstanceSoid),
+                    static_cast<unsigned>(outcome.profileItemAcquisition.actionSource),
+                    static_cast<unsigned>(outcome.profileItemAcquisition.appended),
+                    static_cast<unsigned>(outcome.profileItemAcquisitionUpdate.appendedResident));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (outcome.hasItemDismantle) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=dismantle stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    static_cast<unsigned long long>(
+                        outcome.itemDismantleUpdate.dismantledInstanceSoid));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
         }
     }
     clear_prefix(scratch.plaintext, plaintextSize);

+ 129 - 0
Sunrise/src/server/bap/encrypted/internal.h

@@ -43,6 +43,31 @@ struct ServiceOutcome {
     queuez::ChangeCharacter changeCharacter{};
     bool hasSelectCharacter{};
     queuez::SelectCharacter selectCharacter{};
+    bool hasEquipmentSwap{};
+    state::PendingEquipmentSwap equipmentSwap{};
+    /** Exact Queuez after-image promised by the opcode-403 status value. */
+    queuez::EquipmentSwap equipmentSwapUpdate{};
+    bool hasSocketPlug{};
+    state::PendingSocketPlug socketPlug{};
+    /** Exact Queuez item-instance revision promised by the opcode-903 status value. */
+    queuez::SocketPlug socketPlugUpdate{};
+    bool hasItemState{};
+    state::PendingItemState itemState{};
+    /** Exact Queuez character revision promised by opcode 406. */
+    queuez::EquipmentSwap itemStateUpdate{};
+    bool hasItemAcquisition{};
+    state::PendingItemAcquisition itemAcquisition{};
+    /** Exact Queuez after-image promised by the item-creation status value. */
+    queuez::ItemAcquisition itemAcquisitionUpdate{};
+    bool hasProfileItemAcquisition{};
+    state::PendingProfileItemAcquisition profileItemAcquisition{};
+    /** Exact Queuez account revision and optional resident append promised by profile acquisition.
+     */
+    queuez::ProfileItemAcquisition profileItemAcquisitionUpdate{};
+    bool hasItemDismantle{};
+    state::PendingItemDismantle itemDismantle{};
+    /** Exact Queuez after-image promised by the dismantle status value. */
+    queuez::ItemDismantle itemDismantleUpdate{};
     bool hasActivitySessionAllocation{};
     state::activity::PendingAllocation activitySessionAllocation{};
     bool hasActivityTransaction{};
@@ -239,6 +264,110 @@ append_select_character_notification(Scratch& scratch,
                                      std::span<std::byte> response,
                                      std::size_t& written) noexcept;
 
+/** Appends the opcode-403 Family-4 character upsert that exposes the equipped item swap. */
+[[nodiscard]] bool
+append_equipment_swap_notification(Scratch& scratch,
+                                   const queuez::EquipmentSwap& swap,
+                                   const state::PendingEquipmentSwap& mutation,
+                                   std::span<const std::byte, state::kAesKeySize> key,
+                                   std::span<const std::byte, state::kBapNonceSize> nonce,
+                                   std::span<std::byte> response,
+                                   std::size_t& written) noexcept;
+
+/** Appends the opcode-406 Family-4 character upsert carrying changed inventory-row flags. */
+[[nodiscard]] bool
+append_item_state_notification(Scratch& scratch,
+                               const queuez::EquipmentSwap& update,
+                               const state::PendingItemState& mutation,
+                               std::span<const std::byte, state::kAesKeySize> key,
+                               std::span<const std::byte, state::kBapNonceSize> nonce,
+                               std::span<std::byte> response,
+                               std::size_t& written) noexcept;
+
+/**
+ * Appends the same-character Family-0 appearance upsert paired with one equipment swap.
+ * The
+ * update owns its nonce advance only after the complete notification fits.
+ */
+[[nodiscard]] bool
+append_equipment_appearance_refresh_notification(Scratch& scratch,
+                                                 const queuez::CharacterAppearanceRefresh& refresh,
+                                                 const state::PendingEquipmentSwap& mutation,
+                                                 std::span<const std::byte, state::kAesKeySize> key,
+                                                 std::array<std::byte, state::kBapNonceSize>& nonce,
+                                                 std::span<std::byte> response,
+                                                 std::size_t& written) noexcept;
+
+/** Appends the Family-0 refresh owed by a socket change on an equipped item. */
+[[nodiscard]] bool
+append_socket_appearance_refresh_notification(Scratch& scratch,
+                                              const queuez::CharacterAppearanceRefresh& refresh,
+                                              const state::PendingSocketPlug& mutation,
+                                              std::span<const std::byte, state::kAesKeySize> key,
+                                              std::array<std::byte, state::kBapNonceSize>& nonce,
+                                              std::span<std::byte> response,
+                                              std::size_t& written) noexcept;
+
+/** Appends a Family-3 character record followed by the changed account roster after equip. */
+[[nodiscard]] bool
+append_equipment_roster_refresh_notification(Scratch& scratch,
+                                             const queuez::RosterAppearanceRefresh& refresh,
+                                             const state::PendingEquipmentSwap& mutation,
+                                             std::span<const std::byte, state::kAesKeySize> key,
+                                             std::array<std::byte, state::kBapNonceSize>& nonce,
+                                             std::span<std::byte> response,
+                                             std::size_t& written) noexcept;
+
+/** Appends a Family-3 character-only appearance refresh after an equipped socket change. */
+[[nodiscard]] bool
+append_socket_roster_refresh_notification(Scratch& scratch,
+                                          const queuez::RosterAppearanceRefresh& refresh,
+                                          const state::PendingSocketPlug& mutation,
+                                          std::span<const std::byte, state::kAesKeySize> key,
+                                          std::array<std::byte, state::kBapNonceSize>& nonce,
+                                          std::span<std::byte> response,
+                                          std::size_t& written) noexcept;
+
+/** Appends the opcode-903 Family-4 item-instance upsert exposing one socket selection. */
+[[nodiscard]] bool
+append_socket_plug_notification(Scratch& scratch,
+                                const queuez::SocketPlug& socketPlug,
+                                const state::PendingSocketPlug& mutation,
+                                std::span<const std::byte, state::kAesKeySize> key,
+                                std::span<const std::byte, state::kBapNonceSize> nonce,
+                                std::span<std::byte> response,
+                                std::size_t& written) noexcept;
+
+/** Appends a Family-4 character upsert plus newly acquired item-instance upsert. */
+[[nodiscard]] bool
+append_item_acquisition_notification(Scratch& scratch,
+                                     const queuez::ItemAcquisition& acquisition,
+                                     const state::PendingItemAcquisition& mutation,
+                                     std::span<const std::byte, state::kAesKeySize> key,
+                                     std::span<const std::byte, state::kBapNonceSize> nonce,
+                                     std::span<std::byte> response,
+                                     std::size_t& written) noexcept;
+
+/** Appends one full Family-4 account upsert for a profile-stack acquisition. */
+[[nodiscard]] bool
+append_profile_item_acquisition_notification(Scratch& scratch,
+                                             const queuez::ProfileItemAcquisition& acquisition,
+                                             const state::PendingProfileItemAcquisition& mutation,
+                                             std::span<const std::byte, state::kAesKeySize> key,
+                                             std::span<const std::byte, state::kBapNonceSize> nonce,
+                                             std::span<std::byte> response,
+                                             std::size_t& written) noexcept;
+
+/** Appends a Family-4 character upsert followed by one empty item-instance release. */
+[[nodiscard]] bool
+append_item_dismantle_notification(Scratch& scratch,
+                                   const queuez::ItemDismantle& dismantle,
+                                   const state::PendingItemDismantle& mutation,
+                                   std::span<const std::byte, state::kAesKeySize> key,
+                                   std::span<const std::byte, state::kBapNonceSize> nonce,
+                                   std::span<std::byte> response,
+                                   std::size_t& written) noexcept;
+
 } // namespace push
 
 } // namespace sunrise::server::bap::encrypted

+ 248 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -1,3 +1,7 @@
+#include <array>
+#include <cstdio>
+
+#include "../../../../../core/logging/log.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/internal.h"
 #include "queuez_push_reporting.h"
@@ -42,4 +46,248 @@ bool append_select_character_notification(Scratch& scratch,
     return true;
 }
 
+/** Appends the opcode-403 character upsert as one increment above the current peer version. */
+bool append_equipment_swap_notification(Scratch& scratch,
+                                        const queuez::EquipmentSwap& swap,
+                                        const state::PendingEquipmentSwap& mutation,
+                                        std::span<const std::byte, state::kAesKeySize> key,
+                                        std::span<const std::byte, state::kBapNonceSize> nonce,
+                                        std::span<std::byte> response,
+                                        std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_equipment_swap(scratch, swap, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    if (objectCount == 1) {
+        const middleware::queuez::Object& object = prepared.family.objects.front();
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=equip stage=queuez_object result=ok family_version=%d object_id=%u "
+                          "object_version=0x%llX encoding=%u payload_bytes=%zu",
+                          prepared.family.version,
+                          object.id,
+                          static_cast<unsigned long long>(object.version),
+                          static_cast<unsigned>(object.encoding),
+                          object.payload.size());
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
+    }
+    const std::size_t beforeBytes = written;
+    if (!queuez_frame::append(scratch,
+                              prepared.family,
+                              prepared.rawClearSize,
+                              prepared.compressedClearSize,
+                              key,
+                              nonce,
+                              response,
+                              written)) {
+        return false;
+    }
+    queuez_report::push("equip", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one opcode-406 selected-character item-state upsert. */
+bool append_item_state_notification(Scratch& scratch,
+                                    const queuez::EquipmentSwap& update,
+                                    const state::PendingItemState& mutation,
+                                    std::span<const std::byte, state::kAesKeySize> key,
+                                    std::span<const std::byte, state::kBapNonceSize> nonce,
+                                    std::span<std::byte> response,
+                                    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_state(scratch, update, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != 1 || prepared.family.objects.front().id != update.characterDefinitionId
+        || prepared.family.objects.front().version != update.characterSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "item_state", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one socket item upsert and its charged account balances when required. */
+bool append_socket_plug_notification(Scratch& scratch,
+                                     const queuez::SocketPlug& socketPlug,
+                                     const state::PendingSocketPlug& mutation,
+                                     std::span<const std::byte, state::kAesKeySize> key,
+                                     std::span<const std::byte, state::kBapNonceSize> nonce,
+                                     std::span<std::byte> response,
+                                     std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_socket_plug(scratch, socketPlug, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t expectedObjectCount = socketPlug.updatesAccount ? 2U : 1U;
+    const std::size_t beforeBytes = written;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects.front().id != socketPlug.itemInstanceDefinitionId
+        || prepared.family.objects.front().version != socketPlug.targetInstanceSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || (socketPlug.updatesAccount
+            && (prepared.family.objects[1].id != socketPlug.accountDefinitionId
+                || prepared.family.objects[1].version != socketPlug.accountSoid
+                || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[1].payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "socket_plug", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one atomic new-instance-before-character Family-4 acquisition update. */
+bool append_item_acquisition_notification(Scratch& scratch,
+                                          const queuez::ItemAcquisition& acquisition,
+                                          const state::PendingItemAcquisition& mutation,
+                                          std::span<const std::byte, state::kAesKeySize> key,
+                                          std::span<const std::byte, state::kBapNonceSize> nonce,
+                                          std::span<std::byte> response,
+                                          std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_acquisition(scratch, acquisition, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const std::size_t expectedObjectCount = acquisition.updatesAccount ? 3U : 2U;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects[0].id != acquisition.itemInstanceDefinitionId
+        || prepared.family.objects[0].version != acquisition.acquiredInstanceSoid
+        || prepared.family.objects[0].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[0].payload.empty()
+        || prepared.family.objects[1].id != acquisition.characterDefinitionId
+        || prepared.family.objects[1].version != acquisition.characterSoid
+        || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[1].payload.empty()
+        || (acquisition.updatesAccount
+            && (prepared.family.objects[2].id != acquisition.accountDefinitionId
+                || prepared.family.objects[2].version != acquisition.accountSoid
+                || prepared.family.objects[2].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[2].payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends an optional new profile resident followed by the full account after-image. */
+bool append_profile_item_acquisition_notification(
+    Scratch& scratch,
+    const queuez::ProfileItemAcquisition& acquisition,
+    const state::PendingProfileItemAcquisition& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_profile_item_acquisition(scratch, acquisition, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const std::size_t expectedObjectCount = acquisition.appendedResident ? 2U : 1U;
+    const std::size_t accountIndex = acquisition.appendedResident ? 1U : 0U;
+    if (prepared.family.type != queuez::kAccountFamilyType
+        || prepared.family.rootSoid != acquisition.after.family4RootSoid
+        || prepared.family.version != acquisition.after.family4Version || prepared.family.flags != 0
+        || objectCount != expectedObjectCount || accountIndex >= objectCount
+        || prepared.family.objects[accountIndex].id != acquisition.accountDefinitionId
+        || prepared.family.objects[accountIndex].version != acquisition.accountSoid
+        || prepared.family.objects[accountIndex].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[accountIndex].payload.empty()
+        || (acquisition.appendedResident
+            && (prepared.family.objects.front().id != acquisition.itemInstanceDefinitionId
+                || prepared.family.objects.front().version != acquisition.acquiredInstanceSoid
+                || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.front().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "profile_acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one atomic character-upsert-and-instance-release Family-4 dismantle update. */
+bool append_item_dismantle_notification(Scratch& scratch,
+                                        const queuez::ItemDismantle& dismantle,
+                                        const state::PendingItemDismantle& mutation,
+                                        std::span<const std::byte, state::kAesKeySize> key,
+                                        std::span<const std::byte, state::kBapNonceSize> nonce,
+                                        std::span<std::byte> response,
+                                        std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_dismantle(scratch, dismantle, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != 2 || prepared.family.objects[0].id != dismantle.characterDefinitionId
+        || prepared.family.objects[0].version != dismantle.characterSoid
+        || prepared.family.objects[1].id != dismantle.itemInstanceDefinitionId
+        || prepared.family.objects[1].version != dismantle.dismantledInstanceSoid
+        || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+        || !prepared.family.objects[1].payload.empty()
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "dismantle", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push

+ 906 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -1,14 +1,20 @@
 #include <algorithm>
+#include <array>
 #include <cstddef>
+#include <cstdio>
+#include <limits>
 #include <optional>
 #include <span>
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/account/selection_patch/\
 account_selection_patch_encoder.h"
 #include "../../../../../middleware/datagen/family4/character/character_encoder.h"
 #include "../../../../../middleware/datagen/family4/character/layout.h"
+#include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
+#include "../../../../../middleware/datagen/family4/instance/layout.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "internal.h"
 #include "snapshot_storage.h"
@@ -19,6 +25,77 @@ namespace {
 namespace family4_datagen = middleware::datagen::family4;
 namespace selection_patch = middleware::datagen::family4::account::selection_patch;
 
+/** Logs the exact resolved and encoded fields used by the opcode-403 character upsert. */
+void report_equipment_object(const queuez::EquipmentSwap& swap,
+                             const state::PendingEquipmentSwap& mutation,
+                             const Resolved& selected,
+                             const family4_datagen::character::layout::Object& object) noexcept {
+    constexpr std::size_t kMissing = (std::numeric_limits<std::size_t>::max)();
+    std::size_t requestedRow = kMissing;
+    std::size_t previousRow = kMissing;
+    std::size_t nativeSlot = kMissing;
+    bool requestedEquipped = false;
+    bool previousEquipped = false;
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const auto& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid == mutation.requestedInstanceSoid) {
+            requestedRow = item.inventoryRow;
+            nativeSlot = item.equipmentSlot;
+            requestedEquipped = item.equipped;
+        } else if (item.instance.instanceSoid == mutation.previousInstanceSoid) {
+            previousRow = item.inventoryRow;
+            previousEquipped = item.equipped;
+        }
+    }
+    const std::uint64_t equippedSoid = nativeSlot < object.equippedInstanceSoids.size()
+                                           ? object.equippedInstanceSoids[nativeSlot]
+                                           : 0;
+    const std::uint64_t requestedRowSoid = requestedRow < object.inventoryItems.size()
+                                               ? object.inventoryItems[requestedRow].instanceSoid
+                                               : 0;
+    const std::uint64_t previousRowSoid = previousRow < object.inventoryItems.size()
+                                              ? object.inventoryItems[previousRow].instanceSoid
+                                              : 0;
+    const std::int32_t requestedSerial = requestedRow < object.inventoryItems.size()
+                                             ? object.inventoryItems[requestedRow].mutationSerial
+                                             : -1;
+    const std::int32_t previousSerial = previousRow < object.inventoryItems.size()
+                                            ? object.inventoryItems[previousRow].mutationSerial
+                                            : -1;
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=character_object result=ok family_version=%d root=0x%llX definition=%u "
+        "character=0x%llX items=%zu next_serial=%u requested=0x%llX requested_row=%zu "
+        "requested_row_soid=0x%llX requested_equipped=%u native_slot=%zu equipped_soid=0x%llX "
+        "requested_serial=%d previous=0x%llX previous_row=%zu previous_row_soid=0x%llX "
+        "previous_equipped=%u previous_serial=%d",
+        swap.after.family4Version,
+        static_cast<unsigned long long>(swap.after.family4RootSoid),
+        swap.characterDefinitionId,
+        static_cast<unsigned long long>(mutation.characterSoid),
+        selected.loadout.itemCount,
+        object.nextInventorySerial,
+        static_cast<unsigned long long>(mutation.requestedInstanceSoid),
+        requestedRow,
+        static_cast<unsigned long long>(requestedRowSoid),
+        static_cast<unsigned>(requestedEquipped),
+        nativeSlot,
+        static_cast<unsigned long long>(equippedSoid),
+        requestedSerial,
+        static_cast<unsigned long long>(mutation.previousInstanceSoid),
+        previousRow,
+        static_cast<unsigned long long>(previousRowSoid),
+        static_cast<unsigned>(previousEquipped),
+        previousSerial);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
 } // namespace
 
 /** Builds the Family-4 increment that moves the character object to the picked character. */
@@ -137,4 +214,833 @@ bool prepare_selection_move(Scratch& scratch,
     return true;
 }
 
+/** Builds a single-character Family-4 upsert from an uncommitted equipment after-image. */
+bool prepare_equipment_swap(Scratch& scratch,
+                            const queuez::EquipmentSwap& swap,
+                            const state::PendingEquipmentSwap& mutation,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("equip_reservation");
+    }
+    state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.characterSoid == 0
+        || mutation.characterSoid != swap.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("equip_mutation");
+    }
+    account.characters[mutation.characterIndex] = mutation.afterCharacter;
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != swap.characterDefinitionId) {
+        return report_failure("equip_selection");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("equip_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("equip_character_object");
+    }
+    report_equipment_object(swap,
+                            mutation,
+                            selected,
+                            *reinterpret_cast<const family4_datagen::character::layout::Object*>(
+                                characterBytes.data()));
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       swap.characterDefinitionId,
+                       swap.characterSoid,
+                       staged.objects.front(),
+                       compressedExtent)) {
+        return report_failure("equip_character_object");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        swap.after.family4RootSoid,
+        swap.after.family4Version,
+        0,
+        std::span(staged.objects).first(1),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_commit");
+    }
+    return true;
+}
+
+/** Builds a single-character Family-4 upsert from an uncommitted item-state after-image. */
+bool prepare_item_state(Scratch& scratch,
+                        const queuez::EquipmentSwap& update,
+                        const state::PendingItemState& mutation,
+                        Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("item_state_reservation");
+    }
+    state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.characterSoid == 0
+        || mutation.characterSoid != update.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("item_state_mutation");
+    }
+    account.characters[mutation.characterIndex] = mutation.afterCharacter;
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId) {
+        return report_failure("item_state_selection");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("item_state_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("item_state_character_object");
+    }
+    const auto& encoded =
+        *reinterpret_cast<const family4_datagen::character::layout::Object*>(characterBytes.data());
+    if (mutation.itemIndex >= mutation.afterCharacter.inventory.count && !mutation.targetEquipped) {
+        return report_failure("item_state_inventory_index");
+    }
+    std::size_t matchingRows = 0;
+    for (const auto& row : encoded.inventoryItems) {
+        matchingRows +=
+            static_cast<std::size_t>(row.instanceSoid == mutation.targetInstanceSoid
+                                     && row.definitionIndex == mutation.targetDefinitionIndex
+                                     && row.flags == mutation.afterFlags);
+    }
+    if (matchingRows != 1) {
+        return report_failure("item_state_character_shape");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       update.characterDefinitionId,
+                       update.characterSoid,
+                       staged.objects.front(),
+                       compressedExtent)) {
+        return report_failure("item_state_character_object");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        update.after.family4RootSoid,
+        update.after.family4Version,
+        0,
+        std::span(staged.objects).first(1),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("item_state_commit");
+    }
+    return true;
+}
+
+/** Builds a resident item upsert followed by charged account balances when the cost consumes. */
+bool prepare_socket_plug(Scratch& scratch,
+                         const queuez::SocketPlug& socketPlug,
+                         const state::PendingSocketPlug& mutation,
+                         Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("socket_plug_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.targetInstanceSoid == 0 || mutation.accountSoid != socketPlug.accountSoid
+        || mutation.characterSoid != socketPlug.characterSoid
+        || mutation.targetInstanceSoid != socketPlug.targetInstanceSoid
+        || mutation.profileChanged != socketPlug.updatesAccount
+        || mutation.accountSoid != socketPlug.after.family4RootSoid
+        || socketPlug.accountDefinitionId == 0 || !state::preview_socket_plug(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != mutation.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("socket_plug_mutation");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.itemInstanceObjectId != socketPlug.itemInstanceDefinitionId) {
+        return report_failure("socket_plug_selection");
+    }
+
+    family4_datagen::loadout::ResolvedInstances changed{};
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const family4_datagen::loadout::ResolvedItem& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid != mutation.targetInstanceSoid) {
+            continue;
+        }
+        if (changed.itemCount != 0
+            || item.instance.baseDefinitionIndex != mutation.targetDefinitionIndex
+            || mutation.socketLane >= item.instance.ordinarySockets.plugs.size()
+            || item.instance.ordinarySockets.state
+                   != family4_datagen::instance::OrdinarySocketBlockState::present
+            || !item.instance.ordinarySockets.plugs[mutation.socketLane].has_value()
+            || *item.instance.ordinarySockets.plugs[mutation.socketLane]
+                   != mutation.plugDefinitionIndex) {
+            return report_failure("socket_plug_item_shape");
+        }
+        changed.items[0] = {item.equipmentSlot, item.instance};
+        changed.itemCount = 1;
+    }
+    if (changed.itemCount != 1) {
+        return report_failure("socket_plug_item_missing");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    const std::size_t requiredRawSize = socketPlug.updatesAccount
+                                            ? family4_datagen::account::layout::kObjectSize
+                                            : family4_datagen::instance::layout::kObjectSize;
+    if (requiredRawSize > rawStorage.size()) {
+        return report_failure("socket_plug_item_storage");
+    }
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::instance::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      socketPlug.itemInstanceDefinitionId,
+                      changed,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("socket_plug_item_object");
+    }
+
+    std::size_t objectCount = 1;
+    if (socketPlug.updatesAccount) {
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)
+            || !append_object(scratch,
+                              accountBytes,
+                              socketPlug.accountDefinitionId,
+                              socketPlug.accountSoid,
+                              staged.objects[1],
+                              compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("socket_plug_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 2;
+    }
+
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        socketPlug.after.family4RootSoid,
+        socketPlug.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("socket_plug_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=socket_plug stage=family4_objects result=ok family_version=%d root=0x%llX "
+        "character=0x%llX instance=0x%llX item_definition=%u target_definition=%u "
+        "target_bucket=%u lane=%u plug_definition=%u plug_bucket=%u equipped=%u "
+        "material_set=%u material_set_hash=%u material_rows=%u account_update=%u objects=%zu "
+        "order=%s",
+        socketPlug.after.family4Version,
+        static_cast<unsigned long long>(socketPlug.after.family4RootSoid),
+        static_cast<unsigned long long>(socketPlug.characterSoid),
+        static_cast<unsigned long long>(socketPlug.targetInstanceSoid),
+        socketPlug.itemInstanceDefinitionId,
+        static_cast<unsigned>(mutation.targetDefinitionIndex),
+        static_cast<unsigned>(mutation.targetBucketId),
+        static_cast<unsigned>(mutation.socketLane),
+        static_cast<unsigned>(mutation.plugDefinitionIndex),
+        static_cast<unsigned>(mutation.plugBucketId),
+        static_cast<unsigned>(mutation.targetEquipped),
+        static_cast<unsigned>(mutation.materialRequirementSetIndex),
+        mutation.materialRequirementSetHash,
+        static_cast<unsigned>(mutation.materialRequirementCount),
+        static_cast<unsigned>(socketPlug.updatesAccount),
+        objectCount,
+        socketPlug.updatesAccount ? "item_account" : "item");
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
+/** Builds a single full account-object upsert from an uncommitted profile-stack after-image. */
+bool prepare_profile_item_acquisition(Scratch& scratch,
+                                      const queuez::ProfileItemAcquisition& acquisition,
+                                      const state::PendingProfileItemAcquisition& mutation,
+                                      Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("profile_acquire_reservation");
+    }
+    if (!mutation.prepared || mutation.accountSoid == 0
+        || mutation.accountSoid != acquisition.accountSoid
+        || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
+        || mutation.actionSource != acquisition.actionSource
+        || acquisition.appendedResident != (mutation.appended && mutation.actionSource)
+        || acquisition.accountSoid != acquisition.after.family4RootSoid
+        || acquisition.accountDefinitionId == 0
+        || (acquisition.appendedResident && acquisition.itemInstanceDefinitionId == 0)) {
+        return report_failure("profile_acquire_mutation");
+    }
+    state::AccountState account{};
+    if (!state::preview_profile_item_acquisition(mutation, account)
+        || account.primarySoid != acquisition.accountSoid || !state::account::valid(account)) {
+        return report_failure("profile_acquire_account");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("profile_acquire_account_storage");
+    }
+    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+    if (!family4_datagen::account::encode(account, accountBytes)) {
+        return report_failure("profile_acquire_account_encode");
+    }
+
+    // The native account observer compares profile quantities but only emits pickup feedback when
+    // the changed row's mutation serial also appears in this transient 16-record bank at 0x6978.
+    // Keep the descriptor local to this one incremental upsert; ordinary snapshots encode an empty
+    // bank, while the row's rising mutation serial remains persistent State.
+    constexpr std::uint16_t kAcquisitionChangeSequence = 0;
+    constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
+    constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
+    constexpr std::uint8_t kAcquisitionChangeKind = 1;
+    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
+    auto& accountObject =
+        *reinterpret_cast<family4_datagen::account::layout::Object*>(accountBytes.data());
+    std::size_t acquiredRow = accountObject.profileItems.size();
+    for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
+        const auto& inventoryRow = accountObject.profileItems[row];
+        if (inventoryRow.mutationSerial != mutation.acquiredMutationSerial) {
+            continue;
+        }
+        if (acquiredRow != accountObject.profileItems.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("profile_acquire_row_duplicate");
+        }
+        acquiredRow = row;
+    }
+    const auto recordIsZero =
+        [](const family4_datagen::account::layout::ProfileInventoryChangeRecord& record) noexcept {
+            return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+                   && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+        };
+    const bool recordsAreZero = std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                                            accountObject.profileInventoryChanges.records.cend(),
+                                            recordIsZero);
+    if (acquiredRow >= accountObject.profileItems.size()
+        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
+        || accountObject.profileInventoryChanges.writeSlot != 0
+        || accountObject.profileInventoryChanges.nextSequence != 0 || !recordsAreZero) {
+        clear_after(scratch, reservation);
+        return report_failure("profile_acquire_inventory_change_state");
+    }
+    accountObject.profileInventoryChanges.writeSlot = kAcquisitionChangeNextWriteSlot;
+    accountObject.profileInventoryChanges.nextSequence = kAcquisitionChangeNextSequence;
+    auto& acquisitionChange = accountObject.profileInventoryChanges.records.front();
+    acquisitionChange.sequence = kAcquisitionChangeSequence;
+    acquisitionChange.mutationSerial = mutation.acquiredMutationSerial;
+    acquisitionChange.kind = kAcquisitionChangeKind;
+    acquisitionChange.flags = kAcquisitionChangeFlags;
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    const std::size_t accountObjectIndex = acquisition.appendedResident ? 1U : 0U;
+    if (!append_object(scratch,
+                       accountBytes,
+                       acquisition.accountDefinitionId,
+                       acquisition.accountSoid,
+                       staged.objects[accountObjectIndex],
+                       compressedExtent)) {
+        return report_failure("profile_acquire_account_object");
+    }
+    std::size_t objectCount = 1;
+    if (acquisition.appendedResident) {
+        if (mutation.profileIndex >= account.profileItemCount) {
+            return report_failure("profile_acquire_instance_index");
+        }
+        family4_datagen::instance::ResolvedInstance instance{};
+        const state::account::inventory::ProfileItem& profileItem =
+            account.profileItems[mutation.profileIndex];
+        const auto instanceBytes = rawStorage.first(family4_datagen::instance::layout::kObjectSize);
+        if (!resolve_profile_item_instance(profileItem, instance)
+            || instance.instanceSoid != acquisition.acquiredInstanceSoid
+            || !family4_datagen::instance::encode(instance, instanceBytes)
+            || !append_object(scratch,
+                              instanceBytes,
+                              acquisition.itemInstanceDefinitionId,
+                              acquisition.acquiredInstanceSoid,
+                              staged.objects.front(),
+                              compressedExtent)) {
+            return report_failure("profile_acquire_instance_object");
+        }
+        objectCount = 2;
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        acquisition.after.family4RootSoid,
+        acquisition.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("profile_acquire_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=profile_acquire stage=account_object result=ok family_version=%d "
+                      "account=0x%llX definition=%u item_count=%zu definition_hash=%u quantity=%d "
+                      "native_row=%zu mutation_serial=%d change_slot=%u change_next_sequence=%u "
+                      "change_kind=%u account_payload_bytes=%zu objects=%zu object_order=%s",
+                      acquisition.after.family4Version,
+                      static_cast<unsigned long long>(acquisition.accountSoid),
+                      acquisition.accountDefinitionId,
+                      mutation.afterItemCount,
+                      mutation.acquiredDefinitionHash,
+                      mutation.acquiredQuantity,
+                      acquiredRow,
+                      mutation.acquiredMutationSerial,
+                      static_cast<unsigned>(kAcquisitionChangeNextWriteSlot),
+                      static_cast<unsigned>(kAcquisitionChangeNextSequence),
+                      static_cast<unsigned>(kAcquisitionChangeKind),
+                      prepared.family.objects[accountObjectIndex].payload.size(),
+                      objectCount,
+                      acquisition.appendedResident ? "item-account" : "account");
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
+/** Builds a new item-instance upsert before its character after-image. */
+bool prepare_item_acquisition(Scratch& scratch,
+                              const queuez::ItemAcquisition& acquisition,
+                              const state::PendingItemAcquisition& mutation,
+                              Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("acquire_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.characterSoid == 0 || mutation.acquiredInstanceSoid == 0
+        || mutation.accountSoid == 0 || mutation.accountSoid != acquisition.accountSoid
+        || mutation.characterSoid != acquisition.characterSoid
+        || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
+        || mutation.profileChanged != acquisition.updatesAccount
+        || acquisition.accountSoid != acquisition.after.family4RootSoid
+        || acquisition.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
+        || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
+                   .objectSoid
+               != mutation.acquiredInstanceSoid
+        || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
+                   .definitionId
+               != acquisition.itemInstanceDefinitionId) {
+        return report_failure("acquire_mutation");
+    }
+    if (!state::preview_item_acquisition(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != acquisition.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("acquire_account");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != acquisition.characterDefinitionId
+        || selected.itemInstanceObjectId != acquisition.itemInstanceDefinitionId) {
+        return report_failure("acquire_selection");
+    }
+
+    family4_datagen::loadout::ResolvedInstances acquired{};
+    std::int32_t acquiredMutationSerial = -1;
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const family4_datagen::loadout::ResolvedItem& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid != mutation.acquiredInstanceSoid) {
+            continue;
+        }
+        if (acquired.itemCount != 0 || item.equipped || item.inventoryRow != mutation.inventoryRow
+            || item.equipmentSlot != mutation.equipmentSlot) {
+            return report_failure("acquire_item_row");
+        }
+        acquired.items[0] = {item.equipmentSlot, item.instance};
+        acquired.itemCount = 1;
+        acquiredMutationSerial = item.mutationSerial;
+    }
+    if (acquired.itemCount != 1 || acquiredMutationSerial < 0) {
+        return report_failure("acquire_item_missing");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("acquire_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("acquire_character_object");
+    }
+
+    // The native character-object observer requires a transient inventory-change record whose
+    // serial matches the newly filled row. Without it the row is accepted, but the observer never
+    // queues the acquisition feedback. Keep this record local to this one acquisition push; the
+    // canonical encoder leaves the bank empty on every later snapshot.
+    constexpr std::size_t kBitsPerFlagByte = 8;
+    constexpr std::int32_t kEncodedOccupiedRowWatermark = 1;
+    constexpr std::uint16_t kAcquisitionChangeSequence = 0;
+    constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
+    constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
+    constexpr std::uint8_t kAcquisitionChangeKind = 1;
+    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
+    auto& characterObject =
+        *reinterpret_cast<family4_datagen::character::layout::Object*>(characterBytes.data());
+    const std::size_t acquiredRow = mutation.inventoryRow;
+    if (acquiredRow >= characterObject.inventoryItems.size()
+        || acquiredRow >= characterObject.instanceProgressWatermarks.size()) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_new_item_row");
+    }
+    const std::size_t newItemFlagIndex = acquiredRow / kBitsPerFlagByte;
+    const std::byte newItemFlagMask = std::byte{1U} << (acquiredRow % kBitsPerFlagByte);
+    const auto recordIsZero =
+        [](const family4_datagen::character::layout::InventoryChangeRecord& record) noexcept {
+            return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+                   && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+        };
+    const bool unknownIsZero =
+        std::all_of(characterObject.inventoryChangeUnknown.cbegin(),
+                    characterObject.inventoryChangeUnknown.cend(),
+                    [](std::byte value) noexcept { return value == std::byte{}; });
+    const bool recordsAreZero = std::all_of(characterObject.inventoryChanges.records.cbegin(),
+                                            characterObject.inventoryChanges.records.cend(),
+                                            recordIsZero);
+    const auto& acquiredInventoryRow = characterObject.inventoryItems[acquiredRow];
+    if (newItemFlagIndex >= characterObject.newItemFlags.size()
+        || acquiredInventoryRow.instanceSoid != mutation.acquiredInstanceSoid
+        || acquiredInventoryRow.mutationSerial != acquiredMutationSerial
+        || (characterObject.newItemFlags[newItemFlagIndex] & newItemFlagMask) != newItemFlagMask
+        || characterObject.instanceProgressWatermarks[acquiredRow] != kEncodedOccupiedRowWatermark
+        || !unknownIsZero || characterObject.inventoryChanges.writeSlot != 0
+        || characterObject.inventoryChanges.nextSequence != 0 || !recordsAreZero) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_inventory_change_state");
+    }
+    characterObject.inventoryChanges.writeSlot = kAcquisitionChangeNextWriteSlot;
+    characterObject.inventoryChanges.nextSequence = kAcquisitionChangeNextSequence;
+    auto& acquisitionChange = characterObject.inventoryChanges.records.front();
+    acquisitionChange.sequence = kAcquisitionChangeSequence;
+    acquisitionChange.mutationSerial = acquiredMutationSerial;
+    acquisitionChange.kind = kAcquisitionChangeKind;
+    acquisitionChange.flags = kAcquisitionChangeFlags;
+    if (!std::all_of(characterObject.inventoryChanges.records.cbegin() + 1,
+                     characterObject.inventoryChanges.records.cend(),
+                     recordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_inventory_change_records");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       acquisition.characterDefinitionId,
+                       acquisition.characterSoid,
+                       staged.objects[0],
+                       compressedExtent)) {
+        return report_failure("acquire_character_object");
+    }
+
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      acquisition.itemInstanceDefinitionId,
+                      acquired,
+                      1,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_item_object");
+    }
+
+    const auto& acquiredObject =
+        *reinterpret_cast<const family4_datagen::instance::layout::Object*>(rawStorage.data());
+    if (acquiredObject.instanceSoid != mutation.acquiredInstanceSoid
+        || acquiredObject.roll.progress
+               != family4_datagen::instance::layout::kInitialInstanceProgress) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_item_progress");
+    }
+    const std::int32_t acquiredInstanceProgress = acquiredObject.roll.progress;
+
+    std::size_t objectCount = 2;
+    if (acquisition.updatesAccount) {
+        if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("acquire_account_storage");
+        }
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)
+            || !append_object(scratch,
+                              accountBytes,
+                              acquisition.accountDefinitionId,
+                              acquisition.accountSoid,
+                              staged.objects[2],
+                              compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("acquire_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 3;
+    }
+
+    // Creation increments publish the dependency before the reference to it. Compression order is
+    // irrelevant because each descriptor already owns its sealed span, so exchange only the wire
+    // descriptors: new item first, then the character after-image. Dismantle deliberately uses the
+    // inverse dependency order (drop the character reference, then release the item).
+    std::swap(staged.objects[0], staged.objects[1]);
+
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        acquisition.after.family4RootSoid,
+        acquisition.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=acquire stage=family4_objects result=ok family_version=%d root=0x%llX "
+        "character=0x%llX character_definition=%u instance=0x%llX item_definition=%u "
+        "definition_hash=%u inventory_row=%u equipment_slot=%u next_serial=%u objects=%zu "
+        "order=%s new_item_flag=1 watermark=1 acquired_row_serial=%d "
+        "inventory_change_write_slot=%u inventory_change_next_sequence=%u "
+        "inventory_change_record=0 inventory_change_sequence=%u "
+        "inventory_change_serial=%d inventory_change_kind=%u inventory_change_flags=%u "
+        "instance_progress=%d",
+        acquisition.after.family4Version,
+        static_cast<unsigned long long>(acquisition.after.family4RootSoid),
+        static_cast<unsigned long long>(acquisition.characterSoid),
+        acquisition.characterDefinitionId,
+        static_cast<unsigned long long>(acquisition.acquiredInstanceSoid),
+        acquisition.itemInstanceDefinitionId,
+        mutation.acquiredDefinitionHash,
+        static_cast<unsigned>(mutation.inventoryRow),
+        static_cast<unsigned>(mutation.equipmentSlot),
+        mutation.afterCharacter.nextInventorySerial,
+        objectCount,
+        acquisition.updatesAccount ? "item_character_account" : "item_character",
+        acquiredMutationSerial,
+        static_cast<unsigned>(kAcquisitionChangeNextWriteSlot),
+        static_cast<unsigned>(kAcquisitionChangeNextSequence),
+        static_cast<unsigned>(kAcquisitionChangeSequence),
+        acquiredMutationSerial,
+        static_cast<unsigned>(kAcquisitionChangeKind),
+        static_cast<unsigned>(kAcquisitionChangeFlags),
+        acquiredInstanceProgress);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
+/** Builds a character upsert followed by one empty item-instance release descriptor. */
+bool prepare_item_dismantle(Scratch& scratch,
+                            const queuez::ItemDismantle& dismantle,
+                            const state::PendingItemDismantle& mutation,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("dismantle_reservation");
+    }
+
+    state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.characterSoid == 0 || mutation.dismantledInstanceSoid == 0
+        || mutation.dismantledItem.instanceSoid != mutation.dismantledInstanceSoid
+        || mutation.characterSoid != dismantle.characterSoid
+        || mutation.dismantledInstanceSoid != dismantle.dismantledInstanceSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("dismantle_mutation");
+    }
+    account.characters[mutation.characterIndex] = mutation.afterCharacter;
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != dismantle.characterDefinitionId
+        || selected.itemInstanceObjectId != dismantle.itemInstanceDefinitionId) {
+        return report_failure("dismantle_selection");
+    }
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        if (selected.loadout.items[index].instance.instanceSoid
+            == mutation.dismantledInstanceSoid) {
+            return report_failure("dismantle_item_present");
+        }
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("dismantle_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("dismantle_character_object");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       dismantle.characterDefinitionId,
+                       dismantle.characterSoid,
+                       staged.objects[0],
+                       compressedExtent)) {
+        return report_failure("dismantle_character_object");
+    }
+    // Queuez represents a release with the ordinary object key and an empty payload. The
+    // encoding selector is not read for empty descriptors; oodle matches the surrounding objects.
+    staged.objects[1] = middleware::queuez::Object{
+        dismantle.itemInstanceDefinitionId,
+        dismantle.dismantledInstanceSoid,
+        middleware::queuez::Encoding::oodle,
+        {},
+    };
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        dismantle.after.family4RootSoid,
+        dismantle.after.family4Version,
+        0,
+        std::span(staged.objects).first(2),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("dismantle_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=dismantle stage=family4_objects result=ok family_version=%d root=0x%llX "
+                      "character=0x%llX character_definition=%u instance=0x%llX item_definition=%u "
+                      "definition_hash=%u inventory_index=%zu inventory_row=%u equipment_slot=%u "
+                      "moved_items=%zu items_after=%zu next_serial=%u",
+                      dismantle.after.family4Version,
+                      static_cast<unsigned long long>(dismantle.after.family4RootSoid),
+                      static_cast<unsigned long long>(dismantle.characterSoid),
+                      dismantle.characterDefinitionId,
+                      static_cast<unsigned long long>(dismantle.dismantledInstanceSoid),
+                      dismantle.itemInstanceDefinitionId,
+                      mutation.dismantledItem.definitionHash,
+                      mutation.inventoryIndex,
+                      static_cast<unsigned>(mutation.inventoryRow),
+                      static_cast<unsigned>(mutation.equipmentSlot),
+                      mutation.movedInventoryItemCount,
+                      mutation.afterCharacter.inventory.count,
+                      mutation.afterCharacter.nextInventorySerial);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 103 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/internal.h

@@ -101,6 +101,90 @@ inline constexpr std::size_t kSingleObjectCount = 1;
                                           const queuez::SelectCharacter& select,
                                           Prepared& prepared) noexcept;
 
+/**
+ * Builds the Family-4 character upsert for one prepared equipment swap.
+ * @param scratch
+ * Object and compression storage owned by the lock.
+ * @param swap Checked queuez version
+ * after-image and resident character definition.
+ * @param mutation Checked State after-image that
+ * is not committed yet.
+ * @param prepared Gets the single character upsert descriptor.
+ * @return
+ * True when the after-image encodes and the complete object fits.
+ */
+[[nodiscard]] bool prepare_equipment_swap(Scratch& scratch,
+                                          const queuez::EquipmentSwap& swap,
+                                          const state::PendingEquipmentSwap& mutation,
+                                          Prepared& prepared) noexcept;
+
+/** Builds the Family-4 character upsert carrying one accumulated item-state change. */
+[[nodiscard]] bool prepare_item_state(Scratch& scratch,
+                                      const queuez::EquipmentSwap& update,
+                                      const state::PendingItemState& mutation,
+                                      Prepared& prepared) noexcept;
+
+/**
+ * Builds the Family-4 item-instance upsert for one prepared ordinary-socket selection.
+ * The
+ * character object is unchanged because item identity, placement, and mutation generation are
+ *
+ * preserved; the socket block lives entirely in the resident instance object.
+ */
+[[nodiscard]] bool prepare_socket_plug(Scratch& scratch,
+                                       const queuez::SocketPlug& socketPlug,
+                                       const state::PendingSocketPlug& mutation,
+                                       Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the newly resident item object followed by the
+ *
+ * changed character that references it.
+ * @param scratch Object and compression storage owned by
+ * the lock.
+ * @param acquisition Exact queuez after-image promised by the correlated response.
+ *
+ * @param mutation Checked State after-image that remains uncommitted while output is staged.
+ *
+ * @param prepared Gets the two complete upsert descriptors in item-then-character dependency
+ *
+ * order.
+ * @return True when both after-image objects encode and fit atomically.
+ */
+[[nodiscard]] bool prepare_item_acquisition(Scratch& scratch,
+                                            const queuez::ItemAcquisition& acquisition,
+                                            const state::PendingItemAcquisition& mutation,
+                                            Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the full account after-image for a profile stack.
+ *
+ * No resident is added: the account root is upserted at the exact staged +1 revision.
+ */
+[[nodiscard]] bool
+prepare_profile_item_acquisition(Scratch& scratch,
+                                 const queuez::ProfileItemAcquisition& acquisition,
+                                 const state::PendingProfileItemAcquisition& mutation,
+                                 Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the changed character and released item instance.
+ *
+ * @param scratch Object and compression storage owned by the lock.
+ * @param dismantle Exact queuez
+ * after-image promised by the correlated response.
+ * @param mutation Checked State after-image
+ * that remains uncommitted while output is staged.
+ * @param prepared Gets the character upsert
+ * followed by the empty release descriptor.
+ * @return True when the character after-image and
+ * complete two-operation update fit atomically.
+ */
+[[nodiscard]] bool prepare_item_dismantle(Scratch& scratch,
+                                          const queuez::ItemDismantle& dismantle,
+                                          const state::PendingItemDismantle& mutation,
+                                          Prepared& prepared) noexcept;
+
 /** Selected-character mappings the character and item-instance encoders need. */
 struct Resolved {
     std::size_t characterIndex{};
@@ -171,4 +255,23 @@ append_items(Scratch& scratch,
              std::size_t& itemCursor,
              std::size_t& compressedExtent) noexcept;
 
+/** Resolves one source-backed profile stack into the shared Family-4 item-instance schema. */
+[[nodiscard]] bool resolve_profile_item_instance(
+    const state::account::inventory::ProfileItem& profileItem,
+    middleware::datagen::family4::instance::ResolvedInstance& output) noexcept;
+
+/**
+ * Appends every source-backed profile item after all character-owned item residents.
+ * Native
+ * currency/material/consumable rows have zero SOIDs and intentionally add no descriptor.
+ */
+[[nodiscard]] 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;
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 40 - 4
Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h

@@ -14,13 +14,18 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 /** Account and selected-character identity take the first two family-four descriptors. */
 inline constexpr std::size_t kFamily4IdentityObjectCount = 2;
 /**
- * Family four carries both identity objects plus one item record per equipped item, for every
- * character. The equip-summary reader looks up an instance with no null check, so no character in
- * the roster may point at a record this snapshot leaves out.
+ * Family four carries both identity objects plus one record per equipped or unequipped character
+ *
+ * item and every resident-backed profile stack. The character inventory, equip-summary, and
+ *
+ * profile action-source readers all follow instance SOIDs, so every nonzero row key needs a
+ *
+ * published record. The fixed profile-row capacity also bounds future runtime acquisitions.
  */
 inline constexpr std::size_t kObjectCapacity =
     kFamily4IdentityObjectCount
-    + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity;
+    + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity
+    + state::account::inventory::kProfileActionSourceCapacity;
 
 /** Prepared descriptors and scratch extents owned until the update codec copies their bodies. */
 struct Prepared {
@@ -64,4 +69,35 @@ struct Prepared {
                                   std::uint64_t previousCharacter,
                                   Prepared& prepared) noexcept;
 
+/**
+ * Builds the one-record Family-0 incremental that refreshes rendered equipment in place.
+ * The
+ * record is encoded from the prepared State after-image because the equipment transaction is
+ * not
+ * committed until both Family-4 and Family-0 frames fit.
+ * @param replaceCharacterRecord Release
+ * and recreate the same resident record so same-instance
+ *        shader and ornament changes
+ * rebuild the live-world render binding.
+ */
+[[nodiscard]] bool
+prepare_character_appearance_refresh(Scratch& scratch,
+                                     const queuez::CharacterAppearanceRefresh& refresh,
+                                     const state::CharacterState& afterCharacter,
+                                     std::size_t characterIndex,
+                                     std::uint8_t nativeEquipmentSlot,
+                                     bool replaceCharacterRecord,
+                                     Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-3 appearance increment from an uncommitted character after-image.
+ * The
+ * character record is always first; when requested, the changed account roster follows it.
+ */
+[[nodiscard]] bool prepare_roster_appearance_refresh(Scratch& scratch,
+                                                     const queuez::RosterAppearanceRefresh& refresh,
+                                                     const state::CharacterState& afterCharacter,
+                                                     std::size_t characterIndex,
+                                                     Prepared& prepared) noexcept;
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 89 - 4
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -18,11 +18,17 @@ inline constexpr std::uint32_t kAccountFamilyType = 4;
 /** Initial and replayed full snapshots use version zero. */
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
 /**
- * Family four holds account, character, and one id per equipped item on every character.
- * It matches the snapshot descriptor size, so a snapshot that builds always stages.
+ * Family four holds account, character, one id per character-owned item, and one id per
+ *
+ * resident-backed mod or shader stack. Profile currency rows deliberately carry no instance
+ *
+ * SOID; the fixed addition covers all 50 native rows in each supported action-source bucket.
+ * It
+ * matches the snapshot descriptor size, so a snapshot that builds always stages.
  */
 inline constexpr std::size_t kResidentCapacity =
-    2 + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity;
+    2 + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity
+    + state::account::inventory::kProfileActionSourceCapacity;
 /** Resident zero is the account object. The character object is found by its definition id. */
 
 /** When the roster is published after a change, as measured in the character-select flow. */
@@ -41,15 +47,21 @@ struct ResidentObject {
 /** Fixed queuez transaction state owned by one authenticated BAP peer. */
 struct SessionState {
     std::uint64_t family4RootSoid{};
+    /** Root whose Family-3 roster store has accepted its full snapshot. */
+    std::uint64_t family3RootSoid{};
     std::array<ResidentObject, kResidentCapacity> family4Residents{};
     /** Character the resident family-zero pair names. Only a change earns an incremental. */
     std::uint64_t family0Character{};
     std::int32_t family4Version{};
+    /** Version zero is the full roster; every accepted appearance refresh adds exactly one. */
+    std::int32_t family3Version{};
     /** Retail sets the full-snapshot flag once per family, then increments this by one. */
     std::int32_t family0Version{};
-    std::uint8_t family4ResidentCount{};
+    std::uint16_t family4ResidentCount{};
     Family3Phase family3Phase{Family3Phase::normal};
     bool family4Active{};
+    /** Set only after a complete Family-3 full-snapshot frame has been staged for publication. */
+    bool family3Active{};
     /** Set once the family-zero full snapshot has been published to this peer. */
     bool family0Active{};
 };
@@ -79,6 +91,79 @@ struct SelectCharacter {
     bool patchAccount{};
 };
 
+/** Validated opcode-403 after-image for a single resident character upsert. */
+struct EquipmentSwap {
+    SessionState after{};
+    std::uint32_t characterDefinitionId{};
+    std::uint64_t characterSoid{};
+};
+
+/**
+ * Validated Family-0 after-image for the appearance record changed by one equipment swap.
+ *
+ * The resident character key does not move: the update upserts that one record in place.
+ */
+struct CharacterAppearanceRefresh {
+    SessionState after{};
+    std::uint64_t characterSoid{};
+};
+
+/** Validated Family-3 appearance after-image for one character and optional account roster. */
+struct RosterAppearanceRefresh {
+    SessionState after{};
+    std::uint64_t characterSoid{};
+    /** Equipment moves change the roster's slot definition; socket-only changes do not. */
+    bool includeRoster{};
+};
+
+/** Validated item-acquisition after-image for one character upsert and one new instance object. */
+struct ItemAcquisition {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t characterDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t acquiredInstanceSoid{};
+    /** True when the same revision also publishes the charged profile-material balances. */
+    bool updatesAccount{};
+};
+
+/** Validated socket-action after-image for one item upsert and optional material balances. */
+struct SocketPlug {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t targetInstanceSoid{};
+    bool updatesAccount{};
+};
+
+/** Validated profile-stack acquisition after-image for an account upsert and optional resident. */
+struct ProfileItemAcquisition {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    /** Family-4 slot-three schema used by a resident-backed shader or modification stack. */
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    /** Stable profile-row identity, or zero for a non-actionable currency/material stack. */
+    std::uint64_t acquiredInstanceSoid{};
+    /** Shared installed-build classifier copied from the prepared State mutation. */
+    bool actionSource{};
+    /** True only when this revision appends the resident at the prior manifest tail. */
+    bool appendedResident{};
+};
+
+/** Validated item-dismantle after-image for one character upsert and one instance release. */
+struct ItemDismantle {
+    SessionState after{};
+    std::uint32_t characterDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t characterSoid{};
+    std::uint64_t dismantledInstanceSoid{};
+};
+
 /** Queuez fields published after every staged frame is copied to caller output. */
 struct StagedPublication {
     SessionState after{};

+ 295 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -1,5 +1,7 @@
 #include "queuez_outcome_staging.h"
 
+#include <limits>
+
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 #include "queuez_state_validation.h"
@@ -42,6 +44,299 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = outcome.changeCharacter.after;
+    } else if (outcome.hasEquipmentSwap) {
+        // Body processing already staged this exact after-image so the correlated opcode-403
+        // response could promise its version. Reuse it here; staging a second revision would make
+        // the response and pushed Family-4 ladder disagree.
+        const EquipmentSwap& swap = outcome.equipmentSwapUpdate;
+        if (!valid(swap.after) || swap.characterSoid != outcome.equipmentSwap.characterSoid
+            || swap.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || swap.after.family4Version != before.family4Version + 1
+            || !push::append_equipment_swap_notification(
+                scratch, swap, outcome.equipmentSwap, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=equip result=fail");
+            // The State transaction must not commit when the Client cannot receive its after-image.
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = swap.after;
+        // Family four drives inventory placement, while Family zero owns the rendered appearance
+        // consumed by the open cosmetic panels and world player. Its resident character record is
+        // updated in place: releasing and re-adding the same key tears down the ship/banner
+        // binding.
+        if (after.family0Active) {
+            CharacterAppearanceRefresh refresh{};
+            if (!stage_character_appearance_refresh(
+                    after, outcome.equipmentSwap.characterSoid, refresh)
+                || !push::append_equipment_appearance_refresh_notification(
+                    scratch, refresh, outcome.equipmentSwap, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=equip_appearance result=fail");
+                // The State transaction and both peer ladders remain unpublished when either
+                // member of the paired Family-4/Family-0 delivery cannot fit.
+                return false;
+            }
+            after = refresh.after;
+        }
+        // Family three owns the orbit roster and a separate copy of the same appearance record.
+        // Equipment movement changes both bodies, so publish character first and roster second at
+        // one exact +1 Family-3 revision.  Failure keeps all three peer ladders and State private.
+        if (after.family3Active) {
+            RosterAppearanceRefresh refresh{};
+            if (!stage_roster_appearance_refresh(
+                    after, outcome.equipmentSwap.characterSoid, true, refresh)
+                || !push::append_equipment_roster_refresh_notification(
+                    scratch, refresh, outcome.equipmentSwap, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=equip_roster result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+    } else if (outcome.hasItemState) {
+        // Item-state bits live in the selected-character inventory row. Publish only that
+        // resident character body; item-instance, appearance, roster and manifest are unchanged.
+        const EquipmentSwap& update = outcome.itemStateUpdate;
+        bool preservedManifest = update.after.family4ResidentCount == before.family4ResidentCount;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            preservedManifest = update.after.family4Residents[index].objectSoid
+                                    == before.family4Residents[index].objectSoid
+                                && update.after.family4Residents[index].definitionId
+                                       == before.family4Residents[index].definitionId;
+        }
+        if (!valid(update.after) || !preservedManifest
+            || update.characterSoid != outcome.itemState.characterSoid
+            || update.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || update.after.family4Version != before.family4Version + 1
+            || !push::append_item_state_notification(
+                scratch, update, outcome.itemState, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=item_state result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = update.after;
+    } else if (outcome.hasSocketPlug) {
+        // Body processing staged this exact +1 revision before encoding opcode 903's status pair.
+        // A socket selection changes only one already-resident item-instance body.
+        const SocketPlug& socketPlug = outcome.socketPlugUpdate;
+        bool preservedManifest =
+            socketPlug.after.family4ResidentCount == before.family4ResidentCount;
+        std::size_t accountMatches = 0;
+        std::size_t targetMatches = 0;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            const ResidentObject& staged = socketPlug.after.family4Residents[index];
+            preservedManifest = staged.objectSoid == resident.objectSoid
+                                && staged.definitionId == resident.definitionId;
+            targetMatches += static_cast<std::size_t>(
+                resident.objectSoid == socketPlug.targetInstanceSoid
+                && resident.definitionId == socketPlug.itemInstanceDefinitionId);
+            accountMatches += static_cast<std::size_t>(resident.objectSoid == socketPlug.accountSoid
+                                                       && resident.definitionId
+                                                              == socketPlug.accountDefinitionId);
+        }
+        if (!valid(socketPlug.after) || !preservedManifest || accountMatches != 1
+            || targetMatches != 1 || socketPlug.accountSoid != outcome.socketPlug.accountSoid
+            || socketPlug.characterSoid != outcome.socketPlug.characterSoid
+            || socketPlug.targetInstanceSoid != outcome.socketPlug.targetInstanceSoid
+            || socketPlug.updatesAccount != outcome.socketPlug.profileChanged
+            || socketPlug.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || socketPlug.after.family4Version != before.family4Version + 1
+            || !push::append_socket_plug_notification(
+                scratch, socketPlug, outcome.socketPlug, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=socket_plug result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = socketPlug.after;
+        // Equipped plugs feed Family zero's material, overflow-hash and sandbox-perk banks. An
+        // inventory-only socket change has no rendered character record to refresh.
+        if (outcome.socketPlug.targetEquipped && after.family0Active) {
+            CharacterAppearanceRefresh refresh{};
+            if (!stage_character_appearance_refresh(
+                    after, outcome.socketPlug.characterSoid, refresh)
+                || !push::append_socket_appearance_refresh_notification(
+                    scratch, refresh, outcome.socketPlug, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=socket_appearance result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+        // A socket change can alter the rendered shader/perk banks in Family three, but it does not
+        // change the roster's base-definition references.  Only the character record is owed.
+        if (outcome.socketPlug.targetEquipped && after.family3Active) {
+            RosterAppearanceRefresh refresh{};
+            if (!stage_roster_appearance_refresh(
+                    after, outcome.socketPlug.characterSoid, false, refresh)
+                || !push::append_socket_roster_refresh_notification(
+                    scratch, refresh, outcome.socketPlug, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=socket_roster result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+    } else if (outcome.hasItemAcquisition) {
+        // Body processing staged this exact manifest append before encoding the response version.
+        // The character and new item objects must both fit or the State insertion is not committed.
+        const ItemAcquisition& acquisition = outcome.itemAcquisitionUpdate;
+        const std::size_t appendedIndex = before.family4ResidentCount;
+        bool preservedManifest = acquisition.after.family4ResidentCount == appendedIndex + 1U;
+        for (std::size_t index = 0; preservedManifest && index < appendedIndex; ++index) {
+            preservedManifest = acquisition.after.family4Residents[index].objectSoid
+                                    == before.family4Residents[index].objectSoid
+                                && acquisition.after.family4Residents[index].definitionId
+                                       == before.family4Residents[index].definitionId;
+        }
+        if (!valid(acquisition.after) || !preservedManifest
+            || acquisition.accountSoid != outcome.itemAcquisition.accountSoid
+            || acquisition.characterSoid != outcome.itemAcquisition.characterSoid
+            || acquisition.acquiredInstanceSoid != outcome.itemAcquisition.acquiredInstanceSoid
+            || acquisition.updatesAccount != outcome.itemAcquisition.profileChanged
+            || acquisition.accountSoid != before.family4RootSoid
+            || acquisition.after.family4RootSoid != before.family4RootSoid
+            || before.family4ResidentCount >= before.family4Residents.size()
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || acquisition.after.family4Version != before.family4Version + 1
+            || acquisition.after.family4Residents[appendedIndex].objectSoid
+                   != acquisition.acquiredInstanceSoid
+            || acquisition.after.family4Residents[appendedIndex].definitionId
+                   != acquisition.itemInstanceDefinitionId
+            || !push::append_item_acquisition_notification(
+                scratch, acquisition, outcome.itemAcquisition, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=acquire result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = acquisition.after;
+    } else if (outcome.hasProfileItemAcquisition) {
+        // A source-backed profile append creates one dependency before the account starts naming
+        // it. Existing stacks and non-actionable currency rows preserve the complete manifest.
+        const ProfileItemAcquisition& acquisition = outcome.profileItemAcquisitionUpdate;
+        const std::size_t priorResidentCount = before.family4ResidentCount;
+        const std::size_t expectedResidentCount =
+            priorResidentCount + static_cast<std::size_t>(acquisition.appendedResident);
+        bool validManifest = expectedResidentCount <= acquisition.after.family4Residents.size()
+                             && acquisition.after.family4ResidentCount == expectedResidentCount;
+        for (std::size_t index = 0; validManifest && index < priorResidentCount; ++index) {
+            validManifest = acquisition.after.family4Residents[index].objectSoid
+                                == before.family4Residents[index].objectSoid
+                            && acquisition.after.family4Residents[index].definitionId
+                                   == before.family4Residents[index].definitionId;
+        }
+        std::size_t priorProfileResidentMatches = 0;
+        for (std::size_t index = 0; index < priorResidentCount; ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            priorProfileResidentMatches += static_cast<std::size_t>(
+                acquisition.acquiredInstanceSoid != 0
+                && resident.objectSoid == acquisition.acquiredInstanceSoid
+                && resident.definitionId == acquisition.itemInstanceDefinitionId);
+        }
+        const bool appendedResidentValid =
+            !acquisition.appendedResident
+            || (priorResidentCount < acquisition.after.family4Residents.size()
+                && acquisition.after.family4Residents[priorResidentCount].objectSoid
+                       == acquisition.acquiredInstanceSoid
+                && acquisition.after.family4Residents[priorResidentCount].definitionId
+                       == acquisition.itemInstanceDefinitionId
+                && priorProfileResidentMatches == 0);
+        const bool sourceIdentityValid =
+            acquisition.actionSource == (acquisition.acquiredInstanceSoid != 0)
+            && (acquisition.actionSource
+                    ? acquisition.itemInstanceDefinitionId != 0 && appendedResidentValid
+                          && (acquisition.appendedResident || priorProfileResidentMatches == 1)
+                    : acquisition.itemInstanceDefinitionId == 0 && !acquisition.appendedResident
+                          && priorProfileResidentMatches == 0);
+        if (!valid(acquisition.after) || !validManifest || !sourceIdentityValid
+            || acquisition.accountSoid != outcome.profileItemAcquisition.accountSoid
+            || acquisition.acquiredInstanceSoid
+                   != outcome.profileItemAcquisition.acquiredInstanceSoid
+            || acquisition.actionSource != outcome.profileItemAcquisition.actionSource
+            || acquisition.appendedResident
+                   != (outcome.profileItemAcquisition.appended
+                       && outcome.profileItemAcquisition.actionSource)
+            || acquisition.accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
+            || acquisition.accountDefinitionId != before.family4Residents.front().definitionId
+            || acquisition.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || acquisition.after.family4Version != before.family4Version + 1
+            || !push::append_profile_item_acquisition_notification(scratch,
+                                                                   acquisition,
+                                                                   outcome.profileItemAcquisition,
+                                                                   key,
+                                                                   nonce,
+                                                                   response,
+                                                                   written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=profile_acquire result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = acquisition.after;
+    } else if (outcome.hasItemDismantle) {
+        // A dismantle removes exactly one resident while preserving the relative order of every
+        // survivor. The character after-image and empty release descriptor must fit together or
+        // the State removal is not committed.
+        const ItemDismantle& dismantle = outcome.itemDismantleUpdate;
+        bool compactedManifest =
+            before.family4ResidentCount != 0
+            && dismantle.after.family4ResidentCount + 1U == before.family4ResidentCount;
+        std::size_t afterIndex = 0;
+        std::size_t removedCount = 0;
+        for (std::size_t beforeIndex = 0;
+             compactedManifest && beforeIndex < before.family4ResidentCount;
+             ++beforeIndex) {
+            const ResidentObject& resident = before.family4Residents[beforeIndex];
+            if (resident.objectSoid == dismantle.dismantledInstanceSoid) {
+                compactedManifest = resident.definitionId == dismantle.itemInstanceDefinitionId;
+                ++removedCount;
+                continue;
+            }
+            if (afterIndex >= dismantle.after.family4ResidentCount) {
+                compactedManifest = false;
+                break;
+            }
+            const ResidentObject& survivor = dismantle.after.family4Residents[afterIndex++];
+            compactedManifest = survivor.objectSoid == resident.objectSoid
+                                && survivor.definitionId == resident.definitionId;
+        }
+        compactedManifest = compactedManifest && removedCount == 1U
+                            && afterIndex == dismantle.after.family4ResidentCount;
+
+        if (!valid(dismantle.after) || !compactedManifest
+            || dismantle.characterSoid != outcome.itemDismantle.characterSoid
+            || dismantle.dismantledInstanceSoid != outcome.itemDismantle.dismantledInstanceSoid
+            || dismantle.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || dismantle.after.family4Version != before.family4Version + 1
+            || !push::append_item_dismantle_notification(
+                scratch, dismantle, outcome.itemDismantle, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=dismantle result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = dismantle.after;
     } else if (outcome.hasSelectCharacter) {
         // The reply is the Client's task completion and the move is a separate frame. A move that
         // cannot be built leaves the selection where it is, instead of holding back that reply.

+ 13 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.cpp

@@ -18,6 +18,16 @@ bool valid(const SessionState& state) noexcept {
     if (!valid_phase(state.family3Phase)) {
         return false;
     }
+    // Family three has its own object store and version ladder.  It can become active one frame
+    // before the Family-4 companion during boot, but an active pair must always share one root.
+    if (state.family3Active) {
+        if (state.family3RootSoid == 0 || state.family3Version < kInitialFamilyVersion) {
+            return false;
+        }
+    } else if (state.family3RootSoid != 0 || state.family3Version != kInitialFamilyVersion
+               || state.family3Phase != Family3Phase::normal) {
+        return false;
+    }
     // Family zero holds no resident manifest, so its whole contract is the version ladder. An
     // inactive family carries nothing. An active one names a character and never goes back.
     if (state.family0Active) {
@@ -43,6 +53,9 @@ bool valid(const SessionState& state) noexcept {
         || state.family4ResidentCount > state.family4Residents.size()) {
         return false;
     }
+    if (state.family3Active && state.family3RootSoid != state.family4RootSoid) {
+        return false;
+    }
     // Version zero is the full snapshot, and the roster phase leaves normal only once an
     // increment has gone out. Above zero the ladder is open. Opcode 505 and every ws-504
     // character move add one, so there is no fixed top.

+ 114 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -71,6 +71,120 @@ namespace sunrise::server::bap::encrypted::queuez {
                                           std::uint64_t selectedCharacterSoid,
                                           SelectCharacter& select) noexcept;
 
+/**
+ * Stages one Family-4 version increment without changing its resident manifest.
+ * @param
+ * before Current queuez state owned by the peer.
+ * @param characterSoid Selected character whose
+ * equipment changed.
+ * @param swap Gets the checked version after-image and resident definition.
+
+ * * @return True only when the named character object is resident in active Family 4.
+ */
+[[nodiscard]] bool stage_equipment_swap(const SessionState& before,
+                                        std::uint64_t characterSoid,
+                                        EquipmentSwap& swap) noexcept;
+
+/**
+ * Stages one same-character Family-0 appearance-record increment after an equipment swap.
+ * Family zero already owns the record, so the character key is preserved and only its version
+ * ladder advances.
+ *
+ * @param before Peer state after the corresponding Family-4 character upsert.
+ * @param characterSoid Selected character whose rendered equipment changed.
+ * @param refresh Gets the exact combined Family-4/Family-0 after-image.
+ * @return True only when active Family zero already names the same character.
+ */
+[[nodiscard]] bool stage_character_appearance_refresh(const SessionState& before,
+                                                      std::uint64_t characterSoid,
+                                                      CharacterAppearanceRefresh& refresh) noexcept;
+
+/**
+ * Advances the active Family-3 ladder for one appearance refresh.
+ * @param before Peer state after any paired Family-4 and Family-0 frames.
+ * @param characterSoid Character whose Family-3 record changes.
+ * @param includeRoster Whether the account roster body changes in the same increment.
+ * @param refresh Gets the exact +1 Family-3 after-image.
+ * @return True only for an active roster store rooted with the peer's account family.
+ */
+[[nodiscard]] bool stage_roster_appearance_refresh(const SessionState& before,
+                                                   std::uint64_t characterSoid,
+                                                   bool includeRoster,
+                                                   RosterAppearanceRefresh& refresh) noexcept;
+
+/**
+ * Stages one Family-4 version increment without changing its resident manifest.
+ * @param before Current active peer state.
+ * @param characterSoid Selected resident character owning the changed item.
+ * @param targetInstanceSoid Existing resident item-instance key to upsert.
+ * @param socketPlug Gets the exact +1 version and item-instance schema id.
+ * @return True when both the selected character and target instance are resident exactly once.
+ */
+[[nodiscard]] bool stage_socket_plug(const SessionState& before,
+                                     std::uint64_t accountSoid,
+                                     std::uint64_t characterSoid,
+                                     std::uint64_t targetInstanceSoid,
+                                     bool updatesAccount,
+                                     SocketPlug& socketPlug) noexcept;
+
+/**
+ * Stages one Family-4 increment that adds a new resident item and updates its character.
+ *
+ * @param before Current active peer state.
+ * @param characterSoid Selected resident character
+ * receiving the item.
+ * @param acquiredInstanceSoid Fresh item-instance SOID absent from the
+ * resident manifest.
+ * @param acquisition Gets the exact +1 version and appended resident
+ * after-image.
+ * @return True when both schemas resolve and the manifest has one free resident
+ * slot.
+ */
+[[nodiscard]] bool stage_item_acquisition(const SessionState& before,
+                                          std::uint64_t accountSoid,
+                                          std::uint64_t characterSoid,
+                                          std::uint64_t acquiredInstanceSoid,
+                                          bool updatesAccount,
+                                          ItemAcquisition& acquisition) noexcept;
+
+/**
+ * Stages one Family-4 version increment for a full resident account-object upsert.
+ * A profile row with a nonzero action-source SOID must already be resident when its stack grows,
+ * or is appended exactly once when Collections creates the row. Currency/material rows keep a
+ * zero SOID and preserve the manifest.
+ *
+ * @param before Current active peer state.
+ * @param accountSoid Account root receiving the profile stack.
+ * @param acquiredInstanceSoid Profile action-source key, or zero for a non-actionable stack.
+ * @param actionSource Shared installed-build classification from the prepared State mutation.
+ * @param appended True when the account mutation created a new profile row.
+ * @param acquisition Gets the exact +1 version, definitions, and optional manifest append.
+ * @return True when the account/root and optional profile resident transition are exact.
+ */
+[[nodiscard]] bool stage_profile_item_acquisition(const SessionState& before,
+                                                  std::uint64_t accountSoid,
+                                                  std::uint64_t acquiredInstanceSoid,
+                                                  bool actionSource,
+                                                  bool appended,
+                                                  ProfileItemAcquisition& acquisition) noexcept;
+
+/**
+ * Stages one Family-4 increment that removes an item resident and updates its character.
+ *
+ * @param before Current active peer state.
+ * @param characterSoid Selected resident character
+ * losing the item.
+ * @param dismantledInstanceSoid Existing item-instance SOID to release.
+ *
+ * @param dismantle Gets the exact +1 version and compacted resident after-image.
+ * @return True
+ * when both schemas and both named residents exist exactly once.
+ */
+[[nodiscard]] bool stage_item_dismantle(const SessionState& before,
+                                        std::uint64_t characterSoid,
+                                        std::uint64_t dismantledInstanceSoid,
+                                        ItemDismantle& dismantle) noexcept;
+
 /** Clears state for the active root. Zero or another root leaves the state unchanged. */
 void stage_unsubscription(const SessionState& before,
                           std::uint64_t familyRootSoid,

+ 457 - 2
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -1,5 +1,9 @@
+#include <array>
 #include <cstddef>
+#include <cstdio>
+#include <limits>
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/definitions.h"
 #include "../queuez_state_validation.h"
 
@@ -8,8 +12,8 @@ namespace sunrise::server::bap::encrypted::queuez {
 /** Stages the account-selection patch without changing the resident manifest. */
 bool stage_change_character(const SessionState& before, ChangeCharacter& change) noexcept {
     change = {};
-    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0
-        || before.family4ResidentCount == 0
+    if (!valid(before) || !before.family4Active || !before.family3Active
+        || before.family4RootSoid == 0 || before.family4ResidentCount == 0
         || before.family4ResidentCount > before.family4Residents.size()
         || before.family3Phase != Family3Phase::normal) {
         return false;
@@ -99,4 +103,455 @@ bool stage_select_character(const SessionState& before,
     return valid(select.after);
 }
 
+/** Stages a resident character upsert while preserving the Family-4 object manifest. */
+bool stage_equipment_swap(const SessionState& before,
+                          std::uint64_t characterSoid,
+                          EquipmentSwap& swap) noexcept {
+    swap = {};
+    std::uint32_t characterDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || characterSoid == 0
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)) {
+        return false;
+    }
+    bool resident = false;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        if (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid) {
+            resident = true;
+            break;
+        }
+    }
+    if (!resident) {
+        return false;
+    }
+    swap.after = before;
+    ++swap.after.family4Version;
+    swap.characterDefinitionId = characterDefinitionId;
+    swap.characterSoid = characterSoid;
+    const bool staged = valid(swap.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=queuez_version result=%s root=0x%llX before=%d after=%d residents=%u "
+        "character=0x%llX definition=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        swap.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned long long>(characterSoid),
+        characterDefinitionId);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages a same-character Family-0 appearance-record upsert after an equipment swap. */
+bool stage_character_appearance_refresh(const SessionState& before,
+                                        std::uint64_t characterSoid,
+                                        CharacterAppearanceRefresh& refresh) noexcept {
+    refresh = {};
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0
+        || !before.family0Active || characterSoid == 0 || before.family0Character != characterSoid
+        || before.family0Version == (std::numeric_limits<std::int32_t>::max)()) {
+        return false;
+    }
+    refresh.after = before;
+    ++refresh.after.family0Version;
+    refresh.characterSoid = characterSoid;
+    const bool staged = valid(refresh.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=equip stage=family0_version result=%s root=0x%llX before=%d after=%d "
+                      "character=0x%llX",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family0Version,
+                      refresh.after.family0Version,
+                      static_cast<unsigned long long>(characterSoid));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages one in-place Family-3 character refresh and its optional account roster upsert. */
+bool stage_roster_appearance_refresh(const SessionState& before,
+                                     std::uint64_t characterSoid,
+                                     bool includeRoster,
+                                     RosterAppearanceRefresh& refresh) noexcept {
+    refresh = {};
+    if (!valid(before) || !before.family3Active || before.family3RootSoid == 0 || characterSoid == 0
+        || before.family3Version == (std::numeric_limits<std::int32_t>::max)()
+        || (before.family4Active && before.family4RootSoid != before.family3RootSoid)) {
+        return false;
+    }
+    refresh.after = before;
+    ++refresh.after.family3Version;
+    refresh.characterSoid = characterSoid;
+    refresh.includeRoster = includeRoster;
+    const bool staged = valid(refresh.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=appearance stage=family3_version result=%s root=0x%llX before=%d after=%d "
+        "character=0x%llX roster=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family3RootSoid),
+        before.family3Version,
+        refresh.after.family3Version,
+        static_cast<unsigned long long>(characterSoid),
+        includeRoster ? 1U : 0U);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages a resident item-instance upsert while preserving the Family-4 manifest. */
+bool stage_socket_plug(const SessionState& before,
+                       std::uint64_t accountSoid,
+                       std::uint64_t characterSoid,
+                       std::uint64_t targetInstanceSoid,
+                       bool updatesAccount,
+                       SocketPlug& socketPlug) noexcept {
+    socketPlug = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0 || targetInstanceSoid == 0
+        || characterSoid == targetInstanceSoid || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    std::size_t accountMatches = 0;
+    std::size_t characterMatches = 0;
+    std::size_t targetMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        accountMatches += static_cast<std::size_t>(object.objectSoid == accountSoid
+                                                   && object.definitionId == accountDefinitionId);
+        characterMatches += static_cast<std::size_t>(
+            object.objectSoid == characterSoid && object.definitionId == characterDefinitionId);
+        targetMatches +=
+            static_cast<std::size_t>(object.objectSoid == targetInstanceSoid
+                                     && object.definitionId == itemInstanceDefinitionId);
+    }
+    if (accountMatches != 1 || characterMatches != 1 || targetMatches != 1) {
+        return false;
+    }
+
+    socketPlug.after = before;
+    ++socketPlug.after.family4Version;
+    socketPlug.accountDefinitionId = accountDefinitionId;
+    socketPlug.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    socketPlug.accountSoid = accountSoid;
+    socketPlug.characterSoid = characterSoid;
+    socketPlug.targetInstanceSoid = targetInstanceSoid;
+    socketPlug.updatesAccount = updatesAccount;
+    const bool staged = valid(socketPlug.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=socket_plug stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+        "residents=%u account=0x%llX character=0x%llX instance=0x%llX "
+        "item_definition=%u account_update=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        socketPlug.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned long long>(accountSoid),
+        static_cast<unsigned long long>(characterSoid),
+        static_cast<unsigned long long>(targetInstanceSoid),
+        itemInstanceDefinitionId,
+        static_cast<unsigned>(updatesAccount));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages the character upsert and appended resident required by one new item instance. */
+bool stage_item_acquisition(const SessionState& before,
+                            std::uint64_t accountSoid,
+                            std::uint64_t characterSoid,
+                            std::uint64_t acquiredInstanceSoid,
+                            bool updatesAccount,
+                            ItemAcquisition& acquisition) noexcept {
+    acquisition = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0 || acquiredInstanceSoid == 0
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount >= before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    bool accountResident = false;
+    bool characterResident = false;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        if (object.objectSoid == acquiredInstanceSoid) {
+            return false;
+        }
+        accountResident =
+            accountResident
+            || (object.definitionId == accountDefinitionId && object.objectSoid == accountSoid);
+        characterResident =
+            characterResident
+            || (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid);
+    }
+    if (!accountResident || !characterResident) {
+        return false;
+    }
+
+    acquisition.after = before;
+    ++acquisition.after.family4Version;
+    acquisition.after.family4Residents[before.family4ResidentCount] =
+        ResidentObject{acquiredInstanceSoid, itemInstanceDefinitionId};
+    ++acquisition.after.family4ResidentCount;
+    acquisition.accountDefinitionId = accountDefinitionId;
+    acquisition.characterDefinitionId = characterDefinitionId;
+    acquisition.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    acquisition.accountSoid = accountSoid;
+    acquisition.characterSoid = characterSoid;
+    acquisition.acquiredInstanceSoid = acquiredInstanceSoid;
+    acquisition.updatesAccount = updatesAccount;
+    const bool staged = valid(acquisition.after);
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=acquire stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+                      "residents_before=%u residents_after=%u character=0x%llX instance=0x%llX "
+                      "character_definition=%u item_definition=%u account_update=%u",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family4Version,
+                      acquisition.after.family4Version,
+                      static_cast<unsigned>(before.family4ResidentCount),
+                      static_cast<unsigned>(acquisition.after.family4ResidentCount),
+                      static_cast<unsigned long long>(characterSoid),
+                      static_cast<unsigned long long>(acquiredInstanceSoid),
+                      characterDefinitionId,
+                      itemInstanceDefinitionId,
+                      static_cast<unsigned>(updatesAccount));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages an account upsert and, for a newly source-backed profile row, one manifest append. */
+bool stage_profile_item_acquisition(const SessionState& before,
+                                    std::uint64_t accountSoid,
+                                    std::uint64_t acquiredInstanceSoid,
+                                    bool actionSource,
+                                    bool appended,
+                                    ProfileItemAcquisition& acquisition) noexcept {
+    acquisition = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || actionSource != (acquiredInstanceSoid != 0) || !before.family4Active
+        || before.family4RootSoid == 0 || accountSoid == 0 || accountSoid != before.family4RootSoid
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)) {
+        return false;
+    }
+    const ResidentObject& account = before.family4Residents.front();
+    if (account.objectSoid != accountSoid || account.definitionId != accountDefinitionId) {
+        return false;
+    }
+
+    std::size_t profileResidentMatches = 0;
+    if (actionSource) {
+        if (!middleware::datagen::object_id(kAccountFamilyType,
+                                            middleware::datagen::kItemInstanceSlot,
+                                            itemInstanceDefinitionId)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            if (resident.objectSoid != acquiredInstanceSoid) {
+                continue;
+            }
+            if (resident.definitionId != itemInstanceDefinitionId) {
+                return false;
+            }
+            ++profileResidentMatches;
+        }
+    }
+
+    const bool appendResident = appended && actionSource;
+    if ((appendResident
+         && (profileResidentMatches != 0
+             || before.family4ResidentCount >= before.family4Residents.size()))
+        || (!appendResident && acquiredInstanceSoid != 0 && profileResidentMatches != 1)) {
+        return false;
+    }
+
+    acquisition.after = before;
+    ++acquisition.after.family4Version;
+    if (appendResident) {
+        acquisition.after.family4Residents[before.family4ResidentCount] =
+            ResidentObject{acquiredInstanceSoid, itemInstanceDefinitionId};
+        ++acquisition.after.family4ResidentCount;
+    }
+    acquisition.accountDefinitionId = accountDefinitionId;
+    acquisition.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    acquisition.accountSoid = accountSoid;
+    acquisition.acquiredInstanceSoid = acquiredInstanceSoid;
+    acquisition.actionSource = actionSource;
+    acquisition.appendedResident = appendResident;
+    const bool staged = valid(acquisition.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=profile_acquire stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+        "residents_before=%u residents_after=%u account_definition=%u "
+        "instance=0x%llX item_definition=%u action_source=%u appended_row=%u "
+        "appended_resident=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        acquisition.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned>(acquisition.after.family4ResidentCount),
+        accountDefinitionId,
+        static_cast<unsigned long long>(acquiredInstanceSoid),
+        itemInstanceDefinitionId,
+        static_cast<unsigned>(actionSource),
+        static_cast<unsigned>(appended),
+        static_cast<unsigned>(appendResident));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages the character upsert and resident release required by one item dismantle. */
+bool stage_item_dismantle(const SessionState& before,
+                          std::uint64_t characterSoid,
+                          std::uint64_t dismantledInstanceSoid,
+                          ItemDismantle& dismantle) noexcept {
+    dismantle = {};
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || characterSoid == 0
+        || dismantledInstanceSoid == 0 || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    bool characterResident = false;
+    std::size_t dismantledResidentIndex = before.family4Residents.size();
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        characterResident =
+            characterResident
+            || (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid);
+        if (object.objectSoid != dismantledInstanceSoid) {
+            continue;
+        }
+        if (object.definitionId != itemInstanceDefinitionId
+            || dismantledResidentIndex != before.family4Residents.size()) {
+            return false;
+        }
+        dismantledResidentIndex = index;
+    }
+    if (!characterResident || dismantledResidentIndex >= before.family4ResidentCount) {
+        return false;
+    }
+
+    dismantle.after = before;
+    ++dismantle.after.family4Version;
+    for (std::size_t index = dismantledResidentIndex + 1U; index < before.family4ResidentCount;
+         ++index) {
+        dismantle.after.family4Residents[index - 1U] = before.family4Residents[index];
+    }
+    --dismantle.after.family4ResidentCount;
+    dismantle.after.family4Residents[dismantle.after.family4ResidentCount] = {};
+    dismantle.characterDefinitionId = characterDefinitionId;
+    dismantle.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    dismantle.characterSoid = characterSoid;
+    dismantle.dismantledInstanceSoid = dismantledInstanceSoid;
+    const bool staged = valid(dismantle.after);
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=dismantle stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+                      "residents_before=%u residents_after=%u resident_index=%zu character=0x%llX "
+                      "instance=0x%llX character_definition=%u item_definition=%u",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family4Version,
+                      dismantle.after.family4Version,
+                      static_cast<unsigned>(before.family4ResidentCount),
+                      static_cast<unsigned>(dismantle.after.family4ResidentCount),
+                      dismantledResidentIndex,
+                      static_cast<unsigned long long>(characterSoid),
+                      static_cast<unsigned long long>(dismantledInstanceSoid),
+                      characterDefinitionId,
+                      itemInstanceDefinitionId);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
 } // namespace sunrise::server::bap::encrypted::queuez

+ 35 - 10
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_family_staging.cpp

@@ -18,9 +18,14 @@ bool staging::same_resident(const ResidentObject& left, const ResidentObject& ri
  */
 bool staging::same_state(const SessionState& left, const SessionState& right) noexcept {
     if (!valid(left) || !valid(right) || left.family4RootSoid != right.family4RootSoid
+        || left.family3RootSoid != right.family3RootSoid
         || left.family4Version != right.family4Version
+        || left.family3Version != right.family3Version
+        || left.family0Version != right.family0Version
+        || left.family0Character != right.family0Character
         || left.family4ResidentCount != right.family4ResidentCount
-        || left.family3Phase != right.family3Phase || left.family4Active != right.family4Active) {
+        || left.family3Phase != right.family3Phase || left.family4Active != right.family4Active
+        || left.family3Active != right.family3Active || left.family0Active != right.family0Active) {
         return false;
     }
     for (std::size_t index = 0; index < left.family4Residents.size(); ++index) {
@@ -67,15 +72,17 @@ bool stage_family4_snapshot(const SessionState& before,
         || family.flags != middleware::queuez::kFullSnapshotFlag || family.objects.empty()
         || family.objects.size() > kResidentCapacity
         || family.objects.size()
-               > static_cast<std::size_t>((std::numeric_limits<std::uint8_t>::max)())) {
+               > static_cast<std::size_t>((std::numeric_limits<std::uint16_t>::max)())) {
         return false;
     }
 
-    SessionState candidate{};
+    // The Family-3 full snapshot may have been appended immediately before its Family-4 companion.
+    // Preserve that independently published ladder while replacing only the Family-4 manifest.
+    SessionState candidate = before;
+    candidate.family4Residents = {};
     candidate.family4RootSoid = family.rootSoid;
     candidate.family4Version = family.version;
-    candidate.family4ResidentCount = static_cast<std::uint8_t>(family.objects.size());
-    candidate.family3Phase = before.family3Phase;
+    candidate.family4ResidentCount = static_cast<std::uint16_t>(family.objects.size());
     candidate.family4Active = true;
     for (std::size_t index = 0; index < family.objects.size(); ++index) {
         const middleware::queuez::Object& object = family.objects[index];
@@ -92,6 +99,9 @@ bool stage_family4_snapshot(const SessionState& before,
     if (candidate.family4Residents.front().objectSoid != family.rootSoid) {
         return false;
     }
+    if (!valid(candidate)) {
+        return false;
+    }
     if (before.family4Active) {
         return before.family4Version == kInitialFamilyVersion && same_manifest(before, candidate);
     }
@@ -131,7 +141,7 @@ bool stage_family0_subscription(const SessionState& before,
     return true;
 }
 
-/** Stages the measured Family-3 subscription policy: full first, then response-only. */
+/** Stages the measured Family-3 subscription reset: full first, then response-only. */
 bool stage_family3_subscription(const SessionState& before,
                                 const middleware::queuez::Subscription& subscription,
                                 bool& publish,
@@ -142,20 +152,34 @@ bool stage_family3_subscription(const SessionState& before,
         || subscription.familyRootSoid == 0) {
         return false;
     }
-    if (before.family4Active && subscription.familyRootSoid != before.family4RootSoid) {
+    if ((before.family4Active && subscription.familyRootSoid != before.family4RootSoid)
+        || (before.family3Active && subscription.familyRootSoid != before.family3RootSoid)) {
         return false;
     }
+    if (!before.family3Active) {
+        // Publication is transactional: the caller installs this seed only after the full frame is
+        // copied.  Until then the before-image remains inactive and version zero has no meaning.
+        publish = true;
+        after.family3RootSoid = subscription.familyRootSoid;
+        after.family3Version = kInitialFamilyVersion;
+        after.family3Active = true;
+        return valid(after);
+    }
     if (before.family3Phase == Family3Phase::normal) {
         publish = true;
-        return true;
+        // An explicit subscription establishes a fresh client-side store.  Its current full body is
+        // version zero even when the prior subscribed store had consumed incrementals.
+        after.family3Version = kInitialFamilyVersion;
+        return valid(after);
     }
     if (!before.family4Active) {
         return false;
     }
     if (before.family3Phase == Family3Phase::publishOnce) {
         publish = true;
+        after.family3Version = kInitialFamilyVersion;
         after.family3Phase = Family3Phase::responseOnly;
-        return true;
+        return valid(after);
     }
     return before.family3Phase == Family3Phase::responseOnly;
 }
@@ -164,7 +188,8 @@ void stage_unsubscription(const SessionState& before,
                           std::uint64_t familyRootSoid,
                           SessionState& after) noexcept {
     after = before;
-    if (before.family4Active && familyRootSoid == before.family4RootSoid) {
+    if ((before.family4Active && familyRootSoid == before.family4RootSoid)
+        || (before.family3Active && familyRootSoid == before.family3RootSoid)) {
         after = {};
     }
 }

+ 58 - 1
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -1,7 +1,9 @@
 #include "service_outcome_commit.h"
 
+#include "../../../../core/logging/log.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/matchmaking/matchmaking_state.h"
+#include "../../../../state/runtime/runtime.h"
 #include "../internal.h"
 
 namespace sunrise::server::bap::encrypted::transactions {
@@ -16,7 +18,13 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
     publication = {};
     const unsigned mutationCount = static_cast<unsigned>(outcome.hasActivitySessionAllocation)
                                    + static_cast<unsigned>(outcome.hasActivityTransaction)
-                                   + static_cast<unsigned>(outcome.hasMatchmakingMutation);
+                                   + static_cast<unsigned>(outcome.hasMatchmakingMutation)
+                                   + static_cast<unsigned>(outcome.hasEquipmentSwap)
+                                   + static_cast<unsigned>(outcome.hasSocketPlug)
+                                   + static_cast<unsigned>(outcome.hasItemState)
+                                   + static_cast<unsigned>(outcome.hasItemAcquisition)
+                                   + static_cast<unsigned>(outcome.hasProfileItemAcquisition)
+                                   + static_cast<unsigned>(outcome.hasItemDismantle);
     // A service route may never combine independently versioned State transactions.
     if (mutationCount > 1U) {
         return false;
@@ -44,6 +52,55 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
     if (outcome.hasMatchmakingMutation) {
         return state::matchmaking::commit(outcome.matchmakingMutation);
     }
+    if (outcome.hasEquipmentSwap) {
+        const bool committed = state::commit_equipment_swap(outcome.equipmentSwap);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=equip stage=transaction_commit result=ok"
+                                   : "ev=equip stage=transaction_commit result=fail");
+        return committed;
+    }
+    if (outcome.hasItemAcquisition) {
+        const bool committed = state::commit_item_acquisition(outcome.itemAcquisition);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=acquire stage=transaction_commit result=ok"
+                                   : "ev=acquire stage=transaction_commit result=fail");
+        return committed;
+    }
+    if (outcome.hasSocketPlug) {
+        const bool committed = state::commit_socket_plug(outcome.socketPlug);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=socket_plug stage=transaction_commit result=ok"
+                                   : "ev=socket_plug stage=transaction_commit result=fail");
+        return committed;
+    }
+    if (outcome.hasItemState) {
+        const bool committed = state::commit_item_state(outcome.itemState);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=item_state stage=transaction_commit result=ok"
+                                   : "ev=item_state stage=transaction_commit result=fail");
+        return committed;
+    }
+    if (outcome.hasProfileItemAcquisition) {
+        const bool committed =
+            state::commit_profile_item_acquisition(outcome.profileItemAcquisition);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=profile_acquire stage=transaction_commit result=ok"
+                                   : "ev=profile_acquire stage=transaction_commit result=fail");
+        return committed;
+    }
+    if (outcome.hasItemDismantle) {
+        const bool committed = state::commit_item_dismantle(outcome.itemDismantle);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=dismantle stage=transaction_commit result=ok"
+                                   : "ev=dismantle stage=transaction_commit result=fail");
+        return committed;
+    }
     return true;
 }