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

Merge remote-tracking branch 'origin/master' into triumph-work-code

# Conflicts:
#	Sunrise/src/client/content/items/packages/package_item_build.cpp
#	Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
#	Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
#	Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
#	Sunrise/src/server/web_service/web_service_actions.cpp
#	Sunrise/src/server/web_service/web_service_runtime.cpp
#	Sunrise/src/state/build_data/cache/records/format.h
#	Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
#	Sunrise/src/state/runtime/state_account_profile_runtime.cpp
stan 4 дней назад
Родитель
Сommit
74ec77add4
85 измененных файлов с 3621 добавлено и 260 удалено
  1. 12 0
      Sunrise/Sunrise.vcxproj
  2. BIN
      Sunrise/resources/bootflow/texture_80A145FF.dds
  3. BIN
      Sunrise/resources/bootflow/texture_80A14601.dds
  4. BIN
      Sunrise/resources/bootflow/texture_80A14607.dds
  5. BIN
      Sunrise/resources/bootflow/texture_80A1460E.dds
  6. BIN
      Sunrise/resources/bootflow/texture_80A1461D.dds
  7. BIN
      Sunrise/resources/bootflow/texture_80A1461F.dds
  8. BIN
      Sunrise/resources/bootflow/texture_80A14621.dds
  9. BIN
      Sunrise/resources/bootflow/texture_80A14624.dds
  10. BIN
      Sunrise/resources/bootflow/texture_80A14625.dds
  11. BIN
      Sunrise/resources/bootflow/texture_80A14628.dds
  12. BIN
      Sunrise/resources/bootflow/texture_80A14629.dds
  13. BIN
      Sunrise/resources/bootflow/texture_80A1462B.dds
  14. BIN
      Sunrise/resources/bootflow/texture_80A1462E.dds
  15. BIN
      Sunrise/resources/bootflow/texture_80A1462F.dds
  16. BIN
      Sunrise/resources/bootflow/texture_80A14631.dds
  17. BIN
      Sunrise/resources/bootflow/texture_80A14633.dds
  18. BIN
      Sunrise/resources/bootflow/texture_80A14636.dds
  19. BIN
      Sunrise/resources/bootflow/texture_80A146D5.dds
  20. 3 0
      Sunrise/resources/default_settings.json
  21. 21 0
      Sunrise/resources/resource.h
  22. 18 0
      Sunrise/resources/sunrise.rc
  23. 21 0
      Sunrise/resources/vendor_rules/README.md
  24. 61 0
      Sunrise/resources/vendor_rules/vendor_bounty_roll.txt
  25. 24 0
      Sunrise/resources/vendor_rules/vendor_catalog.txt
  26. 298 0
      Sunrise/resources/vendor_rules/vendor_exchange.txt
  27. 16 0
      Sunrise/resources/vendor_rules/vendor_item_substitute.txt
  28. 12 0
      Sunrise/src/client/content/investment/investment_refresh.cpp
  29. 58 3
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  30. 2 2
      Sunrise/src/client/content/vendors/layout.h
  31. 99 11
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  32. 9 3
      Sunrise/src/client/content/vendors/vendor_build.h
  33. 459 0
      Sunrise/src/client/hooks/bootflow/bootflow_texture_override.cpp
  34. 19 0
      Sunrise/src/client/hooks/bootflow/bootflow_texture_override.h
  35. 8 1
      Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp
  36. 85 0
      Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.cpp
  37. 46 0
      Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.h
  38. 2 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  39. 8 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  40. 70 0
      Sunrise/src/core/filesystem/path.cpp
  41. 28 0
      Sunrise/src/core/filesystem/path.h
  42. 18 0
      Sunrise/src/core/logging/log.cpp
  43. 13 0
      Sunrise/src/core/logging/log.h
  44. 12 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  45. 8 0
      Sunrise/src/core/settings/client/definition.h
  46. 132 0
      Sunrise/src/core/settings/rule_text.h
  47. 6 0
      Sunrise/src/core/settings/state_settings.cpp
  48. 1 0
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  49. 10 3
      Sunrise/src/middleware/datagen/family4/account/layout.h
  50. 13 12
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  51. 4 0
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.h
  52. 9 3
      Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp
  53. 34 0
      Sunrise/src/middleware/web_service/messages/biased_field.h
  54. 5 54
      Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.cpp
  55. 1 37
      Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.h
  56. 56 0
      Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp
  57. 50 0
      Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h
  58. 2 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  59. 4 0
      Sunrise/src/server/bap/encrypted/internal.h
  60. 123 33
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  61. 9 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  62. 9 1
      Sunrise/src/server/web_service/opcode_routes.cpp
  63. 847 26
      Sunrise/src/server/web_service/web_service_actions.cpp
  64. 2 0
      Sunrise/src/server/web_service/web_service_actions.h
  65. 155 3
      Sunrise/src/server/web_service/web_service_runtime.cpp
  66. 9 0
      Sunrise/src/server/web_service/web_service_runtime.h
  67. 2 0
      Sunrise/src/state/account/account_state.h
  68. 69 0
      Sunrise/src/state/account/pursuit_hold.cpp
  69. 39 0
      Sunrise/src/state/account/pursuit_hold.h
  70. 2 2
      Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp
  71. 2 2
      Sunrise/src/state/build_data/cache/records/format.h
  72. 15 0
      Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp
  73. 25 0
      Sunrise/src/state/build_data/collectibles/collectible_catalog.h
  74. 13 10
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp
  75. 8 6
      Sunrise/src/state/build_data/vendors/definition.h
  76. 35 3
      Sunrise/src/state/build_data/vendors/vendor_catalog.cpp
  77. 24 0
      Sunrise/src/state/build_data/vendors/vendor_catalog.h
  78. 52 0
      Sunrise/src/state/runtime/runtime.h
  79. 45 15
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  80. 20 29
      Sunrise/src/state/runtime/state_account_dismantle_staging.cpp
  81. 189 1
      Sunrise/src/state/runtime/state_account_profile_runtime.cpp
  82. 21 0
      Sunrise/src/state/runtime/state_account_runtime.cpp
  83. 85 0
      Sunrise/src/state/vendors/answered_interactions.cpp
  84. 55 0
      Sunrise/src/state/vendors/answered_interactions.h
  85. 9 0
      Sunrise/src/steam/runtime/steam_lifecycle.cpp

+ 12 - 0
Sunrise/Sunrise.vcxproj

@@ -137,6 +137,7 @@
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_device.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode504_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode701\opcode701_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode904\opcode904_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode903_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1901_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode402_codec.cpp" />
@@ -247,6 +248,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" />
@@ -268,6 +270,7 @@
     <ClCompile Include="src\client\hooks\external_server\external_server_route.cpp" />
     <ClCompile Include="src\client\hooks\external_server\external_server_setopt_guard.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\bootflow_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\bootflow_texture_override.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\character_select_hold.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\composition_check.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\orbit_handoff.cpp" />
@@ -611,6 +614,8 @@
     <ClCompile Include="src\state\matchmaking\transactions\matchmaking_prepare.cpp" />
     <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" />
@@ -1211,6 +1216,7 @@
     <ClInclude Include="src\core\logging\snapshot\internal.h" />
     <ClInclude Include="src\core\logging\view\log_snapshot_view.h" />
     <ClInclude Include="src\core\filesystem\path.h" />
+    <ClInclude Include="src\core\settings\rule_text.h" />
     <ClInclude Include="src\core\filesystem\temporary_sibling.h" />
     <ClInclude Include="src\core\settings\settings.h" />
     <ClInclude Include="src\core\settings\parser.h" />
@@ -1231,6 +1237,8 @@
     <ClInclude Include="src\client\hooks\banner\banner_hook_lifecycle.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode504.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode701\opcode701_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode904\opcode904_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\biased_field.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode903.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1901.h" />
     <ClInclude Include="src\core\ui\busy\busy.h" />
@@ -1288,6 +1296,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" />
@@ -1414,6 +1423,8 @@
     <ClInclude Include="src\state\steam\steam_state.h" />
     <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" />
@@ -1771,6 +1782,7 @@
     <ClInclude Include="src\client\hooks\net_tick_probe\net_tick_probe.h" />
     <ClInclude Include="src\client\hooks\bootflow\internal.h" />
     <ClInclude Include="src\client\hooks\bootflow\bootflow_hook_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\bootflow\bootflow_texture_override.h" />
     <ClInclude Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.h" />
     <ClInclude Include="src\client\hooks\bootflow\spawn\probe.h" />
     <ClInclude Include="src\client\hooks\config_getter\config_getter_answers.h" />

BIN
Sunrise/resources/bootflow/texture_80A145FF.dds


BIN
Sunrise/resources/bootflow/texture_80A14601.dds


BIN
Sunrise/resources/bootflow/texture_80A14607.dds


BIN
Sunrise/resources/bootflow/texture_80A1460E.dds


BIN
Sunrise/resources/bootflow/texture_80A1461D.dds


BIN
Sunrise/resources/bootflow/texture_80A1461F.dds


BIN
Sunrise/resources/bootflow/texture_80A14621.dds


BIN
Sunrise/resources/bootflow/texture_80A14624.dds


BIN
Sunrise/resources/bootflow/texture_80A14625.dds


BIN
Sunrise/resources/bootflow/texture_80A14628.dds


BIN
Sunrise/resources/bootflow/texture_80A14629.dds


BIN
Sunrise/resources/bootflow/texture_80A1462B.dds


BIN
Sunrise/resources/bootflow/texture_80A1462E.dds


BIN
Sunrise/resources/bootflow/texture_80A1462F.dds


BIN
Sunrise/resources/bootflow/texture_80A14631.dds


BIN
Sunrise/resources/bootflow/texture_80A14633.dds


BIN
Sunrise/resources/bootflow/texture_80A14636.dds


BIN
Sunrise/resources/bootflow/texture_80A146D5.dds


+ 3 - 0
Sunrise/resources/default_settings.json

@@ -36,7 +36,9 @@
       "config_guid": "d2legacy-0000-0000-0000-000000000001"
     },
     "suppress_peer_relay": true,
+    "custom_bootflow_textures": true,
     "fade_release": true,
+    "skip_profile_setup": false,
     "region_private": false,
     "pin_replicated_record": true,
     "hold_spawn": true,
@@ -155,6 +157,7 @@
     },
     "account": {
       "primary_soid": "0x9EAA300100100100",
+      "profile_setup_completed": true,
       "dismantle_rewards": [
         { "definition_hash": "0xBC53E66E", "quantity": 25, "rarity": "common" },
         { "definition_hash": "0xBC53E66E", "quantity": 50, "rarity": "uncommon" },

+ 21 - 0
Sunrise/resources/resource.h

@@ -10,6 +10,27 @@
 #define IDR_LOGO_SHEET 104
 /** The next module-local RCDATA identifier embeds the required PUC-Lua MIT notice. */
 #define IDR_LUA_LICENSE 105
+
+/** User-authored bootflow DDS files consumed by the runtime TagHash override. */
+#define IDR_BOOTFLOW_TEXTURE_80A145FF 2007
+#define IDR_BOOTFLOW_TEXTURE_80A14601 2008
+#define IDR_BOOTFLOW_TEXTURE_80A14607 2010
+#define IDR_BOOTFLOW_TEXTURE_80A1460E 2012
+#define IDR_BOOTFLOW_TEXTURE_80A1461D 2014
+#define IDR_BOOTFLOW_TEXTURE_80A1461F 2015
+#define IDR_BOOTFLOW_TEXTURE_80A14621 2016
+#define IDR_BOOTFLOW_TEXTURE_80A14624 2017
+#define IDR_BOOTFLOW_TEXTURE_80A14625 2018
+#define IDR_BOOTFLOW_TEXTURE_80A14628 2019
+#define IDR_BOOTFLOW_TEXTURE_80A14629 2020
+#define IDR_BOOTFLOW_TEXTURE_80A1462B 2021
+#define IDR_BOOTFLOW_TEXTURE_80A1462E 2022
+#define IDR_BOOTFLOW_TEXTURE_80A1462F 2023
+#define IDR_BOOTFLOW_TEXTURE_80A14631 2024
+#define IDR_BOOTFLOW_TEXTURE_80A14633 2025
+#define IDR_BOOTFLOW_TEXTURE_80A14636 2026
+#define IDR_BOOTFLOW_TEXTURE_80A146D5 2031
+
 /** The four numeric fields of the version resource, in FILEVERSION order. */
 #define SUNRISE_VER_MAJOR 0
 #define SUNRISE_VER_MINOR 4

+ 18 - 0
Sunrise/resources/sunrise.rc

@@ -6,6 +6,24 @@ IDR_DEFAULT_SETTINGS RCDATA "default_settings.json"
 IDR_IMGUI_LICENSE RCDATA "../vendor/imgui/LICENSE.txt"
 IDR_DETOURS_LICENSE RCDATA "../vendor/detours/LICENSE.md"
 IDR_LUA_LICENSE RCDATA "../vendor/lua/LICENSE"
+IDR_BOOTFLOW_TEXTURE_80A145FF RCDATA "bootflow/texture_80A145FF.dds"
+IDR_BOOTFLOW_TEXTURE_80A14601 RCDATA "bootflow/texture_80A14601.dds"
+IDR_BOOTFLOW_TEXTURE_80A14607 RCDATA "bootflow/texture_80A14607.dds"
+IDR_BOOTFLOW_TEXTURE_80A1460E RCDATA "bootflow/texture_80A1460E.dds"
+IDR_BOOTFLOW_TEXTURE_80A1461D RCDATA "bootflow/texture_80A1461D.dds"
+IDR_BOOTFLOW_TEXTURE_80A1461F RCDATA "bootflow/texture_80A1461F.dds"
+IDR_BOOTFLOW_TEXTURE_80A14621 RCDATA "bootflow/texture_80A14621.dds"
+IDR_BOOTFLOW_TEXTURE_80A14624 RCDATA "bootflow/texture_80A14624.dds"
+IDR_BOOTFLOW_TEXTURE_80A14625 RCDATA "bootflow/texture_80A14625.dds"
+IDR_BOOTFLOW_TEXTURE_80A14628 RCDATA "bootflow/texture_80A14628.dds"
+IDR_BOOTFLOW_TEXTURE_80A14629 RCDATA "bootflow/texture_80A14629.dds"
+IDR_BOOTFLOW_TEXTURE_80A1462B RCDATA "bootflow/texture_80A1462B.dds"
+IDR_BOOTFLOW_TEXTURE_80A1462E RCDATA "bootflow/texture_80A1462E.dds"
+IDR_BOOTFLOW_TEXTURE_80A1462F RCDATA "bootflow/texture_80A1462F.dds"
+IDR_BOOTFLOW_TEXTURE_80A14631 RCDATA "bootflow/texture_80A14631.dds"
+IDR_BOOTFLOW_TEXTURE_80A14633 RCDATA "bootflow/texture_80A14633.dds"
+IDR_BOOTFLOW_TEXTURE_80A14636 RCDATA "bootflow/texture_80A14636.dds"
+IDR_BOOTFLOW_TEXTURE_80A146D5 RCDATA "bootflow/texture_80A146D5.dds"
 IDR_LOGO_SHEET RCDATA "logo_sheet.png"
 
 VS_VERSION_INFO VERSIONINFO

+ 21 - 0
Sunrise/resources/vendor_rules/README.md

@@ -0,0 +1,21 @@
+# Vendor rule files
+
+Reference copies of the authored rule files that drive the vendor behaviours, exactly as run
+for the in-game verification. Without them most of the vendor code is inert: no catalog file
+means only the head of the vendor index resolves (the Drifter is row 195 and never will), and
+no bounty, exchange or substitution file means those behaviours never trigger.
+
+Install them to `bin\x64\Sunrise\` beside `settings.json`. They are re-read on every use, so
+editing one takes effect without a relaunch or rebuild.
+
+| file | drives | keyed by |
+|---|---|---|
+| `vendor_catalog.txt` | which vendor definitions are published | vendor definition hash |
+| `vendor_item_substitute.txt` | what a placeholder row really grants | item definition hash |
+| `vendor_bounty_roll.txt` | the repeatable-bounty pools | vendor hash + trigger category |
+| `vendor_exchange.txt` | recycle rows: cost and payouts | vendor hash + sale row |
+
+Each file documents its own format and the reasoning in its header comments. Hashes are the
+item and vendor definition hashes the manifest names; a hash this build does not carry is
+skipped (bounty pools) or logged and refused (the rest), so rules authored from a newer
+manifest degrade rather than fail whole.

+ 61 - 0
Sunrise/resources/vendor_rules/vendor_bounty_roll.txt

@@ -0,0 +1,61 @@
+# vendorDefinitionHash  triggerCategory  repeatableItemHash...
+#
+# The row whose click grants a repeatable bounty, and the pool it may draw from. The vendor hash and
+# the item hashes are hex; the trigger category is decimal. A key may span several lines - they
+# accumulate - so a long pool stays readable.
+#
+# The trigger row is the vendor's "Additional Bounties", which costs 3000 glimmer where an ordinary
+# daily costs 250. It sells a Dummy placeholder, because what it hands out is NOT one of the
+# vendor's sale rows: repeatable bounties appear in no vendor's sale list anywhere in the manifest.
+# They exist only as item definitions, so they have to be named by hash. That is why this pool is
+# authored rather than discovered, and why rolling from the vendor's own rows could never be right.
+#
+# A character may hold five of a vendor's repeatables at once; the roll refuses past that.
+
+# Banshee-44 - 17 repeatable bounties (bounties.gunsmith.repeatable)
+280FB4FD 5 5D141638 883888D2 872EA8A1 01DC598F 07C714F3 AA5E726B
+280FB4FD 5 F3746DA5 127BA906 CCFA8EED BFECC497 32887AB0 83F12C63
+280FB4FD 5 DDD3FA2C 8ACFB0ED 2A687BEF 6932ADCD 1454BEAA
+
+# Commander Zavala - 19 repeatable bounties (bounties.strikes.repeatable)
+04243655 0 4A29BCDA 42578428 6C0B30AF 1DE83B38 7136176E BF4F2EB8
+04243655 0 776E4F0F 331E70E8 D4355ED6 763A6B0E 4AFC6C33 AD0B6203
+04243655 0 3F10251B 94D5C797 B5211F5D 5528D807 DE637D93 ECBD6C23
+04243655 0 A0A316E4
+
+# Eva Levante, Solstice - 10 repeatable bounties (events.solstice.bounties.repeatable)
+36D32C3C 20 E28EF438 E28EF43D E28EF43F E28EF439 E28EF43C E28EF43B
+36D32C3C 20 E28EF433 E28EF432 E28EF43E E28EF43A
+
+# Eva Levante, Festival of the Lost - 9 repeatable bounties (events.fotl.bounties.repeatable)
+36D32C3C 27 FC107EB8 FC107EBC FC107EBE FC107EB9 FC107EBF FC107EBB
+36D32C3C 27 FC107EBD FC107EBA FC107EB2
+
+# Eva Levante, the Dawning - 22 repeatable bounties (events.dawning.bounties.repeatable)
+36D32C3C 30 5894443C 95DE552D 59174661 2A6CE6A3 B98A680F D721DDD6
+36D32C3C 30 055EE67C 83D99283 2EA24BB0 B8221B31 904BFF45 31A9A638
+36D32C3C 30 2F0A2D8C D261B6E4 732B179B D0D15AE8 C4465457 2B7F9270
+36D32C3C 30 52B73743 58E0BC90 7E752198 D15D4925
+
+# Eva Levante, the Revelry - 9 repeatable bounties (events.spring.bounties.repeatable)
+36D32C3C 41 66B44990 C7BF4216 19945903 DA145597 DB13A891 42798A54
+36D32C3C 41 53793490 A2D17516 F4A68C03
+
+# Lord Shaxx - 15 repeatable bounties (bounties.crucible.repeatable)
+D6C4CCA1 4 A73D657C 00CC9E16 D15E5F76 A126163D 720AC276 D17E2078
+D6C4CCA1 4 A7DBB1F8 29469C1D 341AA115 BAD0CF69 EDDEA6C3 C56B6E01
+D6C4CCA1 4 C81C234A DB8AE8D2 175AAE0F
+
+# Prismatic Recaster - 16 repeatable bounties (v490.bounties.limited.ritual.jerboa.repeatable)
+EE0F473E 1 666880DF 666880DD 9A8D481D 9A8D481F 666880DE 666880DC
+EE0F473E 1 9A8D481C 666880DB 9A8D4812 9A8D4813 666880DA 9A8D481A
+EE0F473E 1 9A8D4818 9A8D481B 9A8D481E 9A8D4819
+
+# Saint-14 - 10 repeatable bounties (trials.bounties.repeatable)
+2D9E6DC1 9 FC778E25 35D929EC D8C2F3E5 AF40F3A2 1CE52836 69C3ECF6
+2D9E6DC1 9 69F44F55 0C94A580 F1F06DF7 92BF37EB
+
+# The Drifter - 15 repeatable bounties (bounties.gambit.repeatable)
+0ED2CB2F 0 08B80A43 BB43FD5B 9F735B92 9E2A6995 7B51B3E4 56A313A2
+0ED2CB2F 0 BC1AE1FB BE3E039D 7F25B0AB 90C0F8EA DC5F50C6 DC50FE3E
+0ED2CB2F 0 F540D54C BD6ED67A 4E6181E7

+ 24 - 0
Sunrise/resources/vendor_rules/vendor_catalog.txt

@@ -0,0 +1,24 @@
+# Vendor definition hashes to publish definitions for, in priority order.
+# A definition is over 100 KiB, so only a window of them fits; the window used to be the head of
+# the index, which assumed the Tower's vendors sit low in it. They do not - the Drifter is row 195
+# - so the ones that matter are named here by hash, which is stable where a row position is not.
+0ED2CB2F  # The Drifter
+280FB4FD  # Banshee-44
+04243655  # Commander Zavala
+D6C4CCA1  # Lord Shaxx
+86748412  # Master Rahool - the 277 shader recycle rows in vendor_exchange.txt are his
+1B731E4F  # Amanda Holliday - the Legacy Content rows in vendor_item_substitute.txt are hers
+ADE600F9  # Ada-1, for when she is reachable
+2D9E6DC1  # Saint-14
+36D32C3C  # Eva Levante
+EE0F473E  # Prismatic Recaster
+6B147C2D  # Yuna, IGR_VENDOR - Mugunghwa Merchant, 115 rows, 112 gated on "IGR Benefactor"
+17A817DE  # Devrim Kay, PLANET_EDZ
+3F59FB01  # Sloane, PLANET_TITAN
+5DF413A9  # Failsafe, PLANET_NESSUS
+ED6345FD  # Asher Mir, PLANET_IO
+8EF4CCBA  # Brother Vance, PLANET_MERCURY
+6770811D  # Ana Bray, PLANET_MARS
+6053823D  # Eris Morn, ERIS_MORN
+337EAF04  # Spider, TANGLED_SHORE_SPIDER
+6DC6627C  # Petra Venj, DREAMING_CITY_PETRA_VENJ

+ 298 - 0
Sunrise/resources/vendor_rules/vendor_exchange.txt

@@ -0,0 +1,298 @@
+# vendorDefinitionHash  rowIndex  costItemHash  costQuantity  payoutItemHash payoutQuantity...
+#
+# What a vendor's recycle row charges and what it pays out. Hashes are hex, quantities decimal,
+# alternating. A rule may name several payouts.
+#
+# The cost is authored rather than read off the sale row because the row's own cost-bearing fields
+# are still role-open on this build - nothing here can say which item a row charges. The manifest
+# can, and a row's position in it is exactly the row index this build reports: 304 rows checked
+# against Lord Shaxx, every category in the same order, no mismatch.
+#
+# Payouts: 5 synths -> 100 Glimmer is Bungie's own figure (TWAB, 14 March 2019). The shader figure
+# is authored to choice - 5 shaders -> 250 Glimmer and 5 Legendary Shards - because no source
+# states what retail paid.
+
+# The Drifter - category 25, 4 rows
+0ED2CB2F 43 EB520CB8 5 BC53E66E 100  # Collector Synth
+0ED2CB2F 44 D3C0580E 5 BC53E66E 100  # Sentry Synth
+0ED2CB2F 45 64D3519A 5 BC53E66E 100  # Invader Synth
+0ED2CB2F 46 350ABF36 5 BC53E66E 100  # Reaper Synth
+
+# Master Rahool - category 4, 277 rows
+86748412 7 4C90E336 5 BC53E66E 250 3CF2E8E2 5  # Frumious Blue
+86748412 8 4C90E337 5 BC53E66E 250 3CF2E8E2 5  # Midnight Talons
+86748412 9 4C90E334 5 BC53E66E 250 3CF2E8E2 5  # Noble Constant Red
+86748412 10 4C90E335 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Magnus Gloss
+86748412 11 4C90E332 5 BC53E66E 250 3CF2E8E2 5  # Omolon Meteor Gloss
+86748412 12 4C90E333 5 BC53E66E 250 3CF2E8E2 5  # VEIST Poison Shimmer
+86748412 13 4C90E330 5 BC53E66E 250 3CF2E8E2 5  # Häkke History Polish
+86748412 14 4C90E331 5 BC53E66E 250 3CF2E8E2 5  # SUROS Modular Shine
+86748412 15 4C90E33E 5 BC53E66E 250 3CF2E8E2 5  # Tarnished Copper
+86748412 16 4C90E33F 5 BC53E66E 250 3CF2E8E2 5  # Xenosilver
+86748412 17 BF15AFA5 5 BC53E66E 250 3CF2E8E2 5  # Dawn and Dusk
+86748412 18 BF15AFA4 5 BC53E66E 250 3CF2E8E2 5  # Metro Shift
+86748412 19 BF15AFA7 5 BC53E66E 250 3CF2E8E2 5  # Watermelon
+86748412 20 BF15AFA6 5 BC53E66E 250 3CF2E8E2 5  # Arctic Pearl
+86748412 21 BF15AFA1 5 BC53E66E 250 3CF2E8E2 5  # Monochromatic
+86748412 22 BF15AFA0 5 BC53E66E 250 3CF2E8E2 5  # Golden Trace
+86748412 23 BF15AFA3 5 BC53E66E 250 3CF2E8E2 5  # Nebula Rose
+86748412 24 BF15AFA2 5 BC53E66E 250 3CF2E8E2 5  # Cerulean Divide
+86748412 25 BF15AFAD 5 BC53E66E 250 3CF2E8E2 5  # Bumblebee
+86748412 26 BF15AFAC 5 BC53E66E 250 3CF2E8E2 5  # Indigo Matrix
+86748412 27 3EBCB287 5 BC53E66E 250 3CF2E8E2 5  # Crimson Passion
+86748412 28 3EBCB286 5 BC53E66E 250 3CF2E8E2 5  # Crimson Valor
+86748412 29 E2837A96 5 BC53E66E 250 3CF2E8E2 5  # Dawning Brilliance
+86748412 30 E2837A97 5 BC53E66E 250 3CF2E8E2 5  # Dawning Hope
+86748412 31 E2837A94 5 BC53E66E 250 3CF2E8E2 5  # Dawning Warmth
+86748412 32 E2837A95 5 BC53E66E 250 3CF2E8E2 5  # Dawning Festiveness
+86748412 33 8EC817EE 5 BC53E66E 250 3CF2E8E2 5  # Descendant Vex Chrome
+86748412 34 8EC817EF 5 BC53E66E 250 3CF2E8E2 5  # Mercury Vex Chrome
+86748412 35 8EC817EC 5 BC53E66E 250 3CF2E8E2 5  # Precursor Vex Chrome
+86748412 36 8EC817ED 5 BC53E66E 250 3CF2E8E2 5  # Desert of Gold
+86748412 37 8EC817EA 5 BC53E66E 250 3CF2E8E2 5  # Mercurian Sunrise
+86748412 38 3CA5B0B1 5 BC53E66E 250 3CF2E8E2 5  # Molten Bronze
+86748412 39 3CA5B0B0 5 BC53E66E 250 3CF2E8E2 5  # Mars Sunset
+86748412 40 3CA5B0B3 5 BC53E66E 250 3CF2E8E2 5  # Cargulo Bristle
+86748412 41 3CA5B0B2 5 BC53E66E 250 3CF2E8E2 5  # Ancient Republic
+86748412 42 3CA5B0B5 5 BC53E66E 250 3CF2E8E2 5  # Petiolora Growth
+86748412 43 3CA5B0B4 5 BC53E66E 250 3CF2E8E2 5  # Buffer Overflow
+86748412 44 FCDDFF31 5 BC53E66E 250 3CF2E8E2 5  # Calus's Elite
+86748412 45 FCDDFF30 5 BC53E66E 250 3CF2E8E2 5  # Calus's Preferred
+86748412 46 C655CEEE 5 BC53E66E 250 3CF2E8E2 5  # Benevolence of the Nine
+86748412 47 05395FA6 5 BC53E66E 250 3CF2E8E2 5  # Endless Glory
+86748412 48 41F59DFE 5 BC53E66E 250 3CF2E8E2 5  # Dead Orbit Resurrection
+86748412 49 2B91D1A4 5 BC53E66E 250 3CF2E8E2 5  # War Cult Endgame
+86748412 50 E18FB932 5 BC53E66E 250 3CF2E8E2 5  # Ironwood
+86748412 51 B5523FFE 5 BC53E66E 250 3CF2E8E2 5  # Kairos Gold
+86748412 52 B5523FFF 5 BC53E66E 250 3CF2E8E2 5  # Kairos Bronze
+86748412 53 B5523FFC 5 BC53E66E 250 3CF2E8E2 5  # Kairos Black
+86748412 54 C8A2D153 5 BC53E66E 250 3CF2E8E2 5  # New Monarchy Succession
+86748412 55 310B7A6B 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Discipline
+86748412 56 DD695288 5 BC53E66E 250 3CF2E8E2 5  # Watcher's Shade
+86748412 57 E5B168D3 5 BC53E66E 250 3CF2E8E2 5  # Mercury Prophetic
+86748412 58 C3E3B370 5 BC53E66E 250 3CF2E8E2 5  # GENOTYPENULL-ZERO
+86748412 59 CD9514A1 5 BC53E66E 250 3CF2E8E2 5  # Iron to Steel
+86748412 60 979E212C 5 BC53E66E 250 3CF2E8E2 5  # Praetorian Visage
+86748412 61 979E212D 5 BC53E66E 250 3CF2E8E2 5  # Calus's Shadow
+86748412 62 DB830057 5 BC53E66E 250 3CF2E8E2 5  # Cognition of the Nine
+86748412 63 8CC8474B 5 BC53E66E 250 3CF2E8E2 5  # Crucible Triumph
+86748412 64 3B61B4BF 5 BC53E66E 250 3CF2E8E2 5  # Dead Orbit Vision
+86748412 65 88DCD1AB 5 BC53E66E 250 3CF2E8E2 5  # War Cult Scheme
+86748412 66 AEB43096 5 BC53E66E 250 3CF2E8E2 5  # New Monarchy Allegiance
+86748412 67 0E07E17E 5 BC53E66E 250 3CF2E8E2 5  # Arctic Dreamscape
+86748412 68 0E07E17F 5 BC53E66E 250 3CF2E8E2 5  # Bray Innovation
+86748412 69 0E07E17C 5 BC53E66E 250 3CF2E8E2 5  # The Mad Monk
+86748412 70 5379391E 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Unity
+86748412 71 33874F0F 5 BC53E66E 250 3CF2E8E2 5  # Iron Battalion
+86748412 72 33874F0E 5 BC53E66E 250 3CF2E8E2 5  # Iron Wolf
+86748412 73 54CCDFF2 5 BC53E66E 250 3CF2E8E2 5  # Calus's Selected
+86748412 74 54CCDFF3 5 BC53E66E 250 3CF2E8E2 5  # Calus's Treasured
+86748412 75 00F85FB9 5 BC53E66E 250 3CF2E8E2 5  # Gift of the Nine
+86748412 76 00F85FB8 5 BC53E66E 250 3CF2E8E2 5  # Honors of the Nine
+86748412 77 EEC9A4B6 5 BC53E66E 250 3CF2E8E2 5  # Atlantis Wash
+86748412 78 EEC9A4B7 5 BC53E66E 250 3CF2E8E2 5  # Avalon Teal
+86748412 79 EEC9A4B4 5 BC53E66E 250 3CF2E8E2 5  # Blue Geometry
+86748412 80 AE7154B0 5 BC53E66E 250 3CF2E8E2 5  # Boreal Defiant
+86748412 81 AE7154B1 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 82 AE7154B2 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 83 AE7154B3 5 BC53E66E 250 3CF2E8E2 5  # Maroon Moon
+86748412 84 AE7154B4 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 85 AE7154B5 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 86 AE7154B6 5 BC53E66E 250 3CF2E8E2 5  # Tidal Hope
+86748412 87 AE7154B7 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 88 AE7154B8 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 89 AE7154B9 5 BC53E66E 250 3CF2E8E2 5  # Dusk Mine
+86748412 90 4D685293 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 91 4D685292 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 92 E6FE1F85 5 BC53E66E 250 3CF2E8E2 5  # Crucible Glory
+86748412 93 6DC250A9 5 BC53E66E 250 3CF2E8E2 5  # Dead Orbit's Fate
+86748412 94 6DC250AB 5 BC53E66E 250 3CF2E8E2 5  # Dead Orbit Camo
+86748412 95 3E531ABD 5 BC53E66E 250 3CF2E8E2 5  # War Cult Rain
+86748412 96 3E531ABF 5 BC53E66E 250 3CF2E8E2 5  # War Cult Camo
+86748412 97 47ED7D6C 5 BC53E66E 250 3CF2E8E2 5  # New Monarchy Diamonds
+86748412 98 47ED7D6E 5 BC53E66E 250 3CF2E8E2 5  # New Monarchy Regalia
+86748412 99 0D4F69B8 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Magnus
+86748412 100 14ACF3D0 5 BC53E66E 250 3CF2E8E2 5  # VEIST Fieldscale
+86748412 101 14ACF3D2 5 BC53E66E 250 3CF2E8E2 5  # VEIST Poison
+86748412 102 A330D450 5 BC53E66E 250 3CF2E8E2 5  # Häkke History
+86748412 103 A330D452 5 BC53E66E 250 3CF2E8E2 5  # Häkke Camo
+86748412 104 29BE1308 5 BC53E66E 250 3CF2E8E2 5  # Omolon Meteor
+86748412 105 29BE130A 5 BC53E66E 250 3CF2E8E2 5  # Omolon Camo
+86748412 106 89888A50 5 BC53E66E 250 3CF2E8E2 5  # SUROS Tone
+86748412 107 89888A52 5 BC53E66E 250 3CF2E8E2 5  # SUROS Modular
+86748412 108 CDA3D284 5 BC53E66E 250 3CF2E8E2 5  # Crucible Entrant
+86748412 109 CDA3D285 5 BC53E66E 250 3CF2E8E2 5  # Crucible Entrant (Worn)
+86748412 110 CDA3D287 5 BC53E66E 250 3CF2E8E2 5  # Crucible Aspirant
+86748412 111 CDA3D280 5 BC53E66E 250 3CF2E8E2 5  # Crucible Aspirant (Worn)
+86748412 112 58C30E29 5 BC53E66E 250 3CF2E8E2 5  # Echoes of Io
+86748412 113 58C30E28 5 BC53E66E 250 3CF2E8E2 5  # Echoes of Io (Worn)
+86748412 114 58C30E2B 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 115 58C30E2A 5 BC53E66E 250 3CF2E8E2 5  # Flowers of Io
+86748412 116 58C30E2D 5 BC53E66E 250 3CF2E8E2 5  # Flowers of Io (Worn)
+86748412 117 58C30E2C 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 118 E9B5CF10 5 BC53E66E 250 3CF2E8E2 5  # Dead Zone Foliage
+86748412 119 E9B5CF11 5 BC53E66E 250 3CF2E8E2 5  # Dead Zone Foliage (Worn)
+86748412 120 E9B5CF12 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 121 E9B5CF13 5 BC53E66E 250 3CF2E8E2 5  # Dead Zone Bark
+86748412 122 E9B5CF14 5 BC53E66E 250 3CF2E8E2 5  # Dead Zone Bark (Worn)
+86748412 123 E9B5CF15 5 BC53E66E 250 3CF2E8E2 5  # Classified
+86748412 124 3EB34D97 5 BC53E66E 250 3CF2E8E2 5  # New Pacific Rush
+86748412 125 3EB34D96 5 BC53E66E 250 3CF2E8E2 5  # New Pacific Rush (Worn)
+86748412 126 3EB34D95 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 127 3EB34D94 5 BC53E66E 250 3CF2E8E2 5  # New Pacific Sink
+86748412 128 3EB34D93 5 BC53E66E 250 3CF2E8E2 5  # New Pacific Sink (Worn)
+86748412 129 3EB34D92 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 130 9E6CD37C 5 BC53E66E 250 3CF2E8E2 5  # Desert Matte
+86748412 131 9E6CD37D 5 BC53E66E 250 3CF2E8E2 5  # Desert Matte (Worn)
+86748412 132 9E6CD37E 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 133 9E6CD37F 5 BC53E66E 250 3CF2E8E2 5  # Powder Blue
+86748412 134 9E6CD378 5 BC53E66E 250 3CF2E8E2 5  # Powder Blue (Worn)
+86748412 135 9E6CD379 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 136 CAD770F9 5 BC53E66E 250 3CF2E8E2 5  # Nessus Pursuit
+86748412 137 CAD770F8 5 BC53E66E 250 3CF2E8E2 5  # Nessus Pursuit (Worn)
+86748412 138 CAD770FB 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 139 CAD770FA 5 BC53E66E 250 3CF2E8E2 5  # Nessus Mirage
+86748412 140 CAD770FD 5 BC53E66E 250 3CF2E8E2 5  # Nessus Mirage (Worn)
+86748412 141 CAD770FC 5 BC53E66E 250 3CF2E8E2 5  #
+86748412 142 4F98F7A7 5 BC53E66E 250 3CF2E8E2 5  # Gambit Leather
+86748412 143 4F98F7A6 5 BC53E66E 250 3CF2E8E2 5  # Gambit Chrome
+86748412 144 4F98F7A5 5 BC53E66E 250 3CF2E8E2 5  # Gambit Suede
+86748412 145 DAAB73B5 5 BC53E66E 250 3CF2E8E2 5  # Dreaming Cast
+86748412 146 DAAB73B4 5 BC53E66E 250 3CF2E8E2 5  # Dreaming Spectrum
+86748412 147 4B52B12E 5 BC53E66E 250 3CF2E8E2 5  # Always North
+86748412 148 B95E2E62 5 BC53E66E 250 3CF2E8E2 5  # Burnished Dreams
+86748412 149 B95E2E63 5 BC53E66E 250 3CF2E8E2 5  # Blueshift Dreams
+86748412 150 491C30A4 5 BC53E66E 250 3CF2E8E2 5  # Tangled Rust
+86748412 151 491C30A5 5 BC53E66E 250 3CF2E8E2 5  # Tangled Bronze
+86748412 152 05531949 5 BC53E66E 250 3CF2E8E2 5  # Cayde's Duds
+86748412 153 4682DFF1 5 BC53E66E 250 3CF2E8E2 5  # Metallic Sunrise
+86748412 154 4682DFF0 5 BC53E66E 250 3CF2E8E2 5  # Metallic Sunset
+86748412 155 4682DFF3 5 BC53E66E 250 3CF2E8E2 5  # Clouds at Sea
+86748412 156 4682DFF2 5 BC53E66E 250 3CF2E8E2 5  # Copper and Blood
+86748412 157 6C950696 5 BC53E66E 250 3CF2E8E2 5  # Distant Earth
+86748412 158 6C950697 5 BC53E66E 250 3CF2E8E2 5  # Glacial Whisper
+86748412 159 6C950694 5 BC53E66E 250 3CF2E8E2 5  # Bold Statement
+86748412 160 6C950695 5 BC53E66E 250 3CF2E8E2 5  # Burnished Orchid
+86748412 161 91EAEEE6 5 BC53E66E 250 3CF2E8E2 5  # Rusted Iron
+86748412 162 9A4D07FA 5 BC53E66E 250 3CF2E8E2 5  # Crucible Legacy
+86748412 163 9A4D07FB 5 BC53E66E 250 3CF2E8E2 5  # Crucible Metallic
+86748412 164 9A4D07F8 5 BC53E66E 250 3CF2E8E2 5  # Crucible Solemnity
+86748412 165 A63B6B7F 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Veteran
+86748412 166 A63B6B7E 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Metallic
+86748412 167 A63B6B7D 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Divide
+86748412 168 A7CB0FAA 5 BC53E66E 250 3CF2E8E2 5  # Smashing Success
+86748412 169 A7CB0FAB 5 BC53E66E 250 3CF2E8E2 5  # Melchizedek Bramble
+86748412 170 A7CB0FA8 5 BC53E66E 250 3CF2E8E2 5  # Safety First
+86748412 171 A7CB0FA9 5 BC53E66E 250 3CF2E8E2 5  # Flavedo Core
+86748412 172 A7CB0FAE 5 BC53E66E 250 3CF2E8E2 5  # Metropolitan Acoustics
+86748412 173 A7CB0FAF 5 BC53E66E 250 3CF2E8E2 5  # Celestial Dome
+86748412 174 A7CB0FAC 5 BC53E66E 250 3CF2E8E2 5  # Forty-Four Steel
+86748412 175 E60B0CAA 5 BC53E66E 250 3CF2E8E2 5  # Blazing Virtue
+86748412 176 E60B0CAB 5 BC53E66E 250 3CF2E8E2 5  # Malachite Gold
+86748412 177 FBD3F836 5 BC53E66E 250 3CF2E8E2 5  # Dark Fluorescence
+86748412 178 FBD3F837 5 BC53E66E 250 3CF2E8E2 5  # Shadowstrike
+86748412 179 9DA139BF 5 BC53E66E 250 3CF2E8E2 5  # Sunrise Warrior
+86748412 180 9DA139BE 5 BC53E66E 250 3CF2E8E2 5  # Chalco's Finery
+86748412 181 9DA139BD 5 BC53E66E 250 3CF2E8E2 5  # Vibrant Beach
+86748412 182 9DA139BC 5 BC53E66E 250 3CF2E8E2 5  # Lilac Bombast
+86748412 183 9DA139BB 5 BC53E66E 250 3CF2E8E2 5  # Temperature Wash
+86748412 184 9DA139BA 5 BC53E66E 250 3CF2E8E2 5  # Deep-Sea Jaunt
+86748412 185 C66A0F2B 5 BC53E66E 250 3CF2E8E2 5  # Aniline Shock
+86748412 186 C66A0F2A 5 BC53E66E 250 3CF2E8E2 5  # Resilient Laurel
+86748412 187 09FE27C4 5 BC53E66E 250 3CF2E8E2 5  # Crimson Passion
+86748412 188 7DF02477 5 BC53E66E 250 3CF2E8E2 5  # Crimson Valor
+86748412 189 9C61E127 5 BC53E66E 250 3CF2E8E2 5  # Dawning Brilliance
+86748412 190 100DC9F4 5 BC53E66E 250 3CF2E8E2 5  # Dawning Hope
+86748412 191 E679671D 5 BC53E66E 250 3CF2E8E2 5  # Dawning Warmth
+86748412 192 6DEB1D6A 5 BC53E66E 250 3CF2E8E2 5  # Dawning Festiveness
+86748412 194 21C7A568 5 BC53E66E 250 3CF2E8E2 5  # Amaranth Atrocity
+86748412 195 2DD787C9 5 BC53E66E 250 3CF2E8E2 5  # Devil in the Details
+86748412 196 BFC6D164 5 BC53E66E 250 3CF2E8E2 5  # Gambit Duds
+86748412 197 0DCCECF3 5 BC53E66E 250 3CF2E8E2 5  # Iron Strength
+86748412 198 1061C63C 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Unyielding
+86748412 199 D9977450 5 BC53E66E 250 3CF2E8E2 5  # New Age Black Armory
+86748412 200 D9977451 5 BC53E66E 250 3CF2E8E2 5  # Refurbished Black Armory
+86748412 201 D9977452 5 BC53E66E 250 3CF2E8E2 5  # Rasmussen Clan
+86748412 202 D9977453 5 BC53E66E 250 3CF2E8E2 5  # House of Meyrin
+86748412 203 D9977454 5 BC53E66E 250 3CF2E8E2 5  # Satou Tribe
+86748412 204 D9977455 5 BC53E66E 250 3CF2E8E2 5  # Bergusian Night
+86748412 205 9873C036 5 BC53E66E 250 3CF2E8E2 5  # Midnight Smith
+86748412 206 29240B56 5 BC53E66E 250 3CF2E8E2 5  # Shrouded Stripes
+86748412 207 368E08A8 5 BC53E66E 250 3CF2E8E2 5  # Warbrick
+86748412 208 368E08A9 5 BC53E66E 250 3CF2E8E2 5  # Reefmade
+86748412 209 368E08AA 5 BC53E66E 250 3CF2E8E2 5  # Verdigris
+86748412 210 368E08AB 5 BC53E66E 250 3CF2E8E2 5  # Chrome Stock
+86748412 211 368E08AC 5 BC53E66E 250 3CF2E8E2 5  # Atlantic Rush
+86748412 212 368E08AD 5 BC53E66E 250 3CF2E8E2 5  # Bloody Tooth
+86748412 213 7672CB00 5 BC53E66E 250 3CF2E8E2 5  # Iron Ruby
+86748412 214 DEA3CF5B 5 BC53E66E 250 3CF2E8E2 5  # Prime Palette
+86748412 215 72246BE5 5 BC53E66E 250 3CF2E8E2 5  # Verdant Chrome
+86748412 216 72246BE4 5 BC53E66E 250 3CF2E8E2 5  # Verdant Crown
+86748412 217 E4F75BDA 5 BC53E66E 250 3CF2E8E2 5  # Shadow Gilt
+86748412 218 E4F75BDB 5 BC53E66E 250 3CF2E8E2 5  # Cinderchar
+86748412 219 E4F75BD8 5 BC53E66E 250 3CF2E8E2 5  # Golden Empire
+86748412 220 E4F75BD9 5 BC53E66E 250 3CF2E8E2 5  # Goldleaf
+86748412 221 78DAB0F2 5 BC53E66E 250 3CF2E8E2 5  # Imperial Opulence
+86748412 222 78DAB0F3 5 BC53E66E 250 3CF2E8E2 5  # Imperial Dress
+86748412 223 EA273F08 5 BC53E66E 250 3CF2E8E2 5  # Crucible Carmine
+86748412 224 EA273F09 5 BC53E66E 250 3CF2E8E2 5  # Crucible Redjack
+86748412 225 966DC59D 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Steel
+86748412 226 966DC59C 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Burnished Steel
+86748412 227 849D9655 5 BC53E66E 250 3CF2E8E2 5  # Gambit Steel
+86748412 228 849D9654 5 BC53E66E 250 3CF2E8E2 5  # Gambit Blackguard
+86748412 229 5658DD92 5 BC53E66E 250 3CF2E8E2 5  # Iron Bone
+86748412 230 5658DD93 5 BC53E66E 250 3CF2E8E2 5  # Iron Gold
+86748412 231 51487F23 5 BC53E66E 250 3CF2E8E2 5  # Coastal Suede
+86748412 232 51487F22 5 BC53E66E 250 3CF2E8E2 5  # Amethyst Veil
+86748412 233 51487F21 5 BC53E66E 250 3CF2E8E2 5  # Atmospheric Glow
+86748412 234 51487F20 5 BC53E66E 250 3CF2E8E2 5  # First Light
+86748412 235 51487F27 5 BC53E66E 250 3CF2E8E2 5  # Royal Welcome
+86748412 236 F8C20F18 5 BC53E66E 250 3CF2E8E2 5  # Tangerine Gloss
+86748412 237 F8C20F19 5 BC53E66E 250 3CF2E8E2 5  # Pomegranate Gloss
+86748412 238 819221EB 5 BC53E66E 250 3CF2E8E2 5  # Gambit Jadestone
+86748412 239 B1CC2BBC 5 BC53E66E 250 3CF2E8E2 5  # Iron Fuchsite
+86748412 240 3A6A85EF 5 BC53E66E 250 3CF2E8E2 5  # Vitrified Duality
+86748412 241 3A6A85EE 5 BC53E66E 250 3CF2E8E2 5  # Vitrified Chronology
+86748412 242 53952A2A 5 BC53E66E 250 3CF2E8E2 5  # Gunmetal Marigold
+86748412 243 53952A2B 5 BC53E66E 250 3CF2E8E2 5  # Regal Medallion
+86748412 244 8719C5A9 5 BC53E66E 250 3CF2E8E2 5  # Lighthouse Sun
+86748412 245 1BCA3616 5 BC53E66E 250 3CF2E8E2 5  # Dawning Elegance
+86748412 246 1BCA3617 5 BC53E66E 250 3CF2E8E2 5  # Dawning Welcome
+86748412 247 D6CDA506 5 BC53E66E 250 3CF2E8E2 5  # Crucible Prestige
+86748412 248 C91AB35B 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Nightbeam
+86748412 249 7E4942A9 5 BC53E66E 250 3CF2E8E2 5  # Dawning Tranquility
+86748412 250 7E4942A8 5 BC53E66E 250 3CF2E8E2 5  # Dawning Celebration
+86748412 251 E8D48BD5 5 BC53E66E 250 3CF2E8E2 5  # Welded Brass
+86748412 252 E8D48BD4 5 BC53E66E 250 3CF2E8E2 5  # Grayscale Undergrowth
+86748412 253 E8D48BD7 5 BC53E66E 250 3CF2E8E2 5  # Circadian Chill
+86748412 254 E8D48BD6 5 BC53E66E 250 3CF2E8E2 5  # Byzantium Lotus
+86748412 255 2BF054EC 5 BC53E66E 250 3CF2E8E2 5  # Midnight Exigent
+86748412 256 2BF054ED 5 BC53E66E 250 3CF2E8E2 5  # Valkyrie Zero
+86748412 257 52974D7B 5 BC53E66E 250 3CF2E8E2 5  # Iron Precious
+86748412 258 83907422 5 BC53E66E 250 3CF2E8E2 5  # Raw Idocrase
+86748412 259 93533049 5 BC53E66E 250 3CF2E8E2 5  # Silver Tactical
+86748412 260 93533048 5 BC53E66E 250 3CF2E8E2 5  # Darkwater Froth
+86748412 261 83F19DA7 5 BC53E66E 250 3CF2E8E2 5  # Rivalry Resolute
+86748412 262 83F19DA6 5 BC53E66E 250 3CF2E8E2 5  # Rivalry Stoic
+86748412 263 A7982F37 5 BC53E66E 250 3CF2E8E2 5  # Crucible Peppermint
+86748412 264 FF85BE2A 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Flashfire
+86748412 265 8171EE53 5 BC53E66E 250 3CF2E8E2 5  # Rivalry Whitesand
+86748412 266 8171EE52 5 BC53E66E 250 3CF2E8E2 5  # Rivalry Blacksand
+86748412 267 ABFB9A9C 5 BC53E66E 250 3CF2E8E2 5  # Golden Age Wine
+86748412 268 ABFB9A9D 5 BC53E66E 250 3CF2E8E2 5  # Vibrant Medusa
+86748412 269 ABFB9A9E 5 BC53E66E 250 3CF2E8E2 5  # Coppertone Patina
+86748412 270 ABFB9A9F 5 BC53E66E 250 3CF2E8E2 5  # Neopop Wave
+86748412 273 0315BBD8 5 BC53E66E 250 3CF2E8E2 5  # Throne of Soot
+86748412 274 0315BBD9 5 BC53E66E 250 3CF2E8E2 5  # Horizons Beyond
+86748412 275 3E67834B 5 BC53E66E 250 3CF2E8E2 5  # Butterbark
+86748412 276 3E67834A 5 BC53E66E 250 3CF2E8E2 5  # Biolume
+86748412 277 3E678349 5 BC53E66E 250 3CF2E8E2 5  # Jacarina
+86748412 278 3E678348 5 BC53E66E 250 3CF2E8E2 5  # Gloamstrife
+86748412 279 0F9C12E3 5 BC53E66E 250 3CF2E8E2 5  # Vintage Timber
+86748412 280 0F9C12E2 5 BC53E66E 250 3CF2E8E2 5  # Oiled Algae
+86748412 281 F551E067 5 BC53E66E 250 3CF2E8E2 5  # Envious Touch
+86748412 282 F551E066 5 BC53E66E 250 3CF2E8E2 5  # Dark Omolon
+86748412 283 8DCB38BC 5 BC53E66E 250 3CF2E8E2 5  # Carminica
+86748412 284 9A606379 5 BC53E66E 250 3CF2E8E2 5  # Ruin Wreath
+86748412 285 BD5CE9E6 5 BC53E66E 250 3CF2E8E2 5  # Iron Vendetta
+86748412 286 5A4CACB1 5 BC53E66E 250 3CF2E8E2 5  # Vanguard Marshal

+ 16 - 0
Sunrise/resources/vendor_rules/vendor_item_substitute.txt

@@ -0,0 +1,16 @@
+# soldHash  grantHash   - both hex, keyed by the item a sale row names.
+#
+# A row whose item is DestinyItemType 20 (Dummy) is a UI placeholder: buying it is meant to hand
+# over something the row does not name. Granting the placeholder itself puts an item in the
+# player's inventory the client will not draw.
+#
+# Amanda Holliday's Legacy Content rows each stand for a campaign's first quest step.
+#
+# These rows do not settle after purchase, and that is a client-side gate rather than a gap here:
+# each is offered only while its own value slot reads zero (Red War VAL(12578), Curse of Osiris
+# VAL(12609), Warmind VAL(12624)), and those slots are kind-0 - no account object backs them, so
+# no bank write reaches the evaluator. Only the family-5 override list does, and family 5 is
+# answered once per launch.
+BEB63647 37DD26F0  # Legacy: The Red War        -> Homecoming
+6CBEA754 6706D3EC  # Legacy: Curse of Osiris    -> The Gateway
+65683247 F5B78E7F  # Legacy: Warmind            -> Ice and Shadow

+ 12 - 0
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -45,6 +45,18 @@ bool refresh() noexcept {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         const std::lock_guard lock(g_refreshLock);
+        // The vendor catalog is deliberately not part of `ready()` - a boot without vendors is
+        // still a boot - but a restored cache can carry every mapping domain and no catalog,
+        // because the boot that wrote it lost the vendor pass. Every domain in `ready()` retries
+        // through the pass below until it publishes; this is the one domain that gate skips, so
+        // it gets one retry here. Once per session, because a pass that failed against these
+        // packages will keep failing against them, and its own log lines already say why.
+        static bool vendorRetryDone = false;
+        if (!vendorRetryDone && !state::build_data::vendor_catalog_ready()
+            && items::packages::readable()) {
+            vendorRetryDone = true;
+            (void)items::packages::build();
+        }
         const bool persisted = state::ensure_profile_item_identities()
                                && state::ensure_character_subclasses()
                                && emote_collection_settled()

+ 58 - 3
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -1,15 +1,20 @@
 #include <Windows.h>
 
 #include <array>
+#include <span>
 
 #include "../../../../core/filesystem/path.h"
+#include "../../../../core/logging/log.h"
+#include "../../../../core/settings/rule_text.h"
 #include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "../../../../state/build_data/vendors/vendor_catalog.h"
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
+#include "../../vendors/vendor_build.h"
 #include "build.h"
 #include "internal.h"
 #include "package_socket_plug_build.h"
@@ -17,6 +22,54 @@
 namespace sunrise::client::content::items::packages {
 namespace {
 
+/**
+ * Reads the vendors to publish definitions for, by definition hash, from `vendor_catalog.txt`.
+ *
+ * A row position is not a stable name for a vendor and the useful ones are not all at the head of
+ * the index, so the list is authored by hash. An absent or empty file leaves the caller with the
+ * leading window it used before.
+ *
+ * @param hashes Receives the requested definition hashes.
+ * @return How many were read.
+ */
+[[nodiscard]] std::size_t read_vendor_hashes(std::span<std::uint32_t> hashes) noexcept {
+    static std::array<char, core::rule_text::kRuleTextCapacity> text{};
+    if (!core::path::read_artifact_text(L"vendor_catalog.txt", text)) {
+        return 0;
+    }
+    std::size_t count = 0;
+    core::rule_text::Cursor rules{text.data()};
+    while (count < hashes.size() && rules.seek_field()) {
+        const std::uint32_t parsed = rules.read_hex();
+        if (parsed != 0) {
+            hashes[count++] = parsed;
+        }
+    }
+    return count;
+}
+
+/**
+ * Publishes the vendor catalog, index and definitions both.
+ *
+ * `vendors::build` reads the whole index and a definition for each vendor named by hash, filling
+ * any room left from the head of the index. The names come from `vendor_catalog.txt`: a row
+ * position is not a stable name for a vendor and the useful ones are not all at the head - the
+ * Drifter is row 195, so every request against him once failed to resolve a definition that had
+ * never been read.
+ *
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Block storage shared with the other content passes.
+ */
+void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    if (state::build_data::vendor_catalog_ready()) {
+        return;
+    }
+    static std::array<std::uint32_t, vendor_domain::kDefinitionCapacity> named{};
+    const std::size_t namedCount = read_vendor_hashes(named);
+    (void)content::vendors::build(source, scratch, std::span(named).first(namedCount));
+}
+
 /** @return True when every item and investment-root domain is published. */
 [[nodiscard]] bool root_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
@@ -46,9 +99,6 @@ bool ready() noexcept {
 
 /** Publishes the dense item table from the installed packages, once. */
 bool build() noexcept {
-    if (ready()) {
-        return true;
-    }
     static Storage storage{};
     reader::BlockKeys keys{};
     core::path::Buffer directory{};
@@ -70,6 +120,11 @@ bool build() noexcept {
         (void)content::scenarios::build(packageSource, storage.scratch);
         (void)content::spawn_sets::build(packageSource, storage.scratch);
         (void)content::hash_names::build(packageSource, storage.scratch);
+        build_vendor_catalog(packageSource, storage.scratch);
+        if (ready()) {
+            SecureZeroMemory(&keys, sizeof keys);
+            return true;
+        }
     }
     if (root_domains_ready()) {
         SecureZeroMemory(&keys, sizeof keys);

+ 2 - 2
Sunrise/src/client/content/vendors/layout.h

@@ -25,8 +25,8 @@ inline constexpr std::size_t kSaleExpression8Offset = 8;
 inline constexpr std::size_t kSaleNestedRecordOffset = 32;
 /** Sale row main item-definition index. */
 inline constexpr std::size_t kSaleItemIndexOffset = 70;
-/** Sale row installed/runtime table index. */
-inline constexpr std::size_t kSaleInstalledIndexOffset = 100;
+/** Sale row vendor category index. */
+inline constexpr std::size_t kSaleCategoryIndexOffset = 100;
 /** Sale row scalar with no closed consumer. */
 inline constexpr std::size_t kSaleRaw104Offset = 104;
 /** Sale row scalar with no closed consumer. */

+ 99 - 11
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -161,7 +161,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
         value.rowIndex = static_cast<std::uint16_t>(row);
         if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
             || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
-            || !read(blob, at + kSaleInstalledIndexOffset, value.installedIndex)
+            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)
             || !read(blob, at + kSaleRaw104Offset, value.raw104)
             || !read(blob, at + kSaleRaw108Offset, value.raw108)
             || !read(blob, at + kSaleRaw172Offset, value.raw172)
@@ -251,10 +251,18 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
     definition.thirdCount = third.count;
     definition.saleRowOffset = static_cast<std::uint32_t>(storage.saleRowCount);
     definition.installedRowOffset = static_cast<std::uint32_t>(storage.installedRowCount);
+    // Each row reader advances its bank before the other runs, so a definition whose sale rows fit
+    // but whose installed rows do not would leave orphan sale rows behind; the next definition's
+    // offset then carries the gap and `valid()` rejects the whole set. A skipped definition has to
+    // leave both banks exactly as it found them.
+    const std::size_t saleRowsBefore = storage.saleRowCount;
+    const std::size_t installedRowsBefore = storage.installedRowCount;
     if (!read(blob, kResetIntervalOffset, definition.resetIntervalRaw)
         || !read(blob, kResetPhaseOffset, definition.resetPhaseRaw)
         || !read_sale_rows(blob, definition, storage)
         || !read_installed_rows(blob, definition, storage)) {
+        storage.saleRowCount = saleRowsBefore;
+        storage.installedRowCount = installedRowsBefore;
         return false;
     }
     storage.definitions[storage.definitionCount] = definition;
@@ -262,6 +270,57 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
     return true;
 }
 
+/**
+ * Chooses which definitions this pass reads.
+ *
+ * The named hashes come first, each checked against the index just read: a hash the index does
+ * not carry is a mistyped rule, and dropping it silently reads exactly like the vendor resolving
+ * - until a request against it fails with no line to say the catalog never held it. A hash named
+ * twice would spend two of the few definition slots on one vendor. Whatever room is left is
+ * filled from the head of the index.
+ *
+ * @param storage Pass storage holding the index.
+ * @param namedHashes Hashes named by the rule file, in priority order.
+ * @param hashes Receives the definitions to read.
+ * @return How many were chosen.
+ */
+[[nodiscard]] std::size_t select_definitions(const Storage& storage,
+                                             std::span<const std::uint32_t> namedHashes,
+                                             std::span<std::uint32_t> hashes) noexcept {
+    std::size_t wanted = 0;
+    for (std::size_t at = 0; at < namedHashes.size() && wanted < hashes.size(); ++at) {
+        bool present = false;
+        for (std::size_t held = 0; held < wanted && !present; ++held) {
+            present = hashes[held] == namedHashes[at];
+        }
+        if (present) {
+            continue;
+        }
+        bool installed = false;
+        for (std::size_t row = 0; row < storage.indexCount && !installed; ++row) {
+            installed = storage.index[row].definitionHash == namedHashes[at];
+        }
+        if (installed) {
+            hashes[wanted++] = namedHashes[at];
+            continue;
+        }
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=vendor stage=catalog result=skip reason=unknown_hash hash=0x%08X",
+                          namedHashes[at]);
+    }
+    for (std::size_t row = 0; row < storage.indexCount && wanted < hashes.size(); ++row) {
+        bool present = false;
+        for (std::size_t at = 0; at < wanted && !present; ++at) {
+            present = hashes[at] == storage.index[row].definitionHash;
+        }
+        if (!present) {
+            hashes[wanted++] = storage.index[row].definitionHash;
+        }
+    }
+    return wanted;
+}
+
 /** @param hashes Requested hashes. @param hash Index row hash. @return True when requested. */
 [[nodiscard]] bool requested(std::span<const std::uint32_t> hashes, std::uint32_t hash) noexcept {
     for (const std::uint32_t value : hashes) {
@@ -275,22 +334,25 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
 /**
  * Reports the pass so a boot with no vendor catalog says which step lost the rows.
  * @param storage Pass storage holding every count.
+ * @param skipped Requested definitions that could not be read or could not fit.
  * @param result Outcome text for the log line.
  */
-void report(const Storage& storage, const char* result) noexcept {
+void report(const Storage& storage, std::size_t skipped, const char* result) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=build_data stage=vendors index=%zu definitions=%zu "
-                                      "sale=%zu installed=%zu result=%s",
+                                      "sale=%zu installed=%zu skipped=%zu result=%s",
                                       storage.indexCount,
                                       storage.definitionCount,
                                       storage.saleRowCount,
                                       storage.installedRowCount,
+                                      skipped,
                                       result);
     if (written > 0) {
         core::log::write(core::log::Channel::state,
-                         storage.indexCount != 0 ? core::log::Level::info : core::log::Level::warn,
+                         storage.indexCount != 0 && skipped == 0 ? core::log::Level::info
+                                                                 : core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
 }
@@ -300,31 +362,57 @@ void report(const Storage& storage, const char* result) noexcept {
 /** Extracts and publishes the vendor catalog from the installed packages. */
 bool build(const reader::Source& source,
            reader::Scratch& scratch,
-           std::span<const std::uint32_t> definitionHashes) noexcept {
+           std::span<const std::uint32_t> namedHashes) noexcept {
     if (state::build_data::vendor_catalog_ready()) {
         return true;
     }
     static Storage storage{};
     storage = {};
     if (!read_index(source, scratch, storage)) {
-        report(storage, "index");
+        report(storage, 0, "index");
         return false;
     }
+    static std::array<std::uint32_t, domain::kDefinitionCapacity> chosen{};
+    const std::span<const std::uint32_t> definitionHashes =
+        std::span(chosen).first(select_definitions(storage, namedHashes, chosen));
     // Walking the index in order gives the ascending definition order the catalog requires.
+    //
+    // A definition that cannot be read - or cannot fit the definition or row banks - costs that
+    // vendor alone, not the pass. Failing whole here is what a full bank used to do, and it was
+    // the worst failure this domain had: the empty catalog was cached, every later boot restored
+    // it, and every vendor stayed unresolvable with one boot-time line to say why.
+    std::size_t skipped = 0;
     for (std::size_t row = 0; row < storage.indexCount; ++row) {
         const domain::IndexEntry entry = storage.index[row];
-        if (requested(definitionHashes, entry.definitionHash)
-            && !read_definition(source, scratch, entry, storage)) {
-            report(storage, "definition");
-            return false;
+        if (!requested(definitionHashes, entry.definitionHash)) {
+            continue;
+        }
+        if (read_definition(source, scratch, entry, storage)) {
+            continue;
         }
+        ++skipped;
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=build_data stage=vendors result=skip hash=0x%08X row=%zu "
+                          "definitions=%zu sale=%zu",
+                          entry.definitionHash,
+                          row,
+                          storage.definitionCount,
+                          storage.saleRowCount);
     }
     const bool published = state::build_data::publish_vendor_catalog(
         std::span(storage.index).first(storage.indexCount),
         std::span(storage.definitions).first(storage.definitionCount),
         std::span(storage.saleRows).first(storage.saleRowCount),
         std::span(storage.installedRows).first(storage.installedRowCount));
-    report(storage, published ? "ok" : "publish");
+    report(storage, skipped, published ? "ok" : "publish");
+    core::log::writef(core::log::Channel::state,
+                      published ? core::log::Level::info : core::log::Level::warn,
+                      "ev=vendor stage=catalog result=%s named=%zu requested=%zu index_rows=%zu",
+                      published ? "ok" : "fail",
+                      namedHashes.size(),
+                      definitionHashes.size(),
+                      storage.indexCount);
     return published;
 }
 

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

@@ -9,14 +9,20 @@ namespace sunrise::client::content::vendors {
 
 /**
  * Extracts the vendor catalog from the installed packages, once.
- * The whole index is read. A definition is read only when asked for, as each is over 100 KiB.
+ *
+ * The whole index is read. A definition is read only for a vendor asked for by hash, as each is
+ * over 100 KiB and the banks hold nowhere near all 511. The named hashes are checked against the
+ * index the pass has just read - one it does not carry is a mistyped rule and is logged - and
+ * whatever room is left is filled from the head of the index, so a short list still gets the
+ * vendors the old leading window would have covered.
+ *
  * @param source Package directory and borrowed block keys.
  * @param scratch Lock-owned block storage shared with the other content passes.
- * @param definitionHashes Vendor definition hashes to read definitions for.
+ * @param namedHashes Vendor definition hashes named by `vendor_catalog.txt`, in priority order.
  * @return True when State already holds the catalog or a full pass publishes it.
  */
 [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
                          middleware::content::packages::reader::Scratch& scratch,
-                         std::span<const std::uint32_t> definitionHashes) noexcept;
+                         std::span<const std::uint32_t> namedHashes) noexcept;
 
 } // namespace sunrise::client::content::vendors

+ 459 - 0
Sunrise/src/client/hooks/bootflow/bootflow_texture_override.cpp

@@ -0,0 +1,459 @@
+#include "bootflow_texture_override.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <span>
+#include <string_view>
+
+#include "../../../../resources/resource.h"
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "../../hooking/detour.h"
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::bootflow::texture_override {
+namespace {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * Resourcerer's GPU-entry dispatcher. Its second argument is the TagHash and its third and fourth
+ * arguments are the decoded entry pointer and byte count. The switch immediately after this
+ * prologue distinguishes the GPU resource classes.
+ */
+constexpr std::string_view kGpuEntryDispatcherText =
+    "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 20 "
+    "49 8B F9 49 8B F0 8B DA 8B E9 E8 ? ? ? ? 84 C0 0F 85 ? ? ? ? "
+    "8D 45 FF 83 F8 12";
+constexpr auto kGpuEntryDispatcher =
+    signature<signature_length(kGpuEntryDispatcherText)>(kGpuEntryDispatcherText);
+
+constexpr std::size_t kTigerTextureHeaderSize = 0x28;
+constexpr std::size_t kDdsLegacyHeaderSize = 4 + 124;
+constexpr std::size_t kDdsDx10HeaderSize = kDdsLegacyHeaderSize + 20;
+constexpr std::uint32_t kDdsMagic = 0x20534444U;
+constexpr std::uint32_t kDx10FourCc = 0x30315844U;
+constexpr std::uint32_t kDdsPixelAlphaPixels = 0x1U;
+constexpr std::uint32_t kDdsPixelAlpha = 0x2U;
+constexpr std::uint32_t kDdsPixelFourCc = 0x4U;
+constexpr std::uint32_t kDdsPixelRgb = 0x40U;
+constexpr std::uint32_t kDdsPixelLuminance = 0x20000U;
+constexpr std::uint32_t kDdsResourceTexture2d = 3U;
+constexpr std::uint32_t kDdsResourceMiscCube = 0x4U;
+constexpr std::uint16_t kTigerTextureMarker = 0xCAFEU;
+constexpr std::uint32_t kGpuTextureClass = 1U;
+
+constexpr std::size_t kTigerDataSizeOffset = 0x00;
+constexpr std::size_t kTigerFormatOffset = 0x04;
+constexpr std::size_t kTigerMarkerOffset = 0x0C;
+constexpr std::size_t kTigerWidthOffset = 0x0E;
+constexpr std::size_t kTigerHeightOffset = 0x10;
+constexpr std::size_t kTigerDepthOffset = 0x12;
+constexpr std::size_t kTigerArraySizeOffset = 0x14;
+
+/** Exact stock texture-header/data pairs selected from package 0x010A. */
+struct AssetSpec final {
+    std::uint32_t headerTag{};
+    std::uint32_t dataTag{};
+    int resourceId{};
+};
+
+constexpr std::array kAssetSpecs{
+    AssetSpec{0x80A145FEU, 0x80A145FFU, IDR_BOOTFLOW_TEXTURE_80A145FF},
+    AssetSpec{0x80A14602U, 0x80A14601U, IDR_BOOTFLOW_TEXTURE_80A14601},
+    AssetSpec{0x80A14608U, 0x80A14607U, IDR_BOOTFLOW_TEXTURE_80A14607},
+    AssetSpec{0x80A1460DU, 0x80A1460EU, IDR_BOOTFLOW_TEXTURE_80A1460E},
+    AssetSpec{0x80A1461CU, 0x80A1461DU, IDR_BOOTFLOW_TEXTURE_80A1461D},
+    AssetSpec{0x80A14620U, 0x80A1461FU, IDR_BOOTFLOW_TEXTURE_80A1461F},
+    AssetSpec{0x80A14622U, 0x80A14621U, IDR_BOOTFLOW_TEXTURE_80A14621},
+    AssetSpec{0x80A14623U, 0x80A14624U, IDR_BOOTFLOW_TEXTURE_80A14624},
+    AssetSpec{0x80A14626U, 0x80A14625U, IDR_BOOTFLOW_TEXTURE_80A14625},
+    AssetSpec{0x80A14627U, 0x80A14628U, IDR_BOOTFLOW_TEXTURE_80A14628},
+    AssetSpec{0x80A1462AU, 0x80A14629U, IDR_BOOTFLOW_TEXTURE_80A14629},
+    AssetSpec{0x80A1462CU, 0x80A1462BU, IDR_BOOTFLOW_TEXTURE_80A1462B},
+    AssetSpec{0x80A1462DU, 0x80A1462EU, IDR_BOOTFLOW_TEXTURE_80A1462E},
+    AssetSpec{0x80A14630U, 0x80A1462FU, IDR_BOOTFLOW_TEXTURE_80A1462F},
+    AssetSpec{0x80A14632U, 0x80A14631U, IDR_BOOTFLOW_TEXTURE_80A14631},
+    AssetSpec{0x80A14634U, 0x80A14633U, IDR_BOOTFLOW_TEXTURE_80A14633},
+    AssetSpec{0x80A14635U, 0x80A14636U, IDR_BOOTFLOW_TEXTURE_80A14636},
+    AssetSpec{0x80A146D4U, 0x80A146D5U, IDR_BOOTFLOW_TEXTURE_80A146D5},
+};
+
+struct DdsView final {
+    const std::byte* pixels{};
+    std::uint32_t pixelSize{};
+    std::uint32_t format{};
+    std::uint16_t width{};
+    std::uint16_t height{};
+    std::uint16_t depth{};
+    std::uint16_t arraySize{};
+};
+
+struct Asset final {
+    AssetSpec spec{};
+    DdsView dds{};
+    std::array<std::byte, kTigerTextureHeaderSize> header{};
+    bool headerReady{};
+    bool reported{};
+};
+
+using GpuEntryDispatcher = std::uint64_t(__fastcall*)(std::uint32_t resourceClass,
+                                                      std::uint32_t tag,
+                                                      const void* decoded,
+                                                      std::uint64_t decodedSize) noexcept;
+
+hooking::detour::Handle g_handle{};
+SRWLOCK g_assetLock{SRWLOCK_INIT};
+std::array<Asset, kAssetSpecs.size()> g_assets{};
+
+template <typename Value>
+[[nodiscard]] bool
+load_value(const std::byte* bytes, std::size_t size, std::size_t offset, Value& output) noexcept {
+    if (bytes == nullptr || offset > size || sizeof(Value) > size - offset) {
+        return false;
+    }
+    std::memcpy(&output, bytes + offset, sizeof output);
+    return true;
+}
+
+template <typename Value>
+void store_value(std::span<std::byte> bytes, std::size_t offset, Value value) noexcept {
+    if (offset <= bytes.size() && sizeof(Value) <= bytes.size() - offset) {
+        std::memcpy(bytes.data() + offset, &value, sizeof value);
+    }
+}
+
+[[nodiscard]] constexpr std::uint32_t
+four_cc(char first, char second, char third, char fourth) noexcept {
+    return static_cast<std::uint32_t>(static_cast<unsigned char>(first))
+           | (static_cast<std::uint32_t>(static_cast<unsigned char>(second)) << 8U)
+           | (static_cast<std::uint32_t>(static_cast<unsigned char>(third)) << 16U)
+           | (static_cast<std::uint32_t>(static_cast<unsigned char>(fourth)) << 24U);
+}
+
+/** Converts the legacy DDS formats used by the supplied bootflow assets to DXGI values. */
+[[nodiscard]] bool
+legacy_format(const std::byte* bytes, std::size_t size, std::uint32_t& output) noexcept {
+    std::uint32_t flags = 0;
+    std::uint32_t formatFourCc = 0;
+    std::uint32_t bits = 0;
+    std::uint32_t red = 0;
+    std::uint32_t green = 0;
+    std::uint32_t blue = 0;
+    std::uint32_t alpha = 0;
+    if (!load_value(bytes, size, 0x50, flags) || !load_value(bytes, size, 0x54, formatFourCc)
+        || !load_value(bytes, size, 0x58, bits) || !load_value(bytes, size, 0x5C, red)
+        || !load_value(bytes, size, 0x60, green) || !load_value(bytes, size, 0x64, blue)
+        || !load_value(bytes, size, 0x68, alpha)) {
+        return false;
+    }
+    if ((flags & kDdsPixelFourCc) != 0) {
+        switch (formatFourCc) {
+        case four_cc('D', 'X', 'T', '1'):
+            output = 71U;
+            return true;
+        case four_cc('D', 'X', 'T', '3'):
+            output = 74U;
+            return true;
+        case four_cc('D', 'X', 'T', '5'):
+            output = 77U;
+            return true;
+        case four_cc('A', 'T', 'I', '1'):
+        case four_cc('B', 'C', '4', 'U'):
+            output = 80U;
+            return true;
+        case four_cc('A', 'T', 'I', '2'):
+        case four_cc('B', 'C', '5', 'U'):
+            output = 83U;
+            return true;
+        default:
+            return false;
+        }
+    }
+    if ((flags & kDdsPixelRgb) != 0 && bits == 32U) {
+        if (red == 0x000000FFU && green == 0x0000FF00U && blue == 0x00FF0000U
+            && alpha == 0xFF000000U) {
+            output = 28U;
+            return true;
+        }
+        if (red == 0x00FF0000U && green == 0x0000FF00U && blue == 0x000000FFU) {
+            output = alpha == 0xFF000000U ? 87U : 88U;
+            return alpha == 0xFF000000U || alpha == 0U;
+        }
+    }
+    if ((flags & kDdsPixelRgb) != 0 && bits == 16U) {
+        if (red == 0xF800U && green == 0x07E0U && blue == 0x001FU && alpha == 0U) {
+            output = 85U;
+            return true;
+        }
+        if (red == 0x7C00U && green == 0x03E0U && blue == 0x001FU && alpha == 0x8000U) {
+            output = 86U;
+            return true;
+        }
+        if (red == 0x0F00U && green == 0x00F0U && blue == 0x000FU && alpha == 0xF000U) {
+            output = 115U;
+            return true;
+        }
+    }
+    if ((flags & kDdsPixelLuminance) != 0 && bits == 8U && red == 0xFFU) {
+        output = 61U;
+        return true;
+    }
+    if ((flags & kDdsPixelLuminance) != 0 && (flags & kDdsPixelAlphaPixels) != 0 && bits == 16U
+        && red == 0x00FFU && alpha == 0xFF00U) {
+        output = 49U;
+        return true;
+    }
+    if ((flags & kDdsPixelAlpha) != 0 && bits == 8U && alpha == 0xFFU) {
+        output = 65U;
+        return true;
+    }
+    return false;
+}
+
+/** Parses one embedded 2D DDS without allocating or copying its pixel payload. */
+[[nodiscard]] bool parse_dds(const std::byte* bytes, std::size_t size, DdsView& output) noexcept {
+    output = {};
+    std::uint32_t magic = 0;
+    std::uint32_t headerSize = 0;
+    std::uint32_t pixelHeaderSize = 0;
+    std::uint32_t pixelFlags = 0;
+    std::uint32_t formatFourCc = 0;
+    if (size < kDdsLegacyHeaderSize || !load_value(bytes, size, 0x00, magic)
+        || !load_value(bytes, size, 0x04, headerSize)
+        || !load_value(bytes, size, 0x4C, pixelHeaderSize)
+        || !load_value(bytes, size, 0x50, pixelFlags)
+        || !load_value(bytes, size, 0x54, formatFourCc) || magic != kDdsMagic || headerSize != 124U
+        || pixelHeaderSize != 32U) {
+        return false;
+    }
+    const bool dx10 = (pixelFlags & kDdsPixelFourCc) != 0 && formatFourCc == kDx10FourCc;
+    const std::size_t pixelOffset = dx10 ? kDdsDx10HeaderSize : kDdsLegacyHeaderSize;
+    std::uint32_t width = 0;
+    std::uint32_t height = 0;
+    std::uint32_t depth = 0;
+    std::uint32_t arraySize = 1;
+    std::uint32_t resourceDimension = kDdsResourceTexture2d;
+    std::uint32_t miscFlag = 0;
+    std::uint32_t format = 0;
+    if (size <= pixelOffset || !load_value(bytes, size, 0x10, width)
+        || !load_value(bytes, size, 0x0C, height) || !load_value(bytes, size, 0x18, depth)) {
+        return false;
+    }
+    if (dx10) {
+        if (!load_value(bytes, size, 0x80, format)
+            || !load_value(bytes, size, 0x84, resourceDimension)
+            || !load_value(bytes, size, 0x88, miscFlag)
+            || !load_value(bytes, size, 0x8C, arraySize)) {
+            return false;
+        }
+    } else if (!legacy_format(bytes, size, format)) {
+        return false;
+    }
+    const std::size_t pixelSize = size - pixelOffset;
+    if (width == 0 || height == 0 || width > 0xFFFFU || height > 0xFFFFU || pixelSize == 0
+        || pixelSize > 0xFFFFFFFFULL || format == 0 || resourceDimension != kDdsResourceTexture2d
+        || (miscFlag & kDdsResourceMiscCube) != 0 || arraySize == 0 || arraySize > 0xFFFFU) {
+        return false;
+    }
+    output = DdsView{bytes + pixelOffset,
+                     static_cast<std::uint32_t>(pixelSize),
+                     format,
+                     static_cast<std::uint16_t>(width),
+                     static_cast<std::uint16_t>(height),
+                     1,
+                     static_cast<std::uint16_t>(arraySize)};
+    return true;
+}
+
+/** Loads and validates every embedded DDS before the detour can expose any of them. */
+[[nodiscard]] bool load_assets(HMODULE module) noexcept {
+    if (module == nullptr) {
+        return false;
+    }
+    for (std::size_t index = 0; index < kAssetSpecs.size(); ++index) {
+        const AssetSpec& spec = kAssetSpecs[index];
+        const HRSRC resource = FindResourceW(module, MAKEINTRESOURCEW(spec.resourceId), RT_RCDATA);
+        if (resource == nullptr) {
+            return false;
+        }
+        const HGLOBAL loaded = LoadResource(module, resource);
+        const DWORD size = SizeofResource(module, resource);
+        const auto* bytes = static_cast<const std::byte*>(LockResource(loaded));
+        Asset asset{};
+        asset.spec = spec;
+        if (loaded == nullptr || bytes == nullptr || size == 0
+            || !parse_dds(bytes, static_cast<std::size_t>(size), asset.dds)) {
+            return false;
+        }
+        g_assets[index] = asset;
+    }
+    return true;
+}
+
+/** @return The asset owning this exact header or data TagHash. */
+[[nodiscard]] Asset* find_asset(std::uint32_t tag, bool& header) noexcept {
+    for (Asset& asset : g_assets) {
+        if (asset.spec.headerTag == tag) {
+            header = true;
+            return &asset;
+        }
+        if (asset.spec.dataTag == tag) {
+            header = false;
+            return &asset;
+        }
+    }
+    return nullptr;
+}
+
+/** Builds one replacement Tiger descriptor from its stock descriptor and embedded DDS. */
+[[nodiscard]] const void*
+prepare_header(Asset& asset, const void* stock, std::uint64_t stockSize, bool& report) noexcept {
+    report = false;
+    if (stock == nullptr || stockSize < kTigerTextureHeaderSize) {
+        return stock;
+    }
+    AcquireSRWLockExclusive(&g_assetLock);
+    if (!asset.headerReady) {
+        std::memcpy(asset.header.data(), stock, asset.header.size());
+        std::uint16_t marker = 0;
+        std::memcpy(&marker, asset.header.data() + kTigerMarkerOffset, sizeof marker);
+        if (marker == kTigerTextureMarker) {
+            const std::span header(asset.header);
+            store_value(header, kTigerDataSizeOffset, asset.dds.pixelSize);
+            store_value(header, kTigerFormatOffset, asset.dds.format);
+            store_value(header, kTigerWidthOffset, asset.dds.width);
+            store_value(header, kTigerHeightOffset, asset.dds.height);
+            store_value(header, kTigerDepthOffset, asset.dds.depth);
+            store_value(header, kTigerArraySizeOffset, asset.dds.arraySize);
+            asset.headerReady = true;
+        }
+    }
+    if (asset.headerReady && !asset.reported) {
+        asset.reported = true;
+        report = true;
+    }
+    const void* result = asset.headerReady ? asset.header.data() : stock;
+    ReleaseSRWLockExclusive(&g_assetLock);
+    return result;
+}
+
+void report_override(const Asset& asset) noexcept {
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow_texture stage=entry tag=0x%08X size=%u "
+                                      "width=%u height=%u result=override",
+                                      static_cast<unsigned>(asset.spec.dataTag),
+                                      static_cast<unsigned>(asset.dds.pixelSize),
+                                      static_cast<unsigned>(asset.dds.width),
+                                      static_cast<unsigned>(asset.dds.height));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::info,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
+/** Replaces only selected decoded GPU texture entries, then preserves the native dispatcher. */
+std::uint64_t __fastcall dispatch(std::uint32_t resourceClass,
+                                  std::uint32_t tag,
+                                  const void* decoded,
+                                  std::uint64_t decodedSize) noexcept {
+    const auto original = reinterpret_cast<GpuEntryDispatcher>(g_handle.original);
+    if (original == nullptr) {
+        return 7;
+    }
+    bool header = false;
+    Asset* const asset = resourceClass == kGpuTextureClass ? find_asset(tag, header) : nullptr;
+    if (asset == nullptr) {
+        return original(resourceClass, tag, decoded, decodedSize);
+    }
+    if (!header) {
+        return original(resourceClass, tag, asset->dds.pixels, asset->dds.pixelSize);
+    }
+    bool report = false;
+    const void* const replacement = prepare_header(*asset, decoded, decodedSize, report);
+    if (report) {
+        report_override(*asset);
+    }
+    return original(resourceClass,
+                    tag,
+                    replacement,
+                    replacement == decoded ? decodedSize : kTigerTextureHeaderSize);
+}
+
+void clear_assets() noexcept {
+    AcquireSRWLockExclusive(&g_assetLock);
+    g_assets = {};
+    ReleaseSRWLockExclusive(&g_assetLock);
+}
+
+} // namespace
+
+/** Loads embedded DDS files and attaches the decoded GPU-entry dispatcher. */
+bool install(void* module) noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    if (!core::settings::get().client.customBootflowTextures) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         "ev=bootflow_texture stage=setting enabled=0 result=skip");
+        return true;
+    }
+    if (!load_assets(static_cast<HMODULE>(module))) {
+        clear_assets();
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=bootflow_texture stage=resources result=fail");
+        return false;
+    }
+    std::byte* const target =
+        scan_main_image_unique(kGpuEntryDispatcher, "bootflow_gpu_entry_dispatcher");
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&dispatch)};
+    if (target == nullptr || !hooking::detour::install(spec, g_handle)) {
+        clear_assets();
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=bootflow_texture stage=attach result=fail");
+        return false;
+    }
+    std::array<char, 80> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow_texture stage=attach count=%zu result=ok",
+                                      kAssetSpecs.size());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::info,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+    return true;
+}
+
+/** Detaches before releasing the resource views and generated Tiger descriptors. */
+bool uninstall() noexcept {
+    if (g_handle.attached && !hooking::detour::uninstall(g_handle)) {
+        return false;
+    }
+    clear_assets();
+    return true;
+}
+
+/** @return True while the decoded GPU-entry dispatcher is attached. */
+bool is_installed() noexcept {
+    return g_handle.attached;
+}
+
+} // namespace sunrise::client::hooks::bootflow::texture_override

+ 19 - 0
Sunrise/src/client/hooks/bootflow/bootflow_texture_override.h

@@ -0,0 +1,19 @@
+#pragma once
+
+namespace sunrise::client::hooks::bootflow::texture_override {
+
+/**
+ * Replaces selected decoded bootflow texture entries by TagHash with DDS data embedded in Sunrise.
+ * The stock package remains registered and unchanged.
+ * @param module Sunrise DLL module that owns the RCDATA resources.
+ * @return True while the native GPU-resource callback detour is attached.
+ */
+[[nodiscard]] bool install(void* module) noexcept;
+
+/** @return True when the callback detour is detached. */
+[[nodiscard]] bool uninstall() noexcept;
+
+/** @return True while decoded bootflow texture entries are being overridden. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::bootflow::texture_override

+ 8 - 1
Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp

@@ -6,6 +6,7 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
 #include "../../hooking/detour.h"
 #include "internal.h"
 
@@ -72,7 +73,13 @@ std::atomic_bool g_reported{false};
  */
 __declspec(noinline) char __fastcall update(std::byte* step) noexcept {
     const Update original = g_original.load(std::memory_order_acquire);
-    if (step != nullptr) {
+    if (!core::settings::get().client.skipProfileSetup) {
+        if (!g_reported.exchange(true, std::memory_order_relaxed)) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             "ev=bootflow stage=profile_setup result=disabled");
+        }
+    } else if (step != nullptr) {
         std::uint32_t state = 0;
         std::memcpy(&state, step + StepLayout::state, sizeof state);
         if (is_waiting(state)) {

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

+ 8 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -7,6 +7,7 @@
 #include "../hooks/async_io/async_io_lifetime_guard.h"
 #include "../hooks/bitmap/bitmap_hook_lifecycle.h"
 #include "../hooks/bootflow/bootflow_hook_lifecycle.h"
+#include "../hooks/bootflow/bootflow_texture_override.h"
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
@@ -76,6 +77,13 @@ bool shutdown() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return false;
     }
+    if (!hooks::bootflow::texture_override::uninstall()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=shutdown stage=bootflow_texture result=fail");
+        ReleaseSRWLockExclusive(&runtime::g_lock);
+        return false;
+    }
     if (!hooks::package_trust::uninstall()) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::error,

+ 70 - 0
Sunrise/src/core/filesystem/path.cpp

@@ -2,6 +2,7 @@
 
 #include <Windows.h>
 
+#include <cstdint>
 #include <cstring>
 
 namespace sunrise::core::path {
@@ -63,6 +64,40 @@ bool artifact_directory(void* module, Buffer& output) noexcept {
     return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
 
+/** Resolves one Sunrise-owned file beside this DLL. */
+bool artifact_file(std::wstring_view relative, Buffer& output) noexcept {
+    HMODULE self{};
+    // From this function's own address, so it names the DLL rather than the host executable. The
+    // two differ: the game sits in the install root and this module in `bin\x64`.
+    if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
+                               | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+                           reinterpret_cast<LPCWSTR>(&artifact_file),
+                           &self)
+            == FALSE
+        || self == nullptr) {
+        return false;
+    }
+    if (!artifact_directory(self, output)) {
+        return false;
+    }
+    // Create each directory named before the file, so `exports\x.txt` works on a fresh install.
+    std::size_t start = 0;
+    for (std::size_t index = 0; index < relative.size(); ++index) {
+        if (relative[index] != L'\\') {
+            continue;
+        }
+        if (!append(output, L"\\") || !append(output, relative.substr(start, index - start))) {
+            return false;
+        }
+        if (CreateDirectoryW(output.chars.data(), nullptr) == FALSE
+            && GetLastError() != ERROR_ALREADY_EXISTS) {
+            return false;
+        }
+        start = index + 1;
+    }
+    return append(output, L"\\") && append(output, relative.substr(start));
+}
+
 /** Appends a path suffix without exceeding fixed storage. */
 bool append(Buffer& path, std::wstring_view suffix) noexcept {
     if (path.length + suffix.size() >= path.chars.size()) {
@@ -74,4 +109,39 @@ bool append(Buffer& path, std::wstring_view suffix) noexcept {
     return true;
 }
 
+/** Reads one Sunrise-owned text file whole, into caller storage, terminated. */
+bool read_artifact_text(std::wstring_view relative, std::span<char> text) noexcept {
+    if (text.empty()) {
+        return false;
+    }
+    text[0] = '\0';
+    Buffer file{};
+    if (!artifact_file(relative, file)) {
+        return false;
+    }
+    const HANDLE handle = CreateFileW(file.chars.data(), GENERIC_READ, FILE_SHARE_READ, nullptr,
+                                      OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
+    if (handle == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    // The size is measured before anything is read, so a file too large for the caller's storage
+    // is refused outright rather than read in part. Half a rule would parse as a whole one, which
+    // is worse than having no rule at all. One byte is kept for the terminator, so a file that
+    // exactly fills the rest still reads whole.
+    LARGE_INTEGER size{};
+    DWORD read = 0;
+    const bool measured = GetFileSizeEx(handle, &size) != FALSE;
+    const bool fits = measured && size.QuadPart >= 0
+                      && static_cast<std::uint64_t>(size.QuadPart) < text.size();
+    const bool ok = fits
+                    && ReadFile(handle, text.data(), static_cast<DWORD>(text.size() - 1), &read,
+                                nullptr) != FALSE;
+    (void)CloseHandle(handle);
+    if (!ok || read == 0) {
+        return false;
+    }
+    text[read] = '\0';
+    return true;
+}
+
 } // namespace sunrise::core::path

+ 28 - 0
Sunrise/src/core/filesystem/path.h

@@ -2,6 +2,7 @@
 
 #include <array>
 #include <cstddef>
+#include <span>
 #include <string_view>
 
 namespace sunrise::core::path {
@@ -24,7 +25,34 @@ struct Buffer {
 /** Resolves and creates the one Sunrise-owned generated-artifact directory. */
 [[nodiscard]] bool artifact_directory(void* module, Buffer& output) noexcept;
 
+/**
+ * Resolves one Sunrise-owned file beside this DLL, creating any directory it needs.
+ *
+ * Callers get `<directory holding steam_api64.dll>\Sunrise\<relative>`, which is where
+ * `settings.json`, `logs` and `cache` already live. This finds the module from its own address, so
+ * nothing has to thread a handle down, and moving the whole install needs no rebuild.
+ *
+ * @param relative File name, optionally with one leading subdirectory such as `exports\x.txt`.
+ * @param output Receives the full path.
+ * @return True when the path fits and every directory in it exists or was created.
+ */
+[[nodiscard]] bool artifact_file(std::wstring_view relative, Buffer& output) noexcept;
+
 /** Appends one suffix without exceeding fixed path storage. */
 [[nodiscard]] bool append(Buffer& path, std::wstring_view suffix) noexcept;
 
+/**
+ * Reads one Sunrise-owned text file whole, into caller storage, terminated.
+ *
+ * Every authored rule file is read this way: opened fresh each time it is consulted, so editing one
+ * takes effect without a relaunch, and never partially - a file too large for the caller's storage
+ * is refused rather than truncated into a half-rule that would parse as something else.
+ *
+ * @param relative File name, resolved the same way `artifact_file` resolves one.
+ * @param text Caller storage. Receives the file's bytes followed by a terminating NUL, so one byte
+ *        of it is always spent on the terminator.
+ * @return True only when the file opened, fitted, and held at least one byte.
+ */
+[[nodiscard]] bool read_artifact_text(std::wstring_view relative, std::span<char> text) noexcept;
+
 } // namespace sunrise::core::path

+ 18 - 0
Sunrise/src/core/logging/log.cpp

@@ -5,6 +5,7 @@
 #include <algorithm>
 #include <array>
 #include <atomic>
+#include <cstdarg>
 #include <cstdio>
 #include <cstring>
 #include <mutex>
@@ -250,6 +251,23 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
     snapshot::internal::record(channel, level, std::string_view(line.data(), snapshotLength));
 }
 
+/** Formats and emits one structured event when allowed by the channel threshold. */
+void writef(Channel channel, Level level, const char* format, ...) noexcept {
+    if (format == nullptr || !accepts(channel, level)) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    va_list arguments;
+    va_start(arguments, format);
+    const int count = std::vsnprintf(line.data(), line.size(), format, arguments);
+    va_end(arguments);
+    if (count <= 0) {
+        return;
+    }
+    const std::size_t length = (std::min)(static_cast<std::size_t>(count), line.size() - 1);
+    write(channel, level, std::string_view(line.data(), length));
+}
+
 /** Formats and emits one debug event carrying a duration in the ms field. */
 void write_elapsed(Channel channel,
                    std::string_view event,

+ 13 - 0
Sunrise/src/core/logging/log.h

@@ -51,6 +51,19 @@ void shutdown() noexcept;
 /** Emits one structured event when allowed by the channel threshold. */
 void write(Channel channel, Level level, std::string_view event) noexcept;
 
+/**
+ * Formats and emits one structured event when allowed by the channel threshold.
+ *
+ * The line is built in `kLineCapacity` storage and truncated to fit, which is what every caller
+ * that spelled out its own array and `snprintf` did by hand. Nothing is formatted for a level the
+ * channel refuses, so a debug line costs nothing when debug is off.
+ *
+ * @param channel Subsystem owning the event.
+ * @param level Severity of the event.
+ * @param format printf-style format; `%s` arguments must be NUL-terminated.
+ */
+void writef(Channel channel, Level level, const char* format, ...) noexcept;
+
 /**
  * Emits one debug event carrying a duration in the ms field.
  * Timing is diagnostic, so it stays off at the levels a normal run uses.

+ 12 - 0
Sunrise/src/core/settings/client/client_settings_parser.cpp

@@ -10,7 +10,9 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     client::Settings candidate = output;
     bool hasUserInterface = false;
     bool hasExternalServer = false;
+    bool hasCustomBootflowTextures = false;
     bool hasFadeRelease = false;
+    bool hasSkipProfileSetup = false;
     bool hasRegionPrivate = false;
     bool hasSkipOrbitCinematicWait = false;
     bool hasSuppressPeerRelay = false;
@@ -38,11 +40,21 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
             }
             hasExternalServer = true;
+        } else if (key == "custom_bootflow_textures") {
+            if (hasCustomBootflowTextures || !boolean(candidate.customBootflowTextures)) {
+                return false;
+            }
+            hasCustomBootflowTextures = true;
         } else if (key == "fade_release") {
             if (hasFadeRelease || !boolean(candidate.fadeRelease)) {
                 return false;
             }
             hasFadeRelease = true;
+        } else if (key == "skip_profile_setup") {
+            if (hasSkipProfileSetup || !boolean(candidate.skipProfileSetup)) {
+                return false;
+            }
+            hasSkipProfileSetup = true;
         } else if (key == "region_private") {
             if (hasRegionPrivate || !boolean(candidate.regionPrivate)) {
                 return false;

+ 8 - 0
Sunrise/src/core/settings/client/definition.h

@@ -18,12 +18,20 @@ struct Settings {
     ui::runtime::Settings userInterface;
     /** Points the Client at a server outside this process. Off answers everything in process. */
     external::Settings externalServer;
+    /** Replaces stock bootflow textures that have matching DDS assets embedded in Sunrise. */
+    bool customBootflowTextures{true};
     /**
      * Releases the world-transition fade channel at the in-world step.
      * The client only releases it on the player spawn, so this covers a spawn that never runs
      * and leaves the world black. On by default.
      */
     bool fadeRelease{true};
+    /**
+     * Skips the one-time profile setup bootflow screens.
+     * Off by default: the server-authored account completion byte should drive normal behavior.
+     * Keep this only as a fallback for incomplete profile-state implementations.
+     */
+    bool skipProfileSetup{false};
     /**
      * Reports a public region as private to the region transition.
      * On, a public region loads solo. Off, it waits for a public activity host, which is the

+ 132 - 0
Sunrise/src/core/settings/rule_text.h

@@ -0,0 +1,132 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::core::rule_text {
+
+/**
+ * Storage one rule file is read into. Far larger than any authored file needs, because a file too
+ * large for its buffer is refused whole rather than read in part, and refusal silently turns off
+ * whatever the file configures.
+ */
+inline constexpr std::size_t kRuleTextCapacity = 65536;
+
+/** Line comments run from this character to the end of the line. */
+inline constexpr char kCommentMark = '#';
+
+/** @return True for a character `read_hex` accepts. */
+[[nodiscard]] constexpr bool is_hex_digit(char value) noexcept {
+    return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
+           || (value >= 'A' && value <= 'F');
+}
+
+/** @return The value of one hex digit, or 0 for any other character. */
+[[nodiscard]] constexpr std::uint32_t hex_value(char value) noexcept {
+    if (value >= '0' && value <= '9') {
+        return static_cast<std::uint32_t>(value - '0');
+    }
+    if (value >= 'a' && value <= 'f') {
+        return static_cast<std::uint32_t>(value - 'a') + 10U;
+    }
+    if (value >= 'A' && value <= 'F') {
+        return static_cast<std::uint32_t>(value - 'A') + 10U;
+    }
+    return 0;
+}
+
+/**
+ * Reads one authored rule file, a field at a time.
+ *
+ * Every rule file Sunrise authors has the same shape: `#` comments to end of line, blank lines
+ * ignored, and rules made of hex and decimal fields separated by spaces. Each reader knows its own
+ * field order and asks for fields in that order, so a hex field and a decimal one are never
+ * confused - "100" is 256 read as hex and 100 read as decimal, and only the caller knows which the
+ * file meant.
+ *
+ * Nothing here allocates or throws. The cursor borrows the caller's text and never runs past its
+ * terminating NUL.
+ */
+class Cursor final {
+public:
+    /** @param text NUL-terminated rule text, borrowed for the cursor's lifetime. */
+    explicit constexpr Cursor(const char* text) noexcept : at_{text} {}
+
+    /**
+     * @return True while the cursor sits on a field of the line it is already reading. A leading
+     * minus counts only in front of a digit, so a negative field is never read back as positive.
+     */
+    [[nodiscard]] constexpr bool at_field() const noexcept {
+        return is_hex_digit(*at_) || (*at_ == '-' && at_[1] >= '0' && at_[1] <= '9');
+    }
+
+    /**
+     * Advances to the next rule field, stepping over comments, blank lines and separators.
+     * @return True when a field was found, false at the end of the text.
+     */
+    constexpr bool seek_field() noexcept {
+        while (*at_ != '\0') {
+            if (*at_ == kCommentMark) {
+                while (*at_ != '\0' && *at_ != '\n') {
+                    ++at_;
+                }
+                continue;
+            }
+            if (at_field()) {
+                return true;
+            }
+            ++at_;
+        }
+        return false;
+    }
+
+    /** @return One hex field, and steps past it and any spaces after it. */
+    constexpr std::uint32_t read_hex() noexcept {
+        std::uint32_t value = 0;
+        while (is_hex_digit(*at_)) {
+            value = (value * 16U) + hex_value(*at_);
+            ++at_;
+        }
+        skip_spaces();
+        return value;
+    }
+
+    /**
+     * @return One decimal field, and steps past it and any spaces after it.
+     *
+     * Digits accumulate wide and saturate. A field longer than the type can hold is a malformed
+     * file rather than a rule, and overflowing a signed accumulator to find that out is undefined
+     * behaviour, so the value stops at the end of the range instead of wrapping into a small or
+     * negative one that would read as a plausible rule.
+     */
+    constexpr std::int32_t read_decimal() noexcept {
+        const bool negative = *at_ == '-';
+        if (negative) {
+            ++at_;
+        }
+        constexpr std::int64_t kCeiling = 0x7FFFFFFF;
+        std::int64_t value = 0;
+        while (*at_ >= '0' && *at_ <= '9') {
+            if (value <= kCeiling) {
+                value = (value * 10) + (*at_ - '0');
+            }
+            ++at_;
+        }
+        skip_spaces();
+        if (value > kCeiling) {
+            value = kCeiling;
+        }
+        return static_cast<std::int32_t>(negative ? -value : value);
+    }
+
+private:
+    constexpr void skip_spaces() noexcept {
+        while (*at_ == ' ' || *at_ == '\t') {
+            ++at_;
+        }
+    }
+
+    const char* at_;
+};
+
+} // namespace sunrise::core::rule_text

+ 6 - 0
Sunrise/src/core/settings/state_settings.cpp

@@ -264,6 +264,7 @@ bool Parser::account(state::AccountState& output) noexcept {
         return false;
     }
     bool hasPrimarySoid = false;
+    bool hasProfileSetupCompleted = false;
     bool hasSettings = false;
     bool hasDismantleRewards = false;
     bool hasRecordRewards = false;
@@ -280,6 +281,11 @@ bool Parser::account(state::AccountState& output) noexcept {
                 return false;
             }
             hasPrimarySoid = true;
+        } else if (key == "profile_setup_completed") {
+            if (hasProfileSetupCompleted || !boolean(output.profileSetupCompleted)) {
+                return false;
+            }
+            hasProfileSetupCompleted = true;
         } else if (key == "settings") {
             if (hasSettings || !account_settings(output.settings)) {
                 return false;

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

@@ -87,6 +87,7 @@ bool encode(const state::AccountState& state,
     layout::Object object{};
     object.accountSoid = state.primarySoid;
     object.selectedCharacterSoid = state::account::selected_character_soid(state);
+    object.profileSetupCompleted = state.profileSetupCompleted ? 1U : 0U;
     if (!roster::initialize(state, object.roster)
         || !preferences::encode(state.settings, object.preferences, object.bindings)) {
         return false;

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

@@ -28,8 +28,10 @@ inline constexpr std::size_t kAccountHeaderTailSize = 128;
 inline constexpr std::size_t kRosterSelectionPaddingSize = 8;
 /** 24 reserved bytes separate selection from publicity deadlines. */
 inline constexpr std::size_t kSelectionPublicityPaddingSize = 24;
-/** 24 reserved bytes separate seen messages from account preferences. */
-inline constexpr std::size_t kSeenPreferencesPaddingSize = 24;
+/** 20 reserved bytes separate seen messages from the profile-setup completion byte. */
+inline constexpr std::size_t kSeenProfileSetupPaddingSize = 20;
+/** 3 reserved bytes align account preferences after the profile-setup completion byte. */
+inline constexpr std::size_t kProfileSetupPreferencesPaddingSize = 3;
 /** 607 reserved bytes separate preference and keybinding records. */
 inline constexpr std::size_t kPreferencesBindingsPaddingSize = 607;
 /** 456 reserved bytes follow the replicated keybinding record. */
@@ -68,6 +70,8 @@ inline constexpr std::size_t kSelectedCharacterSoidOffset = 1'832;
 inline constexpr std::size_t kPublicityExpiriesOffset = 1'864;
 /** The seen-message bit bank follows all fixed publicity deadlines. */
 inline constexpr std::size_t kSeenMessagesOffset = 2'888;
+/** One byte at native offset 0xB90 records whether the one-time profile setup is complete. */
+inline constexpr std::size_t kProfileSetupCompletedOffset = 2'960;
 /** The native preference record follows the seen-message padding. */
 inline constexpr std::size_t kPreferencesOffset = 2'964;
 /** The replicated keybinding record follows its fixed preference padding. */
@@ -131,7 +135,9 @@ struct Object {
     std::array<std::byte, kSelectionPublicityPaddingSize> selectionPublicityPadding{};
     std::array<std::uint64_t, kPublicityExpiryCapacity> publicityExpiries{};
     std::array<std::byte, kSeenMessageByteCount> seenMessages{};
-    std::array<std::byte, kSeenPreferencesPaddingSize> seenPreferencesPadding{};
+    std::array<std::byte, kSeenProfileSetupPaddingSize> seenProfileSetupPadding{};
+    std::uint8_t profileSetupCompleted{};
+    std::array<std::byte, kProfileSetupPreferencesPaddingSize> profileSetupPreferencesPadding{};
     preferences::Record preferences{};
     std::array<std::byte, kPreferencesBindingsPaddingSize> preferencesBindingsPadding{};
     preferences::BindingsRecord bindings{};
@@ -165,6 +171,7 @@ static_assert(offsetof(Object, roster) == kRosterOffset);
 static_assert(offsetof(Object, selectedCharacterSoid) == kSelectedCharacterSoidOffset);
 static_assert(offsetof(Object, publicityExpiries) == kPublicityExpiriesOffset);
 static_assert(offsetof(Object, seenMessages) == kSeenMessagesOffset);
+static_assert(offsetof(Object, profileSetupCompleted) == kProfileSetupCompletedOffset);
 static_assert(offsetof(Object, preferences) == kPreferencesOffset);
 static_assert(offsetof(Object, bindings) == kBindingsOffset);
 static_assert(offsetof(Object, profileItemCount) == kProfileItemCountOffset);

+ 13 - 12
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -125,6 +125,7 @@ bool resolve_item(const authored_inventory::Item& authored,
                   const state::CharacterState& character,
                   std::size_t itemDefinitionCount,
                   std::size_t socketEntryListCount,
+                  bool requireEquipmentSlot,
                   Candidate& output) noexcept {
     if (!authored_inventory::valid(authored) || itemDefinitionCount == 0
         || itemDefinitionCount > build_items::kDefinitionCapacity || socketEntryListCount == 0
@@ -141,6 +142,15 @@ bool resolve_item(const authored_inventory::Item& authored,
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
                                                            itemDetail)
         || itemDefinition.bucketId != itemDetail.bucketId
+        // A pursuit - a bounty or a quest step - names no equipment slot, because nothing equips
+        // it. Requiring one refused it here, so it was added to the inventory and then could not
+        // be found in the resolved loadout, and the acquisition failed as `resolve_or_bucket_full`.
+        // Equipped items still must name a slot: they come out of the equipment array, where the
+        // slot is what identifies them.
+        || (!authored_inventory::resolve_native_equipment_slot(
+                authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)
+            && (requireEquipmentSlot || itemDetail.equipmentSlot.has_value()))
+        || static_cast<std::size_t>(nativeEquipmentSlot) >= build_details::kEquipmentSlotCount
         || !state::build_data::find_inventory_bucket_descriptor(itemDetail.bucketId, bucket)
         || bucket.arraySelector != build_buckets::ArraySelector::character
         || !state::build_data::find_socket_entry_list(itemDetail.socketEntryListIndex, socketList)
@@ -152,18 +162,9 @@ bool resolve_item(const authored_inventory::Item& authored,
 
     Candidate candidate{};
     candidate.bucket = bucket;
-    if (authored_inventory::resolve_native_equipment_slot(
-            authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)) {
-        if (static_cast<std::size_t>(nativeEquipmentSlot) >= build_details::kEquipmentSlotCount) {
-            return false;
-        }
-        candidate.item.equipmentSlot = nativeEquipmentSlot;
-    } else if (itemDetail.equipmentSlot.has_value()
-               || bucket.equipmentSlot != build_buckets::kUnavailableEquipmentSlot) {
-        return false;
-    } else {
-        candidate.item.equipmentSlot = kUnavailableEquipmentSlot;
-    }
+    // Slot zero for a slotless item is safe: the encoder reads `equipmentSlot` only when `equipped`
+    // is set, and only items resolved out of the equipment array are ever equipped.
+    candidate.item.equipmentSlot = nativeEquipmentSlot;
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)

+ 4 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.h

@@ -22,6 +22,9 @@ struct Candidate {
  * @param character Authored character that owns the item.
  * @param itemDefinitionCount Stable dense item-table row count.
  * @param socketEntryListCount Stable dense socket-list row count.
+ * @param requireEquipmentSlot True for an item resolved out of an equipment slot, which must name
+ *        one. False for unequipped inventory, where a pursuit - a bounty or a quest - carries no
+ *        equipment slot at all and would otherwise be refused, leaving it unplaceable.
  * @param output Receives a complete candidate only on success.
  * @return True when every base, plug, bucket, and initial socket mapping resolves.
  */
@@ -29,6 +32,7 @@ struct Candidate {
                                 const state::CharacterState& character,
                                 std::size_t itemDefinitionCount,
                                 std::size_t socketEntryListCount,
+                                bool requireEquipmentSlot,
                                 Candidate& output) noexcept;
 
 } // namespace sunrise::middleware::datagen::family4::loadout

+ 9 - 3
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp

@@ -104,7 +104,7 @@ namespace build_buckets = state::build_data::inventory::buckets;
         Candidate candidate{};
         if (itemCount >= resolved.size()
             || !resolve_item(
-                *authored, character, itemDefinitionCount, socketEntryListCount, candidate)
+                *authored, character, itemDefinitionCount, socketEntryListCount, true, candidate)
             || !place_item(candidate, occupied, resolved[itemCount])) {
             return false;
         }
@@ -118,6 +118,7 @@ namespace build_buckets = state::build_data::inventory::buckets;
                                  character,
                                  itemDefinitionCount,
                                  socketEntryListCount,
+                                 false,
                                  candidate)
                 || !place_item(candidate, occupied, resolved[itemCount])) {
                 return false;
@@ -208,8 +209,12 @@ bool resolve(const state::AccountState& account,
             }
             instanceSoids[instanceSoidCount++] = authored->instanceSoid;
             Candidate candidate{};
-            if (!resolve_item(
-                    *authored, character, itemDefinitionCount, socketEntryListCount, candidate)
+            if (!resolve_item(*authored,
+                              character,
+                              itemDefinitionCount,
+                              socketEntryListCount,
+                              true,
+                              candidate)
                 || !record_equipment_slot(semanticIndex,
                                           candidate.item.equipmentSlot,
                                           semanticToNative,
@@ -240,6 +245,7 @@ bool resolve(const state::AccountState& account,
                                  character,
                                  itemDefinitionCount,
                                  socketEntryListCount,
+                                 false,
                                  selectedInventory[selectedInventoryCount])) {
                 return false;
             }

+ 34 - 0
Sunrise/src/middleware/web_service/messages/biased_field.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages {
+
+/** The vendor opcodes carry their indices as 16-bit signed fields. */
+inline constexpr std::uint8_t kBiasedIndexWidth = 16;
+/** Their descriptor bias is the signed 16-bit midpoint. */
+inline constexpr std::int32_t kBiasedIndexBias = 0x8000;
+
+/**
+ * Reads one biased 16-bit index field.
+ *
+ * Opcodes 901 and 904 both open with fields of this shape, and each codec carried its own copy of
+ * the reader. One copy means the two cannot drift.
+ *
+ * @param reader Open reader.
+ * @param output Receives the logical index.
+ * @return True when the field was present.
+ */
+[[nodiscard]] inline bool read_biased_index(encoding::bits::Reader& reader,
+                                            std::int16_t& output) noexcept {
+    std::uint64_t stored = 0;
+    if (!reader.read(kBiasedIndexWidth, stored)) {
+        return false;
+    }
+    output = static_cast<std::int16_t>(static_cast<std::int32_t>(stored) - kBiasedIndexBias);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages

+ 5 - 54
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.cpp

@@ -1,20 +1,16 @@
 /**
- * Opcode 901 is a vendor purchase. The request carries a vendor index, a sale index and a clock.
- * The clock rule checks skew and freshness only. Replay needs a committed purchase to compare
- * against, and no purchase commits yet.
+ * Opcode 901 is a vendor purchase. The request carries a vendor index, a sale index and an
+ * optional clock. The clock is decoded so the body is checked whole; nothing reads it yet.
  */
 
 #include "opcode901_codec.h"
 
 #include "../../../encoding/bit_reader.h"
+#include "../biased_field.h"
 
 namespace sunrise::middleware::web_service::messages::opcode901 {
 namespace {
 
-/** Both index fields are 16-bit signed values. */
-constexpr std::uint8_t kIndexWidth = 16;
-/** Their descriptor bias is the signed 16-bit midpoint. */
-constexpr std::int32_t kIndexBias = 0x8000;
 /** The optional clock is a 64-bit signed value with no bias. */
 constexpr std::uint8_t kClockWidth = 64;
 /** One presence bit precedes the clock. */
@@ -25,21 +21,6 @@ constexpr std::uint8_t kPresenceWidth = 1;
  */
 constexpr std::size_t kPaddingLimit = 8;
 
-/**
- * Reads one biased index field.
- * @param reader Open reader.
- * @param output Receives the logical index.
- * @return True when the field was present.
- */
-[[nodiscard]] bool read_index(encoding::bits::Reader& reader, std::int16_t& output) noexcept {
-    std::uint64_t stored = 0;
-    if (!reader.read(kIndexWidth, stored)) {
-        return false;
-    }
-    output = static_cast<std::int16_t>(static_cast<std::int32_t>(stored) - kIndexBias);
-    return true;
-}
-
 } // namespace
 
 /** Decodes one purchase request body. */
@@ -50,7 +31,8 @@ bool parse_request(const Message& message, Request& output) noexcept {
     encoding::bits::Reader reader(message.payload);
     Request candidate{};
     std::uint64_t present = 0;
-    if (!read_index(reader, candidate.vendorIndex) || !read_index(reader, candidate.saleIndex)
+    if (!read_biased_index(reader, candidate.vendorIndex)
+        || !read_biased_index(reader, candidate.saleIndex)
         || !reader.read(kPresenceWidth, present)) {
         return false;
     }
@@ -69,35 +51,4 @@ bool parse_request(const Message& message, Request& output) noexcept {
     return true;
 }
 
-/** Checks presence first, then both windows. */
-ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept {
-    if (!request.hasClock) {
-        return ClockPolicy::absent;
-    }
-    // Unsigned subtraction keeps the distance exact for any two signed clocks.
-    if (request.clock > serverClock) {
-        const std::uint64_t ahead =
-            static_cast<std::uint64_t>(request.clock) - static_cast<std::uint64_t>(serverClock);
-        return ahead > kClockAheadLimitSeconds ? ClockPolicy::ahead : ClockPolicy::accepted;
-    }
-    const std::uint64_t behind =
-        static_cast<std::uint64_t>(serverClock) - static_cast<std::uint64_t>(request.clock);
-    return behind > kClockBehindLimitSeconds ? ClockPolicy::stale : ClockPolicy::accepted;
-}
-
-/** Names one clock verdict for a log line. */
-const char* clock_policy_name(ClockPolicy policy) noexcept {
-    switch (policy) {
-    case ClockPolicy::accepted:
-        return "ok";
-    case ClockPolicy::absent:
-        return "absent";
-    case ClockPolicy::ahead:
-        return "ahead";
-    case ClockPolicy::stale:
-        return "stale";
-    }
-    return "unknown";
-}
-
 } // namespace sunrise::middleware::web_service::messages::opcode901

+ 1 - 37
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.h

@@ -16,32 +16,12 @@ struct Request {
     std::int16_t vendorIndex{};
     /** Index into that vendor's sale rows. */
     std::int16_t saleIndex{};
-    /** Client clock in Unix seconds. */
+    /** Client clock in Unix seconds. Decoded so the body is checked whole; no rule reads it. */
     std::int64_t clock{};
     /** False for the absent form, which the decoder still accepts. */
     bool hasClock{};
 };
 
-/**
- * Verdict of the clock rule on one decoded request.
- * Every value is Sunrise policy, not a native rule.
- */
-enum class ClockPolicy : std::uint8_t {
-    /** Present, and inside both windows. */
-    accepted,
-    /** The absent form. It decodes, but the clock rule refuses it. */
-    absent,
-    /** Ahead of the server by more than the skew window. */
-    ahead,
-    /** Behind the server by more than the freshness window. */
-    stale,
-};
-
-/** Seconds a request clock may run ahead of the server. Policy window, not a native value. */
-inline constexpr std::uint64_t kClockAheadLimitSeconds = 60;
-/** Seconds a request clock may run behind the server. Policy window, not a native value. */
-inline constexpr std::uint64_t kClockBehindLimitSeconds = 300;
-
 /**
  * Decodes one purchase request body.
  * A body with a whole byte left over after the last field is refused.
@@ -51,20 +31,4 @@ inline constexpr std::uint64_t kClockBehindLimitSeconds = 300;
  */
 [[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
 
-/**
- * Applies the clock rule to one decoded request.
- * The clock must be present. The absent form decodes, then fails here.
- * @param request Decoded request.
- * @param serverClock Server's own time in Unix seconds.
- * @return Which rule the clock met or broke.
- */
-[[nodiscard]] ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept;
-
-/**
- * Names one clock verdict for a log line.
- * @param policy Verdict from check_clock.
- * @return Stable lowercase token.
- */
-[[nodiscard]] const char* clock_policy_name(ClockPolicy policy) noexcept;
-
 } // namespace sunrise::middleware::web_service::messages::opcode901

+ 56 - 0
Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp

@@ -0,0 +1,56 @@
+/**
+ * Opcode 904 acquires a quest or other pursuit from a vendor.
+ *
+ * Captured requests settle the layout: three 16-bit fields biased by `0x8000` - the vendor, the
+ * clicked UI slot, and a field zero in every capture - then one 32-bit field biased by `0x80000000`
+ * naming the sale row, then one trailing byte skipped rather than guessed at.
+ *
+ * | payload | vendor | slot | third | sale row |
+ * |---|---|---|---|---|
+ * | `8016 801C 8000 80000104 00` | 22 | 28 | 0 | 260 |
+ * | `8016 801D 8000 800000C3 00` | 22 | 29 | 0 | 195 |
+ * | `8016 8020 8000 80000109 00` | 22 | 32 | 0 | 265 |
+ * | `8016 8015 8000 7FFFFFFF 00` | 22 | 21 | 0 | -1  |
+ *
+ * The last row fixes the width: `7FFFFFFF` is -1 under the 32-bit bias, the same absent marker a
+ * sale row's own category index carries. Read as a bare 16-bit field it would be 65535.
+ */
+
+#include "opcode904_codec.h"
+
+#include "../../../encoding/bit_reader.h"
+#include "../biased_field.h"
+
+namespace sunrise::middleware::web_service::messages::opcode904 {
+namespace {
+
+/** The sale row is a 32-bit signed value. */
+constexpr std::uint8_t kSaleIndexWidth = 32;
+/** Its bias is the signed 32-bit midpoint, which is the same rule one width up. */
+constexpr std::int64_t kSaleIndexBias = 0x80000000;
+
+} // namespace
+
+/** Decodes one quest-acquire request body. */
+bool parse_request(const Message& message, Request& output) noexcept {
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    Request candidate{};
+    if (!read_biased_index(reader, candidate.vendorIndex)
+        || !read_biased_index(reader, candidate.slotIndex)
+        || !read_biased_index(reader, candidate.third)) {
+        return false;
+    }
+    std::uint64_t saleIndex = 0;
+    if (reader.read(kSaleIndexWidth, saleIndex)) {
+        candidate.saleIndex =
+            static_cast<std::int32_t>(static_cast<std::int64_t>(saleIndex) - kSaleIndexBias);
+        candidate.hasSaleIndex = true;
+    }
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode904

+ 50 - 0
Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode904 {
+
+/** Web Service opcode for acquiring a quest or other pursuit from a vendor. */
+inline constexpr std::uint16_t kOpcode = 904;
+
+/**
+ * One decoded quest-acquire request.
+ *
+ * Structurally a sibling of the opcode-901 purchase: 16-bit fields biased by `0x8000`. It carries
+ * three of them and no clock, where 901 carries two and an optional clock, and then one 32-bit
+ * field biased by `0x80000000` that names the sale row.
+ */
+struct Request {
+    /** Index into the vendor table, the same table 901 indexes. */
+    std::int16_t vendorIndex{};
+    /**
+     * UI slot the click landed on. Not a sale row: indexing sale rows with it grants armour mods.
+     */
+    std::int16_t slotIndex{};
+    /** Third field. Zero in every captured request; role open. */
+    std::int16_t third{};
+    /**
+     * Sale row of the vendor definition, as a 32-bit field biased by `0x80000000`. `7FFFFFFF` is
+     * -1, the same absent marker a sale row's own category index uses; only the full width reads
+     * it as such.
+     */
+    std::int32_t saleIndex{};
+    /** True when the body carried the sale-row field. */
+    bool hasSaleIndex{};
+};
+
+/**
+ * Decodes one quest-acquire request body.
+ *
+ * Unlike 901 this does not refuse a body with a byte to spare: a captured request is 11 bytes and
+ * the four fields account for ten. The trailing byte is skipped rather than read as a field.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param output Receives the request only when the three leading fields decode.
+ * @return True when the opcode matches and those fields are present.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode904

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

@@ -469,6 +469,7 @@ bool process(const ServiceRoute& route,
             }
             transaction->pending =
                 web_service::take_mutation<state::PendingItemAcquisition>(webOutcome);
+            transaction->answeredVendor = webOutcome.answeredVendor;
         }
         if (profileItemAcquisition != nullptr) {
             // Actionable profile stacks may add a resident in the same revision.
@@ -503,6 +504,7 @@ bool process(const ServiceRoute& route,
             }
             transaction->pending =
                 web_service::take_mutation<state::PendingProfileItemAcquisition>(webOutcome);
+            transaction->answeredVendor = webOutcome.answeredVendor;
         }
         if (itemDismantle != nullptr) {
             // Promise the revision carrying the character update and resident release.

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

@@ -79,12 +79,16 @@ struct CurrentActivityTransaction {
 struct ItemAcquisitionTransaction {
     std::unique_ptr<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 {
     std::unique_ptr<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. */

+ 123 - 33
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -27,6 +27,121 @@ constexpr auto kChangeRecordIsZero = [](const auto& record) noexcept {
     return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
            && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
 };
+namespace {
+
+namespace account_layout = middleware::datagen::family4::account::layout;
+
+/**
+ * The change ring the native account observer reads.
+ *
+ * It compares profile quantities but only draws pickup feedback for a row whose mutation serial
+ * also appears here, so a gain the ring does not name lands silently. The bank is local to one
+ * incremental upsert - an ordinary snapshot encodes it empty - while the row's own rising serial
+ * stays persistent State.
+ */
+constexpr std::uint8_t kChangeKind = 1;
+/** Clear policy bits leave the record enabled; the observer skips any other pair. */
+constexpr std::uint16_t kChangeFlags = 0;
+
+/** @return True when the ring carries no record, which is how every snapshot encodes it. */
+[[nodiscard]] bool ring_is_empty(const account_layout::Object& accountObject) noexcept {
+    const auto recordIsZero = [](const account_layout::ProfileInventoryChangeRecord& record) {
+        return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+               && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+    };
+    return accountObject.profileInventoryChanges.writeSlot == 0
+           && accountObject.profileInventoryChanges.nextSequence == 0
+           && std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                          accountObject.profileInventoryChanges.records.cend(),
+                          recordIsZero);
+}
+
+/** Points one ring record at one profile row. */
+void name_row(account_layout::ProfileInventoryChangeRecord& record,
+              std::size_t sequence,
+              std::int32_t mutationSerial) noexcept {
+    record.sequence = static_cast<std::uint16_t>(sequence);
+    record.mutationSerial = mutationSerial;
+    record.kind = kChangeKind;
+    record.flags = kChangeFlags;
+}
+
+/**
+ * Names every row an exchange credited, so each gain is drawn and repeats accumulate.
+ *
+ * @param accountObject Encoded account object being upserted.
+ * @param mutation Prepared exchange carrying the rows it credited.
+ * @return Null on success, or the reason the ring could not be written.
+ */
+[[nodiscard]] const char* write_exchange_changes(
+    account_layout::Object& accountObject,
+    const state::PendingProfileItemAcquisition& mutation) noexcept {
+    if (mutation.changeCount > accountObject.profileInventoryChanges.records.size()
+        || !ring_is_empty(accountObject)) {
+        return "exchange_inventory_change_state";
+    }
+    for (std::size_t change = 0; change < mutation.changeCount; ++change) {
+        const state::ProfileStackChange& announced = mutation.changes[change];
+        std::size_t matchedRows = 0;
+        for (const auto& row : accountObject.profileItems) {
+            if (row.mutationSerial != announced.mutationSerial) {
+                continue;
+            }
+            if (row.quantity != announced.afterQuantity) {
+                return "exchange_change_quantity";
+            }
+            ++matchedRows;
+        }
+        if (matchedRows != 1) {
+            return "exchange_change_row";
+        }
+        name_row(accountObject.profileInventoryChanges.records[change],
+                 change,
+                 announced.mutationSerial);
+    }
+    accountObject.profileInventoryChanges.writeSlot =
+        static_cast<std::uint16_t>(mutation.changeCount);
+    accountObject.profileInventoryChanges.nextSequence =
+        static_cast<std::uint16_t>(mutation.changeCount);
+    return nullptr;
+}
+
+/**
+ * Names the one row an ordinary acquisition added to or grew.
+ *
+ * @param accountObject Encoded account object being upserted.
+ * @param mutation Prepared acquisition naming its acquired row.
+ * @param acquiredRow Receives that row's position, for the checkpoint line.
+ * @return Null on success, or the reason the ring could not be written.
+ */
+[[nodiscard]] const char* write_acquisition_change(
+    account_layout::Object& accountObject,
+    const state::PendingProfileItemAcquisition& mutation,
+    std::size_t& acquiredRow) noexcept {
+    acquiredRow = accountObject.profileItems.size();
+    for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
+        if (accountObject.profileItems[row].mutationSerial != mutation.acquiredMutationSerial) {
+            continue;
+        }
+        if (acquiredRow != accountObject.profileItems.size()) {
+            return "profile_acquire_row_duplicate";
+        }
+        acquiredRow = row;
+    }
+    if (acquiredRow >= accountObject.profileItems.size()
+        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
+        || !ring_is_empty(accountObject)) {
+        return "profile_acquire_inventory_change_state";
+    }
+    accountObject.profileInventoryChanges.writeSlot = 1;
+    accountObject.profileInventoryChanges.nextSequence = 1;
+    name_row(accountObject.profileInventoryChanges.records.front(),
+             0,
+             mutation.acquiredMutationSerial);
+    return nullptr;
+}
+
+} // namespace
 
 /** Builds a single full account-object upsert from an uncommitted profile-stack after-image. */
 bool prepare_profile_item_acquisition(Scratch& scratch,
@@ -64,44 +179,19 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         return report_failure("profile_acquire_account_encode");
     }
 
-    // Pickup feedback requires the changed row's mutation serial in the transient bank at 0x6978.
-    // Populate it only for this incremental upsert; ordinary snapshots leave the bank empty.
-    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 bool recordsAreZero = std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
-                                            accountObject.profileInventoryChanges.records.cend(),
-                                            kChangeRecordIsZero);
-    if (acquiredRow >= accountObject.profileItems.size()
-        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
-        || accountObject.profileInventoryChanges.writeSlot != 0
-        || accountObject.profileInventoryChanges.nextSequence != 0 || !recordsAreZero) {
+    // An exchange names every row it credited; an ordinary acquisition names the one row it added
+    // to or grew. Both write the same kind of record, which is what the observer draws.
+    const char* const ringFailure =
+        mutation.changeCount != 0
+            ? write_exchange_changes(accountObject, mutation)
+            : write_acquisition_change(accountObject, mutation, acquiredRow);
+    if (ringFailure != nullptr) {
         clear_after(scratch, reservation);
-        return report_failure("profile_acquire_inventory_change_state");
+        return report_failure(ringFailure);
     }
-    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 =

+ 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"
 
@@ -268,6 +269,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)) {
@@ -307,6 +313,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 - 1
Sunrise/src/server/web_service/opcode_routes.cpp

@@ -21,7 +21,15 @@ constexpr auto kStatusPairOpcodes = std::to_array<std::uint16_t>({
     1617, 1618, 1701, 1702, 1801, 1802, 1803, 1820, 1821, 1901, 2002, 2200, 2300, 2400,
 });
 
-/** Opcodes whose status pair has one required trailing boolean field. */
+/**
+ * Opcodes whose status pair has one required trailing boolean field.
+ *
+ * 901 is listed from its refusal form. A purchase that prepares a mutation is answered elsewhere:
+ * `bap_service_body.cpp` re-encodes it as a plain status pair, one bit short of this shape, and
+ * that is the reply verified working in game. The two disagree and nothing reconciles them. Only a
+ * captured retail reply to a successful 901 can say which is right, so until one exists neither
+ * side is changed - the working form is the one the game has already accepted.
+ */
 constexpr auto kStatusPairBoolOpcodes = std::to_array<std::uint16_t>({104, 901});
 
 /** Opcodes whose response definition hands its status value to the Client's Family-4 wait. */

+ 847 - 26
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -2,12 +2,16 @@
 
 #include <algorithm>
 #include <array>
+#include <chrono>
 #include <cstdio>
+#include <cstring>
 #include <limits>
 #include <span>
 #include <string_view>
 
+#include "../../core/filesystem/path.h"
 #include "../../core/logging/log.h"
+#include "../../core/settings/rule_text.h"
 #include "../../middleware/crypto/random_bytes.h"
 #include "../../middleware/encoding/byte_order.h"
 #include "../../middleware/web_service/messages/opcode1801.h"
@@ -21,13 +25,19 @@
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode701/opcode701_codec.h"
 #include "../../middleware/web_service/messages/opcode801.h"
+#include "../../middleware/web_service/messages/opcode901/opcode901_codec.h"
 #include "../../middleware/web_service/messages/opcode903.h"
+#include "../../middleware/web_service/messages/opcode904/opcode904_codec.h"
 #include "../../state/account/account_state.h"
+#include "../../state/account/pursuit_hold.h"
+#include "../../state/build_data/items/item_catalog.h"
 #include "../../state/build_data/runtime.h"
+#include "../../state/build_data/vendors/vendor_catalog.h"
 #include "../../state/progression/season_pass_reward_catalog.h"
 #include "../../state/progression/seasonal_experience.h"
 #include "../../state/record_claims/record_claims.h"
 #include "../../state/runtime/runtime.h"
+#include "../../state/vendors/answered_interactions.h"
 
 namespace sunrise::server::web_service {
 
@@ -37,6 +47,24 @@ namespace {
 constexpr std::uint8_t kEquippedShaderModelSocketKind = 0;
 /** Index stored when no definition resolves. The catalog is u16-indexed, so this cannot be one. */
 constexpr std::uint32_t kUnavailableDefinitionIndex = (std::numeric_limits<std::uint16_t>::max)();
+/** Repeatable bounties a character may hold from one vendor at once, as retail allows. */
+constexpr std::uint32_t kRepeatableHoldLimit = 5;
+/** Authored repeatable pool ceiling. The largest set in the manifest is Eva's Dawning, at 22. */
+constexpr std::size_t kRepeatablePoolCapacity = 64;
+/** Stacks one exchange row may credit. Shader recycling pays two: Glimmer and Legendary Shards. */
+constexpr std::size_t kExchangePayoutCapacity = 4;
+// Every credited stack is announced to the account's change ring, so a rule that named more
+// payouts than the mutation can announce would pay out silently. Raising one raises the other.
+static_assert(kExchangePayoutCapacity <= state::kProfileStackChangeCapacity);
+
+/**
+ * Storage every rule reader in this file parses from.
+ *
+ * The three readers run one after another on the request thread, each reading its file and
+ * finishing with it before the next starts, so they share one buffer rather than holding one
+ * each. A reader must not keep a cursor into it across a call to another reader.
+ */
+std::array<char, core::rule_text::kRuleTextCapacity> g_ruleText{};
 
 template <std::size_t Size>
 void write_warning(const std::array<char, Size>& line, int count) noexcept {
@@ -598,32 +626,372 @@ void report_item_acquisition(const middleware::web_service::Message& message,
     write_warning(line, count);
 }
 
-/** Prepares the exact three-byte opcode-1820 Collections item request. */
-void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
-    middleware::web_service::messages::opcode1820::Request request{};
-    if (!middleware::web_service::messages::opcode1820::parse_request(message, request)) {
-        report_item_acquisition(message,
-                                "payload_bits",
-                                kUnavailableDefinitionIndex,
-                                kUnavailableDefinitionIndex,
-                                0,
-                                0);
-        return;
+/**
+ * Writes one purchase line.
+ *
+ * The opcode is carried rather than hard-coded: 901 and 904 share this line, and a quest acquire
+ * reporting itself as `ws901` sends anyone reading the log to the wrong decoder.
+ *
+ * @param opcode Request opcode the line belongs to, 901 or 904.
+ * @param result `ok` or `fail`.
+ * @param reason Step that decided it.
+ * @param vendorIndex Vendor row the request named.
+ * @param saleIndex Sale row the request named.
+ * @param itemDefinitionIndex Item resolved, when the row resolved.
+ */
+void report_purchase(std::uint16_t opcode,
+                     const char* result,
+                     const char* reason,
+                     std::int32_t vendorIndex,
+                     std::int32_t saleIndex,
+                     std::uint16_t itemDefinitionIndex) noexcept {
+    core::log::writef(core::log::Channel::server,
+                      std::strcmp(result, "ok") == 0 ? core::log::Level::info
+                                                     : core::log::Level::warn,
+                      "ev=ws%u stage=purchase result=%s reason=%s vendor=%d sale=%d item=%u",
+                      static_cast<unsigned>(opcode),
+                      result,
+                      reason,
+                      static_cast<int>(vendorIndex),
+                      static_cast<int>(saleIndex),
+                      static_cast<unsigned>(itemDefinitionIndex));
+}
+
+/**
+ * Resolves the vendor a request names to its index row and held definition.
+ *
+ * Every vendor behaviour starts here, and five of them spelled it out by hand. A negative index is
+ * the client's own absent marker and never a row.
+ *
+ * @param vendorIndex Vendor row the request named.
+ * @param entry Receives the index row.
+ * @param definition Receives the held definition.
+ * @return True when the row exists and its definition is published.
+ */
+[[nodiscard]] bool find_vendor(std::int32_t vendorIndex,
+                               state::build_data::vendors::IndexEntry& entry,
+                               state::build_data::vendors::Definition& definition) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    entry = {};
+    definition = {};
+    return vendorIndex >= 0 && vendorIndex <= (std::numeric_limits<std::uint16_t>::max)()
+           && vendor_domain::find_index(static_cast<std::uint16_t>(vendorIndex), entry)
+           && vendor_domain::find(entry.definitionHash, definition);
+}
+
+/** What a substitution rule said about one sale row's item. */
+enum class Substitution : std::uint8_t {
+    /** No rule names this item; the row grants what it names. */
+    none,
+    /** A rule names it and its replacement resolved; the row grants the replacement. */
+    replaced,
+    /** A rule names it but its replacement is not in this build; the row must grant nothing. */
+    broken,
+};
+
+/**
+ * Answers what a placeholder sale row is really selling.
+ *
+ * Several rows name a DestinyItemType 20 Dummy - a UI placeholder for something the row does not
+ * name, as Amanda Holliday's Legacy Content rows stand for a campaign's first quest step. Granting
+ * the placeholder puts an item in the Quests tab the client will not draw, and the row never
+ * settles. `vendor_item_substitute.txt` maps sold hash to granted hash, keyed by item so one rule
+ * covers every seller. A rule whose replacement is absent from this build answers `broken` rather
+ * than `none`: the rule proves the row's item is a placeholder, and granting it would be the exact
+ * wrong grant this file exists to prevent.
+ *
+ * @param itemDefinitionIndex Item the row resolved to.
+ * @param substituteIndex Receives what should be granted in its place.
+ * @return What the rule file said about this item.
+ */
+[[nodiscard]] Substitution substitute_for_item(std::uint16_t itemDefinitionIndex,
+                                               std::uint16_t& substituteIndex) noexcept {
+    substituteIndex = kUnavailableDefinitionIndex;
+    state::build_data::items::Definition sold{};
+    if (!state::build_data::find_item_definition_index(itemDefinitionIndex, sold)) {
+        return Substitution::none;
+    }
+    if (!core::path::read_artifact_text(L"vendor_item_substitute.txt", g_ruleText)) {
+        return Substitution::none;
+    }
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (rules.seek_field()) {
+        const std::uint32_t soldHash = rules.read_hex();
+        const std::uint32_t grantHash = rules.read_hex();
+        if (soldHash != sold.definitionHash) {
+            continue;
+        }
+        state::build_data::items::Definition replacement{};
+        const bool resolved =
+            state::build_data::find_item_definition_hash(grantHash, replacement);
+        if (resolved) {
+            substituteIndex = replacement.definitionIndex;
+        }
+        if (resolved) {
+            core::log::writef(core::log::Channel::server,
+                              core::log::Level::info,
+                              "ev=vendor stage=substitute sold=0x%08X granted=0x%08X item=%u",
+                              sold.definitionHash,
+                              replacement.definitionHash,
+                              static_cast<unsigned>(replacement.definitionIndex));
+            return Substitution::replaced;
+        }
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::warn,
+                          "ev=vendor stage=substitute result=fail reason=missing sold=0x%08X "
+                          "named=0x%08X",
+                          sold.definitionHash,
+                          grantHash);
+        return Substitution::broken;
+    }
+    return Substitution::none;
+}
+
+/**
+ * Rolls one random unheld repeatable bounty, for a row that offers "Additional Bounties".
+ *
+ * The row sells a Dummy placeholder; what it owes is a REPEATABLE bounty, a distinct kind a
+ * character may hold five of. The pool is authored by hash in `vendor_bounty_roll.txt`, because a
+ * repeatable is not a sale row - no vendor in the manifest lists one - so nothing on the vendor can
+ * be discovered or picked from. Rules are keyed by vendor definition hash and trigger category,
+ * since one vendor can own several such rows (Eva Levante has one per event), and lines sharing a
+ * key accumulate. A hash this build does not carry is skipped, so a pool authored from a newer
+ * manifest degrades to what exists rather than failing whole.
+ *
+ * @param vendorIndex Vendor the purchase names.
+ * @param categoryIndex Category of the purchased row, from sale row +100.
+ * @param rolledItemIndex Receives the bounty to grant.
+ * @return True when this row is a bounty roll and its own item must NOT be granted.
+ */
+[[nodiscard]] bool roll_vendor_bounty(std::int32_t vendorIndex,
+                                      std::int32_t categoryIndex,
+                                      std::uint16_t& rolledItemIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    rolledItemIndex = kUnavailableDefinitionIndex;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (categoryIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
     }
-    const std::uint16_t collectibleIndex = request.collectibleIndex;
-    std::uint16_t itemDefinitionIndex = 0;
-    if (!state::build_data::find_collectible_item_definition_index(collectibleIndex,
-                                                                   itemDefinitionIndex)) {
-        report_item_acquisition(
-            message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
-        return;
+    if (!core::path::read_artifact_text(L"vendor_bounty_roll.txt", g_ruleText)) {
+        return false;
+    }
+    // Every hash authored for this exact key. Lines carrying the same key accumulate, so the pool
+    // is gathered from the whole file rather than from the first line that matches.
+    std::array<std::uint32_t, kRepeatablePoolCapacity> pool{};
+    std::size_t poolCount = 0;
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (rules.seek_field()) {
+        const std::uint32_t ruleHash = rules.read_hex();
+        const std::int32_t ruleCategory = rules.read_decimal();
+        const bool wanted = ruleHash == entry.definitionHash && ruleCategory == categoryIndex;
+        // The rest of the line is item hashes. A newline is not a rule field, so this stops at the
+        // end of the line without needing to look for one.
+        while (rules.at_field()) {
+            const std::uint32_t itemHash = rules.read_hex();
+            if (wanted && poolCount < pool.size()) {
+                pool[poolCount++] = itemHash;
+            }
+        }
+    }
+    if (poolCount == 0) {
+        return false;
+    }
+    // Reservoir pick over what this build actually carries and the character does not already hold,
+    // so the pool is walked once and no count is needed up front.
+    std::uint32_t resolved = 0;
+    std::uint32_t held = 0;
+    std::uint32_t candidates = 0;
+    std::uint64_t seed =
+        static_cast<std::uint64_t>(std::chrono::steady_clock::now().time_since_epoch().count());
+    // One account view for the whole pool. Reading it copies the whole account, and the pool is
+    // walked candidate by candidate, so taking it per candidate would copy it dozens of times to
+    // answer dozens of questions about the same unchanging view.
+    const state::AccountState account = state::account_snapshot();
+    for (std::size_t at = 0; at < poolCount; ++at) {
+        state::build_data::items::Definition item{};
+        if (!state::build_data::items::find_hash(pool[at], item)) {
+            continue;
+        }
+        ++resolved;
+        if (state::account::holds_pursuit(account, item.definitionIndex)) {
+            ++held;
+            continue;
+        }
+        ++candidates;
+        seed = (seed * 6364136223846793005ULL) + 1442695040888963407ULL;
+        if ((seed >> 33) % candidates == 0) {
+            rolledItemIndex = item.definitionIndex;
+        }
+    }
+    // Retail lets a character keep five of a vendor's repeatables at once. Refusing here rather
+    // than at the grant keeps the roll from consuming a pick it would only have to throw away.
+    if (held >= kRepeatableHoldLimit) {
+        rolledItemIndex = kUnavailableDefinitionIndex;
+    }
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::info,
+                      "ev=bounty_roll stage=pick vendor=%d hash=0x%08X category=%d authored=%u "
+                      "resolved=%u held=%u pool=%u item=%d",
+                      vendorIndex,
+                      entry.definitionHash,
+                      categoryIndex,
+                      static_cast<unsigned>(poolCount),
+                      resolved,
+                      held,
+                      candidates,
+                      rolledItemIndex == kUnavailableDefinitionIndex
+                          ? -1
+                          : static_cast<int>(rolledItemIndex));
+    return true;
+}
+
+/**
+ * Runs a vendor's recycle row: charges the stack it names and credits what it pays out.
+ *
+ * The Drifter's four Synth Recycling rows take five synths each; Master Rahool's Recycle Shaders
+ * category has one row per shader, 277 of them. The cost is authored in `vendor_exchange.txt`
+ * rather than read off the row, because the sale row's cost-bearing fields are still role-open on
+ * this build; the manifest's row order is this build's (304 rows checked against Lord Shaxx). A
+ * rule is `<vendor> <row> <costItem> <costQuantity>` then `<payoutItem> <payoutQuantity>` pairs.
+ *
+ * @param vendorIndex Vendor the purchase names.
+ * @param rowIndex Sale row the purchase names.
+ * @param mutation Receives the prepared profile-stack change.
+ * @return True when this row was an exchange and its own item must NOT be granted.
+ */
+[[nodiscard]] bool
+exchange_vendor_row(std::int32_t vendorIndex,
+                    std::int32_t rowIndex,
+                    state::PendingProfileItemAcquisition& mutation) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (rowIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
+    }
+    if (!core::path::read_artifact_text(L"vendor_exchange.txt", g_ruleText)) {
+        return false;
+    }
+    std::uint32_t costHash = 0;
+    std::int32_t costQuantity = 0;
+    std::array<state::ProfileExchangePayout, kExchangePayoutCapacity> payouts{};
+    std::size_t payoutCount = 0;
+    bool matched = false;
+    bool overflowed = false;
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (!matched && rules.seek_field()) {
+        const std::uint32_t ruleVendor = rules.read_hex();
+        const std::int32_t ruleRow = rules.read_decimal();
+        const std::uint32_t ruleCost = rules.read_hex();
+        const std::int32_t ruleCostQuantity = rules.read_decimal();
+        // The rest of the line is payout pairs, and every one of them is consumed even past what
+        // can be held. Stopping mid-line would leave the fields that did not fit to be read as the
+        // start of the next rule, turning one over-long rule into a second, invented one.
+        std::array<state::ProfileExchangePayout, kExchangePayoutCapacity> rulePayouts{};
+        std::size_t rulePayoutCount = 0;
+        bool ruleOverflowed = false;
+        while (rules.at_field()) {
+            const std::uint32_t payoutHash = rules.read_hex();
+            const std::int32_t payoutQuantity = rules.read_decimal();
+            if (rulePayoutCount < rulePayouts.size()) {
+                rulePayouts[rulePayoutCount++] = {payoutHash, payoutQuantity};
+            } else {
+                ruleOverflowed = true;
+            }
+        }
+        matched = ruleVendor == entry.definitionHash && ruleRow == rowIndex;
+        if (matched) {
+            overflowed = ruleOverflowed;
+            costHash = ruleCost;
+            costQuantity = ruleCostQuantity;
+            payouts = rulePayouts;
+            payoutCount = rulePayoutCount;
+        }
+    }
+    if (!matched) {
+        return false;
     }
+    // A matched rule owns the row whatever else it got wrong, because the rule proves the row's
+    // own item is a placeholder and falling through would grant it. A rule naming more payouts
+    // than the change ring can announce, or none at all, is refused whole rather than paid in
+    // part - and the refusal is logged, because a rule that silently does nothing reads exactly
+    // like a rule that was never written.
+    if (overflowed || payoutCount == 0) {
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::warn,
+                          "ev=vendor_exchange stage=apply result=fail reason=%s vendor=%d "
+                          "hash=0x%08X row=%d payouts=%zu limit=%zu",
+                          overflowed ? "payout_overflow" : "payout_missing",
+                          vendorIndex,
+                          entry.definitionHash,
+                          rowIndex,
+                          payoutCount,
+                          kExchangePayoutCapacity);
+        return true;
+    }
+    const bool applied = state::prepare_vendor_exchange(
+        costHash, costQuantity,
+        std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount}, mutation);
+    core::log::writef(core::log::Channel::server,
+                      applied ? core::log::Level::info : core::log::Level::warn,
+                      "ev=vendor_exchange stage=apply result=%s vendor=%d hash=0x%08X row=%d "
+                      "cost=0x%08X quantity=%d payouts=%zu",
+                      applied ? "ok" : "fail",
+                      vendorIndex,
+                      entry.definitionHash,
+                      rowIndex,
+                      costHash,
+                      costQuantity,
+                      payoutCount);
+    // Even a refused exchange owns the row. Falling through would grant the Dummy placeholder,
+    // which is the failure this whole path exists to avoid.
+    return true;
+}
+
+/** How one grant ended, so a caller can tell a settled row from a row still owed its item. */
+enum class GrantResult : std::uint8_t {
+    /** The item is prepared for the inventory; the row's offer is answered. */
+    granted,
+    /** The character already holds this pursuit, so the offer was answered some time ago. */
+    alreadyHeld,
+    /** Nothing was granted and nothing was held; the offer still stands. */
+    refused,
+};
 
+/**
+ * Grants one item, given the collectible that owns it and its definition index.
+ *
+ * Split out of `acquire_item` so a vendor purchase reaches the same grant instead of growing a
+ * second acquisition path. The acquisition state is keyed by collectible, so a caller has to arrive
+ * with one; `find_collectible_for_item` is how a purchase gets there.
+ *
+ * @param message Request being answered, for the log line.
+ * @param collectibleIndex Collectible that owns the item.
+ * @param itemDefinitionIndex Item to grant.
+ * @param outcome Receives the prepared mutation on success.
+ * @return How the grant ended, which is what decides whether the row's offer was answered.
+ */
+GrantResult grant_item_definition(const middleware::web_service::Message& message,
+                                  std::uint16_t collectibleIndex,
+                                  std::uint16_t itemDefinitionIndex,
+                                  Outcome& outcome) noexcept {
     state::build_data::items::Definition definition{};
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
         report_item_acquisition(
             message, "item_definition", collectibleIndex, itemDefinitionIndex, 0, 0);
-        return;
+        return GrantResult::refused;
+    }
+    // The same rule the client's native vendor-row gate applies locally, so a row that is still
+    // offered can never be one this grant would refuse.
+    if (state::account::holds_pursuit(itemDefinitionIndex)) {
+        report_item_acquisition(message,
+                                "already_held",
+                                collectibleIndex,
+                                itemDefinitionIndex,
+                                definition.definitionHash,
+                                0);
+        return GrantResult::alreadyHeld;
     }
 
     state::build_data::items::details::Definition detail{};
@@ -639,7 +1007,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                 itemDefinitionIndex,
                                 definition.definitionHash,
                                 0);
-        return;
+        return GrantResult::refused;
     }
 
     namespace bucket_domain = state::build_data::inventory::buckets;
@@ -652,7 +1020,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                     itemDefinitionIndex,
                                     definition.definitionHash,
                                     0);
-            return;
+            return GrantResult::refused;
         }
         auto* mutation = emplace_mutation<state::PendingProfileItemAcquisition>(outcome);
         if (mutation == nullptr) {
@@ -662,7 +1030,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                     itemDefinitionIndex,
                                     definition.definitionHash,
                                     0);
-            return;
+            return GrantResult::refused;
         }
         if (!state::prepare_profile_item_acquisition(
                 collectibleIndex, definition.definitionHash, *mutation)) {
@@ -673,9 +1041,9 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                     itemDefinitionIndex,
                                     definition.definitionHash,
                                     0);
-            return;
+            return GrantResult::refused;
         }
-        return;
+        return GrantResult::granted;
     }
     if (bucket.arraySelector != bucket_domain::ArraySelector::character) {
         report_item_acquisition(message,
@@ -684,7 +1052,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                 itemDefinitionIndex,
                                 definition.definitionHash,
                                 0);
-        return;
+        return GrantResult::refused;
     }
 
     auto* mutation = emplace_mutation<state::PendingItemAcquisition>(outcome);
@@ -695,16 +1063,469 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                 itemDefinitionIndex,
                                 definition.definitionHash,
                                 0);
-        return;
+        return GrantResult::refused;
     }
     if (!state::prepare_item_acquisition(collectibleIndex, definition.definitionHash, *mutation)) {
         clear_mutation(outcome);
         report_item_acquisition(
             message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
+        return GrantResult::refused;
+    }
+    return GrantResult::granted;
+}
+
+/**
+ * Finds the collectible that owns one item definition.
+ *
+ * A sale row names an item, never a collectible, while the acquisition state is keyed by
+ * collectible. Bounties, tokens and quest steps have none at all; those are granted by hash under
+ * `kNoCollectibleIndex`, which is why the caller's sentinel is left in place when nothing matches.
+ *
+ * @param itemDefinitionIndex Item to look up.
+ * @param collectibleIndex Receives the owning collectible row; untouched when none does.
+ * @return True when a collectible names this item.
+ */
+[[nodiscard]] bool find_collectible_for_item(std::uint16_t itemDefinitionIndex,
+                                             std::uint16_t& collectibleIndex) noexcept {
+    return state::build_data::collectibles::find_granting(itemDefinitionIndex, collectibleIndex);
+}
+
+/** Prepares the exact three-byte opcode-1820 Collections item request. */
+void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    middleware::web_service::messages::opcode1820::Request request{};
+    if (!middleware::web_service::messages::opcode1820::parse_request(message, request)) {
+        report_item_acquisition(message,
+                                "payload_bits",
+                                kUnavailableDefinitionIndex,
+                                kUnavailableDefinitionIndex,
+                                0,
+                                0);
+        return;
+    }
+    const std::uint16_t collectibleIndex = request.collectibleIndex;
+    std::uint16_t itemDefinitionIndex = 0;
+    if (!state::build_data::find_collectible_item_definition_index(collectibleIndex,
+                                                                   itemDefinitionIndex)) {
+        report_item_acquisition(
+            message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
+        return;
+    }
+    (void)grant_item_definition(message, collectibleIndex, itemDefinitionIndex, outcome);
+}
+
+/**
+ * Resolves one vendor row to the item it sells.
+ *
+ * Shared by the purchase (901) and the quest acquire (904), which name a row the same way, so the
+ * two cannot drift apart.
+ *
+ * @param vendorIndex Vendor table row.
+ * @param rowIndex Sale row within that vendor.
+ * @param itemDefinitionIndex Receives the item the row sells.
+ * @param reason Receives the step that failed, when one does.
+ * @return True when the row resolved.
+ */
+[[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
+                                      std::int32_t rowIndex,
+                                      std::uint16_t& itemDefinitionIndex,
+                                      std::int32_t& categoryIndex,
+                                      const char*& reason) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    if (vendorIndex < 0 || rowIndex < 0) {
+        reason = "negative_index";
+        return false;
+    }
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (!find_vendor(vendorIndex, entry, definition)) {
+        reason = "vendor";
+        return false;
+    }
+    vendor_domain::SaleRow row{};
+    if (!vendor_domain::sale_row(definition, static_cast<std::size_t>(rowIndex), row)) {
+        reason = "sale_row";
+        return false;
+    }
+    itemDefinitionIndex = row.itemIndex;
+    categoryIndex = row.categoryIndex;
+    return true;
+}
+
+/** Pursuit rows written out when a vendor is asked what it actually sells. */
+constexpr std::size_t kPursuitListCap = 64;
+
+/**
+ * Lists the sale rows of one vendor whose item is a pursuit, when a rowless tile fails to resolve.
+ *
+ * It says what this vendor does offer that would land in the Quests tab, which is the difference
+ * between "this click is broken" and "this click was never a quest". Items rather than rows, because
+ * one placeholder repeats across dozens of rows. The classification is the shared pursuit rule.
+ *
+ * @param vendorIndex Vendor to list.
+ */
+void report_pursuit_rows(std::int32_t vendorIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    namespace detail_domain = state::build_data::items::details;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (!find_vendor(vendorIndex, entry, definition)) {
+        return;
+    }
+    const std::size_t count = definition.saleCount;
+    // One item repeats across dozens of rows - Amanda declares 38 consecutive rows of a single
+    // placeholder - so listing rows rather than items buries everything interesting under filler.
+    static std::array<std::uint16_t, kPursuitListCap> seen{};
+    std::size_t listed = 0;
+    std::size_t pursuits = 0;
+    for (std::size_t row = 0; row < count; ++row) {
+        vendor_domain::SaleRow sale{};
+        if (!vendor_domain::sale_row(definition, row, sale)) {
+            break;
+        }
+        const std::uint16_t itemIndex = sale.itemIndex;
+        detail_domain::Definition detail{};
+        if (!state::build_data::find_configured_item_detail(itemIndex, detail)
+            || detail.equipmentSlot.has_value() || detail.maxStackSize > 1) {
+            continue;
+        }
+        ++pursuits;
+        bool duplicate = false;
+        for (std::size_t index = 0; index < listed; ++index) {
+            duplicate = duplicate || seen[index] == itemIndex;
+        }
+        if (duplicate || listed >= kPursuitListCap) {
+            continue;
+        }
+        seen[listed] = itemIndex;
+        ++listed;
+        // One line per distinct row, so this is the detail behind the summary rather than
+        // something worth putting in front of everything else that reports at info.
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::debug,
+                          "ev=vendor stage=pursuit vendor=%d sale=%zu item=%u hash=0x%08X "
+                          "bucket=%u",
+                          static_cast<int>(vendorIndex),
+                          row,
+                          static_cast<unsigned>(itemIndex),
+                          detail.definitionHash,
+                          static_cast<unsigned>(detail.bucketId));
+    }
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::info,
+                      "ev=vendor stage=pursuits vendor=%d sale_rows=%zu pursuits=%zu "
+                      "distinct_listed=%zu",
+                      static_cast<int>(vendorIndex),
+                      count,
+                      pursuits,
+                      listed);
+}
+
+/** An installed row names its item by definition hash at this offset. */
+constexpr std::size_t kInstalledRowHashOffset = 0;
+/** FNV-1's basis, which this engine also uses as its absent-hash sentinel. */
+constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U;
+
+/**
+ * Resolves the item behind a 904 that names no sale row.
+ *
+ * Amanda Holliday's Legacy Content tiles send `slot=1, row=-1`, so the slot is all that identifies
+ * them - and it indexes the installed array: the Red War tile's vendor declares 220 sale rows but
+ * 22 installed rows, and its slot is 1. That installed row carries the item's definition hash at
+ * `+0`, where a sale row names its item by index. The resolution is logged either way, because a
+ * wrong item that commits cleanly is harder to spot than a refusal.
+ *
+ * @param vendorIndex Vendor the request named.
+ * @param slotIndex The 16-bit slot field, which is all the request carries.
+ * @param itemDefinitionIndex Receives the item, or the unavailable sentinel.
+ * @return True when the row's hash resolved to an installed item definition.
+ */
+[[nodiscard]] bool resolve_rowless_quest(std::int32_t vendorIndex,
+                                         std::int32_t slotIndex,
+                                         std::uint16_t& itemDefinitionIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    itemDefinitionIndex = kUnavailableDefinitionIndex;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (slotIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
+    }
+    vendor_domain::InstalledRow installed{};
+    if (!vendor_domain::installed_row(definition, static_cast<std::size_t>(slotIndex), installed)) {
+        return false;
+    }
+    const auto& raw = installed.raw;
+    std::uint32_t definitionHash = 0;
+    std::memcpy(&definitionHash, raw.data() + kInstalledRowHashOffset, sizeof definitionHash);
+
+    state::build_data::items::Definition item{};
+    const bool resolved = definitionHash != kAbsentNameHash
+                          && state::build_data::find_item_definition_hash(definitionHash, item);
+    if (resolved) {
+        itemDefinitionIndex = item.definitionIndex;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=ws904 stage=rowless vendor=%d slot=%d installed=%u sale=%u "
+                                "third=%u hash=0x%08X item=%u resolved=%u hex=",
+                                static_cast<int>(vendorIndex),
+                                static_cast<int>(slotIndex),
+                                static_cast<unsigned>(definition.installedCount),
+                                static_cast<unsigned>(definition.saleCount),
+                                static_cast<unsigned>(definition.thirdCount),
+                                definitionHash,
+                                static_cast<unsigned>(itemDefinitionIndex),
+                                resolved ? 1U : 0U);
+    if (written > 0 && static_cast<std::size_t>(written) < line.size()) {
+        std::size_t length = static_cast<std::size_t>(written);
+        const auto* const bytes = reinterpret_cast<const std::byte*>(raw.data());
+        (void)core::log::append_hex(line, length, {bytes, raw.size()});
+        if (length != 0) {
+            core::log::write(core::log::Channel::server,
+                             resolved ? core::log::Level::info : core::log::Level::warn,
+                             {line.data(), length});
+        }
+    }
+    return resolved;
+}
+
+/** What one resolved vendor row turned out to be, once it was settled. */
+enum class RowOutcome : std::uint8_t {
+    /** The row rolled a bounty from an authored pool. */
+    bountyRoll,
+    /** The row charged one stack and credited others. */
+    exchange,
+    /** The row's item is prepared for the inventory; its offer is answered once that commits. */
+    granted,
+    /** The character already holds the row's pursuit, so its offer was answered some time ago. */
+    alreadyHeld,
+    /** The row should have granted and could not, so its offer still stands. */
+    grantRefused,
+};
+
+/**
+ * Settles one resolved vendor row, in the order a row's behaviours are tried.
+ *
+ * Both vendor opcodes end here. A row is a bounty roll, an exchange, or a grant, and which cannot
+ * be read off the row itself: each is recognised by an authored rule keyed to the vendor, tried in
+ * turn, and the first that claims the row owns it. One ordered chain is what keeps 901 and 904 from
+ * drifting apart.
+ *
+ * @param message Request being answered.
+ * @param opcode Opcode to report under.
+ * @param vendorIndex Vendor the request names.
+ * @param rowIndex Sale row the request names.
+ * @param categoryIndex Category of that row, from sale row +100.
+ * @param itemDefinitionIndex Item the row names.
+ * @param outcome Receives whatever mutation the row prepared.
+ * @return What the row turned out to be.
+ */
+RowOutcome settle_vendor_row(const middleware::web_service::Message& message,
+                             std::uint16_t opcode,
+                             std::int32_t vendorIndex,
+                             std::int32_t rowIndex,
+                             std::int32_t categoryIndex,
+                             std::uint16_t itemDefinitionIndex,
+                             Outcome& outcome) noexcept {
+    std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
+    if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
+        report_purchase(opcode,
+                        "ok",
+                        rolledBounty == kUnavailableDefinitionIndex ? "bounty_pool_empty"
+                                                                   : "bounty_roll",
+                        vendorIndex,
+                        rowIndex,
+                        itemDefinitionIndex);
+        if (rolledBounty != kUnavailableDefinitionIndex) {
+            std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
+            (void)find_collectible_for_item(rolledBounty, rolledCollectible);
+            (void)grant_item_definition(message, rolledCollectible, rolledBounty, outcome);
+        }
+        return RowOutcome::bountyRoll;
+    }
+    // The exchange is prepared in place: the payload is allocated only for this row, and a row
+    // that turns out not to be an exchange gives it back before the grant path runs.
+    auto* exchange = emplace_mutation<state::PendingProfileItemAcquisition>(outcome);
+    if (exchange == nullptr) {
+        report_purchase(opcode, "fail", "storage", vendorIndex, rowIndex, itemDefinitionIndex);
+        return RowOutcome::grantRefused;
+    }
+    if (exchange_vendor_row(vendorIndex, rowIndex, *exchange)) {
+        report_purchase(opcode, "ok", "exchange", vendorIndex, rowIndex, itemDefinitionIndex);
+        if (!exchange->prepared) {
+            clear_mutation(outcome);
+        }
+        return RowOutcome::exchange;
+    }
+    clear_mutation(outcome);
+    // A placeholder row grants what it stands for, not the placeholder: a Dummy item put in the
+    // Quests bucket is one the client will not draw, and the row never settles because the player
+    // never receives what it offered.
+    std::uint16_t granted = itemDefinitionIndex;
+    std::uint16_t substituteIndex = kUnavailableDefinitionIndex;
+    switch (substitute_for_item(granted, substituteIndex)) {
+    case Substitution::replaced:
+        granted = substituteIndex;
+        break;
+    case Substitution::broken:
+        // The rule proves the row's item is a placeholder, so granting it would be the wrong
+        // grant this path exists to prevent. The rule itself already logged what is missing.
+        report_purchase(opcode, "fail", "substitute_missing", vendorIndex, rowIndex, granted);
+        return RowOutcome::grantRefused;
+    case Substitution::none:
+        break;
+    }
+    std::uint16_t collectibleIndex = state::build_data::collectibles::kNoCollectibleIndex;
+    const bool collected = find_collectible_for_item(granted, collectibleIndex);
+    report_purchase(opcode,
+                    "ok",
+                    collected ? "resolved" : "resolved_no_collectible",
+                    vendorIndex,
+                    rowIndex,
+                    granted);
+    // A grant that failed for a transient reason - the loadout would not resolve, the bucket was
+    // full - leaves the row's offer standing, and the caller must not treat it as answered.
+    switch (grant_item_definition(message, collectibleIndex, granted, outcome)) {
+    case GrantResult::granted:
+        return RowOutcome::granted;
+    case GrantResult::alreadyHeld:
+        return RowOutcome::alreadyHeld;
+    case GrantResult::refused:
+        break;
+    }
+    return RowOutcome::grantRefused;
+}
+
+/**
+ * Prepares one opcode-904 quest acquire.
+ *
+ * A quest names a vendor row exactly as a purchase does, and the item behind it is granted through
+ * the same path, so a quest lands in the inventory the way a bounty now does.
+ */
+void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace quest = middleware::web_service::messages::opcode904;
+    quest::Request request{};
+    if (!quest::parse_request(message, request)) {
+        report_purchase(quest::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
+        return;
+    }
+    // The 16-bit slot field is where the click landed, and indexing sale rows with it granted
+    // armour mods. The 32-bit field is the real row; a body without one has never been captured,
+    // and guessing the slot in as a sale row would reproduce that exact wrong grant - so it is
+    // refused, and the refusal names the shape so a real capture can settle it.
+    if (!request.hasSaleIndex) {
+        report_purchase(quest::kOpcode,
+                        "fail",
+                        "sale_field_missing",
+                        request.vendorIndex,
+                        request.slotIndex,
+                        kUnavailableDefinitionIndex);
+        return;
+    }
+    const std::int32_t row = request.saleIndex;
+    std::uint16_t itemDefinitionIndex = 0;
+    const char* reason = "unknown";
+    // A row of -1 is the client saying this tile is not a sale row at all, rather than a row that
+    // failed to resolve, so it takes the installed array instead. Falling back to the slot as a
+    // sale row would grant whatever sits there, which is the wrong-item bug that made quests hand
+    // out armour mods.
+    const bool rowless = row < 0;
+    // A rowless 904 is an interaction reply rather than a purchase, and the rank-up reward tile is
+    // one: its reply names no sale row, so the slot field is the interaction it answered.
+    std::int32_t questCategoryIndex = -1;
+    const bool located =
+        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, itemDefinitionIndex)
+                : resolve_vendor_row(request.vendorIndex, row, itemDefinitionIndex,
+                                    questCategoryIndex, reason);
+    if (!located) {
+        report_purchase(quest::kOpcode,
+                        "fail",
+                        rowless ? "rowless_unresolved" : reason,
+                        request.vendorIndex,
+                        row,
+                        kUnavailableDefinitionIndex);
+        // A tile that names no row grants nothing, so say what this vendor does offer that would
+        // land in the Quests tab. That is the difference between "this click is broken" and "this
+        // click was never a quest".
+        if (rowless) {
+            report_pursuit_rows(request.vendorIndex);
+        }
+        return;
+    }
+    const RowOutcome settled = settle_vendor_row(message,
+                                                 quest::kOpcode,
+                                                 request.vendorIndex,
+                                                 row,
+                                                 questCategoryIndex,
+                                                 itemDefinitionIndex,
+                                                 outcome);
+    // The banner that offered this quest is answered only by a row whose offer is answered, and
+    // nothing else tells the client so: its picker keeps choosing the same interaction for as long
+    // as the quest is offerable. A bounty roll and an exchange leave the banner's own question
+    // unanswered, and a refused grant still owes the player its quest.
+    if (request.vendorIndex < 0
+        || request.vendorIndex >= static_cast<std::int32_t>(state::vendors::kVendorCapacity)) {
         return;
     }
+    const auto vendor = static_cast<std::uint16_t>(request.vendorIndex);
+    switch (settled) {
+    case RowOutcome::alreadyHeld:
+        // Answered some time ago, and nothing is left to commit, so the banner retires now. This
+        // is the re-click on a quest already in the tab.
+        (void)state::vendors::answer_shown(vendor);
+        break;
+    case RowOutcome::granted:
+        // Prepared, not committed. The answer rides the transaction and is written where the
+        // grant commits, so a mutation dropped on the way never buries a quest still owed.
+        outcome.answeredVendor = vendor;
+        break;
+    case RowOutcome::bountyRoll:
+    case RowOutcome::exchange:
+    case RowOutcome::grantRefused:
+        break;
+    }
 }
 
+/**
+ * Prepares one opcode-901 vendor purchase, for any Tower vendor.
+ *
+ * The request names a vendor row and a sale row. The sale row names an item-definition index, which
+ * is the same thing a Collections pull resolves its collectible to, so this resolves the row and
+ * hands over to the very same grant.
+ *
+ * Cost is deliberately not charged: the sale row's cost-bearing fields are still role-open, and the
+ * domain header warns against naming one a cost without its mutation reader.
+ */
+void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace purchase = middleware::web_service::messages::opcode901;
+    purchase::Request request{};
+    if (!purchase::parse_request(message, request)) {
+        report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
+        return;
+    }
+    std::uint16_t itemDefinitionIndex = 0;
+    const char* reason = "unknown";
+    std::int32_t categoryIndex = -1;
+    if (!resolve_vendor_row(
+            request.vendorIndex, request.saleIndex, itemDefinitionIndex, categoryIndex, reason)) {
+        report_purchase(purchase::kOpcode,
+                        "fail",
+                        reason,
+                        request.vendorIndex,
+                        request.saleIndex,
+                        kUnavailableDefinitionIndex);
+        return;
+    }
+    // Bounties, quest steps and tokens carry no collectible. The acquisition takes the sentinel
+    // rather than a made-up row, and both prepare and commit skip the collectible steps for it.
+    (void)settle_vendor_row(message,
+                            purchase::kOpcode,
+                            request.vendorIndex,
+                            request.saleIndex,
+                            categoryIndex,
+                            itemDefinitionIndex,
+                            outcome);
+}
 /** Reports a record-reward preparation failure. */
 void report_record_reward(const middleware::web_service::Message& message,
                           std::string_view reason,

+ 2 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -22,6 +22,8 @@ void mutate_item_state(const middleware::web_service::Message& message, Outcome&
 mutate_settings(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void dismantle_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 
 /** Persists an opcode-1801 Triumph claim and prepares its optional reward. */
 void claim_record(const middleware::web_service::Message& message, Outcome& outcome) noexcept;

+ 155 - 3
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -7,6 +7,7 @@
 #include <cstdio>
 
 #include "../../core/logging/log.h"
+#include "../../middleware/encoding/bit_reader.h"
 #include "../../middleware/web_service/messages/opcode1801.h"
 #include "../../middleware/web_service/messages/opcode1821.h"
 #include "../../middleware/web_service/messages/opcode1901.h"
@@ -21,6 +22,7 @@
 #include "../../middleware/web_service/messages/opcode702.h"
 #include "../../middleware/web_service/messages/opcode801.h"
 #include "../../middleware/web_service/messages/opcode901/opcode901_codec.h"
+#include "../../middleware/web_service/messages/opcode904/opcode904_codec.h"
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/account_state.h"
@@ -108,6 +110,14 @@ void note_character_writeback(const middleware::web_service::Message& message) n
     }
 }
 
+/** @return True when a purchase names the seasonal artifact vendor, which is answered here. */
+[[nodiscard]] bool names_artifact_vendor(const middleware::web_service::Message& message) noexcept {
+    namespace purchase_codec = middleware::web_service::messages::opcode901;
+    purchase_codec::Request purchase{};
+    return purchase_codec::parse_request(message, purchase)
+           && purchase.vendorIndex == kArtifactVendorIndex;
+}
+
 /**
  * Refuses one vendor purchase and answers it.
  * No award, cost or stock rule exists yet, so no purchase can succeed. The refusal must still be
@@ -248,6 +258,122 @@ bool encode_echo(const middleware::web_service::Message& message,
         message, ws::ResponseShape::generic, ws::StatusResponse{}, response, written);
 }
 
+/** Narrow semantic result from the prefix of reflected WS-701 schema 0x80807603. */
+struct ProfileSetupMarker {
+    bool present{};
+    bool completed{};
+};
+
+/** Reads the presence bit that precedes every optional WS-701 schema node. */
+[[nodiscard]] bool read_ws701_presence(middleware::encoding::bits::Reader& reader,
+                                       bool& present) noexcept {
+    std::uint64_t value = 0;
+    if (!reader.read(1, value)) {
+        return false;
+    }
+    present = value != 0;
+    return true;
+}
+
+/** Consumes one optional fixed-width field without retaining it. */
+[[nodiscard]] bool skip_ws701_optional(middleware::encoding::bits::Reader& reader,
+                                       std::size_t widthBits) noexcept {
+    bool present = false;
+    return read_ws701_presence(reader, present) && (!present || reader.skip(widthBits));
+}
+
+/**
+ * Reads only enough of WS-701 schema 0x80807603 to reach preference path 0.1.1.0.
+ *
+ * PR #71 maps that first preference scalar as the one-bit profile-setup marker. Everything after
+ * it belongs to the broader settings-write implementation and is deliberately left to that work.
+ * This function therefore validates the complete prefix, not the remainder of the request.
+ */
+[[nodiscard]] bool parse_profile_setup_marker(const middleware::web_service::Message& message,
+                                              ProfileSetupMarker& output) noexcept {
+    output = {};
+    if (message.opcode != middleware::web_service::messages::opcode701::kOpcode) {
+        return false;
+    }
+
+    middleware::encoding::bits::Reader reader(message.payload);
+    bool present = false;
+
+    // 0.0? client metadata.
+    if (!read_ws701_presence(reader, present)) {
+        return false;
+    }
+    if (present) {
+        // 0.0.0? [128] optional 64-bit publicity expiries.
+        bool publicityPresent = false;
+        if (!read_ws701_presence(reader, publicityPresent)) {
+            return false;
+        }
+        if (publicityPresent) {
+            for (std::size_t index = 0; index < 128; ++index) {
+                if (!skip_ws701_optional(reader, 64)) {
+                    return false;
+                }
+            }
+        }
+
+        // 0.0.1? [13] required 32-bit seen-message values.
+        bool seenMessagesPresent = false;
+        if (!read_ws701_presence(reader, seenMessagesPresent)
+            || (seenMessagesPresent && !reader.skip(13U * 32U))) {
+            return false;
+        }
+    }
+
+    // 0.1? account data.
+    bool accountPresent = false;
+    if (!read_ws701_presence(reader, accountPresent)) {
+        return false;
+    }
+    if (!accountPresent) {
+        return true;
+    }
+
+    // 0.1.0? [2] optional calibration vectors, each containing two required real32 values.
+    bool calibrationPresent = false;
+    if (!read_ws701_presence(reader, calibrationPresent)) {
+        return false;
+    }
+    if (calibrationPresent) {
+        for (std::size_t index = 0; index < 2; ++index) {
+            bool vectorPresent = false;
+            if (!read_ws701_presence(reader, vectorPresent)
+                || (vectorPresent && !reader.skip(2U * 32U))) {
+                return false;
+            }
+        }
+    }
+
+    // 0.1.1? preference record.
+    bool preferencesPresent = false;
+    if (!read_ws701_presence(reader, preferencesPresent)) {
+        return false;
+    }
+    if (!preferencesPresent) {
+        return true;
+    }
+
+    // 0.1.1.0? one-bit profile-setup marker.
+    if (!read_ws701_presence(reader, output.present)) {
+        return false;
+    }
+    if (!output.present) {
+        return true;
+    }
+
+    std::uint64_t completed = 0;
+    if (!reader.read(1, completed)) {
+        return false;
+    }
+    output.completed = completed != 0;
+    return true;
+}
+
 bool encode_resident_dependent_refusal(std::span<const std::byte> request,
                                        std::span<std::byte> response,
                                        std::size_t& written,
@@ -333,8 +459,11 @@ bool consume(std::span<const std::byte> request,
                || encode_echo(message, response, written);
     }
 
-    // Runs before the shared response-shape path, which would answer the success status.
-    if (message.opcode == middleware::web_service::messages::opcode901::kOpcode) {
+    // The artifact vendor is answered here. Every other vendor purchase falls through to the
+    // shared response-shape path, which runs the action and answers its status: an action that
+    // prepared no mutation is answered with the refused code.
+    if (message.opcode == middleware::web_service::messages::opcode901::kOpcode
+        && names_artifact_vendor(message)) {
         return purchase_artifact_mod(message, response, written, outcome)
                || refuse_purchase(message, response, written)
                || encode_echo(message, response, written);
@@ -357,6 +486,7 @@ bool consume(std::span<const std::byte> request,
     // a valid no-op heartbeat, so that one success is tracked separately from mutation presence.
     bool dispatched = true;
     bool acceptedWithoutMutation = false;
+    bool profileSetupRefused = false;
     if (message.opcode == middleware::web_service::messages::opcode1801::kOpcode) {
         claim_record(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode504::kOpcode) {
@@ -380,10 +510,32 @@ bool consume(std::span<const std::byte> request,
     } else if (message.opcode == middleware::web_service::messages::opcode701::kOpcode) {
         const state::SettingsUpdateDisposition disposition = mutate_settings(message, outcome);
         acceptedWithoutMutation = disposition == state::SettingsUpdateDisposition::acceptedNoChange;
+        // The completion marker is applied here. The shared status path below reports the result.
+        ProfileSetupMarker marker{};
+        const bool parsed = parse_profile_setup_marker(message, marker);
+        if (!parsed) {
+            // Preserve Sunrise's existing WS-701 success behavior outside this narrow feature.
+            // PR #71 owns complete settings-write validation and can later subsume this prefix.
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=ws701 stage=profile_setup result=ignored reason=prefix_parse");
+        } else if (marker.present && marker.completed) {
+            if (!state::complete_profile_setup()) {
+                profileSetupRefused = true;
+            } else {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::info,
+                                 "ev=ws701 stage=profile_setup result=complete marker=1");
+            }
+        }
     } else if (message.opcode == kItemAcquisitionOpcode) {
         acquire_item(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode2400::kOpcode) {
         claim_season_pass_reward(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode901::kOpcode) {
+        purchase_item(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode904::kOpcode) {
+        acquire_quest(message, outcome);
     } else {
         dispatched = false;
     }
@@ -393,7 +545,7 @@ bool consume(std::span<const std::byte> request,
     middleware::web_service::ResponseShape shape{};
     resolve_response_shape(message.opcode, shape);
     middleware::web_service::StatusResponse status{};
-    if (dispatched && !prepared && !acceptedWithoutMutation) {
+    if ((dispatched && !prepared && !acceptedWithoutMutation) || profileSetupRefused) {
         status.code = kRefusedStatus;
     }
     if (!middleware::web_service::encode_response(message, shape, status, response, written)) {

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

@@ -11,6 +11,7 @@
 
 #include "../../middleware/web_service/messages/opcode206.h"
 #include "../../state/runtime/runtime.h"
+#include "../../state/vendors/answered_interactions.h"
 
 namespace sunrise::server::web_service {
 
@@ -43,6 +44,14 @@ struct Outcome {
                                   std::unique_ptr<state::PendingSeasonPassReward>,
                                   std::unique_ptr<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};
 };
 
 /** Allocates only the selected mutation outside the request's already deep stack. */

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

@@ -200,6 +200,8 @@ struct AccountState {
     std::size_t profileItemCount{};
     std::array<CharacterState, kCharacterCapacity> characters{};
     std::size_t characterCount{};
+    /** True after this account has completed the client's one-time profile setup flow. */
+    bool profileSetupCompleted{};
     account::settings::AccountSettings settings;
 };
 

+ 69 - 0
Sunrise/src/state/account/pursuit_hold.cpp

@@ -0,0 +1,69 @@
+#include "pursuit_hold.h"
+
+#include <cstddef>
+
+#include "../../core/logging/log.h"
+#include "../build_data/runtime.h"
+#include "../runtime/runtime.h"
+#include "account_state.h"
+
+namespace sunrise::state::account {
+namespace {
+
+namespace detail_domain = build_data::items::details;
+
+/**
+ * Logs the fields the pursuit rule turns on, so a misclassification can be read off the values.
+ * @param itemDefinitionIndex Item being classified.
+ * @param detail Its configured detail row.
+ */
+void report_classification(std::uint16_t itemDefinitionIndex,
+                           const detail_domain::Definition& detail) noexcept {
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::debug,
+                      "ev=pursuit stage=classify item=%u bucket=%u slot=%d instanced=%u "
+                      "max_stack=%d",
+                      static_cast<unsigned>(itemDefinitionIndex),
+                      static_cast<unsigned>(detail.bucketId),
+                      detail.equipmentSlot.has_value() ? static_cast<int>(*detail.equipmentSlot)
+                                                       : -1,
+                      static_cast<unsigned>(detail.instancedDefinitionState),
+                      detail.maxStackSize);
+}
+
+} // namespace
+
+/** Reports whether an item is a pursuit the selected character already holds. */
+bool holds_pursuit(std::uint16_t itemDefinitionIndex) noexcept {
+    return holds_pursuit(account_snapshot(), itemDefinitionIndex);
+}
+
+/** The same rule, against an account view the caller already holds. */
+bool holds_pursuit(const AccountState& account, std::uint16_t itemDefinitionIndex) noexcept {
+    detail_domain::Definition detail{};
+    if (!build_data::find_configured_item_detail(itemDefinitionIndex, detail)) {
+        return false;
+    }
+    report_classification(itemDefinitionIndex, detail);
+    if (detail.equipmentSlot.has_value() || detail.maxStackSize > 1) {
+        return false;
+    }
+    build_data::items::Definition definition{};
+    if (!build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        const CharacterState& character = account.characters[index];
+        if (!character.selected) {
+            continue;
+        }
+        for (std::size_t item = 0; item < character.inventory.count; ++item) {
+            if (character.inventory.values[item].definitionHash == definition.definitionHash) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::state::account

+ 39 - 0
Sunrise/src/state/account/pursuit_hold.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <cstdint>
+
+#include "account_state.h"
+
+namespace sunrise::state::account {
+
+/**
+ * Reports whether an item is a pursuit the selected character already holds.
+ *
+ * A pursuit - quest, bounty, token - is unique per character. Two kinds of item are deliberately
+ * not pursuits, because holding several of each is legitimate: gear, told apart by carrying an
+ * equipment slot, and consumables and materials, told apart by declaring a stack larger than one.
+ * The stack size is what separates the second group, not the instanced state: a pursuit carries no
+ * instance data either, so it is marked stackable exactly as a consumable is.
+ *
+ * This lives in State because it mirrors the classification the client's native vendor-row gate
+ * applies locally when deciding whether a row is still offered, and the two must not drift.
+ *
+ * @param itemDefinitionIndex Item to classify.
+ * @return True when this is a pursuit the selected character already holds.
+ */
+[[nodiscard]] bool holds_pursuit(std::uint16_t itemDefinitionIndex) noexcept;
+
+/**
+ * The same rule, against an account view the caller already holds.
+ *
+ * Reading the account copies the whole of it, so a walk over many candidates - a bounty roll tests
+ * every item in the vendor's pool - takes one view and reuses it rather than copying per candidate.
+ *
+ * @param account Account view to test against.
+ * @param itemDefinitionIndex Item to classify.
+ * @return True when this is a pursuit that view's selected character already holds.
+ */
+[[nodiscard]] bool holds_pursuit(const AccountState& account,
+                                 std::uint16_t itemDefinitionIndex) noexcept;
+
+} // namespace sunrise::state::account

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

@@ -81,7 +81,7 @@ bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept
     record.rowIndex = value.rowIndex;
     record.itemIndex = value.itemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
-    record.installedIndex = value.installedIndex;
+    record.categoryIndex = value.categoryIndex;
     record.raw104 = value.raw104;
     record.raw108 = value.raw108;
     record.raw172 = value.raw172;
@@ -104,7 +104,7 @@ bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept
     value.rowIndex = record.rowIndex;
     value.itemIndex = record.itemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
-    value.installedIndex = record.installedIndex;
+    value.categoryIndex = record.categoryIndex;
     value.raw104 = record.raw104;
     value.raw108 = record.raw108;
     value.raw172 = record.raw172;

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

@@ -36,7 +36,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * 48: nodes and SObjects joined the unified cache, replacing their incomplete sidecar lifecycle.
  */
 // Upstream's format 45 and the PR's independent format 48 changes are both present.
-inline constexpr std::uint32_t kCacheFormatVersion = 58;
+inline constexpr std::uint32_t kCacheFormatVersion = 59;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -458,7 +458,7 @@ struct VendorSaleRowRecord {
     std::uint16_t rowIndex{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
-    std::int32_t installedIndex{};
+    std::int32_t categoryIndex{};
     std::uint32_t raw104{};
     std::uint32_t raw108{};
     std::int32_t raw172{};

+ 15 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp

@@ -106,6 +106,21 @@ bool grants_item(std::uint16_t itemDefinitionIndex) noexcept {
     return false;
 }
 
+/** Finds the collectible that grants one installed item row. */
+bool find_granting(std::uint16_t itemDefinitionIndex, std::uint16_t& collectibleIndex) noexcept {
+    if (itemDefinitionIndex == kUnavailableItemDefinitionIndex) {
+        return false;
+    }
+    const std::shared_lock guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.itemDefinitionIndex == itemDefinitionIndex) {
+            collectibleIndex = definition.collectibleIndex;
+            return true;
+        }
+    }
+    return false;
+}
+
 /** Copies the dense rows without exposing catalog storage. */
 bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
     const std::shared_lock guard(g_lock);

+ 25 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.h

@@ -11,6 +11,16 @@ namespace sunrise::state::build_data::collectibles {
 inline constexpr std::size_t kDefinitionCapacity = 1U << 15U;
 /** Some collectible rows deliberately do not resolve to an inventory item. */
 inline constexpr std::uint16_t kUnavailableItemDefinitionIndex = 0xFFFFU;
+
+/**
+ * Collectible row meaning "this item has no collectible".
+ *
+ * Vendor sale rows name an item, never a collectible, and bounties, quests and tokens have none at
+ * all. An acquisition carrying this index skips every collectible step - validation, the material
+ * charge, and the cost bookkeeping - and both prepare and commit must agree on it, so the
+ * consistency guard still holds rather than being bypassed.
+ */
+inline constexpr std::uint16_t kNoCollectibleIndex = 0xFFFEU;
 /** A collectible with no acquisition charge carries this native requirement-set sentinel. */
 inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
 
@@ -55,6 +65,21 @@ void clear() noexcept;
  */
 [[nodiscard]] bool grants_item(std::uint16_t itemDefinitionIndex) noexcept;
 
+/**
+ * Finds the collectible that grants one installed item row.
+ *
+ * The reverse of `find`. A vendor sale row names an item and never a collectible, while the
+ * acquisition state is keyed by collectible, so a purchase has to ask this. Walking the table under
+ * its own lock is what stops a caller copying all 32,768 rows to read one.
+ *
+ * @param itemDefinitionIndex Installed item-definition row.
+ * @param collectibleIndex Receives the first collectible naming that item, in native index order.
+ *        Left untouched when none does, so a caller's sentinel survives.
+ * @return True when a collectible grants that item.
+ */
+[[nodiscard]] bool find_granting(std::uint16_t itemDefinitionIndex,
+                                 std::uint16_t& collectibleIndex) noexcept;
+
 /** Copies every row in native collectible-index order. */
 [[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
 

+ 13 - 10
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp

@@ -2,16 +2,19 @@
 
 #include <algorithm>
 #include <atomic>
+#include <mutex>
+#include <shared_mutex>
 
 #include "../../../account/inventory/item_state.h"
 #include "../../../unlocks/definition.h"
 #include "../../table.h"
+#include "core/threading/srw_lock.h"
 #include "../details/definition.h"
 
 namespace sunrise::state::build_data::items::catalysts {
 namespace {
 
-Lock g_lock;
+core::threading::SrwLock g_lock;
 Table<Definition, kDefinitionCapacity> g_definitions;
 std::atomic<bool> g_completionEnabled{true};
 
@@ -130,7 +133,7 @@ std::atomic<bool> g_completionEnabled{true};
 } // namespace
 
 void clear() noexcept {
-    const Lock::Exclusive guard(g_lock);
+    const std::lock_guard guard(g_lock);
     g_definitions.clear();
 }
 
@@ -185,12 +188,12 @@ bool replace(std::span<const Definition> definitions) noexcept {
     if (!valid(definitions)) {
         return false;
     }
-    const Lock::Exclusive guard(g_lock);
+    const std::lock_guard guard(g_lock);
     return g_definitions.replace(definitions);
 }
 
 Result resolve(std::uint16_t itemDefinitionIndex) noexcept {
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
     if (definition == nullptr) {
         return {};
@@ -215,7 +218,7 @@ Result resolve(std::uint16_t itemDefinitionIndex) noexcept {
 std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
                              std::uint8_t socketLane,
                              std::uint16_t plugDefinitionIndex) noexcept {
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
     if (definition == nullptr || definition->availability != Availability::released
         || definition->socketLane != socketLane
@@ -228,7 +231,7 @@ std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
 }
 
 bool owns_lane(std::uint16_t itemDefinitionIndex, std::uint8_t socketLane) noexcept {
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
     return definition != nullptr && definition->socketLane == socketLane;
 }
@@ -265,7 +268,7 @@ bool append_investment_overrides(state::Family5State& family) noexcept {
     }
 
     state::Family5State candidate = family;
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     for (const Definition& definition : g_definitions.rows()) {
         if (definition.availability != Availability::released) {
             continue;
@@ -293,7 +296,7 @@ bool append_objective_completions(std::span<std::int32_t> values) noexcept {
     if (!completion_enabled()) {
         return true;
     }
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     for (const Definition& definition : g_definitions.rows()) {
         if (definition.availability == Availability::released
             && definition.objective.definitionIndex != kUnavailableObjectiveIndex
@@ -314,12 +317,12 @@ bool append_objective_completions(std::span<std::int32_t> values) noexcept {
 
 bool snapshot(std::span<Definition> output, std::size_t& outputCount) noexcept {
     outputCount = 0;
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     return g_definitions.snapshot(output, outputCount);
 }
 
 std::size_t count() noexcept {
-    const Lock::Shared guard(g_lock);
+    const std::shared_lock guard(g_lock);
     return g_definitions.count();
 }
 

+ 8 - 6
Sunrise/src/state/build_data/vendors/definition.h

@@ -9,9 +9,9 @@ namespace sunrise::state::build_data::vendors {
 /** Rows of the installed vendor index. The live table has 511. */
 inline constexpr std::size_t kIndexCapacity = 512;
 /** Vendor definitions this catalog holds rows for. A definition is read only when asked for. */
-inline constexpr std::size_t kDefinitionCapacity = 32;
+inline constexpr std::size_t kDefinitionCapacity = 48;
 /** Sale rows across every held definition. One installed definition declares 277. */
-inline constexpr std::size_t kSaleRowCapacity = 4096;
+inline constexpr std::size_t kSaleRowCapacity = 8192;
 /** Installed rows across every held definition. One installed definition declares 43. */
 inline constexpr std::size_t kInstalledRowCapacity = 2048;
 
@@ -37,8 +37,8 @@ inline constexpr std::uint32_t kSaleRowClass = 0x80807861U;
 
 /** Sale row +176 carries this when the row names no secondary item. */
 inline constexpr std::uint16_t kAbsentSecondaryItem = 0xFFFFU;
-/** Sale row +100 carries this when it selects no installed row. The client tests for it. */
-inline constexpr std::int32_t kAbsentInstalledIndex = -1;
+/** Sale row +100 carries this when the row belongs to no category. The client tests for it. */
+inline constexpr std::int32_t kAbsentCategoryIndex = -1;
 
 /** One row of the installed vendor index, which maps a vendor hash to its definition tag. */
 struct IndexEntry {
@@ -93,9 +93,11 @@ struct SaleRow {
     std::uint16_t itemIndex{};
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     std::uint16_t secondaryItemIndex{};
-    /** Row +100. Row of the owning definition's installed array, and of a parallel runtime table.
+    /**
+     * Row +100. The row's vendor category, established by correlating 3,304 rows against the
+     * manifest. The catalog bounds it by the installed count, as it always has.
      */
-    std::int32_t installedIndex{};
+    std::int32_t categoryIndex{};
     /** Row +104, raw f32 bits. Role open. */
     std::uint32_t raw104{};
     /** Row +108. Role open. */

+ 35 - 3
Sunrise/src/state/build_data/vendors/vendor_catalog.cpp

@@ -90,10 +90,10 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
                                        std::span<const SaleRow> saleRows) noexcept {
     for (std::size_t row = 0; row < definition.saleCount; ++row) {
         const SaleRow& value = saleRows[definition.saleRowOffset + row];
-        // Row +100 selects an installed row, so it is bounded before a reader strides with it.
+        // Row +100 is bounded by the installed count before any reader strides with it.
         const bool selects =
-            value.installedIndex == kAbsentInstalledIndex
-            || (value.installedIndex >= 0 && value.installedIndex < definition.installedCount);
+            value.categoryIndex == kAbsentCategoryIndex
+            || (value.categoryIndex >= 0 && value.categoryIndex < definition.installedCount);
         if (value.vendorIndex != definition.index || value.rowIndex != row || !selects) {
             return false;
         }
@@ -265,6 +265,22 @@ bool sale_rows(const Definition& definition,
         g_saleRows.rows(), definition.saleRowOffset, definition.saleCount, output, count);
 }
 
+/** Reads one sale row of one definition. */
+bool sale_row(const Definition& definition, std::size_t row, SaleRow& output) noexcept {
+    output = {};
+    if (row >= definition.saleCount) {
+        return false;
+    }
+    const std::shared_lock guard(g_lock);
+    const auto bank = g_saleRows.rows();
+    const std::size_t at = static_cast<std::size_t>(definition.saleRowOffset) + row;
+    if (at >= bank.size()) {
+        return false;
+    }
+    output = bank[at];
+    return true;
+}
+
 /** Copies the installed rows one definition owns. */
 bool installed_rows(const Definition& definition,
                     std::span<InstalledRow> output,
@@ -277,6 +293,22 @@ bool installed_rows(const Definition& definition,
                       count);
 }
 
+/** Reads one installed row of one definition. */
+bool installed_row(const Definition& definition, std::size_t row, InstalledRow& output) noexcept {
+    output = {};
+    if (row >= definition.installedCount) {
+        return false;
+    }
+    const std::shared_lock guard(g_lock);
+    const auto bank = g_installedRows.rows();
+    const std::size_t at = static_cast<std::size_t>(definition.installedRowOffset) + row;
+    if (at >= bank.size()) {
+        return false;
+    }
+    output = bank[at];
+    return true;
+}
+
 /** Copies every index row in ascending index order. */
 bool snapshot_index(std::span<IndexEntry> output, std::size_t& count) noexcept {
     const std::shared_lock guard(g_lock);

+ 24 - 0
Sunrise/src/state/build_data/vendors/vendor_catalog.h

@@ -72,6 +72,20 @@ void clear() noexcept;
 [[nodiscard]] bool
 sale_rows(const Definition& definition, std::span<SaleRow> output, std::size_t& count) noexcept;
 
+/**
+ * Reads one sale row of one definition.
+ *
+ * A purchase names a single row, and copying the definition's whole range to read it costs a
+ * bank-sized buffer per caller. This reads the one row under the catalog lock.
+ *
+ * @param definition Definition whose range is read.
+ * @param row Row ordinal inside that definition.
+ * @param output Receives the row, or a cleared row when the definition does not own it.
+ * @return True when the definition owns that row.
+ */
+[[nodiscard]] bool
+sale_row(const Definition& definition, std::size_t row, SaleRow& output) noexcept;
+
 /**
  * Copies the installed rows one definition owns, in row order.
  * @param definition Definition whose range is copied.
@@ -83,6 +97,16 @@ sale_rows(const Definition& definition, std::span<SaleRow> output, std::size_t&
                                   std::span<InstalledRow> output,
                                   std::size_t& count) noexcept;
 
+/**
+ * Reads one installed row of one definition, under the catalog lock.
+ * @param definition Definition whose range is read.
+ * @param row Row ordinal inside that definition.
+ * @param output Receives the row, or a cleared row when the definition does not own it.
+ * @return True when the definition owns that row.
+ */
+[[nodiscard]] bool
+installed_row(const Definition& definition, std::size_t row, InstalledRow& output) noexcept;
+
 /**
  * Copies every index row in ascending index order.
  * @param output Caller-owned fixed row storage.

+ 52 - 0
Sunrise/src/state/runtime/runtime.h

@@ -145,6 +145,15 @@ struct PendingItemAcquisition {
     bool prepared{};
 };
 
+/** One profile row an exchange changed, named the way the account's change ring names it. */
+struct ProfileStackChange {
+    std::int32_t mutationSerial{};
+    std::int32_t afterQuantity{};
+};
+
+/** Rows one exchange may announce. Shader recycling announces two: Glimmer and Legendary Shards. */
+inline constexpr std::size_t kProfileStackChangeCapacity = 4;
+
 /** Prepared account-profile stack insertion kept private until its reply and account upsert fit. */
 struct PendingProfileItemAcquisition {
     /** Exact profile inventory observed while preparing the mutation. */
@@ -168,6 +177,14 @@ struct PendingProfileItemAcquisition {
     std::uint16_t collectibleIndex{};
     std::uint8_t bucketId{};
     std::uint8_t materialRequirementCount{};
+    /**
+     * Rows this mutation announces to the account's change ring, which is what draws the floating
+     * "+5 Legendary Shards" the Client shows. Empty for an ordinary acquisition, which announces
+     * its one acquired row instead; non-empty marks this an exchange, whose quantities move by
+     * more than one and whose row count is not fixed at one.
+     */
+    std::array<ProfileStackChange, kProfileStackChangeCapacity> changes{};
+    std::size_t changeCount{};
     /** True only for installed profile mod/shader rows materialized as Family-4 residents. */
     bool actionSource{};
     bool appended{};
@@ -445,6 +462,14 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept;
  */
 [[nodiscard]] bool set_primary_soid(std::uint64_t primarySoid) noexcept;
 
+/**
+ * Permanently closes the process-local one-time profile-setup gate for the active account.
+ *
+ * The transition is monotonic: repeated profile-setting writes after completion are harmless.
+ * @return False only when no complete active account can be updated.
+ */
+[[nodiscard]] bool complete_profile_setup() noexcept;
+
 /**
  * Moves the selection to one authored character.
  * The Client names its pick only in the select-character request, so this is where a player's
@@ -693,6 +718,33 @@ prepare_settings_update(const account::settings::SettingsDelta& delta,
  * @return True when the after-image was already current or was committed successfully.
  */
 [[nodiscard]] bool commit_settings_update(PendingSettingsUpdate& mutation) noexcept;
+/** One credited side of a vendor exchange: an authored profile stack and how much to add. */
+struct ProfileExchangePayout {
+    std::uint32_t definitionHash{};
+    std::int32_t quantity{};
+};
+
+/**
+ * Prepares one vendor recycle row: charges the stack it names and credits what it pays out.
+ *
+ * This rides the profile-stack mutation rather than writing State directly, because the Client is
+ * only told about a currency gain by the account object's change ring - a row named there is what
+ * draws the floating "+5 Legendary Shards"; a direct write with a resync moves the numbers and
+ * announces nothing. Every credited row is announced under a fresh mutation serial; the charged row
+ * is not. Only an already-held payout stack is credited, since the currencies a recycle pays into
+ * are authored from the start. `preview_profile_item_acquisition` and
+ * `commit_profile_item_acquisition` carry the result the rest of the way.
+ *
+ * @param costDefinitionHash Stack the row charges against.
+ * @param costQuantity Units of it the row consumes.
+ * @param payouts Stacks to credit, each clamped to its own native stack limit.
+ * @param mutation Gets the checked profile before/after images without changing account State.
+ * @return True only when the charge and every credit fit and the whole account stayed valid.
+ */
+[[nodiscard]] bool prepare_vendor_exchange(std::uint32_t costDefinitionHash,
+                                           std::int32_t costQuantity,
+                                           std::span<const ProfileExchangePayout> payouts,
+                                           PendingProfileItemAcquisition& mutation) noexcept;
 
 /** @return A copy of the active account state, read under the lock. */
 [[nodiscard]] AccountState account_snapshot() noexcept;

+ 45 - 15
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -126,20 +126,33 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::items::Definition grantedDefinition{};
+    // A vendor purchase names an item, not a collectible, so the collectible steps are skipped
+    // rather than faked. The item is still validated, just by its own hash.
+    const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
-                                                   grantedDefinition)
-        || grantedDefinition.definitionHash != definitionHash) {
+        || !valid_profile_inventory(account)) {
+        return false;
+    }
+    if (hasCollectible) {
+        if (!build_data::find_collectible_definition(collectibleIndex, collectible)
+            || collectible.itemDefinitionIndex
+                   == build_data::collectibles::kUnavailableItemDefinitionIndex
+            || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
+                                                       grantedDefinition)
+            || grantedDefinition.definitionHash != definitionHash) {
+            return false;
+        }
+    } else if (!build_data::find_item_definition_hash(definitionHash, grantedDefinition)
+               || grantedDefinition.definitionHash != definitionHash) {
         return false;
     }
 
-    AccountState chargedAccount{};
+    AccountState chargedAccount = account;
     bool profileChanged = false;
-    if (!apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
+    // Nothing is charged without a collectible: the cost lives on the collectible's material
+    // requirements, and a sale row's own cost fields are still role-open.
+    if (hasCollectible
+        && !apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
         return false;
     }
 
@@ -312,6 +325,15 @@ namespace {
                                                         definition);
     }
 
+    if (mutation.collectibleIndex == build_data::collectibles::kNoCollectibleIndex) {
+        // The guard is that prepare and commit agree. Without a collectible they agree on there
+        // being none, which means both cost fields must still be clear.
+        build_data::items::Definition definition{};
+        return mutation.materialRequirementSetHash == 0 && mutation.materialRequirementCount == 0
+               && build_data::find_item_definition_hash(mutation.acquiredDefinitionHash,
+                                                        definition);
+    }
+
     build_data::collectibles::Definition collectible{};
     build_data::items::Definition definition{};
     return build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
@@ -684,16 +706,24 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     item_details::Definition detail{};
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
         || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || !resolve_profile_item(collectible.itemDefinitionIndex, item, detail)
+        || !build_data::find_item_definition_hash(definitionHash, item)
+        // The item resolves first, because the collectible cross-check reads it. With no
+        // collectible the item's own hash is the whole check.
+        || (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
+            && (!build_data::find_collectible_definition(collectibleIndex, collectible)
+                || collectible.itemDefinitionIndex
+                       == build_data::collectibles::kUnavailableItemDefinitionIndex
+                || item.definitionIndex != collectible.itemDefinitionIndex))
+        || !resolve_profile_item(item.definitionIndex, item, detail)
         || item.definitionHash != definitionHash) {
         return false;
     }
-    AccountState chargedAccount{};
+    AccountState chargedAccount = account;
     bool materialsChanged = false;
-    if (!apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
+    // Nothing is charged without a collectible: the cost lives on the collectible's material
+    // requirements, and a sale row's own cost fields are still role-open.
+    if (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
+        && !apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
         return false;
     }
     (void)materialsChanged;

+ 20 - 29
Sunrise/src/state/runtime/state_account_dismantle_staging.cpp

@@ -500,6 +500,12 @@ apply_dismantle_rewards(const AccountState& before,
         return false;
     }
 
+    // Every survivor must still resolve to the same equipment lane, and how many the removal
+    // shifted is reported so a dismantle that rearranged a bucket stays visible. Their ordering
+    // tokens are deliberately left alone: the serial on an unequipped row is also the Client's
+    // ordering token for its bucket, and a fresh serial moves the item to the first cell. The rows
+    // below a dismantled item shift up only because the array closed the gap; stamping them
+    // reshuffled the bucket on screen. The whole character is republished, so the shift still lands.
     std::size_t movedItemCount = 0;
     for (std::size_t index = 0; index < after.inventory.count; ++index) {
         const std::uint64_t survivorSoid = after.inventory.values[index].instanceSoid;
@@ -515,31 +521,6 @@ apply_dismantle_rewards(const AccountState& before,
         movedItemCount += static_cast<std::size_t>(beforeRow != afterRow);
     }
 
-    // The serial is signed on the wire, so it must stay inside the positive int32 range.
-    constexpr std::uint32_t kMaximumInventorySerial =
-        static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
-    if (after.nextInventorySerial > kMaximumInventorySerial
-        || movedItemCount > kMaximumInventorySerial - after.nextInventorySerial) {
-        return false;
-    }
-
-    for (std::size_t index = 0; index < after.inventory.count; ++index) {
-        const std::uint64_t survivorSoid = after.inventory.values[index].instanceSoid;
-        std::uint16_t beforeRow = 0;
-        std::uint16_t afterRow = 0;
-        std::uint8_t beforeSlot = 0;
-        std::uint8_t afterSlot = 0;
-        if (!find_unequipped_row(beforeLoadout, survivorSoid, beforeRow, beforeSlot)
-            || !find_unequipped_row(placedAfter, survivorSoid, afterRow, afterSlot)
-            || beforeSlot != afterSlot) {
-            return false;
-        }
-        if (beforeRow != afterRow) {
-            after.inventory.values[index].mutationSerial =
-                static_cast<std::int32_t>(after.nextInventorySerial++);
-        }
-    }
-
     candidate.characters[characterIndex] = after;
     family4_loadout::ResolvedLoadout checkedAfter{};
     if (!account::valid(candidate)
@@ -570,10 +551,20 @@ apply_dismantle_rewards(const AccountState& before,
         || dismantledDetail.definitionIndex != dismantledDefinition.definitionIndex
         || dismantledDetail.definitionHash != dismantledDefinition.definitionHash
         || dismantledDetail.bucketId != dismantledDefinition.bucketId
-        || dismantledDetail.instancedDefinitionState
-               != item_details::InstancedDefinitionState::instanced
-        || !dismantledDetail.equipmentSlot.has_value()
-        || static_cast<std::uint8_t>(*dismantledDetail.equipmentSlot) != dismantledSlot) {
+        // A quest step is authored stackable, not instanced - in the pursuit bucket only bounties
+        // and containers set the instanced flag - so it is accepted while the row holds exactly
+        // one. A larger stack stays refused: decrementing one is a different mutation.
+        || (dismantledDetail.instancedDefinitionState
+                != item_details::InstancedDefinitionState::instanced
+            && dismantledItem.quantity != 1)
+        // A pursuit names no equipment slot, because nothing equips it. The loadout resolver stands
+        // such an item at slot zero, so compare against that default rather than demand a slot.
+        // Native slot zero is the subclass slot, which `gear_class_of` maps to no gear class, so a
+        // dismantled pursuit pays out nothing.
+        || (dismantledDetail.equipmentSlot.has_value()
+                ? static_cast<std::uint8_t>(*dismantledDetail.equipmentSlot)
+                : std::uint8_t{0})
+               != dismantledSlot) {
         return false;
     }
 

+ 189 - 1
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -1,10 +1,13 @@
-/** Profile inventory validation and material charges. */
+/** Profile inventory validation and material charges. */
 
 #include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdint>
 #include <limits>
+#include <span>
+#include <string_view>
+#include <utility>
 
 #include "../build_data/runtime.h"
 #include "runtime.h"
@@ -358,6 +361,64 @@ apply_action_materials(const AccountState& before,
 /** @return True when a pending profile acquisition carries canonical dense before/after images. */
 [[nodiscard]] bool
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept {
+    // An exchange is the other shape this mutation carries. Its quantities move by more than one
+    // and it changes more than one row, so the single-increment rules below cannot describe it -
+    // they exist to pin the Collections pull, which is the only thing that should reach them.
+    if (mutation.changeCount != 0) {
+        if (!mutation.prepared || mutation.accountSoid == 0 || mutation.actionSource
+            || mutation.appended || mutation.acquiredInstanceSoid != 0
+            || mutation.acquiredDefinitionHash == authored_inventory::kNoDefinitionHash
+            || mutation.changeCount > mutation.changes.size()
+            || mutation.expectedItemCount > authored_inventory::kProfileItemCapacity
+            || mutation.afterItemCount > authored_inventory::kProfileItemCapacity
+            || mutation.afterItemCount == 0) {
+            return false;
+        }
+        for (std::size_t index = 0; index < mutation.beforeItems.size(); ++index) {
+            const authored_inventory::ProfileItem& before = mutation.beforeItems[index];
+            const authored_inventory::ProfileItem& after = mutation.afterItems[index];
+            if (index >= mutation.expectedItemCount
+                && (before.instanceSoid != 0 || before.definitionHash != 0 || before.quantity != 0
+                    || before.mutationSerial != 0)) {
+                return false;
+            }
+            if (index >= mutation.afterItemCount
+                && (after.instanceSoid != 0 || after.definitionHash != 0 || after.quantity != 0
+                    || after.mutationSerial != 0)) {
+                return false;
+            }
+        }
+        // Every announced row has to exist exactly once in the after-image, carrying the serial and
+        // quantity the change names. The account's change ring points at rows by serial, so a
+        // serial naming no row or two rows would announce a gain the Client cannot resolve.
+        for (std::size_t change = 0; change < mutation.changeCount; ++change) {
+            const ProfileStackChange& announced = mutation.changes[change];
+            if (announced.mutationSerial <= 0 || announced.afterQuantity <= 0) {
+                return false;
+            }
+            // Two changes naming one row would announce the same gain twice, and the ring has no
+            // way to say they meant different things.
+            for (std::size_t earlier = 0; earlier < change; ++earlier) {
+                if (mutation.changes[earlier].mutationSerial == announced.mutationSerial) {
+                    return false;
+                }
+            }
+            std::size_t matches = 0;
+            for (std::size_t index = 0; index < mutation.afterItemCount; ++index) {
+                if (mutation.afterItems[index].mutationSerial != announced.mutationSerial) {
+                    continue;
+                }
+                if (mutation.afterItems[index].quantity != announced.afterQuantity) {
+                    return false;
+                }
+                ++matches;
+            }
+            if (matches != 1) {
+                return false;
+            }
+        }
+        return true;
+    }
     if (!mutation.prepared || mutation.accountSoid == 0
         || mutation.actionSource != (mutation.acquiredInstanceSoid != 0)
         || mutation.acquiredDefinitionHash == authored_inventory::kNoDefinitionHash
@@ -430,6 +491,15 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
         || !same_profile_inventory(current, mutation.beforeItems, mutation.expectedItemCount)) {
         return false;
     }
+    // An exchange names no collectible, no bucket and no single acquired row, so none of the
+    // acquisition's definition checks apply to it. Its after-image was already checked whole when
+    // it was prepared, and the shape check above proved every announced row is in it.
+    if (mutation.changeCount != 0) {
+        after = current;
+        after.profileItems = mutation.afterItems;
+        after.profileItemCount = mutation.afterItemCount;
+        return account::valid(after) && valid_profile_inventory(after);
+    }
     item_details::Definition detail{};
     inventory_buckets::Descriptor bucket{};
     build_data::items::Definition item{};
@@ -442,6 +512,12 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
             || mutation.materialRequirementCount != 0) {
             return false;
         }
+    } else if (mutation.collectibleIndex == build_data::collectibles::kNoCollectibleIndex) {
+        // A vendor purchase names an item, never a collectible, and arrives with the sentinel.
+        // With no collectible to hold them, both cost fields must still be clear.
+        if (mutation.materialRequirementSetHash != 0 || mutation.materialRequirementCount != 0) {
+            return false;
+        }
     } else {
         build_data::collectibles::Definition collectible{};
         if (!build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
@@ -472,4 +548,116 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
 }
 
 } // namespace runtime::detail
+
+/** Prepares one vendor recycle row: charges the stack it names and credits what it pays out. */
+bool prepare_vendor_exchange(std::uint32_t costDefinitionHash,
+                             std::int32_t costQuantity,
+                             std::span<const ProfileExchangePayout> payouts,
+                             PendingProfileItemAcquisition& mutation) noexcept {
+    namespace authored_inventory = account::inventory;
+    namespace item_details = build_data::items::details;
+    mutation = {};
+    if (costDefinitionHash == authored_inventory::kNoDefinitionHash || costDefinitionHash == 0
+        || costQuantity <= 0 || payouts.empty() || payouts.size() > kProfileStackChangeCapacity) {
+        return false;
+    }
+    const AccountState account = account_snapshot();
+    if (!account::valid(account) || account.primarySoid == 0) {
+        return false;
+    }
+    const auto stack_limit = [](std::uint32_t definitionHash, std::int32_t& limit) noexcept {
+        build_data::items::Definition definition{};
+        item_details::Definition detail{};
+        if (!build_data::find_item_definition_hash(definitionHash, definition)
+            || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
+            || detail.maxStackSize <= 0) {
+            return false;
+        }
+        limit = detail.maxStackSize;
+        return true;
+    };
+    const auto find_stack = [](const AccountState& state, std::uint32_t definitionHash) noexcept {
+        std::size_t at = state.profileItemCount;
+        for (std::size_t index = 0; index < state.profileItemCount; ++index) {
+            if (state.profileItems[index].definitionHash == definitionHash) {
+                at = index;
+                break;
+            }
+        }
+        return at;
+    };
+
+    AccountState after = account;
+    const std::size_t costIndex = find_stack(after, costDefinitionHash);
+    if (costIndex >= after.profileItemCount
+        || after.profileItems[costIndex].quantity < costQuantity) {
+        return false;
+    }
+    // The charged row keeps its ordering token. Only a gain is announced, and the decrement is
+    // read straight off the republished account object, so bumping it would buy nothing and would
+    // move the charged stack to the front of its bucket for no reason the player asked for.
+    after.profileItems[costIndex].quantity -= costQuantity;
+
+    // Serials rise from the greatest already in the profile, so every announced row is unique and
+    // no existing row is displaced in the Client's ordering.
+    std::int32_t serial = 0;
+    for (std::size_t index = 0; index < after.profileItemCount; ++index) {
+        serial = (std::max)(serial, after.profileItems[index].mutationSerial);
+    }
+    if (serial > (std::numeric_limits<std::int32_t>::max)()
+                     - static_cast<std::int32_t>(payouts.size())) {
+        return false;
+    }
+    std::size_t changeCount = 0;
+    for (const ProfileExchangePayout& payout : payouts) {
+        std::int32_t limit = 0;
+        const std::size_t at = find_stack(after, payout.definitionHash);
+        // Paying back into the stack being charged is refused rather than netted out. It says
+        // nothing a recycle could mean, and it would leave the charged row's emptiness decided by
+        // payout order - the row is removed when the charge empties it, and a credit arriving
+        // afterwards would be crediting a row that is about to leave the array.
+        if (payout.quantity <= 0 || payout.definitionHash == costDefinitionHash
+            || !stack_limit(payout.definitionHash, limit) || at >= after.profileItemCount) {
+            return false;
+        }
+        // A currency already at its native cap takes nothing, which is the same outcome the Client
+        // reports as "your Glimmer is full" rather than a failed exchange.
+        const std::int32_t room = (std::max)(limit - after.profileItems[at].quantity, 0);
+        const std::int32_t credited = (std::min)(payout.quantity, room);
+        if (credited == 0) {
+            continue;
+        }
+        after.profileItems[at].quantity += credited;
+        after.profileItems[at].mutationSerial = ++serial;
+        mutation.changes[changeCount++] = {after.profileItems[at].mutationSerial,
+                                           after.profileItems[at].quantity};
+    }
+    // Nothing to announce means nothing was credited, and charging for that would be theft.
+    if (changeCount == 0) {
+        return false;
+    }
+    // A stack the charge emptied has to leave the array, because a zero-quantity row is not a valid
+    // profile row. It is removed last so the credited rows above were found at their real indices.
+    if (after.profileItems[costIndex].quantity == 0) {
+        for (std::size_t index = costIndex; index + 1U < after.profileItemCount; ++index) {
+            after.profileItems[index] = after.profileItems[index + 1U];
+        }
+        --after.profileItemCount;
+        after.profileItems[after.profileItemCount] = {};
+    }
+    if (!account::valid(after) || !runtime::detail::valid_profile_inventory(after)) {
+        return false;
+    }
+
+    mutation.beforeItems = account.profileItems;
+    mutation.afterItems = after.profileItems;
+    mutation.accountSoid = account.primarySoid;
+    mutation.acquiredDefinitionHash = costDefinitionHash;
+    mutation.expectedItemCount = account.profileItemCount;
+    mutation.afterItemCount = after.profileItemCount;
+    mutation.changeCount = changeCount;
+    mutation.prepared = true;
+    return true;
+}
+
 } // namespace sunrise::state

+ 21 - 0
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -184,6 +184,27 @@ bool set_primary_soid(std::uint64_t primarySoid) noexcept {
     return true;
 }
 
+/** Closes the account's one-time profile-setup gate. */
+bool complete_profile_setup() noexcept {
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState& accountState = runtime::storage::g_state.account;
+    if (accountState.primarySoid == 0 || !account::valid(accountState)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+
+    const bool changed = !accountState.profileSetupCompleted;
+    accountState.profileSetupCompleted = true;
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+
+    if (changed) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::info,
+                         "ev=profile_setup stage=complete result=ok");
+    }
+    return true;
+}
+
 /** Moves the selection to one authored character. */
 bool set_selected_character(std::uint64_t characterSoid, bool& changed) noexcept {
     changed = false;

+ 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

+ 9 - 0
Sunrise/src/steam/runtime/steam_lifecycle.cpp

@@ -3,6 +3,7 @@
 #include <atomic>
 
 #include "../../client/graphics/wine_compat.h"
+#include "../../client/hooks/bootflow/bootflow_texture_override.h"
 #include "../../client/hooks/egress/runtime.h"
 #include "../../client/hooks/package_trust/package_trust_bypass.h"
 #include "../../client/runtime/runtime.h"
@@ -76,6 +77,14 @@ bool initialize(void* module) noexcept {
             (void)core::shutdown();
             return false;
         }
+        // Bootflow GPU entries can load before the first Steam callback pump. The decoded-entry
+        // override must therefore attach here while the stock `_unp1` package remains registered
+        // through its native path.
+        if (!client::hooks::bootflow::texture_override::install(module)) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::warn,
+                             "ev=steam_init stage=bootflow_texture result=fail");
+        }
         advance_context_generation();
         g_initialized.store(true, std::memory_order_release);
         core::log::write(