Browse Source

Answer a vendor banner where its grant commits, from a list State owns

A vendor's banner is an interaction, chosen by a picker that keeps the
highest-priority row its retire test does not reject. That test is the
game's own mechanism for dropping an answered banner, and offline
nothing answers it, so a quest already taken keeps being offered and no
other interaction can reach the screen.

state::vendors owns the answered list. A hook on the retire test reads
it and records which interaction each vendor is showing; the Server
carries the answering vendor on the acquisition transaction and writes
the answer only after the grant has committed - the point the shipped
game appends its own entry - so a mutation dropped by preflight or the
commit's staleness guard never buries a quest the player is still owed.
Neither layer includes the other.
chnsw 6 days ago
parent
commit
2ea9b1b5df

+ 4 - 0
Sunrise/Sunrise.vcxproj

@@ -247,6 +247,7 @@
     <ClCompile Include="src\client\hooks\infinite_ammo\infinite_ammo.cpp" />
     <ClCompile Include="src\client\input\window_focus.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\vendor_banner\vendor_banner_retire.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
     <ClCompile Include="src\client\hooks\world_objects\world_object_registry.cpp" />
@@ -612,6 +613,7 @@
     <ClCompile Include="src\state\matchmaking\transactions\matchmaking_commit.cpp" />
     <ClCompile Include="src\state\runtime\equipment\configured_equipment_identity.cpp" />
     <ClCompile Include="src\state\account\pursuit_hold.cpp" />
+    <ClCompile Include="src\state\vendors\answered_interactions.cpp" />
     <ClCompile Include="src\state\account\account_state.cpp" />
     <ClCompile Include="src\state\account\inventory\inventory_state.cpp" />
     <ClCompile Include="src\state\account\settings\settings_state.cpp" />
@@ -1274,6 +1276,7 @@
     <ClInclude Include="src\client\hooks\infinite_ammo\infinite_ammo.h" />
     <ClInclude Include="src\client\input\window_focus.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
+    <ClInclude Include="src\client\hooks\vendor_banner\vendor_banner_retire.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
     <ClInclude Include="src\client\hooks\world_objects\world_object_registry.h" />
     <ClInclude Include="src\client\ui\movement\movement_panel.h" />
@@ -1401,6 +1404,7 @@
     <ClInclude Include="src\state\matchmaking\transactions\internal.h" />
     <ClInclude Include="src\state\runtime\equipment\configured_equipment_identity.h" />
     <ClInclude Include="src\state\account\pursuit_hold.h" />
+    <ClInclude Include="src\state\vendors\answered_interactions.h" />
     <ClInclude Include="src\state\account\account_state.h" />
     <ClInclude Include="src\state\account\inventory\inventory_state.h" />
     <ClInclude Include="src\state\account\settings\settings_state.h" />

+ 85 - 0
Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.cpp

@@ -0,0 +1,85 @@
+/**
+ * Retires a vendor banner the player has answered.
+ *
+ * The banner is an interaction chosen by the picker, which keeps the highest-priority row its
+ * retire test does not reject. Offline nothing answers that test, so a quest already taken keeps
+ * being offered. This hook answers it from the list `state::vendors` keeps, and records on every
+ * call which interaction the vendor is showing, since this is the only place that is readable.
+ */
+
+#include "vendor_banner_retire.h"
+
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+
+#include "../../../state/vendors/answered_interactions.h"
+#include "../../hooking/detour.h"
+
+namespace sunrise::client::hooks::vendor_banner {
+namespace {
+
+/** The picker's per-interaction retire test: `(picker state, interaction index) -> skip`. */
+using RetireFn = bool(__fastcall*)(void*, std::uint16_t);
+
+hooking::detour::Handle g_handle{};
+std::atomic<RetireFn> g_original{nullptr};
+std::atomic_bool g_installed{false};
+
+/**
+ * Answers the picker's retire test, skipping an interaction this vendor has already answered.
+ *
+ * @param self Borrowed picker state for one vendor.
+ * @param interactionIndex Interaction row being tested.
+ * @return True when the picker must skip the row.
+ */
+__declspec(noinline) bool __fastcall retired(void* self, std::uint16_t interactionIndex) noexcept {
+    const RetireFn original = g_original.load(std::memory_order_acquire);
+    if (self != nullptr) {
+        const auto* const picker = static_cast<const std::byte*>(self);
+        std::uint16_t vendorIndex = 0;
+        std::uint16_t selected = 0;
+        std::memcpy(&vendorIndex, picker + StateLayout::vendorIndex, sizeof vendorIndex);
+        std::memcpy(&selected, picker + StateLayout::selectedInteraction, sizeof selected);
+        if (vendorIndex < state::vendors::kVendorCapacity) {
+            state::vendors::record_shown(vendorIndex, selected);
+            if (state::vendors::is_answered(vendorIndex, interactionIndex)) {
+                return true;
+            }
+        }
+    }
+    return original == nullptr ? false : original(self, interactionIndex);
+}
+
+} // namespace
+
+/** Attaches the retire gate. */
+bool install() noexcept {
+    if (g_installed.load(std::memory_order_acquire)) {
+        return true;
+    }
+    state::vendors::clear();
+    std::byte* const target = scan_main_image_unique(kRetireSignature, "vendor_banner_retire");
+    if (target == nullptr) {
+        return false;
+    }
+    if (!hooking::detour::install({target, reinterpret_cast<void*>(&retired)}, g_handle)) {
+        return false;
+    }
+    g_original.store(reinterpret_cast<RetireFn>(g_handle.original), std::memory_order_release);
+    g_installed.store(true, std::memory_order_release);
+    return true;
+}
+
+/** Detaches the gate and forgets every answer. */
+void uninstall() noexcept {
+    if (!g_installed.exchange(false, std::memory_order_acq_rel)) {
+        return;
+    }
+    (void)hooking::detour::uninstall(g_handle);
+    g_original.store(nullptr, std::memory_order_release);
+    state::vendors::clear();
+}
+
+} // namespace sunrise::client::hooks::vendor_banner

+ 46 - 0
Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <cstddef>
+#include <string_view>
+
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::vendor_banner {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * The vendor picker's per-interaction retire test.
+ *
+ * The picker keeps the highest-priority interaction this test does not reject, so answering true
+ * for a row makes it skip to the next. It is the game's own mechanism for dropping an answered
+ * banner, driven by a list nothing appends to offline. `state::vendors` is the list Sunrise keeps
+ * instead: this hook reads it, the Server writes it when a quest grant commits. The prologue is
+ * clean and non-Arxan, and the signature is unique in the image.
+ */
+inline constexpr std::string_view kRetireSignatureText =
+    "48 89 5C 24 ? 48 89 6C 24 ? 56 48 83 EC ? 44 8B 49 ? 33 ED 0F B7 DA 48 8B F1";
+/** Compiled pattern bytes of the signature text above. */
+inline constexpr auto kRetireSignature =
+    signature<signature_length(kRetireSignatureText)>(kRetireSignatureText);
+
+/** Fields of one vendor's picker state, as byte offsets from its base. */
+struct StateLayout {
+    /** Vendor index row, which is the index the wire carries. */
+    static constexpr std::size_t vendorIndex = 0;
+    /** Interaction the vendor is showing right now, or -1 while it shows none. */
+    static constexpr std::size_t selectedInteraction = 2;
+};
+
+/**
+ * Attaches the retire gate.
+ * @return True when the target is found and the detour attaches.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches the gate and forgets every answer. */
+void uninstall() noexcept;
+
+} // namespace sunrise::client::hooks::vendor_banner

+ 2 - 0
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -39,6 +39,7 @@
 #include "../hooks/sense_chain_guard/sense_chain_guard.h"
 #include "../hooks/stall_probe/stall_probe.h"
 #include "../hooks/teleport/runtime.h"
+#include "../hooks/vendor_banner/vendor_banner_retire.h"
 #include "../hooks/world_objects/world_object_registry.h"
 #include "../patterns/registry.h"
 #include "../targets/game.h"
@@ -188,6 +189,7 @@ void clear_game_targets() noexcept {
         core::settings::get().client.stockEntityPool,
         core::settings::get().client.restockDrainedEntityPool);
     (void)hooks::retail_log::install();
+    (void)hooks::vendor_banner::install();
     (void)hooks::assert_handler::install();
     // Read-only. At a hitch it dumps every in-flight job record from the watchdog snapshot,
     // which names the job and thread the in-world freeze blocks on.

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

@@ -426,6 +426,7 @@ bool process(const ServiceRoute& route,
                                                               itemAcquisition->acquiredInstanceSoid,
                                                               output.first(written));
                 transaction.pending = *itemAcquisition;
+                transaction.answeredVendor = webOutcome.answeredVendor;
             }
         }
         if (profileItemAcquisition != nullptr) {
@@ -466,6 +467,7 @@ bool process(const ServiceRoute& route,
                     profileItemAcquisition->acquiredQuantity,
                     output.first(written));
                 transaction.pending = *profileItemAcquisition;
+                transaction.answeredVendor = webOutcome.answeredVendor;
             }
         }
         if (itemDismantle != nullptr) {

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

@@ -68,12 +68,16 @@ struct CurrentActivityTransaction {
 struct ItemAcquisitionTransaction {
     state::PendingItemAcquisition pending{};
     queuez::ItemAcquisition update{};
+    /** Vendor whose shown interaction this grant answers once it commits, or `kAbsentIndex`. */
+    std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
 };
 
 /** Profile acquisition and its exact account/resident QueueZ after-image. */
 struct ProfileItemAcquisitionTransaction {
     state::PendingProfileItemAcquisition pending{};
     queuez::ProfileItemAcquisition update{};
+    /** Vendor whose shown interaction this grant answers once it commits, or `kAbsentIndex`. */
+    std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
 };
 
 /** Dismantle mutation and its exact QueueZ after-image. */

+ 9 - 0
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -9,6 +9,7 @@
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/matchmaking/matchmaking_state.h"
 #include "../../../../state/runtime/runtime.h"
+#include "../../../../state/vendors/answered_interactions.h"
 #include "../bap_connection_publication.h"
 #include "../internal.h"
 
@@ -256,6 +257,11 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
                          committed ? "ev=acquire stage=transaction_commit result=ok"
                                    : "ev=acquire stage=transaction_commit result=fail");
         reason = "acquire";
+        // The item is in the inventory, so the interaction that offered it is answered. This is
+        // the point the shipped game appends its own entry, and why the answer waited.
+        if (committed && transaction->answeredVendor != state::vendors::kAbsentIndex) {
+            (void)state::vendors::answer_shown(transaction->answeredVendor);
+        }
         return committed;
     }
     if (auto* transaction = transaction_if<SocketPlugTransaction>(outcome)) {
@@ -292,6 +298,9 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
                          committed ? "ev=profile_acquire stage=transaction_commit result=ok"
                                    : "ev=profile_acquire stage=transaction_commit result=fail");
         reason = "profile_acquire";
+        if (committed && transaction->answeredVendor != state::vendors::kAbsentIndex) {
+            (void)state::vendors::answer_shown(transaction->answeredVendor);
+        }
         return committed;
     }
     if (auto* transaction = transaction_if<ItemDismantleTransaction>(outcome)) {

+ 9 - 0
Sunrise/src/server/web_service/web_service_runtime.h

@@ -7,6 +7,7 @@
 
 #include "../../middleware/web_service/messages/opcode206.h"
 #include "../../state/runtime/runtime.h"
+#include "../../state/vendors/answered_interactions.h"
 
 namespace sunrise::server::web_service {
 
@@ -31,6 +32,14 @@ struct Outcome {
                                   state::PendingItemState,
                                   state::PendingSettingsUpdate>;
     Mutation mutation{};
+    /**
+     * Vendor whose shown interaction this request answers once its mutation commits, or
+     * `state::vendors::kAbsentIndex`. A quest grant answers the banner that offered it, but only a
+     * committed grant does: queuez preflight or the commit's staleness guard can still drop the
+     * mutation, and an answered list that is append-only for the session would then bury a quest
+     * the player is still owed. So the answer rides the transaction to where the grant commits.
+     */
+    std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
 };
 
 /** @return The prepared mutation of the requested type, or null when another route ran. */

+ 85 - 0
Sunrise/src/state/vendors/answered_interactions.cpp

@@ -0,0 +1,85 @@
+#include "answered_interactions.h"
+
+#include <array>
+#include <atomic>
+
+#include "../build_data/vendors/definition.h"
+
+namespace sunrise::state::vendors {
+
+// The list is indexed by the installed vendor index, so it has to span the same range the index
+// itself does. One that fell short would simply stop answering for the vendors past its end, and
+// nothing else would say so.
+static_assert(kVendorCapacity >= build_data::vendors::kIndexCapacity);
+
+namespace {
+
+/** Answered interactions, packed as `vendorIndex << 16 | interactionIndex`. Append only. */
+std::array<std::atomic<std::uint32_t>, kAnsweredCapacity> g_answered{};
+std::atomic<std::size_t> g_answeredCount{0};
+
+/** Interaction each vendor is showing, so an answered one can be named afterwards. */
+std::array<std::atomic<std::uint16_t>, kVendorCapacity> g_shown{};
+
+/** @param vendorIndex Vendor row. @param interactionIndex Interaction row. @return Packed key. */
+[[nodiscard]] constexpr std::uint32_t pack(std::uint16_t vendorIndex,
+                                           std::uint16_t interactionIndex) noexcept {
+    return (static_cast<std::uint32_t>(vendorIndex) << 16) | interactionIndex;
+}
+
+/** @param key Packed pair. @return True while the pair is answered. */
+[[nodiscard]] bool contains(std::uint32_t key) noexcept {
+    const std::size_t held = g_answeredCount.load(std::memory_order_acquire);
+    for (std::size_t slot = 0; slot < held; ++slot) {
+        if (g_answered[slot].load(std::memory_order_relaxed) == key) {
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace
+
+/** Records the interaction one vendor is showing right now. */
+void record_shown(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept {
+    if (vendorIndex < kVendorCapacity) {
+        g_shown[vendorIndex].store(interactionIndex, std::memory_order_relaxed);
+    }
+}
+
+/** Answers whether one interaction has been answered this session. */
+bool is_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept {
+    return vendorIndex < kVendorCapacity && contains(pack(vendorIndex, interactionIndex));
+}
+
+/** Marks the interaction one vendor is showing right now as answered. */
+bool answer_shown(std::uint16_t vendorIndex) noexcept {
+    if (vendorIndex >= kVendorCapacity) {
+        return false;
+    }
+    const std::uint16_t shown = g_shown[vendorIndex].load(std::memory_order_relaxed);
+    if (shown == kAbsentIndex) {
+        return false;
+    }
+    const std::uint32_t key = pack(vendorIndex, shown);
+    if (contains(key)) {
+        return true;
+    }
+    const std::size_t slot = g_answeredCount.load(std::memory_order_relaxed);
+    if (slot >= kAnsweredCapacity) {
+        return false;
+    }
+    g_answered[slot].store(key, std::memory_order_relaxed);
+    g_answeredCount.store(slot + 1, std::memory_order_release);
+    return true;
+}
+
+/** Forgets every answer and every shown interaction. */
+void clear() noexcept {
+    g_answeredCount.store(0, std::memory_order_release);
+    for (auto& shown : g_shown) {
+        shown.store(kAbsentIndex, std::memory_order_relaxed);
+    }
+}
+
+} // namespace sunrise::state::vendors

+ 55 - 0
Sunrise/src/state/vendors/answered_interactions.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::vendors {
+
+/**
+ * The interactions a player has answered, per vendor, for this session.
+ *
+ * The client's vendor picker skips an interaction its retire test calls answered, and offline
+ * nothing appends to the picker's own list. This is Sunrise's copy. It lives in State because two
+ * layers that must not include each other both need it: the client hook on the retire test reads
+ * it and records what each vendor is showing, and the Server writes it when a quest grant commits.
+ * Append-only for the session; slots are atomic because the two sides run on different threads.
+ */
+
+/** Vendors tracked, which matches the installed vendor index. */
+inline constexpr std::size_t kVendorCapacity = 512;
+
+/** Interactions that can be held answered at once, across every vendor. */
+inline constexpr std::size_t kAnsweredCapacity = 256;
+
+/** Value of a vendor or interaction slot that names nothing. */
+inline constexpr std::uint16_t kAbsentIndex = 0xFFFFU;
+
+/**
+ * Records the interaction one vendor is showing right now.
+ * @param vendorIndex Vendor row of the installed index.
+ * @param interactionIndex Interaction the picker has selected, or `kAbsentIndex` while it has none.
+ */
+void record_shown(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept;
+
+/**
+ * @param vendorIndex Vendor row.
+ * @param interactionIndex Interaction row.
+ * @return True when that interaction has been answered this session.
+ */
+[[nodiscard]] bool is_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept;
+
+/**
+ * Marks the interaction one vendor is showing right now as answered.
+ *
+ * Called once a quest grant has committed, which is the point the shipped game appends its own
+ * entry. Nothing is written for a vendor that is showing no interaction.
+ *
+ * @param vendorIndex Vendor whose shown interaction was answered.
+ * @return True when an interaction was showing and is now answered.
+ */
+bool answer_shown(std::uint16_t vendorIndex) noexcept;
+
+/** Forgets every answer and every shown interaction. */
+void clear() noexcept;
+
+} // namespace sunrise::state::vendors