135 Commitit dc1e527e40 ... a57dc9a9e7

Tekijä SHA1 Viesti Päivämäärä
  stan a57dc9a9e7 Merge pull request #84 from Nyxaraa/triumph-work-code 4 päivää sitten
  stan fd1c441026 Follow master's #75 and #87 shapes in the merged web and push paths 4 päivää sitten
  stan 74ec77add4 Merge remote-tracking branch 'origin/master' into triumph-work-code 4 päivää sitten
  stan 571fa8a36c Guard the new build-data catalogs with SrwLock 4 päivää sitten
  stan 17376ccb6e Merge remote-tracking branch 'origin/master' into triumph-work-code 4 päivää sitten
  stan fca0e86f19 Merge pull request #53 from zeex64/feat_runtime_bootflow 4 päivää sitten
  stan c70e01b6a5 Merge pull request #97 from stanuwu/fix/catalyst-catalog-lock 4 päivää sitten
  stan 2725bba8e6 Fix master link: catalyst catalog uses the deleted build_data Lock 4 päivää sitten
  stan f654a85318 Merge pull request #75 from ehkogh/master 4 päivää sitten
  stan f63357d10f Merge pull request #87 from chnsw/vendor-interactions 4 päivää sitten
  stan 7236a22a12 Rebase fixes: cache format 48 and the SrwLock guard form 4 päivää sitten
  ehko 0bf0ccec41 implements basic profile_setup web service 2 viikkoa sitten
  chnsw 5d10448ec4 Ship reference copies of the vendor rule files 6 päivää sitten
  chnsw 328ddf35ce Buy from a vendor: purchases, quests, bounties and recycling 6 päivää sitten
  chnsw 2ea9b1b5df Answer a vendor banner where its grant commits, from a list State owns 6 päivää sitten
  chnsw 03fb559dd5 Grant an item that no collectible owns, and prepare a vendor exchange on the profile stacks 6 päivää sitten
  chnsw 33b3a7ac65 Let a pursuit be placed, held and discarded 6 päivää sitten
  FourthVolt 188955ff81 Add files via upload 2 viikkoa sitten
  zeex64 11f4008ab5 Delete build-release directory 3 viikkoa sitten
  zeex64 f1ac39ae6a feat(bootflow): add custom textures and menu artwork 3 viikkoa sitten
  zeex64 114f97f880 feat: add bootflow texture toggle 3 viikkoa sitten
  zeex64 62b138a6df feat: add runtime bootflow texture overrides 3 viikkoa sitten
  chnsw 00dad87249 Publish the vendor catalog for vendors named by hash, and survive a definition that does not fit 6 päivää sitten
  chnsw 14c558bb8b Read authored rule files beside settings.json, and log with a format string 6 päivää sitten
  stan 3ca8d43863 Merge pull request #76 from lukezeman/fix/membership-probe-shutdown 4 päivää sitten
  stan bf36864c30 Merge pull request #80 from ocucor/master 4 päivää sitten
  stan 14f7eb38a9 Merge pull request #48 from Nyxaraa/emote-wheel 4 päivää sitten
  stan 99edd4b89b Merge pull request #71 from gagefulwood/settings-state 4 päivää sitten
  stan bc2c0bc184 Merge pull request #85 from yazan-albaiz/fix/exotic_catalyst_investment_state 4 päivää sitten
  stan 22c405cb02 Merge pull request #90 from SkyzerFlyzer/perf/rarest-byte-anchor 4 päivää sitten
  stan a1a7968fce Merge pull request #91 from Polkm105/threading-improvements 4 päivää sitten
  stan eb4727bcd4 Rebase fixes: build under v145 and restore slot connection id 4 päivää sitten
  stan 4757318848 Restore the entity_authority alias the release path uses 4 päivää sitten
  y9522 79190010be fix(investment): restore catalyst presentation state 2 viikkoa sitten
  Joe McNally adaf61ac8b WIP: Last Wish raid research -- entity pool, ambassador slot, auth bodies 1 viikko sitten
  y9522 9b8137b365 fix(cache): advance catalyst cache format 2 viikkoa sitten
  y9522 7fa4001853 fix(investment): resolve final catalyst review findings 2 viikkoa sitten
  y9522 0dda0ac948 fix(investment): address catalyst review findings 2 viikkoa sitten
  Kenny Mecham cc235cf67e Converting steam/runtime 2 viikkoa sitten
  y9522 8d0903f765 fix(investment): gate unsupported cache fallback 2 viikkoa sitten
  y9522 93806259b5 fix(investment): keep catalog failures closed 2 viikkoa sitten
  y9522 07201027ed fix(investment): stop unsupported catalog retries 2 viikkoa sitten
  y9522 9cafa1d7d2 fix(investment): address catalyst review findings 2 viikkoa sitten
  Kenny Mecham 24ebfa4608 Removing `Lock` and converting build_data/ to SrwLock 2 viikkoa sitten
  Kenny Mecham 98a751bf56 Convert middleware/ and server/ 2 viikkoa sitten
  y9522 a294e963b4 fix(investment): complete later exotic catalysts 2 viikkoa sitten
  y9522 cdf69f8e64 docs(investment): document catalyst extraction seams 2 viikkoa sitten
  y9522 eca814b265 fix(investment): restore native catalyst presentation 2 viikkoa sitten
  y9522 ab75cb7295 fix(investment): restore exotic catalyst completion state 3 viikkoa sitten
  stan 7d2bcbdb94 Merge remote-tracking branch 'origin/master' into triumph-work-code 4 päivää sitten
  Gage Fulwood e69bc1421b Implement WS-701 settings state updates 2 viikkoa sitten
  Joe McNally 9a95c2faba reduce boot time in the signature sweep and hook attach 2 viikkoa sitten
  Kenny Mecham 996819ce40 Converting core/. log_snapshot_ring uses DataMutex 2 viikkoa sitten
  Kenny Mecham 71927b6597 Converting client/hooks 2 viikkoa sitten
  Millie 61674670e5 Include account preflight in MSVC build 1 viikko sitten
  Millie 4d847e933f Canonicalize the account before any family image reads it 3 viikkoa sitten
  Kenny Mecham b0d6587071 Convert client/content 2 viikkoa sitten
  Kenny Mecham 489d45ea6d Adding `Sendable`, `DataMutex`, and `SrwLock` 2 viikkoa sitten
  Millie f1343185d0 Tidy the emote wheel changes 3 viikkoa sitten
  Millie 785db89f5c Satisfy the value gate that kept X Marks The Spot access restricted 3 viikkoa sitten
  Millie 9413682f70 Document the emote ownership flags and how they were recovered 3 viikkoa sitten
  Millie 27cc9959c7 Unlock every emote in this build's Collections 3 viikkoa sitten
  Millie 7e45fd71eb Use universal emotes as the default emote wheel picks 3 viikkoa sitten
  Millie 3897d9cb9c Address emote-wheel PR review: scope the slot-14 fallback, fix boot ordering, harden the migration 3 viikkoa sitten
  Millie 5ac678d106 Add the emote wheel via the real "Emotes" collection item 3 viikkoa sitten
  efwxx 3e9ac48128 feat: basic roster functionality 2 viikkoa sitten
  Luke Zeman 0858be3ce0 Detach the membership probe at client shutdown 2 viikkoa sitten
  stan cc8550c027 Merge pull request #72 from ltsReaver/feature/extend-fov-limit 4 päivää sitten
  stan c5f2f4a7c8 Merge pull request #86 from TotalTaxAmount/readme-fix 4 päivää sitten
  stan a1d8f6f724 Merge pull request #96 from Confetti3/fix/weapon-power-stats 4 päivää sitten
  Confetti3 2b73b5c32d Fix missing weapon Power in character stats 4 päivää sitten
  Millie 5b2463c82e Restore upstream fade release and enable its configuration 4 päivää sitten
  Millie f7f544b741 Remove unused byte count causing MSVC warning-as-error failure 4 päivää sitten
  Millie c0b85d7c84 Reveal affected lore books and records without granting progress 4 päivää sitten
  Millie 8c7efa6658 Fix title appearance refresh validation for codec-free deletes 4 päivää sitten
  Millie 5230acaa37 Clean up artifact socket repair and add portable regression coverage 4 päivää sitten
  Millie 7c98b77286 Merge upstream build and restore progression, spawning, and artifact sockets 4 päivää sitten
  Millie ced30ddae4 Refresh weapon perks after artifact reset 1 viikko sitten
  Millie 0a1766e061 Clean up seasonal progression flows 1 viikko sitten
  Millie 6d2237b41d Implement Season Pass reward grants 1 viikko sitten
  Millie d6e67dd7df Implement seasonal artifact progression 1 viikko sitten
  Millie 4e07fe5c1b Silence remaining Queuez banner warning 1 viikko sitten
  Millie d9acdfbf34 Fix warning-strict progression build 1 viikko sitten
  Millie 11bea3f942 Consolidate progression and reward handling 1 viikko sitten
  Millie 80068dfcb8 Publish claimed Season Pass rewards 1 viikko sitten
  Millie 621f38923e Publish seasonal XP to the gameplay HUD progression 1 viikko sitten
  Millie 128ebbbe1a Implement collectible rewards and seasonal progression 1 viikko sitten
  Millie 96c1637dd8 Map Dust collectibles and publish lore visibility 1 viikko sitten
  Millie 77e608872d Add title equipping and exact lore collectible grants 1 viikko sitten
  Millie c315f8e815 Fix two encoder defects and record the four-book lore limitation 1 viikko sitten
  Millie c5b1709ac1 Feed the four activity-acquired lore books their acquisition counter 2 viikkoa sitten
  Millie 96ab534128 Open the ten single-slot lore categories with -1 2 viikkoa sitten
  Millie bb22f3ec66 Publish every value-gated lore category, not a chosen subset 2 viikkoa sitten
  Millie 4c1603ea91 Reveal lore books on collection; keep bars on claims where they differ 2 viikkoa sitten
  Millie 690bb5f748 Measure The Book of Unmaking's parent bar at 2265 2 viikkoa sitten
  Millie a1210c2725 Count claimed chapters again: the live bar moves on claim, not collect 2 viikkoa sitten
  Millie 318e20767c Fill the chapter gate block instead of addressing it per chapter 2 viikkoa sitten
  Millie 4863f0ba35 Gate lore book categories and chapters on the values they actually read 2 viikkoa sitten
  Millie 8652f6b14e Drop The Book of Unmaking's bar entry: 4619 is a chapter's objective slot 2 viikkoa sitten
  Millie a82e2171d0 Map every lore book parent bar to its measured value index 2 viikkoa sitten
  Millie 4991a44041 Drive lore book parent bars from the index their record names 2 viikkoa sitten
  Millie 5447cd42d7 Grant lore collectibles their correct triumph, claimable and persisted 2 viikkoa sitten
  millie 136c7a86fd Grant record rewards on triumph claim 2 viikkoa sitten
  totaltaxamount 6392e6feba readme: fix incorrect build instructions on linux 2 viikkoa sitten
  Millie 3ce463689a Merge the collectible work into the triumph branch 2 viikkoa sitten
  Millie c0ba1c6b75 Satisfy the book gates here, and measure what claimable actually is 2 viikkoa sitten
  Millie 8c4635e3c4 Persist records and nodes, and rule out item ownership as the reveal 2 viikkoa sitten
  Millie 562f3f61a4 Refresh the account on pickup so a chapter appears without a relaunch 2 viikkoa sitten
  Millie be59598cb9 Leave a found chapter claimable rather than claimed 2 viikkoa sitten
  Millie 1fd65a8e65 Grant the next unowned chapter when a collectible is picked up 2 viikkoa sitten
  Millie 2d3f78d9e0 Bring the claim store onto the collectible branch 2 viikkoa sitten
  Millie b881861c00 Join a collectible to the chapter record it completes 2 viikkoa sitten
  Millie 8eb39cee99 Bring the nodes domain onto the collectible branch 2 viikkoa sitten
  Millie 0d48b70d9c Bring the records domain onto the collectible branch 2 viikkoa sitten
  Millie d20b06699d Record what a collectible pickup does not reach 2 viikkoa sitten
  Millie 2e3c523adc Resolve which collectible an incident reports 2 viikkoa sitten
  Millie e002c32458 List the new sources in the MSVC project 2 viikkoa sitten
  Millie bd57a4f744 Simplify the lore counting path and share the expression reader 2 viikkoa sitten
  Millie f6c0253781 Stop flattening authored values on categories we do not manage 2 viikkoa sitten
  Millie 90011fccb0 Stop granting records the account never earned 2 viikkoa sitten
  Millie 5c2f950494 Stop a book's count driving its neighbour's bar 2 viikkoa sitten
  Millie 1c7d63b969 Reveal every lore book category and seed one real chapter in each 2 viikkoa sitten
  Millie 95daba0a3f Keep the node table across restarts so warm starts count categories 2 viikkoa sitten
  Millie 94cadac2ae Count a category and its parent record separately 2 viikkoa sitten
  Millie 19ad8bbbb8 Count nodes in build data readiness so a warm start still has them 2 viikkoa sitten
  Millie fb26833f97 Count claimed records into the value slot a presentation node names 2 viikkoa sitten
  Millie f96d4d9f18 Read the score the record actually carries 2 viikkoa sitten
  Millie 5c17ed7ead Keep claims across restarts, and promise the revision a claim lands on 2 viikkoa sitten
  Millie bf1292751b Cache the records domain so claims survive a warm boot 2 viikkoa sitten
  Millie a7d88fafe3 Total Triumph Score from claimed records, and correct what the completion flag means 2 viikkoa sitten
  Millie 155dc4b264 Push a fresh account image as soon as a Triumph is claimed 2 viikkoa sitten
  Millie ef0ae19938 Hold a claimed record and lay it over the account flag bank 2 viikkoa sitten
  Millie 0f35a9e484 Write a log file and trace every channel at debug 2 viikkoa sitten
  Millie db83c04b43 Decode the Triumphs claim request and resolve the record it names 2 viikkoa sitten
  ltsReaver 2e043b4995 Extend field of view limit to 155 2 viikkoa sitten
100 muutettua tiedostoa jossa 7098 lisäystä ja 392 poistoa
  1. 1 1
      .clang-tidy
  2. 3 1
      .gitignore
  3. 9 0
      CMakeLists.txt
  4. 2 2
      README.md
  5. 74 2
      Sunrise/Sunrise.vcxproj
  6. 152 0
      Sunrise/docs/emote-unlocks.md
  7. BIN
      Sunrise/resources/bootflow/texture_80A145FF.dds
  8. BIN
      Sunrise/resources/bootflow/texture_80A14601.dds
  9. BIN
      Sunrise/resources/bootflow/texture_80A14607.dds
  10. BIN
      Sunrise/resources/bootflow/texture_80A1460E.dds
  11. BIN
      Sunrise/resources/bootflow/texture_80A1461D.dds
  12. BIN
      Sunrise/resources/bootflow/texture_80A1461F.dds
  13. BIN
      Sunrise/resources/bootflow/texture_80A14621.dds
  14. BIN
      Sunrise/resources/bootflow/texture_80A14624.dds
  15. BIN
      Sunrise/resources/bootflow/texture_80A14625.dds
  16. BIN
      Sunrise/resources/bootflow/texture_80A14628.dds
  17. BIN
      Sunrise/resources/bootflow/texture_80A14629.dds
  18. BIN
      Sunrise/resources/bootflow/texture_80A1462B.dds
  19. BIN
      Sunrise/resources/bootflow/texture_80A1462E.dds
  20. BIN
      Sunrise/resources/bootflow/texture_80A1462F.dds
  21. BIN
      Sunrise/resources/bootflow/texture_80A14631.dds
  22. BIN
      Sunrise/resources/bootflow/texture_80A14633.dds
  23. BIN
      Sunrise/resources/bootflow/texture_80A14636.dds
  24. BIN
      Sunrise/resources/bootflow/texture_80A146D5.dds
  25. 15 11
      Sunrise/resources/default_settings.json
  26. 21 0
      Sunrise/resources/resource.h
  27. 18 0
      Sunrise/resources/sunrise.rc
  28. 21 0
      Sunrise/resources/vendor_rules/README.md
  29. 61 0
      Sunrise/resources/vendor_rules/vendor_bounty_roll.txt
  30. 24 0
      Sunrise/resources/vendor_rules/vendor_catalog.txt
  31. 298 0
      Sunrise/resources/vendor_rules/vendor_exchange.txt
  32. 16 0
      Sunrise/resources/vendor_rules/vendor_item_substitute.txt
  33. 4 3
      Sunrise/src/client/content/handles/handle_resolver.cpp
  34. 7 0
      Sunrise/src/client/content/investment/internal.h
  35. 34 24
      Sunrise/src/client/content/investment/investment_refresh.cpp
  36. 220 2
      Sunrise/src/client/content/investment/investment_source.cpp
  37. 2 0
      Sunrise/src/client/content/investment/layout.h
  38. 50 47
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  39. 3 0
      Sunrise/src/client/content/items/packages/build.h
  40. 42 26
      Sunrise/src/client/content/items/packages/internal.h
  41. 25 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  42. 147 0
      Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp
  43. 34 0
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  44. 6 2
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  45. 111 34
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  46. 61 10
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  47. 314 0
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  48. 177 0
      Sunrise/src/client/content/items/packages/package_record_build.cpp
  49. 191 9
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  50. 72 2
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  51. 30 0
      Sunrise/src/client/content/scenarios/internal.h
  52. 2 1
      Sunrise/src/client/content/scenarios/scenario_roster_build.cpp
  53. 152 0
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp
  54. 69 0
      Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp
  55. 2 2
      Sunrise/src/client/content/vendors/layout.h
  56. 99 11
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  57. 9 3
      Sunrise/src/client/content/vendors/vendor_build.h
  58. 1119 0
      Sunrise/src/client/diagnostics/entity_create_probe.cpp
  59. 34 0
      Sunrise/src/client/diagnostics/entity_create_probe.h
  60. 278 0
      Sunrise/src/client/diagnostics/image_dump.cpp
  61. 26 0
      Sunrise/src/client/diagnostics/image_dump.h
  62. 169 30
      Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp
  63. 4 3
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  64. 0 3
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.h
  65. 86 12
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp
  66. 459 0
      Sunrise/src/client/hooks/bootflow/bootflow_texture_override.cpp
  67. 19 0
      Sunrise/src/client/hooks/bootflow/bootflow_texture_override.h
  68. 15 9
      Sunrise/src/client/hooks/bootflow/character_select_hold.cpp
  69. 15 9
      Sunrise/src/client/hooks/bootflow/composition_check.cpp
  70. 75 24
      Sunrise/src/client/hooks/bootflow/internal.h
  71. 15 9
      Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp
  72. 13 8
      Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp
  73. 13 8
      Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp
  74. 23 10
      Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp
  75. 21 13
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  76. 55 8
      Sunrise/src/client/hooks/bootflow/spawn_hold.cpp
  77. 0 1
      Sunrise/src/client/hooks/egress/internal.h
  78. 6 12
      Sunrise/src/client/hooks/egress/lifecycle/egress_guard_lifecycle.cpp
  79. 7 18
      Sunrise/src/client/hooks/graphics/input/graphics_window_input.cpp
  80. 124 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  81. 17 3
      Sunrise/src/client/hooks/network/investment/internal.h
  82. 42 7
      Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.cpp
  83. 10 0
      Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h
  84. 106 4
      Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp
  85. 170 0
      Sunrise/src/client/hooks/network/investment/investment_lore_visibility.cpp
  86. 952 0
      Sunrise/src/client/hooks/network/investment/investment_socket_menu_routing.cpp
  87. 87 0
      Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h
  88. 122 0
      Sunrise/src/client/hooks/network/investment/socket_row_relocation.h
  89. 68 2
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  90. 3 0
      Sunrise/src/client/hooks/teleport/runtime.h
  91. 10 0
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  92. 85 0
      Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.cpp
  93. 46 0
      Sunrise/src/client/hooks/vendor_banner/vendor_banner_retire.h
  94. 114 6
      Sunrise/src/client/patterns/registry.cpp
  95. 17 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  96. 20 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  97. 2 0
      Sunrise/src/client/runtime/internal.h
  98. 2 0
      Sunrise/src/client/targets/game/content.h
  99. 1 0
      Sunrise/src/client/targets/game/game_content_targets.cpp
  100. 70 0
      Sunrise/src/core/filesystem/path.cpp

+ 1 - 1
.clang-tidy

@@ -74,5 +74,5 @@ ExtraArgsBefore:
   - -Wdocumentation
 FormatStyle: file
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,new,optional,span,string_view,type_traits,utility,variant,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,mutex,new,optional,shared_mutex,span,string_view,type_traits,utility,variant,vector'
 ...

+ 3 - 1
.gitignore

@@ -2,7 +2,7 @@
 /.idea/
 /.vscode/
 /.direnv/
-/.xwin-cache/
+/.xwin-cache
 /.cache/
 *.user
 *.suo
@@ -14,4 +14,6 @@
 
 # CMake build files
 /build/
+/build-merge/
+/build-resolved/
 /out/

+ 9 - 0
CMakeLists.txt

@@ -15,7 +15,13 @@ file(GLOB_RECURSE VENDOR_SOURCES CONFIGURE_DEPENDS
     "Sunrise/vendor/detours/*.h"
     "Sunrise/vendor/imgui/*.cpp"
     "Sunrise/vendor/imgui/*.h"
+    "Sunrise/vendor/lua/*.c"
+    "Sunrise/vendor/lua/*.h"
 )
+file(GLOB_RECURSE LUA_SOURCES CONFIGURE_DEPENDS
+    "Sunrise/vendor/lua/*.c"
+)
+set_source_files_properties(${LUA_SOURCES} PROPERTIES LANGUAGE CXX)
 file(GLOB_RECURSE RESOURCE_FILES CONFIGURE_DEPENDS
     "Sunrise/resources/*.rc"
     "Sunrise/resources/*.h"
@@ -52,6 +58,7 @@ target_include_directories(steam_api64 SYSTEM PRIVATE
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/detours"
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui"
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui/backends"
+    "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/lua"
 )
 
 if(DEFINED XWIN_DIR)
@@ -76,6 +83,8 @@ target_compile_definitions(steam_api64 PRIVATE
 )
 
 if(MSVC OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+    target_compile_options(steam_api64 PRIVATE $<$<COMPILE_LANGUAGE:CXX>:/EHsc>)
+
     # Clang warnings are more strict the MSVC I guess, this only works on real clang
     if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
         list(APPEND STRICT_WARNING_FLAGS

+ 2 - 2
README.md

@@ -74,13 +74,13 @@ $ cd Sunrise
 
 2. Download Windows headers:
 ```bash
-$ xwin --accept-license splat --include-debug-libs --sdk-version 10.0.26100 --output .xwin-cache
+$ xwin --sdk-version 10.0.26100 --accept-license splat --include-debug-libs --output .xwin-cache
 ```
 
 3. Configure and build the project
 ```bash
 $ cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=$(pwd)/linux-to-win-toolchain.cmake -DCMAKE_BUILD_TYPE=Release
-$ cmake --build build --config Release
+$ cmake --build build
 ```
 
 ## Contributing

+ 74 - 2
Sunrise/Sunrise.vcxproj

@@ -136,6 +136,8 @@
     <ClCompile Include="src\client\hooks\cursor\cursor_hook_lifecycle.cpp" />
     <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" />
@@ -151,6 +153,7 @@
     <ClCompile Include="src\state\runtime\state_account_identity_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_profile_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_socket_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_settings_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_rolled_socket_plugs.cpp" />
     <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
@@ -208,6 +211,7 @@
     <ClCompile Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_plug_record_codec.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_exotic_catalyst_record_codec.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\selection\graphics_swap_chain_selection.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_frame.cpp" />
@@ -244,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" />
@@ -265,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" />
@@ -274,10 +280,10 @@
     <ClCompile Include="src\client\hooks\bootflow\profile_setup_skip.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn_hold.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_probe.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_targets.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.cpp" />
-    <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_arrival.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_bits.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_snapshot.cpp" />
@@ -608,9 +614,12 @@
     <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" />
+    <ClCompile Include="src\state\account\settings\settings_delta.cpp" />
     <ClCompile Include="src\state\equipment\light\calculation\equipment_light_calculation.cpp" />
     <ClCompile Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.cpp" />
     <ClCompile Include="src\state\content\content_catalog.cpp" />
@@ -682,6 +691,10 @@
     <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_generated.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
@@ -876,6 +889,7 @@
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_deferred_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_update_frame.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_account_preflight.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_subscription.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_change_character.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_select_character.cpp" />
@@ -903,6 +917,7 @@
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\social_roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
@@ -1015,6 +1030,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
@@ -1057,6 +1073,8 @@
     <ClCompile Include="src\client\content\items\packages\package_build_report.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_root_tables.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
+    <ClCompile Include="src\client\diagnostics\entity_create_probe.cpp" />
+    <ClCompile Include="src\client\diagnostics\image_dump.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
     <ClCompile Include="src\core\settings\address_text.cpp" />
@@ -1137,6 +1155,22 @@
     <ClCompile Include="src\middleware\gameplay\group\migration_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\group\notice_messages.cpp" />
     <ClCompile Include="src\server\gameplay\group\group_migration_receipts.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_node_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_record_build.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1801_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1821_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode2400_codec.cpp" />
+    <ClCompile Include="src\state\build_data\nodes\node_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\record_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\records\record_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_persistence.cpp" />
+    <ClCompile Include="src\state\record_claims\record_claims.cpp" />
+    <ClCompile Include="src\state\progression\seasonal_experience.cpp" />
+    <ClCompile Include="src\state\build_data\sobjects\sobject_catalog.cpp" />
+    <ClCompile Include="src\state\lore\lore_grant.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1182,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" />
@@ -1201,6 +1236,9 @@
     <ClInclude Include="src\client\hooks\banner\banner_bind.h" />
     <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" />
@@ -1258,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" />
@@ -1384,10 +1423,14 @@
     <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" />
+    <ClInclude Include="src\state\account\settings\settings_delta.h" />
     <ClInclude Include="src\state\account\settings\key_bindings.h" />
+    <ClInclude Include="src\state\account\settings\native_key_binding_map.h" />
     <ClInclude Include="src\state\equipment\light\definition.h" />
     <ClInclude Include="src\state\equipment\light\calculation\equipment_light_calculation.h" />
     <ClInclude Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.h" />
@@ -1456,6 +1499,9 @@
     <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\definition.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\definition.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.h" />
     <ClInclude Include="src\client\content\items\packages\package_socket_plug_build.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />
@@ -1640,7 +1686,6 @@
     <ClInclude Include="src\middleware\datagen\family4\account\account_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\layout.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\layout.h" />
-    <ClInclude Include="src\middleware\datagen\family4\account\preferences\native_key_binding_map.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\preferences_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\character\abi.h" />
     <ClInclude Include="src\middleware\datagen\family4\loadout\definition.h" />
@@ -1737,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" />
@@ -1866,6 +1912,8 @@
     <ClInclude Include="src\server\transport\internal.h" />
     <ClInclude Include="src\middleware\content\packages\reader\locator_cache.h" />
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
+    <ClInclude Include="src\client\diagnostics\entity_create_probe.h" />
+    <ClInclude Include="src\client\diagnostics\image_dump.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
     <ClInclude Include="src\core\settings\address_text.h" />
@@ -1958,5 +2006,29 @@
     <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
     <ClInclude Include="src\core\runtime\server_clock.h" />
   </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\client\hooks\network\investment\investment_socket_menu_routing.cpp" />
+    <ClInclude Include="src\client\hooks\network\investment\socket_row_relocation.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\unlock_expression.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1801.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1821.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode2400.h" />
+    <ClInclude Include="src\state\build_data\nodes\definition.h" />
+    <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\definition.h" />
+    <ClInclude Include="src\state\build_data\records\record_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\definition.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_persistence.h" />
+    <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
+    <ClInclude Include="src\state\lore\lore_grant.h" />
+    <ClInclude Include="src\state\progression\season_pass_reward_catalog.h" />
+    <ClInclude Include="src\state\progression\seasonal_experience.h" />
+    <ClInclude Include="src\state\record_claims\objective_slot_table.h" />
+    <ClInclude Include="src\state\record_claims\parent_bar_table.h" />
+    <ClInclude Include="src\client\hooks\network\investment\lore_visibility_patch.h" />
+    <ClCompile Include="src\client\hooks\network\investment\investment_lore_visibility.cpp" />
+    <ClInclude Include="src\state\record_claims\record_claims.h" />
+  </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 152 - 0
Sunrise/docs/emote-unlocks.md

@@ -0,0 +1,152 @@
+# Emote ownership flags
+
+How this build decides an emote is owned, how the flags behind that were
+recovered, and the data itself. None of this is public: the Bungie manifest
+exposes only the failure message for these rules, never the expression behind
+it, so everything here was read out of the installed packages directly.
+
+## Where ownership actually lives
+
+**An emote's ownership is gated by its own item definition, not by a
+collectible.** Each emote item carries a plug rule -- the one whose failure
+message is *"You do not own this emote"* -- and that rule's unlock expression
+sits at **item-definition offset 720**. The enabled rule repeats the same
+expression at offset 800.
+
+The collectible table's acquired expression (`+112`) is a dead end for emotes:
+only 94 of this build's 307 emote items have a collectible row at all, so a
+collectible-driven pass cannot reach the other 213 no matter how it is written.
+
+Decoding offset 720 for every item in the individual-emote bucket (**41**)
+gives:
+
+| | count |
+| --- | ---: |
+| emote items in this build | 307 |
+| gated by an ownership flag | 291 |
+| distinct flag slots behind them | 288 |
+| carrying no expression at all | 16 |
+
+The 16 ungated ones are always owned -- that is why Yes, Nope and Cheer worked
+before any of this.
+
+## Slots are not indices
+
+This is the part that silently wastes a day.
+
+`state.unlocks.account_flag_runs` in `default_settings.json` does **not** hold
+flag slot numbers. It fills the account object's acquired-flag byte array, and
+the client addresses that array **by row number in the unlock flag mapping
+table**, not by slot. The two are unrelated number spaces.
+
+The mapping table for this bank:
+
+| | |
+| --- | --- |
+| investment root slot | 111 |
+| tag | `81319322` |
+| rows | 11923 |
+| row shape | `{ u32 unlock_hash; i16 destination_slot; u16 zero; }` |
+
+To set flag slot `s`, find the row whose `destination_slot == s`; that row's
+**number** is the index to write. Writing `s` itself sets an unrelated flag.
+
+Symptoms of getting this wrong, both observed here:
+
+- Writing slot numbers as indices does nothing visible, because the indices
+  that happen to be hit belong to unrelated slots.
+- Filling the whole bank "works" for emotes but also sets every entitlement
+  flag, which leaves the account unable to open the Director, the map, or
+  orbit. Do not blanket fill.
+
+Translating the 288 slots through the table yields **282 row indices**. The 6
+that do not resolve have no row in this table (they are reachable only through
+the family-5 override list, which is capped at 127 rows and so is not a route
+for a set this size).
+
+## The data
+
+Ownership flag **slots** (288) -- what the item definitions name:
+
+```
+229, 237-239, 264, 271, 2218, 2222, 2232-2238, 2240, 2242-2259, 3813-3820,
+3822-3836, 5200-5205, 5209-5220, 5228-5231, 5426-5427, 5429-5436, 6245-6264,
+6896-6902, 7350-7371, 7373-7374, 8159-8165, 8167-8182, 8999-9010, 9012-9013,
+9016-9021, 10408-10428, 10907-10932, 11431-11457, 11740-11770
+```
+
+Mapping-table **row indices** (282) -- what `account_flag_runs` must contain:
+
+```
+807, 811, 821-827, 829, 831-848, 2090-2097, 2099-2113, 3080-3085, 3089-3100,
+3107-3110, 3217-3218, 3220-3227, 3729-3748, 4195-4201, 4428-4449, 4451-4452,
+5057-5063, 5065-5080, 5676-5687, 5689-5690, 5693-5698, 6353-6373, 6734-6759,
+7148-7174, 7404-7434
+```
+
+161 of those indices were already set by the authored data, which is the 179
+emotes that were already owned (161 gated + 16 ungated + 2 sharing a flag).
+The change added the remaining 121.
+
+None of the 288 slots collides with any documented entitlement, platform, or
+pre-release slot. The Director, map and orbit were verified working afterwards.
+
+## Ownership is not always the only gate
+
+An emote can carry more than one plug rule. The ownership rule at offset 720 is
+the common case, but a few items add a second rule immediately after it, and
+that one is a **value** comparison rather than a flag read. Such an emote reads
+as owned and still refuses to equip, showing *"Access Restricted"*.
+
+`X Marks The Spot` (`0x1682F6A3`) is the worked example in this build:
+
+| offset | expression | meaning |
+| ---: | --- | --- |
+| 720 | `FLAG(9004)` | ownership, set by `account_flag_runs` |
+| 736 | `VAL(5549) CONST(50) >=` | `VAL(5549) >= 50` |
+| 864 | `FLAG(9004)` | the enabled rule, pushed down by the extra rule |
+
+`VAL(5549)` is the objective progress counter for the *Golden Offerings*
+triumph, so the real requirement is completing that triumph. Value slots are
+not part of the flag banks; the route to them is the family-5 override list:
+
+```json
+"family5_value_overrides": [ ..., [5549,50] ]
+```
+
+Setting it to the threshold exactly, rather than inflating it, keeps any other
+expression that compares the same slot honest -- raising a value slot too far
+is what breaks unrelated content, per the shared-pool warning in the unlock
+documentation.
+
+Note this also shifts the layout: an emote with the extra rule has its enabled
+rule at 864 rather than 800. 32 of the 307 emotes in this build do not have a
+plain single-flag expression at 800 for this reason, which is expected and not
+a fault.
+
+## Regenerating this
+
+The flag list is specific to this build; a content change invalidates it. To
+rebuild it, three temporary passes over the package data are needed:
+
+1. For every item with `bucketId == 41`, decode the unlock expression at
+   definition offset 720. A single `opcode 1` instruction carries the flag slot
+   as its operand. Expressions resolve as
+   `target = (offset of the pointer field) + (value stored there) + 16`, the
+   same self-relative form plus 16-byte block header that `find_array_at` uses.
+2. Read the mapping table at investment root slot 111 and build
+   `destination_slot -> row index`.
+3. Run-length-encode the union of the existing runs and the new indices.
+
+Watch the cache while doing this. `stale_format()` treats only
+`version < kCacheFormatVersion` as rebuildable, so a `build_data.bin` written
+by a *newer* format version is rejected outright rather than regenerated, which
+fails state initialisation and surfaces in the client as
+*"Verify integrity of game files"*. If the cache format version is changed and
+then reverted, delete `build_data.bin`.
+
+## Known gap
+
+These flags are recorded as a set. Which flag belongs to which *named* emote
+was never captured -- the extraction logged slots without their item hashes. It
+matters only if emotes ever need unlocking selectively rather than all at once.

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


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 15 - 11
Sunrise/resources/default_settings.json


+ 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

+ 4 - 3
Sunrise/src/client/content/handles/handle_resolver.cpp

@@ -128,9 +128,10 @@ bool resolve(const Source& source, std::uint32_t handle, std::uintptr_t& address
     const std::uint64_t extendedMask =
         static_cast<std::uint64_t>(static_cast<std::int64_t>(table.correctionMask));
     const std::uint64_t correction = record.correctionSource & extendedMask;
-    if (correction > recordAddress) {
-        return false;
-    }
+    // Match the native `sub rax, rcx` exactly. The correction source is a two's-complement
+    // relative value when the descriptor mask is -1, so a numerically large unsigned correction
+    // intentionally wraps the subtraction forward. Rejecting it as an underflow made every
+    // installed investment-globals handle appear unresolved.
     address = recordAddress - static_cast<std::uintptr_t>(correction);
     return address != 0;
 }

+ 7 - 0
Sunrise/src/client/content/investment/internal.h

@@ -5,6 +5,13 @@
 
 namespace sunrise::client::content::investment {
 
+/**
+ * Resolves the one installed investment-globals candidate backed by the live content tables.
+ * @param source Receives the checked runtime tag, handle tables, and bounded reader.
+ * @return True when the globals, root, and dense item table all resolve.
+ */
+[[nodiscard]] bool resolve_source(Source& source) noexcept;
+
 /** @return True when the next refresh slice needs one presented overlay before its package sweep.
  */
 [[nodiscard]] bool requires_package_sweep() noexcept;

+ 34 - 24
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -1,38 +1,35 @@
 #include <Windows.h>
 
+#include <mutex>
+
 #include "../../../core/ui/busy/busy.h"
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/runtime/runtime.h"
 #include "../items/packages/build.h"
+#include "core/threading/srw_lock.h"
 #include "internal.h"
 #include "runtime.h"
 
 namespace sunrise::client::content::investment {
 namespace {
 
-SRWLOCK g_refreshLock{SRWLOCK_INIT};
+core::threading::SrwLock g_refreshLock{};
+
+[[nodiscard]] bool ready() noexcept {
+    return state::build_data::named_catalog_ready() && items::packages::ready();
+}
 
 /**
- * @return True when every persistent mapping domain is fully published.
- * The destination layouts and spawn sets belong here even though they are not equipment mappings.
- * This is the only caller of the package pass, so a domain left out of this test stops being
- * extracted once the others finish, and the cache can then never be written.
+ * Runs the emote-collection canonicalization on the extraction path, where it is an opportunistic
+ * head start rather than a precondition: the snapshot path runs the same step behind its own
+ * preflight, so nothing here is the last chance to apply it.
+ * @return False only when the account itself could not be updated, which is the one outcome that
+ * says something is wrong rather than merely unfinished. A build that cannot carry the item, and
+ * one whose data is still being extracted, both leave the cache worth writing.
  */
-[[nodiscard]] bool ready() noexcept {
-    return state::build_data::named_catalog_ready() && state::build_data::item_definitions_ready()
-           && state::build_data::collectible_definitions_ready()
-           && state::build_data::material_requirement_sets_ready()
-           && state::build_data::configured_item_details_ready()
-           && state::build_data::socket_plug_rules_ready()
-           && state::build_data::inventory_bucket_descriptors_ready()
-           && state::build_data::socket_entry_lists_ready()
-           && state::build_data::ability_buckets_ready()
-           && state::build_data::socket_entry_buckets_ready()
-           && state::build_data::progression_definitions_ready()
-           && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
-           && state::build_data::hash_names_ready()
-           && state::build_data::investment_constants_ready();
+[[nodiscard]] bool emote_collection_settled() noexcept {
+    return state::ensure_character_emote_collection() != state::EmoteCollectionOutcome::failed;
 }
 
 } // namespace
@@ -47,19 +44,31 @@ bool refresh() noexcept {
     if (ready()) {
         // 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.
-        AcquireSRWLockExclusive(&g_refreshLock);
+        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()
                                && state::build_data::persist();
         // Nothing reads a package again until the next boot, so the open files and the held
         // tables go back now rather than at process exit.
         middleware::content::packages::reader::release_caches();
-        ReleaseSRWLockExclusive(&g_refreshLock);
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
         return persisted;
     }
 
-    AcquireSRWLockExclusive(&g_refreshLock);
+    const std::lock_guard lock(g_refreshLock);
     // The package pass creates parallel readers. Suspending the client while those threads start
     // can block their DLL thread-attach work behind a suspended owner, so the visible preflight
     // runs one frame early and extraction proceeds with the process live.
@@ -68,12 +77,13 @@ bool refresh() noexcept {
     (void)items::packages::build();
     const bool domainsReady = ready();
     const bool complete = domainsReady && state::ensure_profile_item_identities()
-                          && state::ensure_character_subclasses() && state::build_data::persist();
+                          && state::ensure_character_subclasses()
+                          && emote_collection_settled()
+                          && state::build_data::persist();
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
     }
-    ReleaseSRWLockExclusive(&g_refreshLock);
     return complete;
 }
 

+ 220 - 2
Sunrise/src/client/content/investment/investment_source.cpp

@@ -1,11 +1,17 @@
+#include <algorithm>
 #include <array>
+#include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 
+#include "../../../core/logging/log.h"
 #include "../../../state/content/content_catalog.h"
 #include "../../memory/current_process_memory.h"
-#include "../../targets/game.h"
+#include "../../targets/game/content.h"
+#include "../handles/layout.h"
 #include "internal.h"
+#include "layout.h"
 
 namespace sunrise::client::content::investment {
 namespace {
@@ -13,8 +19,220 @@ namespace {
 /** FNV-1 hash of the investment-globals bootstrap name, so the name itself is not shipped. */
 constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
 /** The bootstrap name is not unique, so every match is collected. */
-constexpr std::size_t kBootstrapMatchCapacity = 8;
+// Keep this identical to the package extractor. The installed catalogue currently has more than
+// eight entries with this shared name; treating a truncated lookup as total failure made live
+// socket routing permanently defer even though the correct candidate was present.
+constexpr std::size_t kBootstrapMatchCapacity = 64;
+/** One native resolver prefix is enough to recover all descriptor field offsets. */
+constexpr std::size_t kResolverDiagnosticBytes = 128;
+/** Package handles name descriptor ids 1,024 slots above their package id. */
+constexpr std::uintptr_t kContentDescriptorBias = 1024;
+/** Package handles keep their package id above thirteen entry-index bits. */
+constexpr unsigned kPackageShift = 13;
+/** Installed definition tags begin at this package-handle base. */
+constexpr std::uint32_t kPackageTagBase = 0x80800000U;
+
+std::atomic_bool g_diagnosticsReported{false};
+
+/** Reads one complete scalar through the bounded live-process reader. */
+template <typename Value>
+[[nodiscard]] bool read(const Source& source, std::uintptr_t address, Value& value) noexcept {
+    return source.handles.read != nullptr
+           && source.handles.read(source.handles.context,
+                                  address,
+                                  std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+/** Reads one scalar directly from the current process for layout diagnostics. */
+template <typename Value>
+[[nodiscard]] bool read_process(std::uintptr_t address, Value& value) noexcept {
+    return memory::read_current_process(
+        nullptr, address, std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+/** Writes one bounded memory range as a single diagnostic line. */
+void report_bytes(const char* stage,
+                  std::uintptr_t address,
+                  std::span<const std::byte> bytes) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int prefix = std::snprintf(line.data(),
+                                     line.size(),
+                                     "ev=investment stage=%s address=0x%llX bytes=",
+                                     stage,
+                                     static_cast<unsigned long long>(address));
+    if (prefix <= 0) {
+        return;
+    }
+    std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
+    static_cast<void>(core::log::append_hex(line, length, bytes));
+    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+}
+
+/** Writes one candidate descriptor and its package identity as a diagnostic line. */
+void report_descriptor(unsigned depth,
+                       const state::content::Definition& candidate,
+                       std::uintptr_t address,
+                       std::span<const std::byte> bytes) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int prefix = std::snprintf(line.data(),
+                                     line.size(),
+                                     "ev=investment stage=descriptor depth=%u tag=0x%08X "
+                                     "class=0x%08X address=0x%llX bytes=",
+                                     depth,
+                                     candidate.tag,
+                                     candidate.classId,
+                                     static_cast<unsigned long long>(address));
+    if (prefix <= 0) {
+        return;
+    }
+    std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
+    static_cast<void>(core::log::append_hex(line, length, bytes));
+    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+}
+
+/** Captures the native resolver and every plausible descriptor base once, without mutation. */
+void report_layout_diagnostics(const targets::game::content::Targets& targets,
+                               std::span<const state::content::Definition> candidates) noexcept {
+    if (g_diagnosticsReported.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<std::byte, kResolverDiagnosticBytes> resolver{};
+    if (targets.queuezObjectResolver != nullptr
+        && memory::read_current_process(
+            nullptr, reinterpret_cast<std::uintptr_t>(targets.queuezObjectResolver), resolver)) {
+        report_bytes("resolver_bytes",
+                     reinterpret_cast<std::uintptr_t>(targets.queuezObjectResolver),
+                     resolver);
+    }
+
+    const std::uintptr_t slot = reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
+    std::array<std::uintptr_t, 3> bases{};
+    const bool base0 = read_process(slot, bases[0]);
+    const bool base1 = base0 && bases[0] != 0 && read_process(bases[0], bases[1]);
+    const bool base2 = base1 && bases[1] != 0 && read_process(bases[1], bases[2]);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=investment stage=table_chain slot=0x%llX "
+                                      "read=%u/%u/%u base=0x%llX/0x%llX/0x%llX",
+                                      static_cast<unsigned long long>(slot),
+                                      base0 ? 1U : 0U,
+                                      base1 ? 1U : 0U,
+                                      base2 ? 1U : 0U,
+                                      static_cast<unsigned long long>(bases[0]),
+                                      static_cast<unsigned long long>(bases[1]),
+                                      static_cast<unsigned long long>(bases[2]));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+
+    for (unsigned depth = 0; depth < bases.size(); ++depth) {
+        if (bases[depth] == 0) {
+            continue;
+        }
+        for (const state::content::Definition& candidate : candidates) {
+            if (candidate.tag < kPackageTagBase) {
+                continue;
+            }
+            const std::uintptr_t package =
+                static_cast<std::uintptr_t>(candidate.tag - kPackageTagBase) >> kPackageShift;
+            const std::uintptr_t descriptor =
+                bases[depth]
+                + (package + kContentDescriptorBias) * handles::layout::kTableDescriptorSize;
+            std::array<std::byte, handles::layout::kTableDescriptorSize> bytes{};
+            if (memory::read_current_process(nullptr, descriptor, bytes)) {
+                report_descriptor(depth, candidate, descriptor, bytes);
+            }
+        }
+    }
+}
 
 } // namespace
 
+/** Resolves the checked live investment source selected by its installed bootstrap name. */
+bool resolve_source(Source& source) noexcept {
+    source = {};
+    const auto& runtimeTargets = targets::game::content::get();
+    if (!targets::game::content::is_resolved()
+        || runtimeTargets.contentHandleTablesSlot == nullptr) {
+        return false;
+    }
+
+    std::array<state::content::Definition, kBootstrapMatchCapacity> candidates{};
+    std::size_t count = 0;
+    // A shared bootstrap name may have more installed matches than this bounded scratch array.
+    // Truncation is not a lookup failure: every copied candidate is still safe to validate, and
+    // rejecting the whole set is what kept socket-category routing permanently deferred.
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, count) && count == 0) {
+        return false;
+    }
+    report_layout_diagnostics(runtimeTargets, std::span(candidates).first(count));
+    std::size_t globalsResolved = 0;
+    std::size_t rootTagsRead = 0;
+    std::size_t rootsResolved = 0;
+    std::size_t tableTagsRead = 0;
+    std::size_t tablesResolved = 0;
+    for (std::size_t index = 0; index < count; ++index) {
+        Source candidate{};
+        candidate.investmentGlobalsTag = candidates[index].tag;
+        candidate.handles.tablesSlot =
+            reinterpret_cast<std::uintptr_t>(runtimeTargets.contentHandleTablesSlot);
+        candidate.handles.read = &memory::read_current_process;
+
+        std::uintptr_t globals = 0;
+        std::uintptr_t root = 0;
+        std::uintptr_t table = 0;
+        std::uint32_t rootTag = 0;
+        std::uint32_t tableTag = 0;
+        std::uint64_t rowCount = 0;
+        if (!handles::resolve(candidate.handles, candidate.investmentGlobalsTag, globals)) {
+            continue;
+        }
+        ++globalsResolved;
+        if (!read(candidate, globals + layout::kGlobalsRootTagOffset, rootTag)) {
+            continue;
+        }
+        ++rootTagsRead;
+        if (!handles::resolve(candidate.handles, rootTag, root)) {
+            continue;
+        }
+        ++rootsResolved;
+        if (!read(candidate, root + layout::kItemTableTagOffset, tableTag)) {
+            continue;
+        }
+        ++tableTagsRead;
+        if (!handles::resolve(candidate.handles, tableTag, table)) {
+            continue;
+        }
+        ++tablesResolved;
+        if (!read(candidate, table + 8U, rowCount) || rowCount == 0 || rowCount > 32768U) {
+            continue;
+        }
+        source = candidate;
+        return true;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=investment stage=source result=deferred candidates=%llu "
+                                      "globals=%llu root_tags=%llu roots=%llu table_tags=%llu "
+                                      "tables=%llu",
+                                      static_cast<unsigned long long>(count),
+                                      static_cast<unsigned long long>(globalsResolved),
+                                      static_cast<unsigned long long>(rootTagsRead),
+                                      static_cast<unsigned long long>(rootsResolved),
+                                      static_cast<unsigned long long>(tableTagsRead),
+                                      static_cast<unsigned long long>(tablesResolved));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+    return false;
+}
+
 } // namespace sunrise::client::content::investment

+ 2 - 0
Sunrise/src/client/content/investment/layout.h

@@ -12,6 +12,8 @@ inline constexpr std::size_t kGlobalsRootTagOffset = 16;
 inline constexpr std::size_t kInventoryBucketTableTagOffset = 280;
 /** The dense item-table handle sits at byte 776 of the investment root. */
 inline constexpr std::size_t kItemTableTagOffset = 776;
+/** The reusable/randomized plug-set table handle sits at byte 824 of the investment root. */
+inline constexpr std::size_t kPlugSetTableTagOffset = 824;
 /** The socket-entry-list table handle sits at byte 1,560 of the investment root. */
 inline constexpr std::size_t kSocketEntryListTableTagOffset = 1560;
 

+ 50 - 47
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -6,6 +6,7 @@
 #include "../internal.h"
 #include "../runtime.h"
 #include "../worker.h"
+#include "core/threading/data_mutex.h"
 
 namespace sunrise::client::content::investment::worker {
 namespace {
@@ -17,73 +18,75 @@ namespace {
  */
 constexpr std::uint64_t kRefreshIntervalMilliseconds = 0;
 
-SRWLOCK g_lifecycleLock{SRWLOCK_INIT};
-bool g_accepting{};
-bool g_complete{};
-bool g_overlayPending{};
-std::uint64_t g_nextEligible{};
+struct Lifecycle {
+    bool accepting{};
+    bool complete{};
+    bool overlayPending{};
+    std::uint64_t nextEligible{};
+};
+
+core::threading::DataMutex<Lifecycle> g_lifecycle{};
 
 } // namespace
 
 /** Allows cooperative investment refresh slices on the caller-owned game thread. */
 void activate() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    g_accepting = true;
-    g_complete = false;
-    g_overlayPending = false;
-    g_nextEligible = 0;
-    sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        lifecycle.accepting = true;
+        lifecycle.complete = false;
+        lifecycle.overlayPending = false;
+        lifecycle.nextEligible = 0;
+        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+    });
 }
 
 /** Runs one due bounded refresh slice on the caller-owned game thread. */
 void service(std::uint64_t nowMilliseconds) noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    if (!g_accepting || g_complete || !sunrise::client::targets::game::content::is_resolved()
-        || nowMilliseconds < g_nextEligible) {
-        ReleaseSRWLockExclusive(&g_lifecycleLock);
-        return;
-    }
-    g_nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
-
-    if (sunrise::client::content::investment::requires_package_sweep()) {
-        g_overlayPending = true;
-        if (sunrise::core::ui::busy::raise_early(
-                sunrise::core::ui::busy::Task::contentExtraction)) {
-            ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([nowMilliseconds](Lifecycle& lifecycle) {
+        if (!lifecycle.accepting || lifecycle.complete
+            || !sunrise::client::targets::game::content::is_resolved()
+            || nowMilliseconds < lifecycle.nextEligible) {
             return;
         }
-    } else if (g_overlayPending) {
-        // A stale preflight must not leave a task raised after another path publishes the rows.
-        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-        g_overlayPending = false;
-    }
+        lifecycle.nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
 
-    g_complete = sunrise::client::content::investment::refresh();
-    sunrise::client::content::diagnostics::report_readiness();
-    g_overlayPending = false;
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+        if (sunrise::client::content::investment::requires_package_sweep()) {
+            lifecycle.overlayPending = true;
+            if (sunrise::core::ui::busy::raise_early(
+                    sunrise::core::ui::busy::Task::contentExtraction)) {
+                return;
+            }
+        } else if (lifecycle.overlayPending) {
+            // A stale preflight must not leave a task raised after another path publishes the rows.
+            sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+            lifecycle.overlayPending = false;
+        }
+
+        lifecycle.complete = sunrise::client::content::investment::refresh();
+        sunrise::client::content::diagnostics::report_readiness();
+        lifecycle.overlayPending = false;
+    });
 }
 
 /** Stops taking refresh slices and clears the pending overlay. */
 void reset() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    g_accepting = false;
-    g_complete = false;
-    g_overlayPending = false;
-    g_nextEligible = 0;
-    sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        lifecycle.accepting = false;
+        lifecycle.complete = false;
+        lifecycle.overlayPending = false;
+        lifecycle.nextEligible = 0;
+        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+    });
 }
 
 /** Makes the next due pump take another refresh slice even though a prior one completed. */
 void request_slice() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    if (g_accepting) {
-        g_complete = false;
-        g_nextEligible = 0;
-    }
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        if (lifecycle.accepting) {
+            lifecycle.complete = false;
+            lifecycle.nextEligible = 0;
+        }
+    });
 }
 
 } // namespace sunrise::client::content::investment::worker

+ 3 - 0
Sunrise/src/client/content/items/packages/build.h

@@ -2,6 +2,9 @@
 
 namespace sunrise::client::content::items::packages {
 
+/** @return True when every package-owned domain is published. */
+[[nodiscard]] bool ready() noexcept;
+
 /**
  * Publishes the dense item table from the installed packages, once.
  * @return True when State already holds the table or a full pass publishes it.

+ 42 - 26
Sunrise/src/client/content/items/packages/internal.h

@@ -15,6 +15,7 @@
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/inventory/buckets/definition.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
@@ -55,12 +56,26 @@ inline constexpr std::size_t kContainerCandidates = 16;
 /** Lock-owned storage kept off the caller stack, shared by every stage of the pass. */
 struct Storage {
     reader::Scratch scratch{};
+    /** Node rows held until the value slot and owned records are resolved. */
+    std::array<state::build_data::nodes::Definition, state::build_data::nodes::kDefinitionCapacity>
+        nodeRows{};
+    /** Record rows held until the completion flag mapping is resolved. */
+    std::array<state::build_data::records::Definition,
+               state::build_data::records::kDefinitionCapacity>
+        recordRows{};
     std::vector<std::byte> container{};
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
     std::vector<std::byte> definition{};
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     std::vector<std::byte> plugSetTable{};
+    /** Dense item-indexed catalyst completion expressions for this package pass. */
+    std::vector<state::build_data::items::catalysts::CompletionCondition>
+        catalystCompletionConditions{};
+    /** Dense socket-type-indexed acquired-state gates for this package pass. */
+    std::vector<state::build_data::items::catalysts::AcquisitionGate> catalystAcquisitionGates{};
+    /** Dense native objective completion values used by legacy catalyst progress items. */
+    std::vector<std::int32_t> catalystObjectiveValues{};
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */
@@ -117,12 +132,7 @@ struct Storage {
 /** Publishes parsed inventory buckets after applying the extracted item-slot relation. */
 [[nodiscard]] bool publish_buckets(Storage& storage) noexcept;
 
-/**
- * Adds one definition index to the deduplicated requested set.
- * @param definitionIndex Native
- * item index.
- * @param requested Requested-set storage.
- */
+/** Adds one native definition index to the deduplicated request set. */
 void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
 /**
@@ -236,18 +246,23 @@ read_investment_constants(const reader::Source& source,
     std::array<std::uint8_t, state::build_data::socket_entry_lists::kEntryCapacity>&
         output) noexcept;
 
-/**
- * Reads the progression definition table and the object array each definition routes to.
- * The table is inline rows, not index rows. The scope byte in a row picks the replicated object
- * holding that progression, and the row's place among rows of that scope is its slot there.
- * @param source Package source.
- * @param scratch Reader scratch.
- * @param root Investment root bytes.
- * @param blob Scratch storage for the table.
- * @param output Row storage in native definition order.
- * @param count Receives the number of rows read.
- * @return True when the table reads and every row fits.
- */
+/** Reads nodes and resolves their value slots and owned records. */
+[[nodiscard]] bool build_nodes(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::span<state::build_data::nodes::Definition> output,
+                               std::size_t& count) noexcept;
+
+/** Reads records and resolves their completion-flag indices. */
+[[nodiscard]] bool build_records(const reader::Source& source,
+                                 reader::Scratch& scratch,
+                                 std::span<const std::byte> root,
+                                 std::vector<std::byte>& blob,
+                                 std::span<state::build_data::records::Definition> output,
+                                 std::size_t& count) noexcept;
+
+/** Reads progression definitions and resolves their replicated-object slots. */
 [[nodiscard]] bool build_progressions(const reader::Source& source,
                                       reader::Scratch& scratch,
                                       std::span<const std::byte> root,
@@ -308,6 +323,14 @@ void report_socket_plug_count(std::size_t rules,
                               std::size_t members,
                               std::size_t skipped) noexcept;
 
+/**
+ * Reports released, placeholder, and unsupported catalyst catalog counts.
+ * @param report Complete catalog report from the build pass.
+ * @param built True when all released catalyst relations were safe.
+ */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept;
+
 /** Reports the validated installed bucket/equipment-slot coverage. */
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;
 
@@ -345,14 +368,7 @@ void report(std::size_t published, const char* reason) noexcept;
                                             Storage& storage,
                                             std::span<const std::byte> root) noexcept;
 
-/**
- * Reads and publishes the root's dense collectible-to-item mapping table.
- * @param source Package source.
- * @param storage Pass storage, including scratch bytes and bounded row storage.
- * @param root Investment root bytes.
- * @param itemDefinitionCount Number of rows in the installed item index table.
- * @return True when every tag, class, bound, and item link validates and publishes.
- */
+/** Reads and publishes the dense collectible-to-item mapping. */
 [[nodiscard]] bool build_collectibles(const reader::Source& source,
                                       Storage& storage,
                                       std::span<const std::byte> root,

+ 25 - 0
Sunrise/src/client/content/items/packages/package_build_report.cpp

@@ -128,6 +128,31 @@ void report_socket_plug_count(std::size_t rules,
     }
 }
 
+/** Reports the build-scoped catalyst catalog and its first safe failure. */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept {
+    std::array<char, 192> line{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=pkg stage=exotic_catalysts result=%s released=%zu placeholder=%zu unsupported=%zu "
+        "error=%s item=0x%08X lane=%u",
+        built ? "ok" : "fail",
+        report.released,
+        report.placeholder,
+        report.unsupported,
+        state::build_data::items::catalysts::error_name(report.error).data(),
+        report.itemDefinitionHash,
+        static_cast<unsigned>(report.socketLane));
+    if (written > 0) {
+        const core::log::Level level = !built                    ? core::log::Level::error
+                                       : report.unsupported != 0 ? core::log::Level::warn
+                                                                 : core::log::Level::info;
+        core::log::write(
+            core::log::Channel::client, level, {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Reports the installed bucket definition relation used by loadout resolution. */
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept {
     std::array<char, 128> line{};

+ 147 - 0
Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp

@@ -0,0 +1,147 @@
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <limits>
+
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "package_socket_plug_build.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Native postfix opcodes for flag, value, literal, and greater-than-or-equal. */
+constexpr std::uint32_t kFlagOpcode = 1;
+constexpr std::uint32_t kValueOpcode = 10;
+constexpr std::uint32_t kLiteralOpcode = 11;
+constexpr std::uint32_t kGreaterEqualOpcode = 14;
+/** Each postfix token is an opcode and one 32-bit operand. */
+constexpr std::size_t kExpressionTokenSize = 8;
+constexpr std::size_t kValueExpressionTokenCount = 3;
+
+/** Adds one unique positive flag term to a bounded condition. */
+[[nodiscard]] bool append_flag(catalysts::CompletionRequirements& output,
+                               std::uint16_t definitionIndex) noexcept {
+    const auto end = output.flags.begin() + output.flagCount;
+    if (std::find(output.flags.begin(), end, definitionIndex) != end) {
+        return true;
+    }
+    if (output.flagCount >= output.flags.size()) {
+        return false;
+    }
+    output.flags[output.flagCount++] = definitionIndex;
+    return true;
+}
+
+/** Adds or raises one unique signed-value minimum in a bounded condition. */
+[[nodiscard]] bool upsert_value(catalysts::CompletionRequirements& output,
+                                std::uint16_t index,
+                                std::int32_t minimum) noexcept {
+    for (std::size_t row = 0; row < output.valueCount; ++row) {
+        if (output.values[row].index == index) {
+            output.values[row].minimum = (std::max)(output.values[row].minimum, minimum);
+            return true;
+        }
+    }
+    if (output.valueCount >= output.values.size()) {
+        return false;
+    }
+    output.values[output.valueCount++] = {index, minimum};
+    return true;
+}
+
+/** Marks a conflicting or over-capacity native rule and clears its partial operands. */
+void mark_ambiguous(catalysts::CompletionCondition& output) noexcept {
+    output.completion = {};
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    output.state = catalysts::CompletionConditionState::ambiguous;
+}
+
+} // namespace
+
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept {
+    output = {};
+    output.itemDefinitionIndex = itemDefinitionIndex;
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    for (std::size_t descriptor = 0; descriptor + 2 * sizeof(std::uint64_t) <= definition.size();
+         descriptor += sizeof(std::uint64_t)) {
+        tables::Array expression{};
+        if (!tables::find_array_at(definition, descriptor, expression)) {
+            continue;
+        }
+        if (expression.elementClass == tables::kObjectiveReferenceArrayClass
+            && expression.count == 1 && expression.dataOffset <= definition.size()
+            && definition.size() - expression.dataOffset >= 2 * sizeof(std::uint32_t)) {
+            std::array<std::uint32_t, 2> reference{};
+            std::memcpy(reference.data(),
+                        definition.data() + expression.dataOffset,
+                        sizeof reference);
+            if (reference[0] < catalysts::kUnavailableObjectiveIndex
+                && reference[1] == tables::kObjectiveReferenceRowClass) {
+                const auto objective = static_cast<std::uint16_t>(reference[0]);
+                if (output.objectiveDefinitionIndex == catalysts::kUnavailableObjectiveIndex) {
+                    output.objectiveDefinitionIndex = objective;
+                } else if (output.objectiveDefinitionIndex != objective) {
+                    mark_ambiguous(output);
+                    return;
+                }
+            }
+            continue;
+        }
+        if (expression.elementClass != tables::kInvestmentExpressionRowClass
+            || expression.dataOffset > definition.size()
+            || expression.count
+                   > (definition.size() - expression.dataOffset) / kExpressionTokenSize) {
+            continue;
+        }
+        for (std::size_t token = 0; token < expression.count; ++token) {
+            std::array<std::uint32_t, 2> current{};
+            std::memcpy(current.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof current);
+            if (current[0] == kFlagOpcode
+                && current[1] < state::build_data::items::kDefinitionCapacity
+                && !append_flag(output.completion, static_cast<std::uint16_t>(current[1]))) {
+                mark_ambiguous(output);
+                return;
+            }
+            if (current[0] != kValueOpcode
+                || token + kValueExpressionTokenCount > expression.count) {
+                continue;
+            }
+            std::array<std::uint32_t, kValueExpressionTokenCount * 2> tokens{};
+            std::memcpy(tokens.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof tokens);
+            if (tokens[2] != kLiteralOpcode || tokens[4] != kGreaterEqualOpcode
+                || tokens[5] != UINT32_MAX
+                || tokens[1] >= catalysts::kUnavailableCompletionValueIndex || tokens[3] == 0
+                || tokens[3]
+                       > static_cast<std::uint32_t>(
+                           (std::numeric_limits<std::int32_t>::max)())) {
+                continue;
+            }
+            if (!upsert_value(output.completion,
+                              static_cast<std::uint16_t>(tokens[1]),
+                              static_cast<std::int32_t>(tokens[3]))) {
+                mark_ambiguous(output);
+                return;
+            }
+        }
+    }
+    std::sort(output.completion.flags.begin(),
+              output.completion.flags.begin() + output.completion.flagCount);
+    std::sort(output.completion.values.begin(),
+              output.completion.values.begin() + output.completion.valueCount,
+              [](const catalysts::CompletionValue& left,
+                 const catalysts::CompletionValue& right) { return left.index < right.index; });
+    if (output.completion.flagCount != 0 || output.completion.valueCount != 0
+        || output.objectiveDefinitionIndex != catalysts::kUnavailableObjectiveIndex) {
+        output.state = catalysts::CompletionConditionState::present;
+    }
+}
+
+} // namespace sunrise::client::content::items::packages

+ 34 - 0
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -2,6 +2,7 @@
 #include <limits>
 
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
@@ -12,6 +13,39 @@ bool build_collectibles(const reader::Source& source,
                         std::span<const std::byte> root,
                         std::uint64_t itemDefinitionCount) noexcept {
     namespace domain = state::build_data::collectibles;
+
+    // The definition table an incident target names. Read here because this pass already holds an
+    // open source, and because a collectible picked up in the world arrives as an incident: without
+    // this table its target is a bare number.
+    if (state::build_data::sobjects::count() == 0) {
+        namespace sobjects = state::build_data::sobjects;
+        // Count at +112; 40-byte rows at +128 hold the name hash, packed lane, and type.
+        constexpr std::size_t kCountOffset = 112;
+        constexpr std::size_t kRowBase = 128;
+        constexpr std::size_t kRowStride = 40;
+        for (const std::uint32_t tag : {0x81327CD4U, 0x80B9E5BFU}) {
+            std::vector<std::byte> blob{};
+            if (!reader::read_tag(source, storage.scratch, tag, blob) || blob.size() < kRowBase) {
+                continue;
+            }
+            std::uint64_t rowCount = 0;
+            std::memcpy(&rowCount, blob.data() + kCountOffset, sizeof rowCount);
+            if (rowCount == 0 || rowCount > sobjects::kDefinitionCapacity
+                || kRowBase + static_cast<std::size_t>(rowCount) * kRowStride > blob.size()) {
+                continue;
+            }
+            std::vector<sobjects::Definition> rows(static_cast<std::size_t>(rowCount));
+            for (std::size_t row = 0; row < rows.size(); ++row) {
+                const std::size_t at = kRowBase + row * kRowStride;
+                std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof rows[row].nameHash);
+                std::memcpy(&rows[row].lane4, blob.data() + at + 16, sizeof rows[row].lane4);
+                std::memcpy(&rows[row].typeCode, blob.data() + at + 36, sizeof rows[row].typeCode);
+            }
+            (void)sobjects::replace(std::span<const sobjects::Definition>{rows});
+            break;
+        }
+    }
+
     if (state::build_data::collectible_definitions_ready()) {
         return true;
     }

+ 6 - 2
Sunrise/src/client/content/items/packages/package_detail_build.cpp

@@ -76,6 +76,8 @@ namespace domain = state::build_data::items::details;
 constexpr std::size_t kConstantsPrefix = 8;
 /** Client offset of the stat row the banner's power number is searched by. */
 constexpr std::size_t kLightStatRowOffset = 592;
+/** Build-86657 sub_140553ED0 reads the weapon Power stat row at this client offset. */
+constexpr std::size_t kWeaponPowerStatRowOffset = 606;
 /**
  * Client offsets of the 6 character stat rows, in the two runs the blob stores them in.
  * The client reads these as 6 separate scalars, not as one array, so each is named here.
@@ -240,12 +242,14 @@ bool read_investment_constants(const reader::Source& source,
     }
     output.lightStatRow =
         std::to_integer<std::uint8_t>(blob[kConstantsPrefix + kLightStatRowOffset]);
+    output.weaponPowerStatRow =
+        std::to_integer<std::uint8_t>(blob[kConstantsPrefix + kWeaponPowerStatRowOffset]);
     for (std::size_t row = 0; row < std::size(kCharacterStatRowOffsets); ++row) {
         output.characterStatRows[row] =
             std::to_integer<std::uint8_t>(blob[kConstantsPrefix + kCharacterStatRowOffsets[row]]);
     }
-    output.extracted = true;
-    return true;
+    output.extracted = output.weaponPowerStatRow < state::build_data::constants::kStatRowCount;
+    return output.extracted;
 }
 
 } // namespace sunrise::client::content::items::packages

+ 111 - 34
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -1,47 +1,73 @@
 #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 "../../../../middleware/content/packages/tables/items.h"
-#include "../../../../state/account/account_state.h"
-#include "../../../../state/build_data/abilities/definition.h"
-#include "../../../../state/build_data/inventory/buckets/definition.h"
-#include "../../../../state/build_data/items/details/definition.h"
-#include "../../../../state/build_data/progressions/definition.h"
 #include "../../../../state/build_data/runtime.h"
-#include "../../../../state/build_data/socket_entry_lists/definition.h"
-#include "../../../../state/content/content_catalog.h"
-#include "../../../../state/runtime/runtime.h"
-#include "../../../memory/current_process_memory.h"
-#include "../../../targets/game.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"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
-/** @return True when every domain owned by the package pass is published. */
-[[nodiscard]] bool package_domains_ready() noexcept {
-    return state::build_data::item_definitions_ready()
-           && state::build_data::collectible_definitions_ready()
-           && state::build_data::material_requirement_sets_ready()
-           && state::build_data::configured_item_details_ready()
-           && state::build_data::socket_plug_rules_ready()
-           && state::build_data::inventory_bucket_descriptors_ready()
-           && state::build_data::socket_entry_lists_ready()
-           && state::build_data::ability_buckets_ready()
-           && state::build_data::socket_entry_buckets_ready()
-           && state::build_data::progression_definitions_ready()
-           && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
-           && state::build_data::hash_names_ready()
-           && state::build_data::investment_constants_ready();
+/**
+ * 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. */
@@ -56,16 +82,23 @@ namespace {
            && state::build_data::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
-           && state::build_data::investment_constants_ready();
+           && state::build_data::record_definitions_ready()
+           && state::build_data::node_definitions_ready()
+           && state::build_data::sobjects::count() != 0
+           && state::build_data::investment_constants_ready()
+           && state::build_data::exotic_catalysts_ready();
 }
 
 } // namespace
 
+/** @return True when every domain owned by the package pass is published. */
+bool ready() noexcept {
+    return root_domains_ready() && state::build_data::scenario_layouts_ready()
+           && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready();
+}
+
 /** Publishes the dense item table from the installed packages, once. */
 bool build() noexcept {
-    if (package_domains_ready()) {
-        return true;
-    }
     static Storage storage{};
     reader::BlockKeys keys{};
     core::path::Buffer directory{};
@@ -87,16 +120,20 @@ 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);
-        return true;
+        return ready();
     }
     reason = "tag";
     std::array<std::uint32_t, kContainerCandidates> candidates{};
     std::size_t candidateCount = 0;
-    const bool named = investment_globals_tags(candidates, candidateCount);
-    if (named) {
+    if (investment_globals_tags(candidates, candidateCount)) {
         const reader::Source source{directory.chars.data(), &keys};
         tables::Array table{};
         bool located = false;
@@ -137,6 +174,22 @@ bool build() noexcept {
                     continue;
                 }
             }
+            if (!state::build_data::exotic_catalysts_ready()) {
+                reason = "catalyst_gates";
+                if (!read_catalyst_acquisition_gates(source,
+                                                     storage.scratch,
+                                                     std::span<const std::byte>{storage.root},
+                                                     storage.child,
+                                                     storage.catalystAcquisitionGates)
+                    || !read_catalyst_objective_values(
+                        source,
+                        storage.scratch,
+                        std::span<const std::byte>{storage.root},
+                        storage.child,
+                        storage.catalystObjectiveValues)) {
+                    continue;
+                }
+            }
             reason = "buckets";
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
                 continue;
@@ -155,6 +208,30 @@ bool build() noexcept {
                         std::span(storage.progressionRows).first(progressionCount));
                 }
             }
+            if (!state::build_data::node_definitions_ready()) {
+                std::size_t nodeCount = 0;
+                if (build_nodes(source,
+                                storage.scratch,
+                                std::span<const std::byte>{storage.root},
+                                storage.child,
+                                storage.nodeRows,
+                                nodeCount)) {
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(nodeCount));
+                }
+            }
+            if (!state::build_data::record_definitions_ready()) {
+                std::size_t recordCount = 0;
+                if (build_records(source,
+                                  storage.scratch,
+                                  std::span<const std::byte>{storage.root},
+                                  storage.child,
+                                  storage.recordRows,
+                                  recordCount)) {
+                    (void)state::build_data::publish_record_definitions(
+                        std::span(storage.recordRows).first(recordCount));
+                }
+            }
             if (!state::build_data::investment_constants_ready()) {
                 state::build_data::constants::InvestmentConstants extracted{};
                 if (read_investment_constants(source,
@@ -191,7 +268,7 @@ bool build() noexcept {
         }
     }
     SecureZeroMemory(&keys, sizeof keys);
-    const bool complete = package_domains_ready();
+    const bool complete = ready();
     const bool itemDomainsReady = root_domains_ready();
     if (complete) {
         // Nothing reads a package again until the next boot, so this reader's files go back now.

+ 61 - 10
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -1,7 +1,7 @@
 #include <array>
 #include <span>
-#include <vector>
 
+#include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
@@ -43,16 +43,28 @@ bool build_item_rows(const reader::Source& source,
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
+    const bool needCatalysts = !state::build_data::exotic_catalysts_ready();
     const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
-    const bool needDetailRows = needDetails || needSocketPlugs;
+    const bool retainDetails = needDetails || needCatalysts;
+    const bool needSocketRows = needSocketPlugs || needCatalysts;
+    const bool needDetailRows = needDetails || needSocketRows;
     // Bucket equipment slots are derived from this same complete item walk, so a partial retry
     // must still revisit the table even when definitions and detail domains already published.
     const bool needRows = needDefinitions || needDetailRows || needBuckets;
     bool published = !needRows;
-    if (needDetails && storage.details.size() != kDetailCapacity) {
+    if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
     }
-    const bool detailStorageReady = !needDetails || storage.details.size() == kDetailCapacity;
+    if (needCatalysts) {
+        storage.catalystCompletionConditions.assign(
+            static_cast<std::size_t>(table.count),
+            state::build_data::items::catalysts::CompletionCondition{});
+        for (std::size_t item = 0; item < storage.catalystCompletionConditions.size(); ++item) {
+            storage.catalystCompletionConditions[item].itemDefinitionIndex =
+                static_cast<std::uint16_t>(item);
+        }
+    }
+    const bool detailStorageReady = !retainDetails || storage.details.size() == kDetailCapacity;
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
     // The detail closure is gathered during this one walk. Collections can name any installed
@@ -78,6 +90,8 @@ bool build_item_rows(const reader::Source& source,
                                                item)) {
             continue;
         }
+        const std::uint32_t plugCategoryHash =
+            corrected_plug_category(item.definitionHash, item.plugCategoryHash);
         storage.rows[rowCount++] =
             state::build_data::items::Definition{item.definitionHash,
                                                  item.definitionIndex,
@@ -85,12 +99,12 @@ bool build_item_rows(const reader::Source& source,
                                                  item.insertionMaterialRequirementSetIndex,
                                                  item.enabledMaterialRequirementSetIndex,
                                                  item.tier,
-                                                 item.plugCategoryHash,
+                                                 plugCategoryHash,
                                                  item.rollSetIndex,
                                                  item.linkedPlugIndex};
-        if (needSocketPlugs) {
+        if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
-                special_plug_category(item.plugCategoryHash);
+                special_plug_category(plugCategoryHash);
         }
         if (needDetailRows) {
             request(item.definitionIndex, storage.detailRequests);
@@ -119,7 +133,7 @@ bool build_item_rows(const reader::Source& source,
     }
     SocketPlugBuild socketPlugBuild;
     const bool socketStorageReady =
-        !needSocketPlugs
+        !needSocketRows
         || socketPlugBuild.prepare(storage.specialPlugCategories,
                                    std::span(storage.rows).first(rowCount));
     if (published && !socketStorageReady) {
@@ -141,10 +155,17 @@ bool build_item_rows(const reader::Source& source,
                 report_detail_failure(slot, storage.requestedDetailIndices[slot]);
                 continue;
             }
-            if (needDetails) {
+            if (retainDetails) {
                 storage.details[builtDetailCount++] = detail;
             }
-            if (needSocketPlugs) {
+            if (needCatalysts
+                && detail.definitionIndex < storage.catalystCompletionConditions.size()) {
+                read_catalyst_completion_condition(
+                    std::span<const std::byte>{storage.definition},
+                    detail.definitionIndex,
+                    storage.catalystCompletionConditions[detail.definitionIndex]);
+            }
+            if (needSocketRows) {
                 (void)socketPlugBuild.append(item,
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.plugSetTable},
@@ -156,6 +177,30 @@ bool build_item_rows(const reader::Source& source,
                 std::span<build_details::Definition>{storage.details}.first(builtDetailCount));
             report_detail_count(detailCount, builtDetailCount);
         }
+        std::array<state::build_data::items::catalysts::Definition,
+                   state::build_data::items::catalysts::kDefinitionCapacity>
+            catalystRows{};
+        std::size_t catalystCount = 0;
+        state::build_data::items::catalysts::Report catalystReport{};
+        const state::build_data::items::catalysts::Source catalystSource{
+            {},
+            std::span(storage.rows).first(rowCount),
+            std::span(storage.details).first(builtDetailCount),
+            socketPlugBuild.rules(),
+            socketPlugBuild.pools(),
+            socketPlugBuild.members(),
+            storage.catalystCompletionConditions,
+            storage.catalystAcquisitionGates,
+            storage.catalystObjectiveValues,
+        };
+        bool catalystBuilt = false;
+        if (published && needCatalysts) {
+            reason = "exotic_catalysts";
+            catalystBuilt = state::build_data::derive_exotic_catalysts(
+                catalystSource, catalystRows, catalystCount, catalystReport);
+            report_catalyst_catalog(catalystReport, catalystBuilt);
+            published = catalystBuilt;
+        }
         if (published && needSocketPlugs) {
             const std::size_t rules = socketPlugBuild.rule_count();
             const std::size_t pools = socketPlugBuild.pool_count();
@@ -167,6 +212,11 @@ bool build_item_rows(const reader::Source& source,
                 report_socket_plug_count(rules, pools, members, skipped);
             }
         }
+        if (published && needCatalysts && catalystBuilt) {
+            reason = "exotic_catalysts";
+            published = state::build_data::publish_exotic_catalysts(
+                catalystSource, std::span(catalystRows).first(catalystCount));
+        }
     }
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
     // they run last. The entry-bucket table never joins the on-disk cache, so a warm boot still
@@ -199,6 +249,7 @@ bool build_item_rows(const reader::Source& source,
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::socket_plug_rules_ready()
+           && state::build_data::exotic_catalysts_ready()
            && state::build_data::ability_buckets_ready();
 }
 

+ 314 - 0
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -0,0 +1,314 @@
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <unordered_map>
+#include <vector>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the node pass stopped, so a silent miss cannot look like a stuck progress bar. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=nodes result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+} // namespace
+
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ *
+ * A node's progress bar shows a value slot named by its own expression, and the records it owns sit
+ * at row `+136` as a row and a gate. Both are read here so a claim never has to walk the node table.
+ */
+bool build_nodes(const reader::Source& source,
+                 reader::Scratch& scratch,
+                 std::span<const std::byte> root,
+                 std::vector<std::byte>& blob,
+                 std::span<state::build_data::nodes::Definition> output,
+                 std::size_t& count) noexcept {
+    namespace domain = state::build_data::nodes;
+    count = 0;
+
+    // The account flag mapping table, read first. A category's gate names a flag slot, and a slot
+    // is not an index: the byte that feeds it sits at the row whose destination is that slot.
+    std::uint32_t flagMapTag = 0;
+    tables::Array flagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> flagIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, flagMapTag) && flagMapTag != 0
+        && tables::package_of(flagMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, flagMapTag, blob)
+        && tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kAccountFlagMapDescriptor, flagMapRows)
+        && flagMapRows.count != 0
+        && flagMapRows.dataOffset
+                   + static_cast<std::size_t>(flagMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0; row < flagMapRows.count && row <= domain::kUnavailableFlagIndex;
+             ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + flagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            flagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+
+    tables::Array characterFlagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterFlagIndexBySlot{};
+    if (flagMapTag != 0
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kCharacterFlagMapDescriptor,
+                                 characterFlagMapRows)
+        && characterFlagMapRows.count != 0
+        && characterFlagMapRows.dataOffset
+                   + static_cast<std::size_t>(characterFlagMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterFlagMapRows.count && row <= domain::kUnavailableFlagIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterFlagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterFlagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, mapTag) || mapTag == 0
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountValueMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("value_map_fail", mapTag);
+        return false;
+    }
+    std::unordered_map<std::int16_t, std::uint16_t> indexBySlot{};
+    for (std::uint64_t row = 0; row < mapRows.count && row <= domain::kUnavailableValueIndex;
+         ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+    }
+
+
+    tables::Array characterValueMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterValueIndexBySlot{};
+    if (tables::find_array_at(std::span<const std::byte>{blob},
+                              tables::kCharacterValueMapDescriptor,
+                              characterValueMapRows)
+        && characterValueMapRows.count != 0
+        && characterValueMapRows.dataOffset
+                   + static_cast<std::size_t>(characterValueMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterValueMapRows.count && row <= domain::kUnavailableValueIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterValueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterValueIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kPresentationNodeTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kNodeRowStride
+               > blob.size()) {
+        report("node_table_fail", tableTag);
+        return false;
+    }
+
+    const std::span<const std::byte> table{blob};
+    std::size_t driving = 0;
+
+    // Pass 1: resolve every node's own value slot and children.
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kNodeRowStride;
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+
+        // The expression sits at one of two fields, and only one of them holds it on any node.
+        std::int16_t slot = 0;
+        const bool named =
+            tables::expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
+            || tables::expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
+        if (named) {
+            definition.valueSlot = slot;
+            const auto found = indexBySlot.find(slot);
+            if (found != indexBySlot.end()) {
+                definition.valueIndex = found->second;
+            }
+            // The same expression may resolve in the character scope: one lore book's bar reads a
+            // slot only the character table carries, and its parent has to be fed there too.
+            definition.characterValueSlot = slot;
+            const auto character_resolved = characterValueIndexBySlot.find(slot);
+            if (character_resolved != characterValueIndexBySlot.end()) {
+                definition.characterValueIndex = character_resolved->second;
+            }
+        }
+
+        // A category gated on a flag rather than on its own progress cannot reveal itself by being
+        // played: with no title shown there is nothing inside to claim, and nothing to claim leaves
+        // the gate shut. Resolve that flag so the gate can be satisfied.
+        std::int16_t gateSlot = 0;
+        if (tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldPrimary, gateSlot)
+            || tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldAlternate, gateSlot)) {
+            const auto gate = flagIndexBySlot.find(gateSlot);
+            if (gate != flagIndexBySlot.end()) {
+                definition.visibilityFlagIndex = gate->second;
+            }
+            const auto characterGate = characterFlagIndexBySlot.find(gateSlot);
+            if (characterGate != characterFlagIndexBySlot.end()) {
+                definition.visibilityCharacterFlagIndex = characterGate->second;
+            }
+        }
+
+
+
+        // Records the node owns, four bytes each as a row and a gate.
+        std::int64_t childCount = 0;
+        std::int64_t childRelative = 0;
+        std::memcpy(&childCount, table.data() + at + tables::kNodeChildRecordField,
+                    sizeof childCount);
+        std::memcpy(&childRelative, table.data() + at + tables::kNodeChildRecordField + 8,
+                    sizeof childRelative);
+        if (childCount >= 1 && childCount <= static_cast<std::int64_t>(domain::kChildCapacity)) {
+            const std::size_t pointerAt = at + tables::kNodeChildRecordField + 8;
+            const std::int64_t target = static_cast<std::int64_t>(pointerAt) + childRelative
+                                        + static_cast<std::int64_t>(tables::kHeaderSkip);
+            if (target >= 0
+                && static_cast<std::size_t>(target)
+                           + static_cast<std::size_t>(childCount) * tables::kNodeChildRecordStride
+                       <= table.size()) {
+                const auto base = static_cast<std::size_t>(target);
+                for (std::int64_t index = 0; index < childCount; ++index) {
+                    std::uint16_t childRow = 0;
+                    std::memcpy(&childRow,
+                                table.data() + base
+                                    + static_cast<std::size_t>(index)
+                                          * tables::kNodeChildRecordStride,
+                                sizeof childRow);
+                    definition.children[static_cast<std::size_t>(definition.childCount++)] =
+                        childRow;
+                }
+            }
+        }
+        if (definition.childCount != 0 && definition.valueIndex != domain::kUnavailableValueIndex) {
+            ++driving;
+        }
+        ++count;
+    }
+
+    // Pass 2: assign each lore book's parent-record bar slot from the shipped allocation.
+    //
+    // The naive rule (parent = category slot + 1) holds only when the slot above a category was
+    // free at allocation time. Categories were handed out in contiguous runs, and a run's parent
+    // slots were deferred to immediately after the run, assigned in reverse category order: the
+    // run's first book takes the last parent slot, its last book takes the first. Verified against
+    // four independent in-game marker readings; see kNodeParentSlotStep in definition_index_table.h.
+    //
+    // Both scopes are walked. Most books are account-scoped; one reads its category from the
+    // character table, and its parent sits in that scope too.
+    {
+        struct BookSlot {
+            std::int32_t slot;
+            std::size_t row;
+        };
+        std::vector<BookSlot> accountBooks;
+        std::vector<BookSlot> characterBooks;
+        for (std::size_t row = 0; row < count; ++row) {
+            const auto& d = output[row];
+            if (!domain::lore_category(d.definitionIndex)) {
+                continue;
+            }
+            if (d.valueSlot >= 0) {
+                accountBooks.push_back({d.valueSlot, row});
+            }
+            if (d.characterValueSlot >= 0) {
+                characterBooks.push_back({d.characterValueSlot, row});
+            }
+        }
+
+        auto assignRuns = [&](std::vector<BookSlot>& books,
+                              const std::unordered_map<std::int16_t, std::uint16_t>& indexBySlot,
+                              auto memberSetter) {
+            std::sort(books.begin(), books.end(),
+                      [](const BookSlot& a, const BookSlot& b) { return a.slot < b.slot; });
+            std::size_t i = 0;
+            while (i < books.size()) {
+                std::size_t j = i;
+                while (j + 1 < books.size() && books[j + 1].slot == books[j].slot + 1) {
+                    ++j;
+                }
+                const std::size_t runLength = j - i + 1;
+                const std::int32_t runEnd = books[j].slot;
+                for (std::size_t k = 0; k < runLength; ++k) {
+                    // Reverse order within the run: book k gets the slot after the run counting
+                    // back from its end.
+                    const std::int32_t parentSlot =
+                        runEnd + static_cast<std::int32_t>(runLength - k);
+                    const auto found = indexBySlot.find(static_cast<std::int16_t>(parentSlot));
+                    if (found != indexBySlot.end()) {
+                        memberSetter(output[books[i + k].row], found->second);
+                    }
+                }
+                i = j + 1;
+            }
+        };
+
+        assignRuns(accountBooks,
+                   indexBySlot,
+                   [](domain::Definition& d, std::uint16_t index) {
+                       d.parentValueIndex = index;
+                   });
+        assignRuns(characterBooks,
+                   characterValueIndexBySlot,
+                   [](domain::Definition& d, std::uint16_t index) {
+                       d.parentCharacterValueIndex = index;
+                   });
+    }
+
+    report("ok", static_cast<unsigned long long>(driving));
+
+
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 177 - 0
Sunrise/src/client/content/items/packages/package_record_build.cpp

@@ -0,0 +1,177 @@
+#include <array>
+#include <cstdio>
+#include <cstring>
+
+#include "../../../../core/logging/log.h"
+
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the record pass stopped, so a silent miss cannot look like a working claim. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=records result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/** A record with no completion flag carries a non-positive slot, which addresses nothing. */
+[[nodiscard]] constexpr bool addressable_slot(std::int16_t slot) noexcept {
+    return slot > 0;
+}
+
+
+} // namespace
+
+/**
+ * Reads the records table and resolves each record's completion flag to a bank index.
+ *
+ * A record row carries the unlock slot of its completion flag, and a slot is not an array index:
+ * the byte that feeds a slot sits at the row number of the mapping table whose destination is that
+ * slot. Both tables are walked here so a claim can go straight from a record row to the index it
+ * has to set.
+ */
+bool build_records(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   std::span<const std::byte> root,
+                   std::vector<std::byte>& blob,
+                   std::span<state::build_data::records::Definition> output,
+                   std::size_t& count) noexcept {
+    namespace domain = state::build_data::records;
+    count = 0;
+
+    // The account flag mapping table, read first because the record rows are matched against it.
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, mapTag) || mapTag == 0
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountFlagMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("flag_map_fail", mapTag);
+        return false;
+    }
+
+    // Destination slot to mapping row. The first row wins, matching how a bank is addressed.
+    constexpr std::size_t kSlotSpace = 32768;
+    static_assert(domain::kUnavailableFlagIndex == 0xFFFFU);
+    std::vector<std::uint16_t> indexBySlot{};
+    indexBySlot.assign(kSlotSpace, domain::kUnavailableFlagIndex);
+    for (std::uint64_t row = 0; row < mapRows.count; ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+            || row > domain::kUnavailableFlagIndex) {
+            continue;
+        }
+        std::uint16_t& existing = indexBySlot[static_cast<std::size_t>(slot)];
+        if (existing == domain::kUnavailableFlagIndex) {
+            existing = static_cast<std::uint16_t>(row);
+        }
+    }
+
+    // The account value mapping table, read while the blob is still free. A record names its
+    // category's value slot, and that slot has to become an index the same way a flag slot does.
+    std::uint32_t valueMapTag = 0;
+    tables::Array valueMapRows{};
+    std::vector<std::uint16_t> valueIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockValueMapTableSlot, valueMapTag) && valueMapTag != 0
+        && tables::package_of(valueMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, valueMapTag, blob)
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kAccountValueMapDescriptor,
+                                 valueMapRows)
+        && valueMapRows.count != 0
+        && valueMapRows.dataOffset
+                   + static_cast<std::size_t>(valueMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        valueIndexBySlot.assign(kSlotSpace, domain::kUnavailableValueIndex);
+        for (std::uint64_t row = 0; row < valueMapRows.count; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + valueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+                || row > domain::kUnavailableValueIndex) {
+                continue;
+            }
+            std::uint16_t& existing = valueIndexBySlot[static_cast<std::size_t>(slot)];
+            if (existing == domain::kUnavailableValueIndex) {
+                existing = static_cast<std::uint16_t>(row);
+            }
+        }
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kRecordTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kRecordRowStride
+               > blob.size()) {
+        report("record_table_fail", tableTag);
+        return false;
+    }
+
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kRecordRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot,
+                    blob.data() + at + tables::kRecordCompletionFlagOffset,
+                    sizeof slot);
+        std::uint32_t score = 0;
+        std::memcpy(&score, blob.data() + at + tables::kRecordScoreOffset, sizeof score);
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+        std::memcpy(&definition.definitionHash, blob.data() + at + tables::kRecordHashOffset,
+                    sizeof definition.definitionHash);
+        // The lore row this record displays, or 0xFFFF for a book's parent triumph.
+        std::memcpy(&definition.loreRow, blob.data() + at + tables::kLoreRowOffset,
+                    sizeof definition.loreRow);
+        // The shipped table tops out at 500, so anything wider is not a score and is dropped.
+        definition.scoreValue = score <= 0xFFFFU ? static_cast<std::uint16_t>(score) : 0U;
+        std::uint32_t hasTitle = 0;
+        std::memcpy(&hasTitle, blob.data() + at + tables::kRecordHasTitleOffset, sizeof hasTitle);
+        definition.hasTitle = hasTitle != 0;
+        std::int16_t categorySlot = 0;
+        if (!valueIndexBySlot.empty()
+            && tables::expression_value_slot(std::span<const std::byte>{blob},
+                                     at,
+                                     tables::kRecordCategoryExpressionField,
+                                     categorySlot)
+            && addressable_slot(categorySlot)
+            && static_cast<std::size_t>(categorySlot) < kSlotSpace) {
+            definition.categoryValueIndex = valueIndexBySlot[static_cast<std::size_t>(categorySlot)];
+        }
+        if (addressable_slot(slot) && static_cast<std::size_t>(slot) < kSlotSpace) {
+            definition.completionFlagIndex = indexBySlot[static_cast<std::size_t>(slot)];
+        }
+        ++count;
+    }
+    report("ok", static_cast<unsigned long long>(count));
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 191 - 9
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -1,8 +1,10 @@
 #include "package_socket_plug_build.h"
 
 #include <algorithm>
+#include <cstring>
 #include <limits>
 
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
 
 namespace sunrise::client::content::items::packages {
@@ -20,11 +22,36 @@ constexpr std::array<std::uint32_t, 3> kTrackerPlugHashes{
     2'302'094'943U,
     38'912'240U,
 };
+/** Enhanced Sword Scavenger already carries the correct Arrivals leg-armour socket relation. */
+constexpr std::uint32_t kArrivalsLegReferenceHash = 3'213'968'579U;
+/** Plug category declared by Enhanced Sword Scavenger and required by leg-armour sockets. */
+constexpr std::uint32_t kArrivalsLegCategoryHash = 0x7DDE0206U;
+/** Arrivals artifact records whose leg-armour label conflicts with their shipped general pool. */
+constexpr std::array<std::uint32_t, 4> kArrivalsLegModHashes{
+    3'465'659'109U, // Flourishing Blade
+    3'465'659'111U, // Automatic Prize
+    3'465'659'104U, // Dimensional Tithes
+    3'465'659'105U, // Ascendant Bounty
+};
 /** Native ordinary socket type whose choices are the synthetic tracker set. */
 constexpr std::uint16_t kTrackerSocketType = 518;
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
+/** One acquired-state rule starts with kind 1 and then its item-definition index. */
+constexpr std::uint32_t kAcquisitionRuleKind = 1;
+constexpr std::size_t kAcquisitionRuleSize = 8;
+
+/** Reads one trivially copied scalar from a bounded package blob. */
+template <typename Value>
+[[nodiscard]] bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
 
 /** Visitor adapter that appends one list member to a bounded lane candidate. */
 struct VisitorContext {
@@ -32,7 +59,11 @@ struct VisitorContext {
     std::size_t itemDefinitionCount{};
 };
 
-/** @return Whether the package-provided member was accepted into bounded scratch. */
+/**
+ * @param opaque Visitor context supplied by the socket build.
+ * @param itemDefinitionIndex Package-provided plug index.
+ * @return True when the plug was accepted into bounded scratch.
+ */
 [[nodiscard]] bool visit_member(void* opaque, std::uint32_t itemDefinitionIndex) noexcept {
     auto& context = *static_cast<VisitorContext*>(opaque);
     return context.build != nullptr
@@ -41,6 +72,92 @@ struct VisitorContext {
 
 } // namespace
 
+bool read_catalyst_acquisition_gates(const reader::Source& source,
+                                     reader::Scratch& scratch,
+                                     std::span<const std::byte> root,
+                                     std::vector<std::byte>& blob,
+                                     std::vector<catalysts::AcquisitionGate>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kSocketTypeTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kSocketTypeTableClass || table.count == 0
+        || table.count > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kSocketTypeRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        catalysts::AcquisitionGate& gate = output[index];
+        gate.socketType = static_cast<std::uint16_t>(index);
+        const std::size_t row = table.dataOffset + index * tables::kSocketTypeRowStride;
+        tables::Array rules{};
+        if (!tables::find_array_at(bytes, row + tables::kSocketTypeAcquisitionDescriptor, rules)) {
+            continue;
+        }
+        if (rules.count != 1 || rules.elementClass != tables::kInvestmentExpressionRowClass
+            || rules.dataOffset > bytes.size()
+            || bytes.size() - rules.dataOffset < kAcquisitionRuleSize) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        std::uint32_t kind = 0;
+        std::uint32_t definitionIndex = 0;
+        if (!read(bytes, rules.dataOffset, kind)
+            || !read(bytes, rules.dataOffset + sizeof kind, definitionIndex)
+            || kind != kAcquisitionRuleKind
+            || definitionIndex >= state::build_data::items::kDefinitionCapacity) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        gate.definitionIndex = static_cast<std::uint16_t>(definitionIndex);
+        gate.state = catalysts::AcquisitionState::present;
+    }
+    return true;
+}
+
+bool read_catalyst_objective_values(const reader::Source& source,
+                                    reader::Scratch& scratch,
+                                    std::span<const std::byte> root,
+                                    std::vector<std::byte>& blob,
+                                    std::vector<std::int32_t>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kObjectiveTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob, tableClass)
+        || tableClass != tables::kObjectiveTableClass
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kObjectiveRowClass || table.count == 0
+        || table.count > catalysts::kUnavailableObjectiveIndex) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kObjectiveRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        const std::size_t offset = table.dataOffset + index * tables::kObjectiveRowStride
+                                   + tables::kObjectiveCompletionValueOffset;
+        if (!read(bytes, offset, output[index])) {
+            output.clear();
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Returns the compact 1-based code of one native category-expansion family. */
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
@@ -51,6 +168,15 @@ std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     return 0;
 }
 
+/** Makes the four mislabeled artifact definitions agree with their leg-armour presentation. */
+std::uint32_t corrected_plug_category(std::uint32_t definitionHash,
+                                      std::uint32_t categoryHash) noexcept {
+    return std::find(kArrivalsLegModHashes.begin(), kArrivalsLegModHashes.end(), definitionHash)
+                   != kArrivalsLegModHashes.end()
+               ? kArrivalsLegCategoryHash
+               : categoryHash;
+}
+
 /** Allocates the bounded build state and indexes expansion/tracker plug definitions. */
 bool SocketPlugBuild::prepare(
     std::span<const std::uint8_t> specialCategories,
@@ -85,6 +211,20 @@ bool SocketPlugBuild::prepare(
             }
             trackerMembers_[trackerCount_++] = static_cast<std::uint16_t>(item);
         }
+        if (itemDefinitions[item].definitionHash == kArrivalsLegReferenceHash) {
+            arrivalsLegReference_ = static_cast<std::uint16_t>(item);
+        }
+        for (std::size_t mod = 0; mod < kArrivalsLegModHashes.size(); ++mod) {
+            if (itemDefinitions[item].definitionHash == kArrivalsLegModHashes[mod]) {
+                arrivalsLegMembers_[mod] = static_cast<std::uint16_t>(item);
+            }
+        }
+    }
+    if (arrivalsLegReference_ == UINT16_MAX
+        || std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), UINT16_MAX)
+               != arrivalsLegMembers_.end()) {
+        release();
+        return false;
     }
     return true;
 }
@@ -101,6 +241,33 @@ bool SocketPlugBuild::add(std::uint32_t itemDefinitionIndex,
     return true;
 }
 
+/** Mirrors Enhanced Sword Scavenger's exact lane admission onto the four reclassified mods. */
+bool SocketPlugBuild::route_arrivals_leg_mods() noexcept {
+    const bool legLane = std::find(candidates_.data(),
+                                   candidates_.data() + candidateCount_,
+                                   arrivalsLegReference_)
+                         != candidates_.data() + candidateCount_;
+    const auto isReclassified = [this](socket_plugs::Member member) noexcept {
+        return std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), member)
+               != arrivalsLegMembers_.end();
+    };
+    candidateCount_ = static_cast<std::size_t>(
+        std::remove_if(candidates_.data(),
+                       candidates_.data() + candidateCount_,
+                       isReclassified)
+        - candidates_.data());
+    if (!legLane) {
+        return true;
+    }
+    for (const socket_plugs::Member member : arrivalsLegMembers_) {
+        if (candidateCount_ >= candidates_.size()) {
+            return false;
+        }
+        candidates_[candidateCount_++] = member;
+    }
+    return true;
+}
+
 /** Expands special category seeds, sorts/deduplicates, then interns one exact pool. */
 bool SocketPlugBuild::intern(std::uint32_t& poolIndex) noexcept {
     poolIndex = socket_plugs::kEmptyPoolIndex;
@@ -133,6 +300,9 @@ bool SocketPlugBuild::intern(std::uint32_t& poolIndex) noexcept {
         std::copy_n(first, categoryCounts_[family], candidates_.data() + candidateCount_);
         candidateCount_ += categoryCounts_[family];
     }
+    if (!route_arrivals_leg_mods()) {
+        return false;
+    }
     std::sort(candidates_.data(), candidates_.data() + candidateCount_);
     candidateCount_ = static_cast<std::size_t>(
         std::unique(candidates_.data(), candidates_.data() + candidateCount_) - candidates_.data());
@@ -218,15 +388,13 @@ bool SocketPlugBuild::append(const tables::items::Row& item,
     return complete;
 }
 
-/** Publishes the bounded relation and releases all transient interning memory. */
+/** Publishes the bounded relation and retains its rows for dependent package builders. */
 bool SocketPlugBuild::publish() noexcept {
-    const bool published =
-        !rules_.empty() && !pools_.empty() && !members_.empty()
-        && state::build_data::publish_socket_plug_rules(std::span(rules_.data(), ruleCount_),
-                                                        std::span(pools_.data(), poolCount_),
-                                                        std::span(members_.data(), memberCount_));
-    release();
-    return published;
+    return !rules_.empty() && !pools_.empty() && !members_.empty()
+           && state::build_data::publish_socket_plug_rules(
+               std::span(rules_.data(), ruleCount_),
+               std::span(pools_.data(), poolCount_),
+               std::span(members_.data(), memberCount_));
 }
 
 /** Reports how many lanes failed closed during extraction. */
@@ -246,6 +414,18 @@ std::size_t SocketPlugBuild::member_count() const noexcept {
     return memberCount_;
 }
 
+std::span<const socket_plugs::Rule> SocketPlugBuild::rules() const noexcept {
+    return std::span(rules_).first(ruleCount_);
+}
+
+std::span<const socket_plugs::Pool> SocketPlugBuild::pools() const noexcept {
+    return std::span(pools_).first(poolCount_);
+}
+
+std::span<const socket_plugs::Member> SocketPlugBuild::members() const noexcept {
+    return std::span(members_).first(memberCount_);
+}
+
 /** Drops all heap-backed extraction scratch and resets every count. */
 void SocketPlugBuild::release() noexcept {
     rules_.clear();
@@ -262,6 +442,8 @@ void SocketPlugBuild::release() noexcept {
     lookup_.shrink_to_fit();
     categoryCounts_ = {};
     trackerMembers_ = {};
+    arrivalsLegReference_ = UINT16_MAX;
+    arrivalsLegMembers_.fill(UINT16_MAX);
     trackerCount_ = 0;
     ruleCount_ = 0;
     poolCount_ = 0;

+ 72 - 2
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -6,14 +6,61 @@
 #include <span>
 #include <vector>
 
+#include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/items.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 
 namespace sunrise::client::content::items::packages {
 
+namespace reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 namespace socket_plugs = state::build_data::items::socket_plugs;
+namespace catalysts = state::build_data::items::catalysts;
+
+/**
+ * Reads the dense socket-type table and extracts each type's acquired-state gate.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the socket-type table.
+ * @param output Receives one row per native socket type.
+ * @return True when the fixed table shape and every row extent are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_acquisition_gates(const reader::Source& source,
+                                reader::Scratch& scratch,
+                                std::span<const std::byte> root,
+                                std::vector<std::byte>& blob,
+                                std::vector<catalysts::AcquisitionGate>& output) noexcept;
+
+/**
+ * Reads the dense objective table's build-defined completion values.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the objective table.
+ * @param output Receives one completion value per native objective index.
+ * @return True when the table class, row class, count, and fixed rows are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_objective_values(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::vector<std::int32_t>& output) noexcept;
+
+/**
+ * Finds every positive flag and `value >= literal` term plus one objective reference.
+ * Duplicate terms are folded. Conflicting objectives or fixed-capacity overflow fail closed.
+ * @param definition Complete installed definition of the catalyst effect item.
+ * @param itemDefinitionIndex Native index of the catalyst effect item.
+ * @param output Receives the unique condition or its absent or ambiguous state.
+ */
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept;
 
 /** Fixed-size, heap-backed interning state for one installed package pass. */
 class SocketPlugBuild final {
@@ -23,7 +70,12 @@ public:
     SocketPlugBuild(const SocketPlugBuild&) = delete;
     SocketPlugBuild& operator=(const SocketPlugBuild&) = delete;
 
-    /** Allocates bounded scratch and indexes the three native expandable plug categories. */
+    /**
+     * Allocates bounded scratch and indexes the three native expandable plug categories.
+     * @param specialCategories Category code for each native item index.
+     * @param itemDefinitions Complete installed item table.
+     * @return True when every bounded scratch bank is ready.
+     */
     [[nodiscard]] bool
     prepare(std::span<const std::uint8_t> specialCategories,
             std::span<const state::build_data::items::Definition> itemDefinitions) noexcept;
@@ -34,14 +86,23 @@ public:
                               std::span<const std::byte> plugSetTable,
                               std::size_t itemDefinitionCount) noexcept;
 
-    /** Publishes the completed exact relation, then releases its transient scratch. */
+    /** Publishes the completed exact relation. The object retains source rows until destruction. */
     [[nodiscard]] bool publish() noexcept;
 
     /** @return Socket lanes skipped because their package lists were malformed or over capacity. */
     [[nodiscard]] std::size_t skipped() const noexcept;
+    /** @return Number of extracted socket rules. */
     [[nodiscard]] std::size_t rule_count() const noexcept;
+    /** @return Number of interned socket pools. */
     [[nodiscard]] std::size_t pool_count() const noexcept;
+    /** @return Number of members in all interned socket pools. */
     [[nodiscard]] std::size_t member_count() const noexcept;
+    /** @return Extracted socket rules, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Rule> rules() const noexcept;
+    /** @return Interned socket pools, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Pool> pools() const noexcept;
+    /** @return Interned socket members, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Member> members() const noexcept;
 
     /** Package-list visitor entry point; accepts only an in-range bounded native index. */
     [[nodiscard]] bool add(std::uint32_t itemDefinitionIndex,
@@ -66,6 +127,9 @@ private:
     std::vector<PoolLookup> lookup_{};
     std::array<std::size_t, kCategoryCount> categoryCounts_{};
     std::array<socket_plugs::Member, 3> trackerMembers_{};
+    socket_plugs::Member arrivalsLegReference_{UINT16_MAX};
+    std::array<socket_plugs::Member, 4> arrivalsLegMembers_{
+        UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX};
     std::size_t trackerCount_{};
     std::size_t ruleCount_{};
     std::size_t poolCount_{};
@@ -75,6 +139,8 @@ private:
 
     /** Expands native category families, canonicalizes, and interns the current candidate. */
     [[nodiscard]] bool intern(std::uint32_t& poolIndex) noexcept;
+    /** Replaces the four Worthy-era general memberships with the reference Arrivals leg set. */
+    [[nodiscard]] bool route_arrivals_leg_mods() noexcept;
     /** Releases every transient allocation and count. */
     void release() noexcept;
 };
@@ -85,4 +151,8 @@ private:
  */
 [[nodiscard]] std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept;
 
+/** Applies installed-season plug-category corrections absent from the Worthy package rows. */
+[[nodiscard]] std::uint32_t corrected_plug_category(std::uint32_t definitionHash,
+                                                    std::uint32_t categoryHash) noexcept;
+
 } // namespace sunrise::client::content::items::packages

+ 30 - 0
Sunrise/src/client/content/scenarios/internal.h

@@ -58,6 +58,35 @@ struct RosterStorage {
     std::size_t cursor{};
     /** Tag reads spent in the current call, which is what bounds how long it blocks. */
     std::size_t reads{};
+    /**
+     * Destination whose scenario is being walked, for diagnostics only.
+     * Several scenarios share one map and walk the same bubbles, so a per-object trace without
+     * this cannot say which destination reached an object and is easy to misread.
+     */
+    std::uint32_t destinationTag{};
+    /**
+     * Why the descriptor walk of the object being resolved fell short, counted per exit.
+     * A group is refused when its found slots miss its declared ones, and the summary says only
+     * how many were refused. These say which step lost them, which is what picks the fix.
+     * Cleared with the slot list, so every count belongs to one object.
+     */
+    struct WalkExits {
+        /** Handles enumerated across the object's per-bubble sub-blocks. */
+        std::size_t handles{};
+        /** Descriptor blobs reached, which is where a slot can still be recorded. */
+        std::size_t blobs{};
+        /** A bubble entry did not decode, which abandons every bubble after it. */
+        std::size_t bubbleAborts{};
+        /** A placed handle did not decode, which abandons the rest of the walk. */
+        std::size_t handleAborts{};
+        /** One handle's chain reached a tag that would not read. */
+        std::size_t readFailures{};
+        /** One handle's chain reached a class with no next tag. */
+        std::size_t chainEnds{};
+        /** One handle's chain was still unresolved at the depth limit. */
+        std::size_t depthExhausted{};
+    };
+    WalkExits exits{};
 };
 
 /** Tag-read budget bounds one process-freeze interval and keeps worker shutdown responsive. */
@@ -246,6 +275,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept;
                                   reader::Scratch& scratch,
                                   RosterStorage& storage,
                                   std::uint32_t objectTag,
+                                  std::uint32_t sliceSetIndex,
                                   std::uint16_t& group) noexcept;
 
 /**

+ 2 - 1
Sunrise/src/client/content/scenarios/scenario_roster_build.cpp

@@ -107,7 +107,7 @@ void note_candidate(Walk& walk,
                 return false;
             }
             std::uint16_t group = kNotARosterGroup;
-            if (!resolve_object(source, scratch, storage, objectTag, group)) {
+            if (!resolve_object(source, scratch, storage, objectTag, sliceSetIndex, group)) {
                 return false;
             }
             if (group == kNotARosterGroup) {
@@ -197,6 +197,7 @@ bool build_rosters(const reader::Source& source,
         }
         layouts::Definition& row = rows[storage.cursor];
         ++storage.cursor;
+        storage.destinationTag = row.tag;
         row.rosterGroupCount = 0;
         row.rosterGroups = {};
         row.bubbleGroupCount = 0;

+ 152 - 0
Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

@@ -1,3 +1,9 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <span>
+
+#include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
@@ -34,6 +40,7 @@ struct ChainReadContext {
     auto& chain = *static_cast<ChainReadContext*>(context);
     ++chain.storage->reads;
     if (!reader::read_tag(*chain.source, *chain.scratch, tag, chain.storage->chain, classId)) {
+        ++chain.storage->exits.readFailures;
         blob = {};
         return false;
     }
@@ -83,13 +90,16 @@ struct ChainReadContext {
     for (std::uint64_t index = 0; index < bubbles.count; ++index) {
         tables::ObjectBubble bubble{};
         if (!tables::object_bubble_at(objectBlob, bubbles, index, bubble)) {
+            ++storage.exits.bubbleAborts;
             return false;
         }
         for (std::uint64_t slot = 0; slot < bubble.handleCount; ++slot) {
             std::uint32_t handle = 0;
             if (!tables::object_placed_handle_at(objectBlob, bubble, slot, handle)) {
+                ++storage.exits.handleAborts;
                 return false;
             }
+            ++storage.exits.handles;
             if (!follow_handle(source, scratch, storage, handle, registryKey)) {
                 return false;
             }
@@ -98,6 +108,136 @@ struct ChainReadContext {
     return true;
 }
 
+/**
+ * Group objects reported per run. The measured drop count is 59, so this shows every one and
+ * still bounds a content tree that drops far more.
+ */
+constexpr std::size_t kMaxUnresolvedReports = 128;
+/** Size of one line, set by its tag, key and the per-exit counts that follow them. */
+constexpr std::size_t kUnresolvedLineCapacity = 256;
+
+/** Lines already spent, so a long walk cannot flood the sink. */
+std::atomic_size_t g_unresolvedReports{0};
+
+/**
+ * Names one group object the descriptor walk could not fill.
+ * The domain summary counts these but names none, so a walk that drops most of what it finds
+ * reads the same as one that found little. The gap between declared and found is what says
+ * whether the chain stopped early or the classification refused what it reached.
+ * @param objectTag Tag of the object being resolved.
+ * @param registryKey Registry key the object declares.
+ * @param declaredSlotCount Slots the object's own slot array declares.
+ * @param storage Working storage holding what the walk recovered.
+ */
+void report_unresolved(std::uint32_t objectTag,
+                       std::uint32_t registryKey,
+                       std::uint64_t declaredSlotCount,
+                       const RosterStorage& storage) noexcept {
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_unresolvedReports.fetch_add(1, std::memory_order_relaxed) >= kMaxUnresolvedReports) {
+        return;
+    }
+    std::array<char, kUnresolvedLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=roster result=unresolved tag=0x%08X "
+                                      "key=0x%08X declared=%llu found=%zu overflow=%u "
+                                      "handles=%zu blobs=%zu bubble_abort=%zu handle_abort=%zu "
+                                      "read_fail=%zu chain_end=%zu depth=%zu",
+                                      objectTag,
+                                      registryKey,
+                                      static_cast<unsigned long long>(declaredSlotCount),
+                                      storage.slotCount,
+                                      storage.slotsOverflowed ? 1U : 0U,
+                                      storage.exits.handles,
+                                      storage.exits.blobs,
+                                      storage.exits.bubbleAborts,
+                                      storage.exits.handleAborts,
+                                      storage.exits.readFailures,
+                                      storage.exits.chainEnds,
+                                      storage.exits.depthExhausted);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Objects named per run by the placement trace.
+ * The installed tree holds 5,991 placed objects and each is traced once, so this shows every one.
+ */
+constexpr std::size_t kMaxPlacementReports = 8192;
+/** Slot types listed per line. No installed object declares more than this many. */
+constexpr std::size_t kTracedSlotTypes = 24;
+/** Size of one line: the fixed fields plus up to `kTracedSlotTypes` short decimal numbers. */
+constexpr std::size_t kPlacementLineCapacity = 256;
+
+/** Lines already spent, so a full content walk cannot flood the sink. */
+std::atomic_size_t g_placementReports{0};
+
+/**
+ * Names one placed object and every slot type it declares, before any filter has judged it.
+ * `carries_roster_slot` admits an object only when it declares one of `kRosterSlotTypes`, and an
+ * object it refuses leaves no trace anywhere: it is not counted, not published, and not reported.
+ * So a bubble whose objects all declare some other type is indistinguishable from a bubble with no
+ * objects at all, which is exactly the ambiguity that has to be settled before that list is
+ * widened. Widening it blind is not safe — `kRosterKeyCapacity` overflow costs a destination every
+ * group it publishes today, so this reports what the filter sees rather than changing it.
+ * @param sliceSetIndex Slice set whose registry named this object.
+ * @param objectTag Tag of the placed object.
+ * @param object Whole placed-object bytes.
+ * @param admitted Whether `carries_roster_slot` accepted it.
+ */
+void report_placement(std::uint32_t destinationTag,
+                      std::uint32_t sliceSetIndex,
+                      std::uint32_t objectTag,
+                      std::span<const std::byte> object,
+                      bool admitted) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_placementReports.fetch_add(1, std::memory_order_relaxed) >= kMaxPlacementReports) {
+        return;
+    }
+    std::uint32_t key = 0;
+    (void)tables::object_key(object, key);
+    tables::Array slots{};
+    const bool hasSlots = tables::object_slots(object, slots);
+    std::array<char, kPlacementLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=placement dest=0x%08X slice=%u bubble=%u "
+                                "tag=0x%08X key=0x%08X admitted=%u slots=%llu types=",
+                                destinationTag,
+                                sliceSetIndex,
+                                sliceSetIndex / tables::kSliceSetIndexFactor,
+                                objectTag,
+                                key,
+                                admitted ? 1U : 0U,
+                                hasSlots ? static_cast<unsigned long long>(slots.count) : 0ULL);
+    if (written <= 0) {
+        return;
+    }
+    auto used = static_cast<std::size_t>(written);
+    const std::uint64_t listed =
+        hasSlots && slots.count < kTracedSlotTypes ? slots.count : kTracedSlotTypes;
+    for (std::uint64_t index = 0; hasSlots && index < listed && used < line.size(); ++index) {
+        tables::Slot slot{};
+        if (!tables::object_slot_at(object, slots, index, slot)) {
+            break;
+        }
+        written = std::snprintf(
+            line.data() + used, line.size() - used, index == 0 ? "%u" : ",%u", slot.type);
+        if (written <= 0) {
+            break;
+        }
+        used += static_cast<std::size_t>(written);
+    }
+    core::log::write(core::log::Channel::state, core::log::Level::debug, {line.data(), used});
+}
+
 /** @param storage Working storage. @param tag Object tag. @return Its memo slot, or capacity. */
 [[nodiscard]] std::size_t memo_slot(const RosterStorage& storage, std::uint32_t tag) noexcept {
     std::size_t probe = tag % kObjectMemoCapacity;
@@ -125,6 +265,7 @@ bool resolve_object(const reader::Source& source,
                     reader::Scratch& scratch,
                     RosterStorage& storage,
                     std::uint32_t objectTag,
+                    std::uint32_t sliceSetIndex,
                     std::uint16_t& group) noexcept {
     group = kNotARosterGroup;
     const std::size_t slot = memo_slot(storage, objectTag);
@@ -132,6 +273,9 @@ bool resolve_object(const reader::Source& source,
         return false;
     }
     if (storage.memo[slot].tag == objectTag) {
+        // The memo spans the whole pass, so an object first seen under another destination is
+        // answered from here and never re-traced. A destination's own trace is therefore its
+        // first sighting of each object, not every registry that names it.
         group = storage.memo[slot].group;
         return true;
     }
@@ -142,6 +286,12 @@ bool resolve_object(const reader::Source& source,
         return true;
     }
 
+    report_placement(storage.destinationTag,
+                     sliceSetIndex,
+                     objectTag,
+                     storage.object,
+                     tables::carries_roster_slot(storage.object));
+
     layouts::RosterGroup candidate{};
     tables::Array declared{};
     if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
@@ -152,8 +302,10 @@ bool resolve_object(const reader::Source& source,
     }
     storage.slotCount = 0;
     storage.slotsOverflowed = false;
+    storage.exits = {};
     if (!collect_descriptors(source, scratch, storage, storage.object, candidate.registryKey)
         || !fill_slots(storage, declared.count, candidate)) {
+        report_unresolved(objectTag, candidate.registryKey, declared.count, storage);
         // A completed walk may prove that some declared slots have no descriptor. A failed walk
         // cannot distinguish that absence from unread content, so it refuses the whole group.
         ++storage.unresolvedGroups;

+ 69 - 0
Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp

@@ -1,5 +1,9 @@
 #include <algorithm>
+#include <array>
 #include <cstddef>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
 
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "internal.h"
@@ -9,6 +13,9 @@ namespace {
 
 namespace tables = middleware::content::packages::tables;
 
+/** Size of one publish line: the fixed fields plus the hex values that follow them. */
+constexpr std::size_t kPublishLineCapacity = 192;
+
 /**
  * Orders the safe groups the way the destination publishes them.
  * A group that binds the player or reports the lifetime comes first, then one reached through the
@@ -99,6 +106,67 @@ void publish_per_bubble(Walk& walk, layouts::Definition& row) noexcept {
 
 } // namespace
 
+/**
+ * Names every candidate and every intersection key one destination reached, and what became of it.
+ * A candidate that is admitted by the slot filter and then lost in the split leaves no trace: the
+ * row simply publishes fewer groups, which reads the same as a destination that never had them.
+ * raid_beanstalk admits objects in bubbles 8 through 13 and 15 but publishes per-bubble groups for
+ * only two of them, and nothing today says which step drops the rest.
+ * @param walk Accumulator for one destination, before the split consumes it.
+ * @param row Destination row being published into.
+ */
+void report_publish(const Walk& walk, const layouts::Definition& row) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    const tables::RosterIntersection& seen = walk.intersection;
+    std::array<char, kPublishLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish tag=0x%08X keys=%zu candidates=%zu "
+                                "overflow=%u unresolved_set=%u observed=0x%llX top=%u bubble=%u",
+                                row.tag,
+                                seen.keyCount,
+                                walk.candidateCount,
+                                seen.overflowed ? 1U : 0U,
+                                seen.unresolvedSet ? 1U : 0U,
+                                static_cast<unsigned long long>(seen.observedSets),
+                                static_cast<unsigned>(row.rosterGroupCount),
+                                static_cast<unsigned>(row.bubbleGroupCount));
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // One line per key, because the split is decided per key: a mask equal to `observed` is
+    // top-level, a partial mask is per-bubble, and zero is dropped.
+    for (std::size_t index = 0; index < seen.keyCount; ++index) {
+        const std::uint64_t mask = seen.masks[index];
+        const char* fate = mask == 0                 ? "none"
+                           : mask == seen.observedSets ? "all"
+                                                       : "partial";
+        // A key with no candidate cannot publish: the split matches candidates against keys.
+        bool paired = false;
+        for (std::size_t candidate = 0; candidate < walk.candidateCount; ++candidate) {
+            paired = paired || walk.candidates[candidate].key == seen.keys[index];
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish_key tag=0x%08X key=0x%08X "
+                                "mask=0x%llX fate=%s paired=%u",
+                                row.tag,
+                                seen.keys[index],
+                                static_cast<unsigned long long>(mask),
+                                fate,
+                                paired ? 1U : 0U);
+        if (written > 0) {
+            core::log::write(core::log::Channel::state,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
 /** Splits the candidates between the destination row's two lists. */
 void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     row.rosterGroupCount = 0;
@@ -110,6 +178,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     // The per-bubble half is independent of the top-level one: its keys register through the
     // delta's own field 1, and a destination may reach one half and not the other.
     publish_per_bubble(walk, row);
+    report_publish(walk, row);
 }
 
 } // namespace sunrise::client::content::scenarios

+ 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

+ 1119 - 0
Sunrise/src/client/diagnostics/entity_create_probe.cpp

@@ -0,0 +1,1119 @@
+#include "entity_create_probe.h"
+
+#include <Windows.h>
+#include <intrin.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+#include <string_view>
+
+#include "../../core/logging/log.h"
+#include "../hooking/detour.h"
+#include "../patterns/image_scan.h"
+#include "../patterns/signature_text.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+namespace patterns = client::patterns;
+namespace detour = client::hooking::detour;
+
+/**
+ * The index allocator the entity creator calls first.
+ * Recovered from the mapped-image dump. Its body is unmistakable: it stores -1 into the caller's
+ * out-parameter, then asks a pool at `+0xC118` sized `0x2000` for a free index. The frame size is
+ * wildcarded so the match carries no position-dependent byte.
+ */
+constexpr std::string_view kIndexAllocatorText =
+    "48 89 5C 24 08 48 89 74 24 10 57 48 83 EC ? 48 8B DA C7 02 FF FF FF FF 48 8B F9 "
+    "BA 00 20 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kIndexAllocator =
+    patterns::signature<patterns::signature_length(kIndexAllocatorText)>(kIndexAllocatorText);
+
+/** The allocator answers this in its out-parameter when it has no index to give. */
+constexpr std::int32_t kNoIndex = -1;
+/**
+ * Byte offset of the free-slot bitmap inside the manager the allocator is handed.
+ * Read out of the allocator's body: it calls the bitmap search with `rcx = manager + 0xC118` and
+ * a width of `0x2000`, then clears the bit it was given. A set bit is therefore a FREE slot, and
+ * the search answers -1 only when every word is zero.
+ */
+constexpr std::size_t kFreeBitmapOffset = 0xC118;
+/** Slots the bitmap covers, from the width the allocator passes. */
+constexpr std::size_t kFreeBitmapBits = 0x2000;
+/** Words in that bitmap. */
+constexpr std::size_t kFreeBitmapWords = kFreeBitmapBits / 32;
+
+/**
+ * Counts the free slots the manager currently holds.
+ * The exhaustion line alone cannot separate "the host never gave the client any slots" from
+ * "the client used everything it was given", and those need opposite fixes.
+ * @param pool Manager the allocator was handed.
+ * @return Set bits in its free bitmap, or -1 when the bitmap cannot be read.
+ */
+[[nodiscard]] std::int64_t free_slot_count(const void* pool) noexcept {
+    if (pool == nullptr) {
+        return -1;
+    }
+    std::int64_t free = 0;
+    __try {
+        const auto* words = reinterpret_cast<const std::uint32_t*>(
+            static_cast<const std::byte*>(pool) + kFreeBitmapOffset);
+        for (std::size_t word = 0; word < kFreeBitmapWords; ++word) {
+            free += static_cast<std::int64_t>(__popcnt(words[word]));
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return -1;
+    }
+    return free;
+}
+/** Outcomes reported per run, so a per-frame failure cannot fill the log. */
+constexpr LONG kMaxReports = 200;
+/**
+ * Stack frames captured above this probe on each allocation.
+ * The allocator itself is generic — one function serves every entity in the game — so its own
+ * address says nothing about what is being built. The callers above it are what differ, and six
+ * frames is enough to separate "the world is placing an object" from "a weapon spawned a
+ * projectile" without unwinding the whole fiber stack.
+ */
+constexpr ULONG kTraceFrames = 6;
+/**
+ * Allocation traces per run.
+ * A raid load builds a few hundred entities, so this holds several bubble loads while still
+ * bounding what a long firefight can write.
+ */
+constexpr LONG kMaxTraces = 4096;
+/** Traces already spent. */
+volatile LONG g_traces{};
+/**
+ * Image offset of the pointer to the game's entity record table.
+ * Recovered from the creation path itself, which indexes it as `base + (handle & 0x1FFF) * stride`
+ * at `0x4D71F7`: `imul ebx, [rip -> 0x1F93430]` then `add rbx, [rip -> 0x1F93428]`. The mask is the
+ * same 13 bits the allocator's bitmap covers, so a record addresses exactly one allocated index.
+ */
+constexpr std::uintptr_t kEntityTableBaseRva = 0x1F93428;
+/** Image offset of the record stride that pairs with the table above. */
+constexpr std::uintptr_t kEntityTableStrideRva = 0x1F93430;
+/** Stride the dump reports. Checked at runtime, because a wrong one would read foreign memory. */
+constexpr std::uint32_t kExpectedRecordStride = 224;
+/** Bytes of each record dumped. The whole record, so the type field can be found by comparison. */
+constexpr std::size_t kRecordDumpBytes = kExpectedRecordStride;
+/** Records dumped per run, bounded so a long session cannot fill the sink. */
+constexpr LONG kMaxRecords = 512;
+/** Records already dumped. */
+volatile LONG g_records{};
+/**
+ * Record class every live entity carries at `+0x64`.
+ * Constant across all 57 records of a run, so it marks a slot the game has actually built rather
+ * than one holding whatever the last entity left behind.
+ */
+constexpr std::uint32_t kRecordClass = 0x80809783;
+/** Offset of the record class within a record. */
+constexpr std::size_t kRecordClassOffset = 0x64;
+/** Offset of the object's definition hash. Varies per object kind; `0xFFFFFFFF` where absent. */
+constexpr std::size_t kRecordDefinitionOffset = 0x88;
+/** Offset of the instance ordinal that counts copies of one definition. */
+constexpr std::size_t kRecordOrdinalOffset = 0x8C;
+/** Offset of the transform block, which is still unset when a record is first dumped. */
+constexpr std::size_t kRecordTransformOffset = 0xA0;
+/** Dwords of the transform block reported, covering the orientation and position quads. */
+constexpr std::size_t kRecordTransformDwords = 8;
+/** Seconds between censuses. Short enough to catch a bubble soon after it settles. */
+constexpr DWORD kCensusIntervalMs = 15'000;
+/**
+ * Most recent manager the allocator was handed.
+ * The census needs the free bitmap to tell a live record from one an entity left behind, and the
+ * allocator is the only place the manager pointer is known.
+ */
+void* volatile g_lastPool{};
+/** Entries one census reports, so a fully populated table cannot fill the sink. */
+constexpr LONG kCensusEntryBudget = 2'048;
+/**
+ * Distinct record classes counted per census.
+ * The census filtered on one class, `kRecordClass`, and so never reported an index above ~1019.
+ * An interaction incident then named entity **3539** as its target while the player stood on the
+ * Wall of Wishes activation plate -- an object that works -- and the twenty panels that do not
+ * work sit at 749..768. Whatever separates them is not visible while the walk only ever admits
+ * one class, so every class is counted and sampled now.
+ */
+constexpr std::size_t kClassCapacity = 24;
+/**
+ * Records dumped per distinct class, so a large class cannot crowd out a small one.
+ * Set at 48 this hid the very thing it was built to find: one class holds every real record, so
+ * only indices 0..47 were ever dumped and the Wall of Wishes panels at 749..768 fell outside the
+ * log entirely. That absence then read as "the player never reached the wall", which was wrong.
+ * The share only needs to stop one class starving another, so it sits at the whole budget.
+ */
+constexpr LONG kPerClassDump = 2'048;
+/** Cleared to stop the census thread. */
+volatile LONG g_censusRunning{};
+/** Census thread handle. */
+HANDLE g_censusThread{};
+
+/**
+ * Index whose record has not been dumped yet.
+ * The record is empty when the allocator hands the index out — the creator fills it afterwards — so
+ * each index is read one allocation late, when whatever built it has finished.
+ */
+volatile LONG g_pendingIndex{-1};
+
+/**
+ * Dumps one entity record so the entity can be named rather than counted.
+ * Counting proved the pool works and says nothing about what is in it. The record is the only place
+ * the client keeps an entity's identity, and every entity in the run shares one creation path, so
+ * the bytes here are what separate a wall panel from a projectile.
+ * @param index Index whose record to read.
+ */
+void report_record(std::int32_t index) noexcept {
+    if (index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_records) > kMaxRecords) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = 0;
+    __try {
+        const auto table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        const auto stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+        // A stride that has moved means this offset no longer names the table, and reading through
+        // it would dump unrelated memory as if it were an entity.
+        if (table == nullptr || stride != kExpectedRecordStride) {
+            return;
+        }
+        const auto* const record = table + static_cast<std::size_t>(index) * stride;
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=entity_create stage=record idx=%d hex=",
+                                static_cast<int>(index));
+        for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                     && static_cast<std::size_t>(written) + 3 < line.size();
+             ++offset) {
+            const int more = std::snprintf(line.data() + written,
+                                           line.size() - static_cast<std::size_t>(written),
+                                           "%02X",
+                                           std::to_integer<unsigned char>(record[offset]));
+            if (more <= 0) {
+                break;
+            }
+            written += more;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (written <= 0) {
+        return;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+/** Resolved `RtlCaptureStackBackTrace`, or null when ntdll would not give it up. */
+USHORT(NTAPI* g_captureBacktrace)(ULONG, ULONG, PVOID*, PULONG){};
+/** Allocations between pool samples. Frequent enough to shape the drain, rare enough to be free. */
+constexpr LONG kSampleInterval = 16;
+/** Bytes in the bitmap, from the width the client's own stocking path passes to its fill. */
+constexpr std::size_t kFreeBitmapBytes = kFreeBitmapBits / 8;
+/**
+ * High slots the host keeps for its own entities and never leases to the client.
+ * The join grant is `kSlotCount - kDefaultServerReserve` = 7936, so the top 256 indices are the
+ * host's. The client's own initialiser frees the whole bitmap because in its intended world it
+ * owns every slot; here it does not, and handing it the reserve would let it allocate an index
+ * the host also considers its own.
+ */
+constexpr std::size_t kServerReserveSlots = 256;
+/** Bytes of the bitmap that stay clear, covering the reserve at the top of the index space. */
+constexpr std::size_t kReserveBytes = kServerReserveSlots / 8;
+/** Bytes of the bitmap that are freed to the client. */
+constexpr std::size_t kClientBytes = kFreeBitmapBytes - kReserveBytes;
+/** Words of the bitmap covering the client's half. The split lands on a word boundary. */
+constexpr std::size_t kClientWords = kClientBytes / sizeof(std::uint32_t);
+static_assert(kClientBytes % sizeof(std::uint32_t) == 0,
+              "the client half must end on a word so a refill never touches the reserve");
+/** Bits per bitmap word. */
+constexpr std::size_t kBitsPerWord = 32;
+/**
+ * Address span treated as belonging to the game's image.
+ * The dump reports an image size of 0x8A5EA00, so this clears it with room for a larger build while
+ * still rejecting a frame that landed in Sunrise's own module or on a foreign allocation.
+ */
+constexpr std::uintptr_t kImageSpan = 0x10000000;
+
+/**
+ * The allocator's real shape, read from its body rather than guessed.
+ * It uses exactly two arguments: `rcx` is the manager whose free-slot bitmap sits at `+0xC118`,
+ * and `rdx` is the out-parameter it fills with the allocated index. It returns `rdx` unchanged.
+ */
+using IndexAllocator = void*(__fastcall*)(void*, std::int32_t*) noexcept;
+
+detour::Handle g_allocator{};
+volatile LONG g_reports{};
+/** Successful allocations seen, used only to space the samples. */
+volatile LONG g_allocations{};
+/**
+ * One manager's record of the indices this probe has watched the allocator hand out.
+ *
+ * A blanket `memset(bitmap, 0xFF, ...)` is what made the very first stocking work and what made
+ * every later one lethal. It frees index 0 upward, and by the time a pool has drained, index 0
+ * belongs to a live entity. The allocator picks the lowest set bit, so the next creation lands on
+ * top of a live entity and the world stops being a consistent list of them. That is the crash on
+ * respawn, the crash on Worldline Zero's ability, and the mainloop stall that ends a Shuro Chi run
+ * a few seconds after the room loads.
+ *
+ * Keeping the set of indices already handed out turns the refill from "free everything" into
+ * "free what was never taken", which is the only form of it that is safe to run on a live pool.
+ */
+struct PoolRecord {
+    /** Manager this record belongs to, or null while the slot is unused. */
+    void* pool;
+    /** Set bit per index the allocator gave out and the client has not since handed back. */
+    std::array<volatile LONG, kFreeBitmapWords> live;
+    /** Whether this pool has been refilled at least once. */
+    volatile LONG stocked;
+};
+
+/** Managers tracked at once. A world change builds a new one, so several are live per run. */
+constexpr std::size_t kTrackedPoolCapacity = 16;
+/** Per-manager occupancy records, claimed on first sight. */
+std::array<PoolRecord, kTrackedPoolCapacity> g_pools{};
+
+/**
+ * Finds the record for one manager, claiming a free slot on first sight.
+ * @param pool Manager the allocator was handed.
+ * @return Its record, or null when the table is full.
+ */
+[[nodiscard]] PoolRecord* find_pool(void* pool) noexcept {
+    for (auto& record : g_pools) {
+        if (record.pool == pool) {
+            return &record;
+        }
+    }
+    for (auto& record : g_pools) {
+        auto* const slot = reinterpret_cast<void* volatile*>(&record.pool);
+        if (InterlockedCompareExchangePointer(slot, pool, nullptr) == nullptr
+            || record.pool == pool) {
+            return &record;
+        }
+    }
+    // Past capacity nothing is tracked, so nothing is refilled either. A missed refill costs this
+    // world's entities; an untracked one corrupts a live pool.
+    return nullptr;
+}
+
+/**
+ * Records that one index is now owned by an entity.
+ * @param record Manager record, or null when the manager is untracked.
+ * @param index Index the allocator produced.
+ */
+void mark_live(PoolRecord* record, std::int32_t index) noexcept {
+    if (record == nullptr || index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits) {
+        return;
+    }
+    const auto slot = static_cast<std::size_t>(index);
+    (void)InterlockedOr(&record->live[slot / kBitsPerWord],
+                        static_cast<LONG>(1u << (slot % kBitsPerWord)));
+}
+/** Off leaves the probe reporting only, which is what it did before it could write. */
+bool g_stockUnstockedPool{};
+/** Refill a drained pool as well as an unstocked one. Safe now that the refill spares live slots. */
+bool g_restockAlways{};
+
+/**
+ * Reports one probe outcome, up to the per-run budget.
+ * @param stage Which half answered.
+ * @param outcome What it answered.
+ * @param detail Free slots left in the pool, or -1 when the bitmap could not be read.
+ */
+void report_pair(const char* stage,
+                 const char* outcome,
+                 std::int64_t detail,
+                 std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld allocs=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail),
+                                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports one refill, naming how many slots it actually handed back.
+ * @param outcome Whether the pool answered after the refill.
+ * @param free Free slots the bitmap holds now.
+ * @param freed Slots this refill put back.
+ * @param allocations Successful allocations seen so far.
+ */
+void report_stock(const char* outcome,
+                  std::int64_t free,
+                  std::int64_t freed,
+                  std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=entity_create stage=allocate result=%s free=%lld freed=%lld allocs=%lld",
+                      outcome,
+                      static_cast<long long>(free),
+                      static_cast<long long>(freed),
+                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Names one allocation and the call sites that asked for it.
+ *
+ * Counting allocations proved the pool works; it cannot say what is being built, and that is the
+ * question a missing Wall of Wishes actually poses. Its panels are one repeated object, so a burst
+ * of identical traces landing on consecutive indices as a bubble loads is the wall being created,
+ * and the absence of such a burst is the wall never being asked for. Those two need opposite fixes.
+ *
+ * Addresses are image-relative because the game is rebased every run; an RVA maps straight into the
+ * mapped-image dump, where file offset equals RVA.
+ * @param pool Manager the index came from, so per-type managers would show as distinct pointers.
+ * @param index Index the allocator produced.
+ * @param sequence Allocation ordinal within the run.
+ */
+void report_allocation(const void* pool, std::int32_t index, LONG sequence) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_traces) > kMaxTraces) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                               line.size(),
+                               "ev=entity_create stage=alloc n=%ld idx=%d pool=0x%llX sites=",
+                               static_cast<long>(sequence),
+                               static_cast<int>(index),
+                               static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(pool)));
+    if (written <= 0) {
+        return;
+    }
+    std::array<void*, kTraceFrames> frames{};
+    // Frame 0 is this probe, which is never interesting, so the capture starts one above it.
+    const USHORT captured = g_captureBacktrace == nullptr
+                                ? 0
+                                : g_captureBacktrace(1, kTraceFrames, frames.data(), nullptr);
+    for (USHORT frame = 0; frame < captured && written > 0
+                           && static_cast<std::size_t>(written) < line.size();
+         ++frame) {
+        const auto site = reinterpret_cast<std::uintptr_t>(frames[frame]);
+        // A frame inside Sunrise's own module is noise here; only the game's code is addressable
+        // in the dump, so anything outside it is printed as a gap rather than a misleading offset.
+        const bool inImage = base != 0 && site >= base && (site - base) < kImageSpan;
+        const int more =
+            std::snprintf(line.data() + written,
+                          line.size() - static_cast<std::size_t>(written),
+                          inImage ? "%s0x%llX" : "%s-",
+                          frame == 0 ? "" : ",",
+                          static_cast<unsigned long long>(inImage ? site - base : 0));
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+
+void report(const char* stage, const char* outcome, std::int64_t detail) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Mirrors the index allocator and reports whether it produced an index.
+ * The out-parameter is the answer: the original writes -1 into it before doing anything, and
+ * overwrites it only on success.
+ */
+/**
+ * Reads the game's entity record table, or reports that it cannot be addressed.
+ * @param table Receives the table base.
+ * @param stride Receives the record stride.
+ * @return True when both were read and the stride still matches this build.
+ */
+[[nodiscard]] bool entity_table(const std::byte*& table, std::uint32_t& stride) noexcept {
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return false;
+    }
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return false;
+    }
+    return table != nullptr && stride == kExpectedRecordStride;
+}
+
+/**
+ * Reports which of one word's 32 indices already hold an entity.
+ *
+ * The probe's own record of handed-out indices covers only what came through the hooked allocator,
+ * and a census measured that as 58 of 830 — the world's placed objects reach the table by some
+ * other path entirely. Trusting that record alone therefore freed 7936 slots while 42 entities
+ * were sitting in them, and the client then allocated straight over the top. The game's own record
+ * table is the authority on which slots are taken, so occupancy is read from there instead.
+ * @param table Entity record table base.
+ * @param stride Record stride.
+ * @param word Word of the free bitmap being refilled.
+ * @return Set bit per index in that word whose record is live.
+ */
+[[nodiscard]] LONG occupied_mask(const std::byte* table, std::uint32_t stride, std::size_t word) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t bit = 0; bit < kBitsPerWord; ++bit) {
+        const std::size_t index = word * kBitsPerWord + bit;
+        __try {
+            if (*reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                        + kRecordClassOffset)
+                == kRecordClass) {
+                mask |= 1u << bit;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            // An unreadable record is treated as taken, which costs a slot rather than an entity.
+            mask |= 1u << bit;
+        }
+    }
+    return static_cast<LONG>(mask);
+}
+
+/**
+ * Frees every client slot that no entity holds, leaving the ones that do alone.
+ *
+ * The client's own initialiser at `0x7FF71DDADB20` fills this bitmap with `0xFF` — every slot free
+ * — but only when a role global reads zero; here it reads 3, so the fill never runs and the bitmap
+ * is all-zero from the first frame. Every entity creation then fails, which is why no enemy, plate,
+ * door or banner ever appeared and why an encounter bubble kicked to orbit. Writing those bytes
+ * ourselves is right exactly once, on a pool that is still empty. On a pool that has drained it is
+ * catastrophic, because the slots the client is using read as clear too and become free again.
+ *
+ * So the refill is driven by `record->live` instead of by a constant. A slot is freed only when the
+ * bitmap says it is taken AND this probe never watched the allocator hand it out. Two passes,
+ * because another thread may claim a slot while the first one runs: the second re-clears anything
+ * that became live in between, so no index is ever offered twice.
+ * @param pool Manager the allocator was handed.
+ * @param record Occupancy record for that manager.
+ * @param failure Receives a Windows error, or 1 for a null pool and 2 for a faulting write.
+ * @return Slots freed, or -1 when the bitmap could not be written.
+ */
+[[nodiscard]] std::int64_t stock_pool(void* pool,
+                                      PoolRecord* record,
+                                      std::uint32_t& failure) noexcept {
+    failure = 0;
+    if (pool == nullptr || record == nullptr) {
+        failure = 1;
+        return -1;
+    }
+    auto* const bitmap = static_cast<std::byte*>(pool) + kFreeBitmapOffset;
+    // The bitmap sits in the game's own allocation, so it carries whatever protection that
+    // allocation was given. Reading it worked, which does not prove it is writable.
+    DWORD previous = 0;
+    if (VirtualProtect(bitmap, kFreeBitmapBytes, PAGE_READWRITE, &previous) == FALSE) {
+        failure = GetLastError();
+        return -1;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    const bool hasTable = entity_table(table, stride);
+    std::int64_t freed = 0;
+    __try {
+        auto* const words = reinterpret_cast<volatile LONG*>(bitmap);
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG available = words[word];
+            // A slot the client has put back is no longer live, so it returns to the pool with the
+            // rest. Without this the record would only ever grow and the refill would fade to a
+            // no-op over a long session.
+            const LONG live = InterlockedAnd(&record->live[word], ~available) & ~available;
+            // The record table is the authority; the probe's own list is kept as a second opinion
+            // for anything created in the window before its record is filled in.
+            const LONG occupied = hasTable ? occupied_mask(table, stride, word) : 0;
+            const LONG missing =
+                static_cast<LONG>(~static_cast<std::uint32_t>(live | available | occupied));
+            if (missing != 0) {
+                (void)InterlockedOr(&words[word], missing);
+                freed += __popcnt(static_cast<unsigned int>(missing));
+            }
+        }
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG live = record->live[word];
+            if (live != 0) {
+                (void)InterlockedAnd(&words[word], ~live);
+            }
+        }
+        // The host's reserve at the top of the space stays clear so the client cannot allocate an
+        // index the host also considers its own.
+        for (std::size_t word = kClientWords; word < kFreeBitmapWords; ++word) {
+            (void)InterlockedAnd(&words[word], 0);
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        failure = 2;
+        freed = -1;
+    }
+    DWORD restored = 0;
+    (void)VirtualProtect(bitmap, kFreeBitmapBytes, previous, &restored);
+    if (freed >= 0) {
+        (void)InterlockedExchange(&record->stocked, 1);
+        if (!hasTable) {
+            // Worth saying out loud: without the table the refill is back to trusting a list that
+            // has been measured as 7% complete, which is how live entities got overwritten.
+            report("allocate", "stock_without_table", freed);
+        }
+    }
+    return freed;
+}
+
+void* __fastcall allocator_body(void* pool, std::int32_t* index) noexcept {
+    const auto call = reinterpret_cast<IndexAllocator>(g_allocator.original);
+    if (call == nullptr) {
+        return nullptr;
+    }
+    void* result = call(pool, index);
+    InterlockedExchangePointer(&g_lastPool, pool);
+    PoolRecord* const record = find_pool(pool);
+    if (index == nullptr || *index != kNoIndex) {
+        // Every index the client takes is recorded before anything else can act on it, because a
+        // refill that does not know about it would offer the same index to a second entity.
+        if (index != nullptr) {
+            mark_live(record, *index);
+        }
+        // Sample the pool as it is spent. A steadily falling count means indices are allocated and
+        // never returned; a count that rises again means the client's own free path does work and
+        // the drain is simply the world being large. Those need opposite fixes, and the exhaustion
+        // line alone cannot tell them apart because it only ever fires at zero.
+        const LONG seen = InterlockedIncrement(&g_allocations);
+        report_allocation(pool, index == nullptr ? kNoIndex : *index, seen);
+        // One allocation behind, so the creator has had time to fill the record being read.
+        report_record(InterlockedExchange(&g_pendingIndex, index == nullptr ? -1 : *index));
+        if ((seen % kSampleInterval) == 0) {
+            // The count is reported beside the free total: if the pool empties while this barely
+            // moves, the bitmap is being cleared by something other than allocation.
+            report_pair("allocate", "sample", free_slot_count(pool), seen);
+        }
+        return result;
+    }
+    const std::int64_t free = free_slot_count(pool);
+    // A pool is refilled the first time it is seen empty, and again on every later drain when the
+    // knob is on. Both are safe now: the refill spares the indices already handed out, so it can
+    // no longer hand one index to two entities the way the old blanket fill did.
+    const bool allowed = g_stockUnstockedPool && record != nullptr
+                         && (g_restockAlways || record->stocked == 0);
+    if (free != 0 || !allowed) {
+        report_pair("allocate", "exhausted", free, g_allocations);
+        return result;
+    }
+    std::uint32_t failure = 0;
+    const std::int64_t freed = stock_pool(pool, record, failure);
+    if (freed < 0) {
+        // Naming the reason matters: a refused write and a faulting page need different fixes.
+        report("allocate", "stock_failed", static_cast<std::int64_t>(failure));
+        return result;
+    }
+    result = call(pool, index);
+    // `freed` is the number that matters. It should fall well short of the whole client half: the
+    // gap is the live entities the old fill used to trample.
+    report_stock(*index == kNoIndex ? "stocked_still_empty" : "stocked",
+                 free_slot_count(pool),
+                 freed,
+                 g_allocations);
+    if (index != nullptr) {
+        mark_live(record, *index);
+    }
+    return result;
+}
+
+/**
+ * Image offset of the pointer that reaches the game's entity pool descriptors.
+ * From the creation path at `0x4D71B5`: `mov rcx, [rip -> 0x2439C70]` then `add rdx, [rcx]` with
+ * the pool ordinal already shifted left by six, so descriptors are 64 bytes apart and their array
+ * base is one further dereference in. Within a descriptor, `+0x08` is the pool base and `+0x30`
+ * its element size -- `imul eax, [rdx + 0x30]` then `add rcx, [rdx + 8]`.
+ */
+constexpr std::uintptr_t kPoolDirectoryRva = 0x2439C70;
+/** Bytes between pool descriptors. */
+constexpr std::size_t kPoolDescriptorStride = 64;
+/** Descriptors probed. The ordinal comes from a handle's high bits, which are six wide. */
+constexpr std::size_t kPoolDescriptorCount = 64;
+/** Offset of a pool's base pointer within its descriptor. */
+constexpr std::size_t kPoolBaseOffset = 0x08;
+/** Offset of a pool's element size within its descriptor. */
+constexpr std::size_t kPoolElementSizeOffset = 0x30;
+/** An element size outside this is not a record, so the descriptor is not one either. */
+constexpr std::uint32_t kMaximumElementSize = 4096;
+/**
+ * Pools whose elements match the entity record stride, walked by the census.
+ * The directory holds TWO 224-byte pools, ordinals 33 and 35, at stable and distinct bases. The
+ * census has only ever read whichever one `kEntityTableBaseRva` points at, so half the records of
+ * this shape were never looked at -- and the activation plate that works, entity 3539, is not in
+ * the half that was.
+ */
+constexpr std::size_t kRecordPoolCapacity = 4;
+/** Bases of the record-shaped pools found in the directory. */
+std::array<const std::byte*, kRecordPoolCapacity> g_recordPools{};
+/** Ordinals of those pools, in the same order. */
+std::array<std::size_t, kRecordPoolCapacity> g_recordPoolOrdinals{};
+/** Record-shaped pools found. */
+std::size_t g_recordPoolCount{};
+
+/**
+ * Reports every entity pool the game keeps, not just the one the census walks.
+ *
+ * The class tally proved the 224-byte table holds exactly one class and 830 records, and that
+ * everything read above them is out-of-bounds noise. So the Wall of Wishes activation plate, which
+ * an interaction incident named as entity 3539 and which visibly works, cannot be in that table at
+ * all -- while the twenty panels that do not work are. Handles carry a pool ordinal in their high
+ * bits, which is why one table was never the whole picture.
+ */
+void report_pools() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (image == 0) {
+        return;
+    }
+    for (std::size_t ordinal = 0; ordinal < kPoolDescriptorCount; ++ordinal) {
+        const std::byte* poolBase = nullptr;
+        std::uint32_t elementSize = 0;
+        __try {
+            const auto* const directory =
+                *reinterpret_cast<const std::byte* const*>(image + kPoolDirectoryRva);
+            if (directory == nullptr) {
+                return;
+            }
+            const auto* const descriptors = *reinterpret_cast<const std::byte* const*>(directory);
+            if (descriptors == nullptr) {
+                return;
+            }
+            const auto* const descriptor = descriptors + ordinal * kPoolDescriptorStride;
+            poolBase = *reinterpret_cast<const std::byte* const*>(descriptor + kPoolBaseOffset);
+            elementSize =
+                *reinterpret_cast<const std::uint32_t*>(descriptor + kPoolElementSizeOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (poolBase == nullptr || elementSize == 0 || elementSize > kMaximumElementSize) {
+            continue;
+        }
+        if (elementSize == kExpectedRecordStride && g_recordPoolCount < kRecordPoolCapacity) {
+            g_recordPoolOrdinals[g_recordPoolCount] = ordinal;
+            g_recordPools[g_recordPoolCount++] = poolBase;
+        }
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=entity_census stage=pool ordinal=%zu base=0x%llX element=%u",
+                          ordinal,
+                          static_cast<unsigned long long>(
+                              reinterpret_cast<std::uintptr_t>(poolBase)),
+                          elementSize);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports the built records of one record-shaped pool other than the cached one.
+ *
+ * The cached pointer at `kEntityTableBaseRva` names a single pool, and the directory shows two of
+ * this shape. An object that works and an object that does not may simply live in different pools,
+ * and that is not visible while only one is read.
+ * @param poolBase Base of the pool to walk.
+ * @param stride Record stride, the same for every pool of this shape.
+ * @param ordinal Directory ordinal, reported so the two can be told apart.
+ */
+void walk_pool(const std::byte* poolBase, std::uint32_t stride, std::size_t ordinal) noexcept {
+    LONG reported = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits && reported < kCensusEntryBudget; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = poolBase + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass != kRecordClass) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry pool=%zu idx=%zu cls=0x%08X def=0x%08X ord=%u rec=",
+                ordinal,
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset));
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++reported;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=pool_end ordinal=%zu records=%ld",
+                                      ordinal,
+                                      static_cast<long>(reported));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Walks the whole entity table and reports every slot the game has built.
+ *
+ * The per-allocation dump reads a record one allocation after it is handed out, which is early
+ * enough that the transform is still its default — every instance of one definition reported the
+ * same placement, which cannot be true. A census taken well after a bubble has settled reads the
+ * finished records instead, and placement is the field that matters here: a grid of identical
+ * co-planar objects is a wall of shootable panels and nothing else is, so this can identify the
+ * Wall of Wishes without knowing the game's own name for it.
+ */
+void run_census() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (table == nullptr || stride != kExpectedRecordStride) {
+        return;
+    }
+    // A record keeps its class marker after the entity is gone, so the marker alone cannot
+    // distinguish a live entity from a slot one left behind. The free bitmap can: a slot the
+    // allocator would hand out is not holding anything, whatever its record still says.
+    const auto* freeWords = static_cast<const std::uint32_t*>(nullptr);
+    if (void* const pool = g_lastPool; pool != nullptr) {
+        freeWords = reinterpret_cast<const std::uint32_t*>(static_cast<std::byte*>(pool)
+                                                           + kFreeBitmapOffset);
+    }
+    // First pass counts every class present. A record whose class word is zero or all ones has
+    // never been built, so those are the only two values treated as empty.
+    std::array<std::uint32_t, kClassCapacity> classes{};
+    std::array<LONG, kClassCapacity> classCounts{};
+    std::array<LONG, kClassCapacity> classDumped{};
+    std::size_t classCount = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::uint32_t value = 0;
+        __try {
+            value = *reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                            + kRecordClassOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (value == 0 || value == 0xFFFFFFFFU) {
+            continue;
+        }
+        std::size_t slot = 0;
+        while (slot < classCount && classes[slot] != value) {
+            ++slot;
+        }
+        if (slot == classCount) {
+            if (classCount == kClassCapacity) {
+                continue;
+            }
+            classes[classCount++] = value;
+        }
+        ++classCounts[slot];
+    }
+    for (std::size_t slot = 0; slot < classCount; ++slot) {
+        std::array<char, core::log::kLineCapacity> head{};
+        const int headWritten = std::snprintf(head.data(),
+                                              head.size(),
+                                              "ev=entity_census stage=class value=0x%08X count=%ld",
+                                              classes[slot],
+                                              static_cast<long>(classCounts[slot]));
+        if (headWritten > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {head.data(), static_cast<std::size_t>(headWritten)});
+        }
+    }
+    g_recordPoolCount = 0;
+    report_pools();
+    // Name the pool the census has been reading all along, so its ordinal can be matched against
+    // the directory rather than assumed.
+    {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=entity_census stage=table base=0x%llX stride=%u pools=%zu",
+            static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(table)),
+            stride,
+            g_recordPoolCount);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+    // Every record-shaped pool, not just the cached one. A pool the cached pointer already names
+    // is not walked twice.
+    for (std::size_t slot = 0; slot < g_recordPoolCount; ++slot) {
+        if (g_recordPools[slot] == table) {
+            continue;
+        }
+        walk_pool(g_recordPools[slot], stride, g_recordPoolOrdinals[slot]);
+    }
+    LONG live = 0;
+    LONG stale = 0;
+    // Per pass, not per run: a shared budget truncated the one census that mattered.
+    LONG entries = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = table + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass == 0 || recordClass == 0xFFFFFFFFU) {
+                continue;
+            }
+            std::size_t slot = 0;
+            while (slot < classCount && classes[slot] != recordClass) {
+                ++slot;
+            }
+            const bool spent = slot == classCount || classDumped[slot] >= kPerClassDump;
+            if (!spent) {
+                ++classDumped[slot];
+            }
+            unsigned slotFree = 0;
+            if (freeWords != nullptr
+                && (freeWords[index / kBitsPerWord] & (1u << (index % kBitsPerWord))) != 0) {
+                slotFree = 1;
+                ++stale;
+            } else {
+                ++live;
+            }
+            // The tally above counts every record; only the dump is rationed.
+            if (spent || entries >= kCensusEntryBudget) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry idx=%zu cls=0x%08X def=0x%08X ord=%u free=%u rec=",
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset),
+                slotFree);
+            // The whole record, not just the transform block. The block at `+0xA0` decodes as a
+            // clean quaternion but the four dwords after it are not the position — as floats they
+            // are denormals and values in the trillions. Somewhere in these 224 bytes there are
+            // three coordinates, and the way to find them is to scan every aligned offset across a
+            // group for one that varies plausibly. A 5x5 grid of co-planar panels is the Wall of
+            // Wishes and nothing else in the room is shaped like that, so placement identifies it
+            // where counting has not.
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++entries;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=end live=%ld stale=%ld allocs=%ld",
+                                      static_cast<long>(live),
+                                      static_cast<long>(stale),
+                                      static_cast<long>(g_allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Runs a census on its own thread so it does not sit inside the game's allocation path.
+ * @param unused Thread parameter, unused.
+ * @return Always zero.
+ */
+DWORD WINAPI census_thread(LPVOID unused) noexcept {
+    (void)unused;
+    while (g_censusRunning != 0) {
+        Sleep(kCensusIntervalMs);
+        if (g_censusRunning == 0) {
+            break;
+        }
+        run_census();
+    }
+    return 0;
+}
+
+/**
+ * Attaches one probe, reporting its own outcome.
+ * @param signature Pattern to find.
+ * @param name Reported name.
+ * @param replacement Probe body.
+ * @param handle Receives the trampoline.
+ * @return True when the target was found and the detour attached.
+ */
+[[nodiscard]] bool attach(std::span<const patterns::PatternByte> signature,
+                          const char* name,
+                          void* replacement,
+                          detour::Handle& handle) noexcept {
+    std::byte* const target = patterns::scan_main_image_unique(signature, name);
+    std::array<char, core::log::kLineCapacity> line{};
+    if (target == nullptr) {
+        const int written = std::snprintf(line.data(),
+                                          line.size(),
+                                          "ev=entity_create stage=attach name=%s result=fail",
+                                          name);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+        return false;
+    }
+    const detour::Spec spec{target, replacement};
+    const bool attached = detour::install(spec, handle);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=attach name=%s result=%s",
+                                      name,
+                                      attached ? "ok" : "fail");
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         attached ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return attached;
+}
+
+} // namespace
+
+/** Reports which half of the client's entity creation refuses. */
+bool install_entity_create_probe(bool stockUnstockedPool, bool restockAlways) noexcept {
+    g_stockUnstockedPool = stockUnstockedPool;
+    g_restockAlways = restockAlways;
+    // Resolved rather than linked: the trace is a diagnostic, and a missing export should cost the
+    // call sites in the log, not the probe that stocks the pool.
+    if (HMODULE const ntdll = GetModuleHandleW(L"ntdll.dll"); ntdll != nullptr) {
+        g_captureBacktrace = reinterpret_cast<decltype(g_captureBacktrace)>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "RtlCaptureStackBackTrace")));
+    }
+    const bool allocator = attach(kIndexAllocator,
+                                  "entity_index_allocator",
+                                  reinterpret_cast<void*>(&allocator_body),
+                                  g_allocator);
+    if (allocator) {
+        InterlockedExchange(&g_censusRunning, 1);
+        g_censusThread = CreateThread(nullptr, 0, &census_thread, nullptr, 0, nullptr);
+    }
+    // The initialiser is deliberately NOT hooked. Its fifth argument is passed on the stack
+    // (`mov dword [var_20h], eax` before the call), and a four-argument replacement got that
+    // wrong and black-screened the load. It does not need hooking anyway: the allocator alone
+    // answers the question, because the initialiser only runs when the allocator succeeded.
+    return allocator;
+}
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept {
+    InterlockedExchange(&g_censusRunning, 0);
+    if (g_censusThread != nullptr) {
+        // The census only reads, so a shutdown that beats it costs a census, never the process.
+        (void)CloseHandle(g_censusThread);
+        g_censusThread = nullptr;
+    }
+    if (g_allocator.attached) {
+        (void)detour::uninstall(g_allocator);
+    }
+}
+
+} // namespace sunrise::client::diagnostics

+ 34 - 0
Sunrise/src/client/diagnostics/entity_create_probe.h

@@ -0,0 +1,34 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Reports which half of the client's entity creation refuses.
+ * The client logs `failed to create '<type>' entity` and nothing else, and Sunrise's own note at
+ * `server/bap/encrypted/transactions/service_outcome_commit.cpp` reads that as "it has no free
+ * index". That reading is an assumption, and acting on it once already cost a build-and-run cycle:
+ * a lease top-up landed and changed nothing.
+ *
+ * The creator calls two things in order — an index allocator that answers -1 when it has nothing
+ * to give, then an initialiser that answers false when it refuses the entity it was handed. Both
+ * end at the same log line, so the line cannot tell them apart. These two detours can: each
+ * reports its own outcome, so one run says which half is failing and the guessing stops.
+ *
+ * Diagnostic only. Neither replacement changes an argument or a result, and both are found with
+ * an independent scan rather than through the shared target registry, so a signature that no
+ * longer matches this build costs the probe and nothing else.
+ * @param stockUnstockedPool Refill a bitmap that is entirely unstocked, which is what the
+ *        client's own initialiser would have done had its role global read zero.
+ * @param restockAlways Also refill a pool that has drained, not only one never stocked. Needed to
+ *        get past an encounter bubble the drained pool would otherwise refuse. Safe: the refill
+ *        spares every index the probe watched the allocator hand out, so it cannot re-free one that
+ *        is still owned the way the earlier blanket fill did.
+ * @return True when the probe attached.
+ */
+[[nodiscard]] bool install_entity_create_probe(bool stockUnstockedPool,
+                                               bool restockAlways) noexcept;
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 278 - 0
Sunrise/src/client/diagnostics/image_dump.cpp

@@ -0,0 +1,278 @@
+#include "image_dump.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+/** Dumps are isolated below the shared generated-artifact directory, beside the logs. */
+constexpr std::wstring_view kDumpDirectorySuffix = L"\\dumps";
+/** One stable name, so a second diagnostic run replaces the first rather than filling the disk. */
+constexpr std::wstring_view kImageFileSuffix = L"\\game_image.bin";
+/** The manifest carries the load base, without which the dump's addresses mean nothing. */
+constexpr std::wstring_view kManifestFileSuffix = L"\\game_image.txt";
+/**
+ * Bytes moved per read.
+ * Reads are page-granular in effect, so this only bounds the staging buffer and the cost of one
+ * failed read. 64 KiB keeps the buffer off the stack-sized path while staying one allocation.
+ */
+constexpr std::size_t kChunkBytes = 64 * 1024;
+/** A mapped image larger than this is not one this build can be looking at. */
+constexpr std::size_t kMaximumImageBytes = 1024ULL * 1024ULL * 1024ULL;
+
+/**
+ * Creates one directory, tolerating an existing one.
+ * @param path Full directory path.
+ * @return True when the directory exists afterwards.
+ */
+[[nodiscard]] bool ensure_directory(const core::path::Buffer& path) noexcept {
+    if (CreateDirectoryW(path.chars.data(), nullptr) != FALSE) {
+        return true;
+    }
+    if (GetLastError() != ERROR_ALREADY_EXISTS) {
+        return false;
+    }
+    // ERROR_ALREADY_EXISTS also covers files, so verify the existing object is a directory.
+    const DWORD attributes = GetFileAttributesW(path.chars.data());
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+}
+
+/**
+ * Opens one file for writing, replacing anything already there.
+ * @param path Full file path.
+ * @return Open handle, or INVALID_HANDLE_VALUE.
+ */
+[[nodiscard]] HANDLE create_file(const core::path::Buffer& path) noexcept {
+    return CreateFileW(path.chars.data(),
+                       GENERIC_WRITE,
+                       0,
+                       nullptr,
+                       CREATE_ALWAYS,
+                       FILE_ATTRIBUTE_NORMAL,
+                       nullptr);
+}
+
+/**
+ * Writes one whole buffer.
+ * @param file Open file handle.
+ * @param data First byte.
+ * @param size Byte count.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool write_all(HANDLE file, const void* data, std::size_t size) noexcept {
+    const auto* cursor = static_cast<const std::byte*>(data);
+    std::size_t remaining = size;
+    while (remaining != 0) {
+        const DWORD wanted =
+            static_cast<DWORD>(remaining < kChunkBytes ? remaining : kChunkBytes);
+        DWORD written = 0;
+        if (WriteFile(file, cursor, wanted, &written, nullptr) == FALSE || written == 0) {
+            return false;
+        }
+        cursor += written;
+        remaining -= written;
+    }
+    return true;
+}
+
+/** Header fields the dump is described by, read once from the mapped image. */
+struct ImageHeader {
+    std::byte* base{};
+    std::size_t imageSize{};
+    std::uint16_t sectionCount{};
+    std::size_t sectionOffset{};
+};
+
+/**
+ * Reads the mapped PE headers of the main module.
+ * @param output Receives the load base and image span.
+ * @return True when the headers are a usable 64-bit PE.
+ */
+[[nodiscard]] bool read_header(ImageHeader& output) noexcept {
+    output = {};
+    auto* base = reinterpret_cast<std::byte*>(GetModuleHandleW(nullptr));
+    if (base == nullptr) {
+        return false;
+    }
+    const auto& dos = *reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
+    if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0) {
+        return false;
+    }
+    const auto& nt = *reinterpret_cast<const IMAGE_NT_HEADERS64*>(base + dos.e_lfanew);
+    if (nt.Signature != IMAGE_NT_SIGNATURE
+        || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
+        return false;
+    }
+    const std::size_t imageSize = nt.OptionalHeader.SizeOfImage;
+    if (imageSize == 0 || imageSize > kMaximumImageBytes) {
+        return false;
+    }
+    output.base = base;
+    output.imageSize = imageSize;
+    output.sectionCount = nt.FileHeader.NumberOfSections;
+    output.sectionOffset = static_cast<std::size_t>(dos.e_lfanew) + sizeof(DWORD)
+                           + sizeof(IMAGE_FILE_HEADER) + nt.FileHeader.SizeOfOptionalHeader;
+    return true;
+}
+
+/**
+ * Writes the flat image span, substituting zeroes for pages the process will not read.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Receives the byte count that had to be zero-filled.
+ * @return True when the whole span was written.
+ */
+[[nodiscard]] bool
+write_image(HANDLE file, const ImageHeader& header, std::size_t& unreadable) noexcept {
+    unreadable = 0;
+    static std::array<std::byte, kChunkBytes> chunk{};
+    for (std::size_t offset = 0; offset < header.imageSize; offset += kChunkBytes) {
+        const std::size_t remaining = header.imageSize - offset;
+        const std::size_t wanted = remaining < kChunkBytes ? remaining : kChunkBytes;
+        SIZE_T copied = 0;
+        // ReadProcessMemory rather than memcpy: a guard or no-access page inside the image is
+        // normal for a packed binary and must not fault the game we are dumping from.
+        if (ReadProcessMemory(
+                GetCurrentProcess(), header.base + offset, chunk.data(), wanted, &copied)
+                == FALSE
+            || copied != wanted) {
+            chunk.fill(std::byte{});
+            unreadable += wanted;
+        }
+        if (!write_all(file, chunk.data(), wanted)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Writes the manifest naming the load base and every section.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Bytes the image pass had to zero-fill.
+ * @return True when the manifest was written.
+ */
+[[nodiscard]] bool
+write_manifest(HANDLE file, const ImageHeader& header, std::size_t unreadable) noexcept {
+    std::array<char, 512> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "# Sunrise mapped-image dump of the running game.\r\n"
+                                "# Load with the base below, e.g.  r2 -B 0x%llX game_image.bin\r\n"
+                                "base=0x%llX\r\n"
+                                "image_size=0x%zX\r\n"
+                                "unreadable_bytes=%zu\r\n"
+                                "sections=%u\r\n",
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                header.imageSize,
+                                unreadable,
+                                static_cast<unsigned>(header.sectionCount));
+    if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+        return false;
+    }
+    for (std::uint16_t index = 0; index < header.sectionCount; ++index) {
+        const auto& section = *reinterpret_cast<const IMAGE_SECTION_HEADER*>(
+            header.base + header.sectionOffset + index * sizeof(IMAGE_SECTION_HEADER));
+        // The name field is not guaranteed to be null-terminated at 8 characters.
+        std::array<char, IMAGE_SIZEOF_SHORT_NAME + 1> name{};
+        for (std::size_t character = 0; character < IMAGE_SIZEOF_SHORT_NAME; ++character) {
+            name[character] = static_cast<char>(section.Name[character]);
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "section name=%-8s va=0x%08lX size=0x%08lX flags=0x%08lX\r\n",
+                                name.data(),
+                                static_cast<unsigned long>(section.VirtualAddress),
+                                static_cast<unsigned long>(section.Misc.VirtualSize),
+                                static_cast<unsigned long>(section.Characteristics));
+        if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Reports the outcome of one dump attempt.
+ * @param stage Step that decided the outcome.
+ * @param succeeded Whether the dump completed.
+ * @param bytes Image bytes written, or zero.
+ * @param unreadable Bytes zero-filled because the page would not read.
+ */
+void report(const char* stage,
+            bool succeeded,
+            std::size_t bytes,
+            std::size_t unreadable) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=image_dump stage=%s result=%s bytes=%zu unreadable=%zu",
+                                      stage,
+                                      succeeded ? "ok" : "fail",
+                                      bytes,
+                                      unreadable);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         succeeded ? core::log::Level::info : core::log::Level::error,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Writes the game's mapped image to disk so it can be disassembled offline. */
+bool dump_game_image(void* module) noexcept {
+    ImageHeader header{};
+    if (!read_header(header)) {
+        report("header", false, 0, 0);
+        return false;
+    }
+    core::path::Buffer directory{};
+    if (!core::path::artifact_directory(module, directory)
+        || !core::path::append(directory, kDumpDirectorySuffix) || !ensure_directory(directory)) {
+        report("path", false, 0, 0);
+        return false;
+    }
+
+    core::path::Buffer imagePath = directory;
+    HANDLE file = core::path::append(imagePath, kImageFileSuffix) ? create_file(imagePath)
+                                                                  : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("create", false, 0, 0);
+        return false;
+    }
+    std::size_t unreadable = 0;
+    const bool wrote = write_image(file, header, unreadable);
+    CloseHandle(file);
+    if (!wrote) {
+        report("write", false, 0, unreadable);
+        return false;
+    }
+
+    core::path::Buffer manifestPath = directory;
+    file = core::path::append(manifestPath, kManifestFileSuffix) ? create_file(manifestPath)
+                                                                 : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("manifest", false, header.imageSize, unreadable);
+        return false;
+    }
+    const bool described = write_manifest(file, header, unreadable);
+    CloseHandle(file);
+    report(described ? "complete" : "manifest", described, header.imageSize, unreadable);
+    return described;
+}
+
+} // namespace sunrise::client::diagnostics

+ 26 - 0
Sunrise/src/client/diagnostics/image_dump.h

@@ -0,0 +1,26 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Writes the game's mapped image to disk so it can be disassembled offline.
+ * `destiny2.exe` is VMProtect-packed: on disk its `.text` is fully encrypted, the retail log
+ * strings are absent, and the byte signatures in `patterns/game_signatures.cpp` match nothing.
+ * They match at runtime because every scan runs against the mapped image the packer has already
+ * decrypted, so that mapped image is the only readable copy of the code and the only thing a
+ * disassembler can be pointed at.
+ *
+ * The dump is one flat file covering the whole `SizeOfImage` span, so a file offset is the image
+ * offset and a virtual address is the load base plus that offset. A page the process will not let
+ * us read is written as zeroes rather than abandoning the dump, because an unreadable page is
+ * normal in a packed image and losing the rest of the file to it helps nobody.
+ *
+ * Off unless `client.dump_game_image` is set. The file is large — the whole image, about 140 MB —
+ * and writing it costs a second or two of boot, so it is a deliberate diagnostic run rather than
+ * something every start pays for.
+ * @param module Sunrise's own loaded module, used to resolve the artifact directory.
+ * @return True when the whole image was written and the manifest beside it was too.
+ */
+[[nodiscard]] bool dump_game_image(void* module) noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 169 - 30
Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp

@@ -13,6 +13,46 @@ namespace {
 /** 4 protected functions per hook bound the fixed range storage, so no heap is used. */
 constexpr std::size_t kProtectedCodeLimit = 64;
 
+/** Access an enlisted thread is opened with. Detours reads and rewrites its context. */
+constexpr DWORD kEnlistAccess =
+    THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT;
+/** Access the walk needs of a thread it only names. Asking for less refuses fewer threads. */
+constexpr DWORD kWalkAccess = THREAD_QUERY_LIMITED_INFORMATION;
+/** The walk is over. NtGetNextThread reports it as a failure status, so it is checked by value. */
+constexpr LONG kStatusNoMoreEntries = static_cast<LONG>(0x8000001AL);
+
+/**
+ * Hands back the next thread of one process, in an order fixed for the length of the walk.
+ * Passing a null cursor starts it. The returned handle carries the requested access.
+ */
+using NextThread = LONG(NTAPI*)(HANDLE process,
+                                HANDLE cursor,
+                                ACCESS_MASK access,
+                                ULONG attributes,
+                                ULONG flags,
+                                HANDLE* next) noexcept;
+
+/**
+ * Finds ntdll's own thread walk, once.
+ * The documented walk is a Toolhelp snapshot, which enumerates every thread on the system to
+ * reach this process's fifty: it costs about 25 ms a pass, twice a transaction, and a boot holds
+ * one transaction per hook. This walk stays inside the process and costs about 0.08 ms. It is
+ * not a documented export, so a build that does not have it keeps the snapshot instead.
+ * @return The entry point, or null when ntdll does not export it.
+ */
+[[nodiscard]] NextThread next_thread_entry() noexcept {
+    static const NextThread entry = [] {
+        const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
+        if (ntdll == nullptr) {
+            return static_cast<NextThread>(nullptr);
+        }
+        // The cast is through a void function pointer because GetProcAddress returns FARPROC.
+        return reinterpret_cast<NextThread>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "NtGetNextThread")));
+    }();
+    return entry;
+}
+
 /** Exact executable range described by one x64 unwind record. */
 struct CodeRange {
     DWORD64 begin{};
@@ -45,6 +85,109 @@ void close_threads(Threads& threads) noexcept {
     return false;
 }
 
+/** How far one enlistment pass got. */
+enum class PassResult {
+    /** Every thread of the process was seen and taken. */
+    complete,
+    /** The walk stopped early without handing Detours anything, so another pass may still run. */
+    enumerationFailed,
+    /** Detours refused a thread. Nothing can continue this transaction. */
+    transactionFailed,
+};
+
+/**
+ * Enlists one process thread by id, unless this transaction already holds it.
+ * The handle comes from OpenThread rather than from whatever named the id. A thread Windows will
+ * not open here is one the transaction must leave alone: handing Detours a thread it cannot
+ * suspend sets a transaction-wide pending error that fails every later attach and that nothing
+ * can clear. The set of enlisted threads therefore stays exactly what a snapshot pass would take,
+ * whichever walk found them.
+ * @param threads Receives the handle, which stays suspended until the transaction ends.
+ * @param threadId Candidate process thread id.
+ * @param currentThreadId The calling thread, which the transaction enlists separately.
+ * @param foundUnseen Set when the thread was new to this transaction.
+ * @return False when Detours refused the thread and the transaction is spent.
+ */
+[[nodiscard]] bool enlist_thread_id(Threads& threads,
+                                    DWORD threadId,
+                                    DWORD currentThreadId,
+                                    bool& foundUnseen) noexcept {
+    if (threadId == 0 || threadId == currentThreadId || contains(threads, threadId)) {
+        return true;
+    }
+    foundUnseen = true;
+    if (threads.count == threads.handles.size()) {
+        return false;
+    }
+    const HANDLE thread = OpenThread(kEnlistAccess, FALSE, threadId);
+    if (thread == nullptr) {
+        // A disappearing thread is absent from the next stable pass.
+        return GetLastError() == ERROR_INVALID_PARAMETER;
+    }
+    if (DetourUpdateThread(thread) != NO_ERROR) {
+        CloseHandle(thread);
+        return false;
+    }
+    threads.handles[threads.count] = thread;
+    threads.ids[threads.count] = threadId;
+    ++threads.count;
+    return true;
+}
+
+/**
+ * Says whether a thread is still running.
+ * The walk reaches threads that have already exited: their objects outlive them for as long as
+ * something holds a handle, and the kernel thread list still carries them. A snapshot never
+ * reports one. Detours suspends a thread the moment it is handed over, suspending an exited
+ * thread fails, and that failure is a transaction-wide error that nothing can clear, so an exited
+ * thread has to be dropped before it is offered.
+ * @param thread Handle opened with at least THREAD_QUERY_LIMITED_INFORMATION.
+ * @return True only when the thread is confirmed running.
+ */
+[[nodiscard]] bool thread_is_running(HANDLE thread) noexcept {
+    DWORD exitCode = 0;
+    return GetExitCodeThread(thread, &exitCode) != FALSE && exitCode == STILL_ACTIVE;
+}
+
+/**
+ * Enlists every unseen live thread of this process using ntdll's own walk.
+ * The walk names and vets each thread; enlisting it then runs on the shared path.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return How far the pass got.
+ */
+[[nodiscard]] PassResult enlist_process_walk(Threads& threads, bool& foundUnseen) noexcept {
+    const NextThread nextThread = next_thread_entry();
+    if (nextThread == nullptr) {
+        return PassResult::enumerationFailed;
+    }
+    const DWORD currentThreadId = GetCurrentThreadId();
+    HANDLE cursor = nullptr;
+    for (;;) {
+        HANDLE next = nullptr;
+        const LONG status = nextThread(GetCurrentProcess(), cursor, kWalkAccess, 0, 0, &next);
+        // The cursor is only a position in the walk; the transaction never holds it.
+        if (cursor != nullptr) {
+            CloseHandle(cursor);
+        }
+        cursor = nullptr;
+        if (status == kStatusNoMoreEntries) {
+            return PassResult::complete;
+        }
+        if (status < 0 || next == nullptr) {
+            return PassResult::enumerationFailed;
+        }
+        // The walk's own handle answers both questions, so the enlist handle is only opened for
+        // a thread that is going to be offered.
+        const DWORD threadId = thread_is_running(next) ? GetThreadId(next) : 0;
+        if (!enlist_thread_id(threads, threadId, currentThreadId, foundUnseen)) {
+            CloseHandle(next);
+            return PassResult::transactionFailed;
+        }
+        cursor = next;
+    }
+}
+
 /**
  * Enlists every unseen thread present in one process-wide snapshot.
  * @param threads Receives handles that stay suspended until the transaction ends.
@@ -52,7 +195,6 @@ void close_threads(Threads& threads) noexcept {
  * @return True when the whole snapshot was inspected without a hard failure.
  */
 [[nodiscard]] bool enlist_snapshot(Threads& threads, bool& foundUnseen) noexcept {
-    foundUnseen = false;
     const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
     if (snapshot == INVALID_HANDLE_VALUE) {
         return false;
@@ -65,33 +207,8 @@ void close_threads(Threads& threads) noexcept {
     const DWORD currentThreadId = GetCurrentThreadId();
     bool succeeded = true;
     while (available != FALSE && succeeded) {
-        const bool belongsToProcess = entry.th32OwnerProcessID == processId;
-        const bool needsEnlistment =
-            entry.th32ThreadID != currentThreadId && !contains(threads, entry.th32ThreadID);
-        if (belongsToProcess && needsEnlistment) {
-            foundUnseen = true;
-            if (threads.count == threads.handles.size()) {
-                succeeded = false;
-                break;
-            }
-
-            const HANDLE thread =
-                OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
-                           FALSE,
-                           entry.th32ThreadID);
-            if (thread == nullptr) {
-                // A disappearing thread is absent from the next stable snapshot.
-                if (GetLastError() != ERROR_INVALID_PARAMETER) {
-                    succeeded = false;
-                }
-            } else if (DetourUpdateThread(thread) != NO_ERROR) {
-                CloseHandle(thread);
-                succeeded = false;
-            } else {
-                threads.handles[threads.count] = thread;
-                threads.ids[threads.count] = entry.th32ThreadID;
-                ++threads.count;
-            }
+        if (entry.th32OwnerProcessID == processId) {
+            succeeded = enlist_thread_id(threads, entry.th32ThreadID, currentThreadId, foundUnseen);
         }
         available = Thread32Next(snapshot, &entry);
     }
@@ -104,14 +221,36 @@ void close_threads(Threads& threads) noexcept {
 }
 
 /**
- * Enlists new process threads until a full snapshot finds no unseen thread id.
+ * Enlists every unseen process thread in one pass, by whichever walk this build has.
+ * A partly finished process walk leaves its handles enlisted and the snapshot completes the pass:
+ * both dedupe on the thread id, so the fallback cannot enlist a thread twice.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return True when the pass completed without a hard failure.
+ */
+[[nodiscard]] bool enlist_pass(Threads& threads, bool& foundUnseen) noexcept {
+    foundUnseen = false;
+    const PassResult walked = enlist_process_walk(threads, foundUnseen);
+    if (walked == PassResult::complete) {
+        return true;
+    }
+    // A refused thread has already spent the transaction, so no second walk can rescue it. Only
+    // a walk that stopped before Detours was told anything falls through to the snapshot.
+    if (walked == PassResult::transactionFailed) {
+        return false;
+    }
+    return enlist_snapshot(threads, foundUnseen);
+}
+
+/**
+ * Enlists new process threads until a full pass finds no unseen thread id.
  * @param threads Receives every handle the transaction holds.
  * @return True when a full pass found no new thread.
  */
 [[nodiscard]] bool enlist_until_stable(Threads& threads) noexcept {
     bool foundUnseen{};
     do {
-        if (!enlist_snapshot(threads, foundUnseen)) {
+        if (!enlist_pass(threads, foundUnseen)) {
             return false;
         }
         // Earlier handles stay suspended while a later pass finds newly created threads.

+ 4 - 3
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp

@@ -7,10 +7,12 @@
 #include <cstdint>
 #include <cstdio>
 #include <cstring>
+#include <mutex>
 
 #include "../../../core/logging/log.h"
 #include "../../targets/game/assert_handler.h"
 #include "../net_tick_probe/net_tick_probe.h"
+#include "core/threading/srw_lock.h"
 
 namespace sunrise::client::hooks::assert_handler {
 namespace {
@@ -35,7 +37,7 @@ constexpr int kGraphicsHaltCategory = 6;
 /** The handler the game installed, called with the same printf-style arguments the sites use. */
 using NativeHandler = void(__cdecl*)(int, const char*, ...);
 
-SRWLOCK g_lock{SRWLOCK_INIT};
+core::threading::SrwLock g_lock{};
 /** Last message seen, so a message that repeats every frame is counted rather than written. */
 std::array<char, kTextCapacity> g_lastText{};
 std::uint32_t g_repeats{};
@@ -54,7 +56,7 @@ std::uint32_t g_seen{};
  * @return True when the caller writes a log line.
  */
 [[nodiscard]] bool admit(const char* text, std::uint32_t& seen, std::uint32_t& repeats) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     ++g_seen;
     if (std::strcmp(g_lastText.data(), text) == 0) {
         ++g_repeats;
@@ -67,7 +69,6 @@ std::uint32_t g_seen{};
     }
     seen = g_seen;
     repeats = g_repeats;
-    ReleaseSRWLockExclusive(&g_lock);
     return repeats <= kRepeatHead || repeats % kRepeatStride == 0;
 }
 

+ 0 - 3
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.h

@@ -4,9 +4,6 @@
 
 namespace sunrise::client::hooks::assert_handler {
 
-extern SRWLOCK g_lock;
-extern bool g_installed;
-
 /** @return Address of the internal assert handler body. */
 [[nodiscard]] void* handler_entry_point() noexcept;
 

+ 86 - 12
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -1,6 +1,9 @@
 #include "bootflow_hook_lifecycle.h"
 
+#include <array>
 #include <atomic>
+#include <cstddef>
+#include <span>
 
 #include "internal.h"
 
@@ -9,29 +12,100 @@ namespace {
 
 std::atomic_bool g_installed{false};
 
+/** One boot-step fix that attaches a detour, in the order the group installs them. */
+struct Fix {
+    StageResult (*stage)(hooking::detour::Spec&) noexcept;
+    void (*publish)(const hooking::detour::Handle&) noexcept;
+};
+
+/**
+ * Every fix that attaches a detour. `world_step` and `fade_release` are absent: they only find
+ * addresses to call, so they open no transaction and cost the group nothing.
+ */
+constexpr std::array kFixes{
+    Fix{&stage_character_select_hold, &publish_character_select_hold},
+    Fix{&stage_orbit_slice_set, &publish_orbit_slice_set},
+    Fix{&stage_profile_setup_skip, &publish_profile_setup_skip},
+    Fix{&stage_composition_check, &publish_composition_check},
+    Fix{&stage_orbit_handoff, &publish_orbit_handoff},
+    Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
+    Fix{&stage_region_private, &publish_region_private},
+    Fix{&stage_spawn_hold, &publish_spawn_hold},
+};
+
+/** Marks a fix that staged nothing, so no handle is ever published to it. */
+constexpr std::size_t kNotStaged = kFixes.size();
+
+/** One fix's place in the batch, and what it already was before staging. */
+struct Placement {
+    std::size_t slot{kNotStaged};
+    StageResult result{StageResult::unavailable};
+};
+
 } // namespace
 
 /**
  * Attaches the boot-step fixes that carry sign-in through to orbit.
  * Each fix stands alone at one site, so a miss on one is reported and the others still attach.
+ *
+ * Every resolved fix attaches in one transaction rather than one each. A transaction enlists the
+ * threads it must suspend by walking every thread on the system, which is far more work than the
+ * attach itself, so nine transactions cost nine of those walks and one costs one. A fix whose
+ * target is missing simply is not in the batch, which is what keeps one miss off the others. If
+ * the batch itself fails the fixes are retried one at a time, so a single target Detours refuses
+ * cannot take the rest of the group down with it.
  * @return True when every fix attached.
  */
 bool install() noexcept {
-    const bool hold = install_character_select_hold();
-    const bool sliceSet = install_orbit_slice_set();
-    const bool skip = install_profile_setup_skip();
-    const bool composition = install_composition_check();
-    const bool handoff = install_orbit_handoff();
-    const bool ownerSlot = install_owner_activity_slot();
-    const bool regionPrivate = install_region_private();
+    std::array<hooking::detour::Spec, kFixes.size()> specs{};
+    std::array<hooking::detour::Handle, kFixes.size()> handles{};
+    std::array<Placement, kFixes.size()> placement{};
+    std::size_t staged = 0;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        hooking::detour::Spec spec{};
+        const StageResult result = kFixes[index].stage(spec);
+        placement[index].result = result;
+        if (result != StageResult::staged) {
+            continue;
+        }
+        specs[staged] = spec;
+        placement[index].slot = staged;
+        ++staged;
+    }
+
+    if (staged != 0
+        && !hooking::detour::install(std::span(specs).first(staged),
+                                     std::span(handles).first(staged))) {
+        // One refused target must not cost the others their fix, so the slow path stands them up
+        // separately. It runs only when the whole batch failed, which no supported build does.
+        for (std::size_t slot = 0; slot < staged; ++slot) {
+            handles[slot] = {};
+            (void)hooking::detour::install(specs[slot], handles[slot]);
+        }
+    }
+
+    bool anyFix = false;
+    bool everyFix = true;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        const Placement& place = placement[index];
+        if (place.slot == kNotStaged) {
+            // An already-attached fix stays attached; only a missing target is a failure.
+            anyFix = anyFix || place.result == StageResult::attached;
+            everyFix = everyFix && place.result == StageResult::attached;
+            continue;
+        }
+        const hooking::detour::Handle& handle = handles[place.slot];
+        kFixes[index].publish(handle);
+        anyFix = anyFix || handle.attached;
+        everyFix = everyFix && handle.attached;
+    }
+
+    // Neither of these attaches anything, so they stay outside the transaction.
     const bool worldStep = install_world_step();
-    const bool spawn = install_spawn_hold();
     const bool fade = install_fade_release();
-    const bool anyFix = hold || sliceSet || skip || composition || handoff || ownerSlot
-                        || regionPrivate || worldStep || spawn || fade;
+    anyFix = anyFix || worldStep || fade;
     g_installed.store(anyFix, std::memory_order_release);
-    return hold && sliceSet && skip && composition && handoff && ownerSlot && regionPrivate
-           && worldStep && spawn && fade;
+    return everyFix && worldStep && fade;
 }
 
 /** Detaches every boot-step fix, in the reverse order of install. */

+ 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

+ 15 - 9
Sunrise/src/client/hooks/bootflow/character_select_hold.cpp

@@ -65,32 +65,38 @@ __declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept {
 } // namespace
 
 /**
- * Attaches the character-select hold.
- * @return True when the target is found and the detour attaches.
+ * Stages the character-select hold.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
-bool install_character_select_hold() noexcept {
+StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&enter_handler)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&enter_handler)};
+    return StageResult::staged;
+}
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<EnterHandler>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=character_select result=ok");
-    return true;
 }
 
 /** Detaches the character-select hold. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/composition_check.cpp

@@ -104,32 +104,38 @@ __declspec(noinline) std::int64_t __fastcall check(void* config, std::byte* prop
 } // namespace
 
 /**
- * Attaches the solo composition fix.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
-bool install_composition_check() noexcept {
+StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "matchmaking_composition");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Check>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=composition result=ok");
-    return true;
 }
 
 /** Detaches the solo composition fix. */

+ 75 - 24
Sunrise/src/client/hooks/bootflow/internal.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "../../hooking/detour.h"
 #include "../../patterns/image_scan.h"
 
 namespace sunrise::client::hooks::bootflow {
@@ -10,66 +11,112 @@ using patterns::signature;
 using patterns::signature_length;
 
 /**
- * Attaches the character-select hold, which stops the sign-in step auto-selecting.
- * @return True when the target is found and the detour attaches.
+ * One boot-step fix resolves its target, then the group attaches every resolved fix together.
+ * Splitting the two halves is what lets the group hold one detour transaction instead of one per
+ * fix. A transaction enlists every thread on the system to find this process's own, which costs
+ * far more than the attach it guards, so the count of transactions is what the boot pays for.
+ *
+ * A publish call is made only for a fix that staged, and takes a detached handle when the group's
+ * attach did not happen.
  */
-[[nodiscard]] bool install_character_select_hold() noexcept;
+enum class StageResult : unsigned char {
+    /** The target is missing. The fix reported that itself and staged nothing. */
+    unavailable,
+    /** An earlier install already attached this fix, so there is nothing to stage. */
+    attached,
+    /** The spec is filled and the fix wants attaching. */
+    staged,
+};
+
+/**
+ * Stages the character-select hold, which stops the sign-in step auto-selecting.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
+ */
+[[nodiscard]] StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the character-select hold. */
 void uninstall_character_select_hold() noexcept;
 
 /**
- * Attaches the profile-setup skip, which skips the startup setup screens.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip, which skips the startup setup screens.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_profile_setup_skip() noexcept;
+[[nodiscard]] StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the profile-setup skip. */
 void uninstall_profile_setup_skip() noexcept;
 
 /**
- * Attaches the orbit slice-set picker, so the sign-in step's map load finds its target.
- * @return True when the picker is found and the detour attaches.
+ * Stages the orbit slice-set picker, so the sign-in step's map load finds its target.
+ * @param spec Receives the target and replacement.
+ * @return staged when the picker was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_orbit_slice_set() noexcept;
+[[nodiscard]] StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit slice-set picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the orbit slice-set picker. */
 void uninstall_orbit_slice_set() noexcept;
 
 /**
- * Attaches the solo composition fix, which clears the count the matchmaking check rejects.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix, which clears the count the matchmaking check rejects.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_composition_check() noexcept;
+[[nodiscard]] StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the solo composition fix. */
 void uninstall_composition_check() noexcept;
 
 /**
- * Attaches the orbit handoff release, which stops the destination step parking.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release, which stops the destination step parking.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_orbit_handoff() noexcept;
+[[nodiscard]] StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the orbit handoff release. */
 void uninstall_orbit_handoff() noexcept;
 
 /**
- * Attaches the owner activity slot force. It pins the participation record to the replicated
+ * Stages the owner activity slot force. It pins the participation record to the replicated
  * snapshot at `comp + 496` instead of the local one at `comp + 1256`.
- * @return True when the target is found and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_owner_activity_slot() noexcept;
+[[nodiscard]] StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the owner activity slot force. */
 void uninstall_owner_activity_slot() noexcept;
 
 /**
- * Attaches the private-region force, so a public region takes the path a private one takes.
+ * Stages the private-region force, so a public region takes the path a private one takes.
  * A public region otherwise holds its slice-set switch until a public activity host connects.
- * @return True when both targets are found, the call site is unique and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when both targets and the call site were found, unavailable on a miss.
  */
-[[nodiscard]] bool install_region_private() noexcept;
+[[nodiscard]] StageResult stage_region_private(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the private-region force. */
 void uninstall_region_private() noexcept;
@@ -91,10 +138,14 @@ void uninstall_world_step() noexcept;
 void observe_world_step() noexcept;
 
 /**
- * Attaches the spawn hold, which puts the player spawn after the world-transition fade is armed.
- * @return True when the target is found and the detour attaches.
+ * Stages the spawn hold, which puts the player spawn after the world-transition fade is armed.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
-[[nodiscard]] bool install_spawn_hold() noexcept;
+[[nodiscard]] StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept;
 
 /** Detaches the spawn hold. */
 void uninstall_spawn_hold() noexcept;

+ 15 - 9
Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp

@@ -65,31 +65,37 @@ __declspec(noinline) bool __fastcall destination_hold(void* stepCtx) noexcept {
 } // namespace
 
 /**
- * Attaches the orbit handoff release.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
-bool install_orbit_handoff() noexcept {
+StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kHoldSignature, "orbit_destination_hold");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&destination_hold)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&destination_hold)};
+    return StageResult::staged;
+}
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=orbit_handoff result=ok");
-    return true;
 }
 
 /** Detaches the orbit handoff release. */

+ 13 - 8
Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp

@@ -68,29 +68,34 @@ std::uint32_t* __fastcall pick_target(LoaderContext* context, std::uint32_t* sel
 
 } // namespace
 
-/** Attaches the picker so the orbit target is found. */
-bool install_orbit_slice_set() noexcept {
+/** Stages the picker so the orbit target is found. */
+StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const picker = scan_main_image_unique(kPickerSignature, "slice_set_target_picker");
     if (picker == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{picker, reinterpret_cast<void*>(&pick_target)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{picker, reinterpret_cast<void*>(&pick_target)};
+    return StageResult::staged;
+}
+
+/** Takes the picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=slice_set result=ok");
-    return true;
 }
 
 /** Detaches the picker. */

+ 13 - 8
Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp

@@ -95,30 +95,35 @@ __declspec(noinline) std::uint8_t __fastcall check(void* container,
 
 } // namespace
 
-/** Attaches the owner activity slot force. */
-bool install_owner_activity_slot() noexcept {
+/** Stages the owner activity slot force. */
+StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "check_activity_bubbles");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<CheckBubbles>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=owner_slot result=ok");
-    return true;
 }
 
 /** Detaches the owner activity slot force. */

+ 23 - 10
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)) {
@@ -90,32 +97,38 @@ __declspec(noinline) char __fastcall update(std::byte* step) noexcept {
 } // namespace
 
 /**
- * Attaches the profile-setup skip.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
-bool install_profile_setup_skip() noexcept {
+StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kUpdateSignature, "profile_setup_update");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&update)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&update)};
+    return StageResult::staged;
+}
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Update>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=profile_setup result=ok");
-    return true;
 }
 
 /** Detaches the profile-setup skip. */

+ 21 - 13
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -135,8 +135,8 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     return !forced;
 }
 
-/** @param reason Key naming the step that failed. @return False, for a direct return. */
-[[nodiscard]] bool fail(const char* reason) noexcept {
+/** @param reason Key naming the step that failed. */
+void report_failure(const char* reason) noexcept {
     std::array<char, kLineCapacity> line{};
     const int written = std::snprintf(
         line.data(), line.size(), "ev=bootflow stage=region result=fail reason=%s", reason);
@@ -145,39 +145,47 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
                          core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
-    return false;
 }
 
 } // namespace
 
-/** Attaches the private-region force. */
-bool install_region_private() noexcept {
+/** Stages the private-region force. */
+StageResult stage_region_private(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kReaderSignature, "slice_set_is_public");
     if (target == nullptr) {
-        return fail("reader");
+        report_failure("reader");
+        return StageResult::unavailable;
     }
     const std::byte* const starter =
         scan_main_image_unique(kStarterSignature, "region_start_transition");
     if (starter == nullptr) {
-        return fail("starter");
+        report_failure("starter");
+        return StageResult::unavailable;
     }
     const std::byte* const returnSite = find_return_site(starter, target);
     if (returnSite == nullptr) {
-        return fail("call_site");
+        report_failure("call_site");
+        return StageResult::unavailable;
     }
     // Published before the detour attaches, so the first call already has its filter.
     g_returnSite.store(returnSite, std::memory_order_release);
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&reader)};
-    if (!hooking::detour::install(spec, g_handle)) {
-        return fail("attach");
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&reader)};
+    return StageResult::staged;
+}
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
+        report_failure("attach");
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Reader>(g_handle.original), std::memory_order_release);
     core::log::write(
         core::log::Channel::client, core::log::Level::info, "ev=bootflow stage=region result=ok");
-    return true;
 }
 
 /** Detaches the private-region force. */

+ 55 - 8
Sunrise/src/client/hooks/bootflow/spawn_hold.cpp

@@ -1,4 +1,9 @@
+#include <windows.h>
+
+#include <algorithm>
+#include <array>
 #include <atomic>
+#include <cstdio>
 #include <cstdint>
 #include <string_view>
 
@@ -30,6 +35,38 @@ using SpawnGate = bool(__fastcall*)(std::int32_t) noexcept;
 
 hooking::detour::Handle g_handle{};
 std::atomic<SpawnGate> g_original{nullptr};
+std::atomic<std::uint64_t> g_lastProbeTick{};
+std::atomic<spawn::Refusal> g_lastRefusal{spawn::Refusal::unknown};
+
+/** Reports a changed refusal immediately and a persistent refusal every five seconds. */
+void report_spawn_refusal(std::int32_t datum,
+                          state::activity::WorldPhase phase,
+                          std::uint64_t age) noexcept {
+    const spawn::Reading reading = spawn::examine(datum);
+    const std::uint64_t now = GetTickCount64();
+    const spawn::Refusal previous = g_lastRefusal.exchange(reading.refusal);
+    const std::uint64_t last = g_lastProbeTick.load(std::memory_order_relaxed);
+    if (reading.refusal == previous && now - last < 5'000U) {
+        return;
+    }
+    g_lastProbeTick.store(now, std::memory_order_relaxed);
+    std::array<char, core::log::kLineCapacity> fields{};
+    const std::size_t count = spawn::describe(reading, fields);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=spawn_gate result=blocked phase=%u age=%llu %.*s",
+                                      static_cast<unsigned>(phase),
+                                      static_cast<unsigned long long>(age),
+                                      static_cast<int>(count),
+                                      fields.data());
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(),
+                          (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
 
 /**
  * Puts the spawn after the world-transition fade is armed.
@@ -54,41 +91,49 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
     if (phase == state::activity::WorldPhase::arrived) {
         release_world_fade();
     }
+    if (!allowed && transitioning) {
+        report_spawn_refusal(datum, phase, age);
+    }
     return allowed && loading ? kHeld : allowed;
 }
 
 } // namespace
 
-/** Attaches the spawn hold. */
-bool install_spawn_hold() noexcept {
+/** Stages the spawn hold. */
+StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     std::byte* const target = scan_main_image_unique(kSpawnGateSignature, "player_spawn_gate");
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     if (!spawn::resolve(target)) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=current_slice result=fail reason=targets");
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&spawn_gate)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&spawn_gate)};
+    return StageResult::staged;
+}
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         spawn::forget();
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=attach");
-        return false;
+        return;
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<SpawnGate>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      "ev=bootflow stage=spawn_hold result=ok");
-    return true;
 }
 
 /** Detaches the spawn hold. */
@@ -97,6 +142,8 @@ void uninstall_spawn_hold() noexcept {
         (void)hooking::detour::uninstall(g_handle);
     }
     g_original.store(nullptr, std::memory_order_release);
+    g_lastProbeTick.store(0, std::memory_order_relaxed);
+    g_lastRefusal.store(spawn::Refusal::unknown, std::memory_order_relaxed);
     spawn::forget();
 }
 

+ 0 - 1
Sunrise/src/client/hooks/egress/internal.h

@@ -47,7 +47,6 @@ enum class HookSlot : std::size_t {
 /** Fixed handle count covers every required and OS-optional egress entry point. */
 inline constexpr std::size_t kHookCount = static_cast<std::size_t>(HookSlot::count);
 
-extern SRWLOCK g_lock;
 extern std::array<hooking::detour::Handle, kHookCount> g_handles;
 
 /**

+ 6 - 12
Sunrise/src/client/hooks/egress/lifecycle/egress_guard_lifecycle.cpp

@@ -1,16 +1,17 @@
 #include <algorithm>
 #include <array>
 #include <cstdio>
+#include <shared_mutex>
 
 #include "../../../../core/logging/log.h"
 #include "../internal.h"
-#include "../platform/abi.h"
 #include "../runtime.h"
+#include "core/threading/srw_lock.h"
 #include "internal.h"
 
 namespace sunrise::client::hooks::egress {
 
-SRWLOCK g_lock{SRWLOCK_INIT};
+core::threading::SrwLock g_lock{};
 std::array<hooking::detour::Handle, kHookCount> g_handles{};
 
 namespace {
@@ -64,13 +65,11 @@ std::size_t g_activeHookCount{};
 
 /** Installs every resolver and socket guard in one process-wide transaction. */
 bool install() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     if (all_installed()) {
-        ReleaseSRWLockExclusive(&g_lock);
         return true;
     }
     if (any_installed() || !pin_owner_module() || !lifecycle::load_modules()) {
-        ReleaseSRWLockExclusive(&g_lock);
         return false;
     }
 
@@ -82,20 +81,17 @@ bool install() noexcept {
         g_activeHookCount = 0;
         g_batchAttached = false;
         lifecycle::release_modules();
-        ReleaseSRWLockExclusive(&g_lock);
         return false;
     }
     g_activeHookCount = count;
     g_batchAttached = true;
-    ReleaseSRWLockExclusive(&g_lock);
     return true;
 }
 
 /** Emits one line per guarded export, then the batch outcome. */
 void report_installation() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     if (g_reported) {
-        ReleaseSRWLockExclusive(&g_lock);
         return;
     }
     g_reported = true;
@@ -118,7 +114,6 @@ void report_installation() noexcept {
                              {line.data(), static_cast<std::size_t>(written)});
         }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     std::array<char, 96> summary{};
     const int written = std::snprintf(summary.data(),
                                       summary.size(),
@@ -134,9 +129,8 @@ void report_installation() noexcept {
 
 /** @return True only when every required guard detour is attached. */
 bool is_installed() noexcept {
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const bool installed = all_installed();
-    ReleaseSRWLockShared(&g_lock);
     return installed;
 }
 

+ 7 - 18
Sunrise/src/client/hooks/graphics/input/graphics_window_input.cpp

@@ -2,11 +2,13 @@
 
 #include <atomic>
 #include <bit>
+#include <shared_mutex>
 
 #include "../../../../core/ui/layout/credits/sunrise_credits_badge.h"
 #include "../../../../core/ui/modules/logs/logs.h"
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../renderer/renderer.h"
+#include "core/threading/srw_lock.h"
 #include "input.h"
 
 namespace sunrise::client::hooks::graphics::input {
@@ -24,7 +26,7 @@ struct Binding {
 
 Binding g_binding{};
 std::atomic_uint g_activeCallbacks{};
-SRWLOCK g_inputLock{SRWLOCK_INIT};
+core::threading::SrwLock g_inputLock{};
 
 /** Counts active procedure calls so teardown can be retried before module unload. */
 class CallbackGuard final {
@@ -48,9 +50,8 @@ public:
  * @return Original procedure for the matching record, or null when nothing matches.
  */
 [[nodiscard]] WNDPROC original_for(HWND window) noexcept {
-    AcquireSRWLockShared(&g_inputLock);
+    const std::shared_lock lock(g_inputLock);
     const WNDPROC original = g_binding.window == window ? g_binding.original : nullptr;
-    ReleaseSRWLockShared(&g_inputLock);
     return original;
 }
 
@@ -122,15 +123,13 @@ bool install(HWND window) noexcept {
     if (window == nullptr || IsWindow(window) == FALSE) {
         return false;
     }
-    AcquireSRWLockExclusive(&g_inputLock);
+    const std::lock_guard lock(g_inputLock);
     if (g_binding.installed) {
         const bool sameWindow = g_binding.window == window;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return sameWindow;
     }
     if (g_activeCallbacks.load(std::memory_order_acquire) != 0) {
         // A retired procedure keeps its forwarding record until every old call returns.
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
     }
 
@@ -138,26 +137,22 @@ bool install(HWND window) noexcept {
     const LONG_PTR original =
         SetWindowLongPtrW(window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&window_procedure));
     if (original == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
     }
     g_binding = Binding{window, std::bit_cast<WNDPROC>(original), true};
-    ReleaseSRWLockExclusive(&g_inputLock);
     return true;
 }
 
 /** Restores the original procedure only when Sunrise still owns the chain head. */
 bool uninstall() noexcept {
-    AcquireSRWLockExclusive(&g_inputLock);
+    const std::lock_guard lock(g_inputLock);
     if (!g_binding.installed) {
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
     }
     if (IsWindow(g_binding.window) == FALSE) {
         g_binding.installed = false;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
     }
 
@@ -165,19 +160,16 @@ bool uninstall() noexcept {
     const LONG_PTR current = GetWindowLongPtrW(g_binding.window, GWLP_WNDPROC);
     const LONG_PTR replacement = reinterpret_cast<LONG_PTR>(&window_procedure);
     if (current == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
     }
     if (current == reinterpret_cast<LONG_PTR>(g_binding.original)) {
         // The window owner already put our forwarding target back itself.
         g_binding.installed = false;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
     }
     if (current != replacement) {
         // A later subclass owns the chain head now, so do not overwrite it.
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
     }
 
@@ -185,19 +177,17 @@ bool uninstall() noexcept {
     const LONG_PTR replaced = SetWindowLongPtrW(
         g_binding.window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(g_binding.original));
     if (replaced == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
     }
     // Keep the forwarding target until a later install replaces this retired record.
     g_binding.installed = false;
     const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-    ReleaseSRWLockExclusive(&g_inputLock);
     return idle;
 }
 
 /** Checks whether Sunrise is still installed, or still sits below a later subclass. */
 bool active(HWND window) noexcept {
-    AcquireSRWLockShared(&g_inputLock);
+    const std::shared_lock lock(g_inputLock);
     bool installed = g_binding.installed && g_binding.window == window && IsWindow(window) != FALSE
                      && IsWindowVisible(window) != FALSE;
     if (installed) {
@@ -209,7 +199,6 @@ bool active(HWND window) noexcept {
             installed = current != reinterpret_cast<LONG_PTR>(g_binding.original);
         }
     }
-    ReleaseSRWLockShared(&g_inputLock);
     return installed;
 }
 

+ 124 - 0
Sunrise/src/client/hooks/membership_probe/membership_probe.cpp

@@ -182,6 +182,129 @@ void report_bind_inputs(const std::byte* client) noexcept {
     }
 }
 
+/** Bytes per hex log line. Two characters a byte keeps a line well inside its capacity. */
+constexpr std::size_t kHexBytesPerLine = 64;
+/** Bytes at the membership header to dump, which is where the decoded member table starts. */
+constexpr std::size_t kMemberDumpBytes = 512;
+/** Most of one region record to dump. A record carrying a 128-byte descriptor still fits. */
+constexpr std::size_t kRegionDumpBytes = 320;
+/** The wire numbers regions 0, 8, 16 ... 504, so consecutive terms differ by this. */
+constexpr std::int32_t kRegionIndexStride = 8;
+/** Terms matched before a candidate is accepted as the region table. */
+constexpr std::size_t kRegionMatchTerms = 4;
+/** Largest in-memory stride between two region records that is still worth testing. */
+constexpr std::size_t kMaximumRegionStride = 8'192;
+/** End of the searchable span. The entity-slot mask sits above it and holds no region table. */
+constexpr std::size_t kScanEndOffset = kPendingMaskOffset;
+/** Clients whose region table is dumped. Later messages repeat a table that has already been read.
+ */
+constexpr std::uint32_t kRegionDumpBudget = 3;
+
+std::atomic<std::uint32_t> g_regionDumps{0};
+
+/**
+ * Emits one labelled hex run over as many lines as it needs.
+ * @param stage Log stage name.
+ * @param base Offset the run starts at, so a line names where its bytes came from.
+ * @param data First byte of the run.
+ * @param size Bytes to emit.
+ */
+void report_hex(const char* stage,
+                std::size_t base,
+                const std::byte* data,
+                std::size_t size) noexcept {
+    for (std::size_t offset = 0; offset < size; offset += kHexBytesPerLine) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const std::size_t run =
+            (size - offset) < kHexBytesPerLine ? size - offset : kHexBytesPerLine;
+        int written = std::snprintf(
+            line.data(), line.size(), "ev=probe stage=%s at=%zu raw=", stage, base + offset);
+        for (std::size_t index = 0; written > 0 && index < run; ++index) {
+            const int part =
+                std::snprintf(line.data() + written,
+                              line.size() - static_cast<std::size_t>(written),
+                              "%02X",
+                              std::to_integer<unsigned>(data[offset + index]));
+            if (part <= 0) {
+                break;
+            }
+            written += part;
+        }
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports one found region table and the two records that differ by the advertisement.
+ * @param client ActivityClient.
+ * @param offset Offset of the record holding region 8.
+ * @param stride Bytes between two consecutive records.
+ */
+void report_region_table(const std::byte* client, std::size_t offset, std::size_t stride) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=probe stage=regions result=found client=0x%llX "
+                                      "at=%zu stride=%zu",
+                                      address_of(client),
+                                      offset,
+                                      stride);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // Region 8 is the one the advertisement rides in and region 16 never carries one, so the
+    // bytes that differ between them are exactly what the client kept of the descriptor.
+    const std::size_t dump = stride < kRegionDumpBytes ? stride : kRegionDumpBytes;
+    report_hex("members", kMembershipHeaderOffset, client + kMembershipHeaderOffset, kMemberDumpBytes);
+    report_hex("region8", offset, client + offset, dump);
+    if (offset + stride + dump <= kScanEndOffset) {
+        report_hex("region16", offset + stride, client + offset + stride, dump);
+    }
+}
+
+/**
+ * Finds the client's decoded region table with no signature and dumps two of its records.
+ * Four consecutive terms of the 8-step region sequence at one fixed stride name the table, and
+ * nothing else in the object is expected to hold that run.
+ * @param client ActivityClient the handler has just committed a body into.
+ */
+void report_regions(const std::byte* client) noexcept {
+    if (g_regionDumps.fetch_add(1, std::memory_order_relaxed) >= kRegionDumpBudget) {
+        return;
+    }
+    for (std::size_t offset = kMembershipHeaderOffset; offset + sizeof(std::int32_t) <= kScanEndOffset;
+         offset += sizeof(std::int32_t)) {
+        if (field<std::int32_t>(client, offset) != kRegionIndexStride) {
+            continue;
+        }
+        for (std::size_t stride = sizeof(std::int32_t); stride <= kMaximumRegionStride;
+             stride += sizeof(std::int32_t)) {
+            if (offset + kRegionMatchTerms * stride > kScanEndOffset) {
+                break;
+            }
+            bool matched = true;
+            for (std::size_t term = 1; matched && term < kRegionMatchTerms; ++term) {
+                matched = field<std::int32_t>(client, offset + term * stride)
+                          == kRegionIndexStride * static_cast<std::int32_t>(term + 1);
+            }
+            if (!matched) {
+                continue;
+            }
+            report_region_table(client, offset, stride);
+            return;
+        }
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=probe stage=regions result=absent");
+}
+
 /** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
 [[nodiscard]] std::size_t pending_slots(const std::byte* client) noexcept {
     const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
@@ -226,6 +349,7 @@ char __fastcall receive(const std::byte* client, std::int64_t body, int size) no
     const auto after = field<std::uint16_t>(client, kStatusWordOffset);
     report(client, before, after);
     report_bind_inputs(client);
+    report_regions(client);
     track(client, GetTickCount64());
     return result;
 }

+ 17 - 3
Sunrise/src/client/hooks/network/investment/internal.h

@@ -21,10 +21,24 @@ using patterns::signature_length;
 /** @return True while the family-five commit rearm is attached. */
 [[nodiscard]] bool family5_rearm_is_installed() noexcept;
 
+/** Applies the armed set correction and services bounded category-readiness retries. */
+void apply_socket_menu_routing() noexcept;
+
+/** Reserves low-address storage before retail content occupies the compatible address domain. */
+void reserve_socket_menu_routing_storage() noexcept;
+
+/** Arms socket-menu correction at native content-table patch completion. */
+void arm_socket_menu_routing() noexcept;
+
+/** Restores owned category fields and plug-set descriptors without recycling published storage. */
+void restore_socket_menu_routing() noexcept;
+
+/** Reveals the specified lore entries without altering any account progress. */
+void apply_lore_visibility() noexcept;
+void restore_lore_visibility() noexcept;
+
 /**
- * Arms one derived-state rebuild, used up by the next freshness verdict. Armed twice: when the
- * family-four lookup first returns a real object, and after each family-five commit, which is the
- * first point the account's unlock overrides can be read back.
+ * Arms one derived-state rebuild after replicated investment state changes.
  */
 void arm_derived_rebuild() noexcept;
 

+ 42 - 7
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.cpp

@@ -49,7 +49,8 @@ std::atomic<Freshness> g_originalFreshness{nullptr};
 std::atomic<Family4Lookup> g_originalFamily4Lookup{nullptr};
 std::atomic_bool g_rebuildArmed{false};
 std::atomic_bool g_reportedRebuild{false};
-std::atomic_bool g_reportedFamily4{false};
+std::atomic<void*> g_committedFamily4{nullptr};
+std::atomic_uint32_t g_pendingFamily4Publications{0};
 
 /** @return True while either primary rebuild detour is attached. */
 [[nodiscard]] bool any_primary_attached() noexcept {
@@ -62,7 +63,8 @@ void clear_runtime() noexcept {
     g_originalFamily4Lookup.store(nullptr, std::memory_order_release);
     g_rebuildArmed.store(false, std::memory_order_release);
     g_reportedRebuild.store(false, std::memory_order_release);
-    g_reportedFamily4.store(false, std::memory_order_release);
+    g_committedFamily4.store(nullptr, std::memory_order_release);
+    g_pendingFamily4Publications.store(0, std::memory_order_release);
 }
 
 /**
@@ -71,6 +73,11 @@ void clear_runtime() noexcept {
  * @return Stale once while armed, otherwise the native verdict.
  */
 __declspec(noinline) char __fastcall freshness(void* accessor) noexcept {
+    const Freshness original = g_originalFreshness.load(std::memory_order_acquire);
+    // The native verdict performs the Family-4 lookup. That lookup is what arms the initial
+    // rebuild, so it must run before the arm is consumed; checking first left the arm stranded
+    // when sign-on made only one freshness query and every Triumph card kept its stale action.
+    const char nativeVerdict = original != nullptr ? original(accessor) : kStale;
     if (g_rebuildArmed.exchange(false, std::memory_order_acq_rel)) {
         if (!g_reportedRebuild.exchange(true, std::memory_order_relaxed)) {
             core::log::write(core::log::Channel::client,
@@ -79,23 +86,41 @@ __declspec(noinline) char __fastcall freshness(void* accessor) noexcept {
         }
         return kStale;
     }
-    const Freshness original = g_originalFreshness.load(std::memory_order_acquire);
-    return original != nullptr ? original(accessor) : kStale;
+    return nativeVerdict;
 }
 
 /**
- * Arms a rebuild when the state-three lookup first returns a real family-four object.
+ * Arms a rebuild whenever the state-three lookup observes a different committed Family-4 object.
+ * The freshness verdict itself performs this lookup, so a simple "nonnull" test would re-arm on
+ * every query and keep the derived state permanently stale. Object identity changes only when the
+ * queuez replacement has committed, which gives initial sign-on and later account after-images the
+ * same boundary without an unrelated Family-5 publication.
  * @param key Borrowed account key.
  * @return The native lookup result, unchanged.
  */
 __declspec(noinline) void* __fastcall family4_lookup(std::uint64_t* key) noexcept {
     const Family4Lookup original = g_originalFamily4Lookup.load(std::memory_order_acquire);
     void* const resolved = original != nullptr ? original(key) : nullptr;
-    if (resolved != nullptr && !g_reportedFamily4.exchange(true, std::memory_order_relaxed)) {
+    if (resolved != nullptr) {
+        std::uint32_t pending = g_pendingFamily4Publications.load(std::memory_order_acquire);
+        while (pending != 0
+               && !g_pendingFamily4Publications.compare_exchange_weak(
+                   pending, pending - 1, std::memory_order_acq_rel, std::memory_order_acquire)) {}
+        if (pending != 0) {
+            arm_derived_rebuild();
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             "ev=investment stage=family4_commit result=armed source=publication");
+        }
+    }
+    void* previous = g_committedFamily4.load(std::memory_order_acquire);
+    if (resolved != nullptr && resolved != previous
+        && g_committedFamily4.compare_exchange_strong(
+            previous, resolved, std::memory_order_acq_rel, std::memory_order_acquire)) {
         arm_derived_rebuild();
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
-                         "ev=investment stage=family4 result=resolved");
+                         "ev=investment stage=family4_commit result=armed");
     }
     return resolved;
 }
@@ -107,6 +132,14 @@ void arm_derived_rebuild() noexcept {
     g_rebuildArmed.store(true, std::memory_order_release);
 }
 
+/** Carries an exact committed account publication to its next native Family-4 lookup. */
+void notify_family4_publication() noexcept {
+    g_pendingFamily4Publications.fetch_add(1, std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=investment stage=family4_publication result=pending");
+}
+
 /** @return True when freshness and both real-arrival rebuild arms are attached. */
 bool install() noexcept {
     if (is_installed()) {
@@ -175,6 +208,8 @@ bool install() noexcept {
 
 /** @return True when every investment rebuild detour is absent. */
 bool uninstall() noexcept {
+    restore_lore_visibility();
+    restore_socket_menu_routing();
     if (!uninstall_family5_rearm()) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,

+ 10 - 0
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h

@@ -1,7 +1,17 @@
 #pragma once
 
+namespace sunrise::state {
+struct Family5State;
+}
+
 namespace sunrise::client::hooks::network::investment {
 
+/** Replaces the live Family-5 override lists and invalidates their derived evaluation. */
+[[nodiscard]] bool publish_live_family5(const state::Family5State& family) noexcept;
+
+/** Notes that a committed response will replace Family 4 before the next freshness query. */
+void notify_family4_publication() noexcept;
+
 /** @return True when freshness and both real-arrival rebuild arms are attached. */
 [[nodiscard]] bool install() noexcept;
 

+ 106 - 4
Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp

@@ -8,10 +8,14 @@
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstring>
+#include <limits>
 #include <string_view>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../state/investment/investment.h"
 #include "../../../hooking/detour.h"
+#include "../../../targets/game/content.h"
 #include "internal.h"
 
 namespace sunrise::client::hooks::network::investment {
@@ -31,29 +35,66 @@ constexpr auto kCommitSignature =
 
 /** Result returned when the trampoline is gone, so no commit ran. */
 constexpr std::int64_t kNoCommit = 0;
-
 using CommitFamily5 = std::int64_t(__fastcall*)(void*, std::uint64_t*);
 
 hooking::detour::Handle g_handle{};
 std::atomic<CommitFamily5> g_original{nullptr};
+std::atomic<void*> g_manager{nullptr};
 std::atomic_bool g_reportedArm{false};
 
+constexpr std::size_t kObjectArraysOffset = 33'624;
+constexpr std::size_t kObjectArraysSize = 878'184;
+constexpr std::size_t kDescriptorSize = 16;
+constexpr std::size_t kFamily5Type = 5;
+constexpr std::size_t kFamily5Slot = 5;
+constexpr std::uint32_t kFamily5Stride = 1'712;
+constexpr std::uint64_t kFamily5Soid =
+    static_cast<std::uint64_t>((std::numeric_limits<std::int64_t>::max)());
+constexpr std::size_t kFlagListOffset = 124;
+constexpr std::size_t kValueListOffset = 528;
+
+struct SlotDescriptor {
+    std::uint32_t base{};
+    std::uint32_t count{};
+    std::uint32_t stride{};
+    std::uint32_t schemaId{};
+};
+
+struct FlagRow {
+    std::int16_t slot{};
+    std::int8_t value{};
+    std::uint8_t padding{};
+};
+
+struct ValueRow {
+    std::int16_t slot{};
+    std::array<std::uint8_t, 2> padding{};
+    std::int32_t value{};
+};
+
+static_assert(sizeof(SlotDescriptor) == 16);
+static_assert(sizeof(FlagRow) == 4);
+static_assert(sizeof(ValueRow) == 8);
+
+using ObjectStoreGetter = std::byte*(__fastcall*)();
+
 /**
  * Runs the family-five commit, then arms one derived-state rebuild. The two callers pass different
  * second arguments, so it is passed on unread. Arming twice is harmless, and the next freshness
  * verdict uses it up, so repeat commits need no latch.
- * @param primaryRecordBlock Borrowed record block the commit writes into.
+ * @param manager Borrowed queuez manager owning the Family-5 commit.
  * @param nested4 Borrowed caller-owned argument, passed on unread.
  * @return The commit's own result, or the no-commit result when the trampoline is gone.
  */
-__declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
+__declspec(noinline) std::int64_t __fastcall commit(void* manager,
                                                     std::uint64_t* nested4) noexcept {
     const CommitFamily5 original = g_original.load(std::memory_order_acquire);
     if (original == nullptr) {
         return kNoCommit;
     }
+    g_manager.store(manager, std::memory_order_release);
     // Arm on the way out: the overrides are in the object only once the commit has run.
-    const std::int64_t result = original(primaryRecordBlock, nested4);
+    const std::int64_t result = original(manager, nested4);
     arm_derived_rebuild();
     if (!g_reportedArm.exchange(true, std::memory_order_relaxed)) {
         core::log::write(core::log::Channel::client,
@@ -65,6 +106,66 @@ __declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
 
 } // namespace
 
+bool publish_live_family5(const state::Family5State& family) noexcept {
+    const auto& targets = client::targets::game::content::get();
+    if (!client::targets::game::content::is_resolved()
+        || targets.queuezObjectStoreGetter == nullptr
+        || family.objectSoid != kFamily5Soid || family.flagCount > family.flags.size()
+        || family.valueCount > family.values.size()) {
+        return false;
+    }
+    const auto getter = reinterpret_cast<ObjectStoreGetter>(targets.queuezObjectStoreGetter);
+    std::byte* const store = getter();
+    if (store == nullptr) {
+        return false;
+    }
+    const std::size_t descriptorIndex =
+        kFamily5Slot + 6U * (kFamily5Type + targets.queuezDescriptorFamilyBias);
+    SlotDescriptor descriptor{};
+    std::memcpy(&descriptor,
+                store + descriptorIndex * kDescriptorSize,
+                sizeof descriptor);
+    if (descriptor.base > kObjectArraysSize - kFamily5Stride || descriptor.count != 1
+        || descriptor.stride != kFamily5Stride) {
+        return false;
+    }
+    std::byte* const object = store + kObjectArraysOffset + descriptor.base;
+    std::uint64_t objectSoid = 0;
+    std::memcpy(&objectSoid, object, sizeof objectSoid);
+    if (objectSoid != kFamily5Soid) {
+        return false;
+    }
+
+    const CommitFamily5 original = g_original.load(std::memory_order_acquire);
+    void* const manager = g_manager.load(std::memory_order_acquire);
+    if (original == nullptr || manager == nullptr) {
+        return false;
+    }
+
+    alignas(16) std::array<std::byte, kFamily5Stride> updated{};
+    std::memcpy(updated.data(), object, updated.size());
+    std::array<FlagRow, state::kUnlockOverrideCapacity> flags{};
+    for (std::size_t index = 0; index < family.flagCount; ++index) {
+        flags[index].slot = static_cast<std::int16_t>(family.flags[index].slot);
+        flags[index].value = static_cast<std::int8_t>(family.flags[index].value);
+    }
+    std::array<ValueRow, state::kUnlockOverrideCapacity> values{};
+    for (std::size_t index = 0; index < family.valueCount; ++index) {
+        values[index].slot = static_cast<std::int16_t>(family.values[index].slot);
+        values[index].value = family.values[index].value;
+    }
+    const auto flagCount = static_cast<std::uint32_t>(family.flagCount);
+    const auto valueCount = static_cast<std::uint32_t>(family.valueCount);
+    std::memcpy(
+        updated.data() + kFlagListOffset + sizeof flagCount, flags.data(), sizeof flags);
+    std::memcpy(
+        updated.data() + kValueListOffset + sizeof valueCount, values.data(), sizeof values);
+    std::memcpy(updated.data() + kFlagListOffset, &flagCount, sizeof flagCount);
+    std::memcpy(updated.data() + kValueListOffset, &valueCount, sizeof valueCount);
+    (void)commit(manager, reinterpret_cast<std::uint64_t*>(updated.data()));
+    return true;
+}
+
 /**
  * Attaches the family-five commit rearm.
  * @return True when the target is found and the detour attaches.
@@ -100,6 +201,7 @@ bool uninstall_family5_rearm() noexcept {
         return false;
     }
     g_original.store(nullptr, std::memory_order_release);
+    g_manager.store(nullptr, std::memory_order_release);
     g_reportedArm.store(false, std::memory_order_release);
     return true;
 }

+ 170 - 0
Sunrise/src/client/hooks/network/investment/investment_lore_visibility.cpp

@@ -0,0 +1,170 @@
+#include <Windows.h>
+
+#include <array>
+#include <limits>
+
+#include "../../../../core/logging/log.h"
+#include "../../../content/handles/handle_resolver.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game/content.h"
+#include "internal.h"
+#include "lore_visibility_patch.h"
+
+namespace sunrise::client::hooks::network::investment {
+namespace {
+struct Descriptor {
+    std::uint64_t count;
+    std::int64_t relative;
+};
+struct Patch {
+    std::uintptr_t address;
+    lore::Instruction before, after;
+};
+std::array<Patch, lore::kTargets.size()> g_patches{};
+std::size_t g_count{};
+SRWLOCK g_lock = SRWLOCK_INIT;
+
+template <class T> bool read(std::uintptr_t address, T& value) noexcept {
+    return memory::read_current_process(
+        nullptr, address, std::as_writable_bytes(std::span(&value, 1)));
+}
+bool data_at(std::uintptr_t address, const Descriptor& desc, std::uintptr_t& data) noexcept {
+    if (address > static_cast<std::uintptr_t>(INT64_MAX) - 24) return false;
+    const auto base = static_cast<std::int64_t>(address) + 8;
+    if (desc.relative < -base || desc.relative > INT64_MAX - base - 16) return false;
+    const auto header = static_cast<std::uintptr_t>(base + desc.relative);
+    std::uint64_t count{};
+    std::uint32_t marker{}, type{};
+    if (header < 4 || !read(header, count) || count != desc.count || !read(header - 4, marker)
+        || !read(header + 8, type) || marker >> 16 != 0x8080 || type >> 16 != 0x8080)
+        return false;
+    data = header + 16;
+    return true;
+}
+bool write(const Patch& patch, bool restore) noexcept {
+    const auto expected = restore ? patch.after : patch.before;
+    const auto desired = restore ? patch.before : patch.after;
+    lore::Instruction current{};
+    if (!read(patch.address, current) || current != expected) return false;
+    auto* destination = reinterpret_cast<void*>(patch.address);
+    DWORD previous{};
+    if (!VirtualProtect(destination, sizeof desired, PAGE_READWRITE, &previous)) return false;
+    SIZE_T written{};
+    const bool copied =
+        WriteProcessMemory(GetCurrentProcess(), destination, &desired, sizeof desired, &written)
+        && written == sizeof desired;
+    DWORD ignored{};
+    const bool protectedAgain =
+        VirtualProtect(destination, sizeof desired, previous, &ignored) != FALSE;
+    return copied && protectedAgain && read(patch.address, current) && current == desired;
+}
+bool rollback() noexcept {
+    bool ok = true;
+    for (std::size_t i = g_count; i > 0; --i) {
+        lore::Instruction current{};
+        if (!read(g_patches[i - 1].address, current)) {
+            ok = false;
+            continue;
+        }
+        if (current == g_patches[i - 1].before) continue;
+        if (!write(g_patches[i - 1], true)) ok = false;
+    }
+    if (ok) g_count = 0;
+    return ok;
+}
+bool prepare(std::array<Patch, lore::kTargets.size()>& staged) noexcept {
+    content::handles::Source source{};
+    source.tablesSlot =
+        reinterpret_cast<std::uintptr_t>(targets::game::content::get().contentHandleTablesSlot);
+    source.read = &memory::read_current_process;
+    std::array<std::uintptr_t, 2> rows{};
+    constexpr std::array<std::uint32_t, 2> tags{0x81319339U, 0x8131933FU};
+    constexpr std::array<std::size_t, 2> counts{2242, 924}, strides{216, 168};
+    for (std::size_t i = 0; i < 2; ++i) {
+        std::uintptr_t table{};
+        Descriptor desc{};
+        if (!content::handles::resolve(source, tags[i], table) || !read(table + 8, desc)
+            || desc.count != counts[i] || !data_at(table + 8, desc, rows[i]))
+            return false;
+    }
+    for (std::size_t i = 0; i < staged.size(); ++i) {
+        const auto& target = lore::kTargets[i];
+        const std::size_t kind = target.node ? 1 : 0;
+        const auto row = rows[kind] + target.row * strides[kind];
+        std::uint32_t hash{};
+        Descriptor desc{};
+        std::uintptr_t data{};
+        std::array<lore::Instruction, 59> code{};
+        if (!read(row + 40, hash) || hash != target.hash || !read(row + target.field, desc)
+            || desc.count == 0 || desc.count > code.size()
+            || !data_at(row + target.field, desc, data)
+            || !memory::read_current_process(
+                nullptr,
+                data,
+                std::as_writable_bytes(std::span(code).first(static_cast<std::size_t>(desc.count))))
+            || !lore::replacement(target.shape,
+                                  std::span(code).first(static_cast<std::size_t>(desc.count)),
+                                  staged[i].after))
+            return false;
+        staged[i].address = data;
+        staged[i].before = code[0];
+    }
+    // Each edited instruction must be owned by exactly one presentation condition. Never mutate
+    // a constant shared with another record, even if that record is not in this repair's list.
+    std::array<unsigned, lore::kTargets.size()> references{};
+    for (std::size_t kind = 0; kind < 2; ++kind) {
+        for (std::size_t row = 0; row < counts[kind]; ++row) {
+            for (const std::size_t field : (kind == 0 ? std::array<std::size_t, 2>{120, 136}
+                                                      : std::array<std::size_t, 2>{48, 64})) {
+                const auto at = rows[kind] + row * strides[kind] + field;
+                Descriptor desc{};
+                std::uintptr_t data{};
+                if (!read(at, desc)) return false;
+                if (desc.count == 0) continue;
+                if (desc.count > 128 || !data_at(at, desc, data)) return false;
+                for (std::size_t i = 0; i < staged.size(); ++i)
+                    if (staged[i].address >= data && staged[i].address - data < desc.count * 8)
+                        ++references[i];
+            }
+        }
+    }
+    for (auto count : references)
+        if (count != 1) return false;
+    return true;
+}
+} // namespace
+
+void apply_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_count != 0) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    std::array<Patch, lore::kTargets.size()> staged{};
+    bool ok = prepare(staged);
+    if (ok) {
+        for (const auto& patch : staged) {
+            g_patches[g_count++] = patch;
+            if (!write(patch, false)) {
+                ok = false;
+                break;
+            }
+        }
+    }
+    const bool restored = ok || rollback();
+    core::log::write(core::log::Channel::client,
+                     ok ? core::log::Level::info : core::log::Level::warn,
+                     ok ? "ev=lore_visibility result=applied conditions=36 progress_unchanged=1"
+                     : restored ? "ev=lore_visibility result=refused originals_retained=1"
+                                : "ev=lore_visibility result=rollback_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+void restore_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!rollback())
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=lore_visibility result=restore_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+} // namespace sunrise::client::hooks::network::investment

+ 952 - 0
Sunrise/src/client/hooks/network/investment/investment_socket_menu_routing.cpp

@@ -0,0 +1,952 @@
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <limits>
+#include <span>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/content/content_catalog.h"
+#include "../../../content/handles/handle_resolver.h"
+#include "../../../content/investment/internal.h"
+#include "../../../content/investment/layout.h"
+#include "../../../content/items/layout.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game/content.h"
+#include "internal.h"
+#include "socket_row_relocation.h"
+
+namespace sunrise::client::hooks::network::investment {
+namespace {
+
+namespace content_handles = client::content::handles;
+namespace content_investment = client::content::investment;
+namespace item_layout = client::content::items::layout;
+
+/** FNV-1 name hash shared by the installed investment-globals candidates. */
+constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
+constexpr std::size_t kBootstrapCandidateCapacity = 64;
+
+/** Exact installed item identities used to validate a candidate investment root. */
+constexpr std::uint32_t kLegArmorReferenceHash = 3'213'968'579U;
+constexpr std::array<std::uint32_t, 4> kArrivalsLegModHashes{
+    3'465'659'109U, // Flourishing Blade
+    3'465'659'111U, // Automatic Prize
+    3'465'659'104U, // Dimensional Tithes
+    3'465'659'105U, // Ascendant Bounty
+};
+
+/** Installed reusable-set layout and the two rows proved by package extraction. */
+constexpr std::size_t kTableArrayDescriptorOffset = 8;
+constexpr std::size_t kPlugSetRowStride = 24;
+constexpr std::size_t kPlugSetMemberDescriptorOffset = 8;
+constexpr std::size_t kPlugMemberStride = 32;
+constexpr std::size_t kArrayMarkerSize = 4;
+constexpr std::size_t kArrayHeaderSize = 16;
+constexpr std::size_t kGeneralSetIndex = 8;
+constexpr std::size_t kLegSetIndex = 14;
+constexpr std::uint64_t kGeneralMemberCount = 19;
+constexpr std::uint64_t kLegMemberCount = 52;
+constexpr std::size_t kMaximumSetCount = 4096;
+constexpr std::size_t kMaximumMemberCount = 4096;
+constexpr std::uintptr_t kMaximumLowAddress = UINT32_MAX;
+constexpr std::size_t kLowArenaSize = 64U * 1024U;
+constexpr std::size_t kArenaAlignment = 16U;
+constexpr std::size_t kPlugBlockOffset = 0x184;
+constexpr std::size_t kPlugCategoryOffset = 4;
+constexpr std::size_t kPlugBlockSize = 64;
+constexpr unsigned kCategoryAttemptLimit = 120;
+constexpr ULONGLONG kCategoryRetryIntervalMs = 250;
+constexpr ULONGLONG kCategoryRetryWindowMs = 30000;
+
+struct ArrayDescriptor {
+    std::uint64_t count{};
+    std::int64_t relative{};
+
+    friend bool operator==(const ArrayDescriptor&, const ArrayDescriptor&) = default;
+};
+
+struct ArrayView {
+    std::uintptr_t descriptor{};
+    std::uintptr_t header{};
+    std::uintptr_t data{};
+    std::uint64_t count{};
+    std::uint32_t elementClass{};
+};
+
+struct LocatedSets {
+    content_investment::Source source{};
+    ArrayView general{};
+    ArrayView legs{};
+};
+
+struct Allocation {
+    std::byte* base{};
+    std::size_t size{};
+
+    [[nodiscard]] explicit operator bool() const noexcept {
+        return base != nullptr && size != 0;
+    }
+};
+
+struct AppliedSet {
+    std::uintptr_t descriptor{};
+    ArrayDescriptor original{};
+    ArrayDescriptor replacement{};
+};
+
+struct CategoryPatch {
+    std::uintptr_t address{};
+    std::uint32_t original{};
+};
+constexpr std::uint32_t kGeneralCategory = 0x94493B9BU;
+constexpr std::uint32_t kLegCategory = 0x7DDE0206U;
+std::array<CategoryPatch, 4> g_categories{};
+std::size_t g_categoryCount{};
+
+enum class Failure : std::uint8_t {
+    none,
+    buildData,
+    targets,
+    source,
+    ambiguous,
+    allocation,
+    write,
+    verification,
+};
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+std::atomic_bool g_armed{false};
+std::array<Allocation, 2> g_allocations{};
+std::array<AppliedSet, 2> g_applied{};
+std::size_t g_appliedCount{};
+Failure g_lastFailure{Failure::none};
+std::byte* g_lowArena{};
+std::size_t g_lowArenaUsed{};
+std::array<std::array<relocation::Row, relocation::kMaximumMembers>, 2> g_expected{};
+std::array<std::size_t, 2> g_expectedCounts{};
+content_investment::Source g_categorySource{};
+std::array<std::uint16_t, 4> g_categoryRoutes{};
+std::uint16_t g_categoryReference{};
+unsigned g_categoryAttempts{};
+ULONGLONG g_categoryNext{};
+ULONGLONG g_categoryDeadline{};
+const char* g_categoryFailure = "none";
+
+[[nodiscard]] const char* failure_name(Failure failure) noexcept {
+    switch (failure) {
+    case Failure::buildData:
+        return "build_data";
+    case Failure::targets:
+        return "targets";
+    case Failure::source:
+        return "source";
+    case Failure::ambiguous:
+        return "ambiguous";
+    case Failure::allocation:
+        return "allocation";
+    case Failure::write:
+        return "write";
+    case Failure::verification:
+        return "verification";
+    case Failure::none:
+        return "none";
+    }
+    return "unknown";
+}
+
+void report_failure(Failure failure, std::uint64_t detail = 0) noexcept {
+    if (failure == g_lastFailure) {
+        return;
+    }
+    g_lastFailure = failure;
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=investment stage=arrivals_leg_sets result=deferred reason=%s detail=%llu",
+                      failure_name(failure),
+                      static_cast<unsigned long long>(detail));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
+[[nodiscard]] bool read_bytes(std::uintptr_t address, std::span<std::byte> output) noexcept {
+    return !output.empty() && memory::read_current_process(nullptr, address, output);
+}
+
+template <typename Value> [[nodiscard]] bool read(std::uintptr_t address, Value& value) noexcept {
+    return read_bytes(address, std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+[[nodiscard]] bool write_bytes(std::uintptr_t address, std::span<const std::byte> bytes) noexcept {
+    if (address == 0 || bytes.empty()) {
+        return false;
+    }
+    void* destination = reinterpret_cast<void*>(address);
+    DWORD previous = 0;
+    if (VirtualProtect(destination, bytes.size(), PAGE_READWRITE, &previous) == FALSE) {
+        return false;
+    }
+    std::memcpy(destination, bytes.data(), bytes.size());
+    DWORD ignored = 0;
+    const bool restored = VirtualProtect(destination, bytes.size(), previous, &ignored) != FALSE;
+    return restored;
+}
+
+template <typename Value>
+[[nodiscard]] bool write(std::uintptr_t address, const Value& value) noexcept {
+    return write_bytes(address,
+                       std::span(reinterpret_cast<const std::byte*>(&value), sizeof value));
+}
+
+[[nodiscard]] bool
+add_relative(std::uintptr_t base, std::int64_t relative, std::uintptr_t& output) noexcept {
+    if (relative >= 0) {
+        const auto distance = static_cast<std::uint64_t>(relative);
+        if (distance > (std::numeric_limits<std::uintptr_t>::max)() - base) {
+            return false;
+        }
+        output = base + static_cast<std::uintptr_t>(distance);
+        return true;
+    }
+    const auto distance = static_cast<std::uint64_t>(-(relative + 1)) + 1U;
+    if (distance > base) {
+        return false;
+    }
+    output = base - static_cast<std::uintptr_t>(distance);
+    return true;
+}
+
+/** Resolves one native count/self-relative array and validates its repeated header. */
+[[nodiscard]] bool resolve_array(std::uintptr_t descriptor,
+                                 std::size_t maximumCount,
+                                 std::size_t stride,
+                                 ArrayView& output) noexcept {
+    output = {};
+    ArrayDescriptor encoded{};
+    if (!read(descriptor, encoded) || encoded.count == 0 || encoded.count > maximumCount) {
+        return false;
+    }
+    std::uintptr_t header = 0;
+    if (!add_relative(descriptor + sizeof(std::uint64_t), encoded.relative, header)
+        || header < kArrayMarkerSize) {
+        return false;
+    }
+    std::uint32_t marker = 0;
+    std::uint64_t repeatedCount = 0;
+    std::uint32_t elementClass = 0;
+    if (!read(header - kArrayMarkerSize, marker) || !read(header, repeatedCount)
+        || !read(header + sizeof(std::uint64_t), elementClass) || repeatedCount != encoded.count
+        || (marker >> 16U) != 0x8080U || (elementClass >> 16U) != 0x8080U
+        || encoded.count
+               > ((std::numeric_limits<std::uintptr_t>::max)() - header - kArrayHeaderSize)
+                     / stride) {
+        return false;
+    }
+    const std::uintptr_t data = header + kArrayHeaderSize;
+    std::byte tail{};
+    if (!read(data + static_cast<std::uintptr_t>(encoded.count * stride) - 1U, tail)) {
+        return false;
+    }
+    output = {descriptor, header, data, encoded.count, elementClass};
+    return true;
+}
+
+[[nodiscard]] bool
+member_index(const ArrayView& array, std::size_t position, std::uint32_t& index) noexcept {
+    return position < array.count
+           && read(array.data + static_cast<std::uintptr_t>(position) * kPlugMemberStride, index);
+}
+
+[[nodiscard]] std::size_t count_member(const ArrayView& array, std::uint32_t index) noexcept {
+    std::size_t count = 0;
+    for (std::size_t position = 0; position < array.count; ++position) {
+        std::uint32_t current = 0;
+        if (!member_index(array, position, current)) {
+            return kMaximumMemberCount + 1U;
+        }
+        count += current == index ? 1U : 0U;
+    }
+    return count;
+}
+
+/** Validates the exact native pre-patch membership, not just its two row numbers. */
+[[nodiscard]] bool native_membership(const ArrayView& general,
+                                     const ArrayView& legs,
+                                     std::span<const std::uint16_t> routes,
+                                     std::uint16_t reference) noexcept {
+    if (general.count != kGeneralMemberCount || legs.count != kLegMemberCount
+        || general.elementClass != legs.elementClass || count_member(general, reference) != 0
+        || count_member(legs, reference) != 1) {
+        return false;
+    }
+    for (const std::uint16_t route : routes) {
+        if (count_member(general, route) != 1 || count_member(legs, route) != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Resolves one investment candidate and accepts only the exact installed item/set relation. */
+[[nodiscard]] bool resolve_candidate(const state::content::Definition& candidate,
+                                     std::span<const std::uint16_t> routes,
+                                     std::uint16_t reference,
+                                     LocatedSets& output) noexcept {
+    const auto& targets = targets::game::content::get();
+    content_investment::Source source{};
+    source.investmentGlobalsTag = candidate.tag;
+    source.handles.tablesSlot = reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
+    source.handles.read = &memory::read_current_process;
+
+    std::uintptr_t globals = 0;
+    std::uintptr_t root = 0;
+    std::uintptr_t itemTable = 0;
+    std::uintptr_t plugSetTable = 0;
+    std::uint32_t rootTag = 0;
+    std::uint32_t itemTableTag = 0;
+    std::uint32_t plugSetTableTag = 0;
+    std::uint64_t itemCount = 0;
+    if (!content_handles::resolve(source.handles, source.investmentGlobalsTag, globals)
+        || !read(globals + content_investment::layout::kGlobalsRootTagOffset, rootTag)
+        || !content_handles::resolve(source.handles, rootTag, root)
+        || !read(root + content_investment::layout::kItemTableTagOffset, itemTableTag)
+        || !content_handles::resolve(source.handles, itemTableTag, itemTable)
+        || !read(itemTable + item_layout::kTableRowCountOffset, itemCount) || itemCount == 0
+        || itemCount > state::build_data::items::kDefinitionCapacity
+        || !read(root + content_investment::layout::kPlugSetTableTagOffset, plugSetTableTag)
+        || !content_handles::resolve(source.handles, plugSetTableTag, plugSetTable)) {
+        return false;
+    }
+
+    const std::uintptr_t itemRows = itemTable + item_layout::kTableFirstRowOffset;
+    const auto matches_item = [&](std::uint16_t index, std::uint32_t hash) noexcept {
+        if (index >= itemCount) {
+            return false;
+        }
+        item_layout::ItemIndexRow row{};
+        return read(itemRows + static_cast<std::uintptr_t>(index) * sizeof row, row)
+               && row.definitionHash == hash;
+    };
+    if (!matches_item(reference, kLegArmorReferenceHash)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < routes.size(); ++index) {
+        if (!matches_item(routes[index], kArrivalsLegModHashes[index])) {
+            return false;
+        }
+    }
+
+    ArrayView sets{};
+    if (!resolve_array(
+            plugSetTable + kTableArrayDescriptorOffset, kMaximumSetCount, kPlugSetRowStride, sets)
+        || sets.count <= kLegSetIndex) {
+        return false;
+    }
+    const std::uintptr_t generalDescriptor =
+        sets.data + kGeneralSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
+    const std::uintptr_t legDescriptor =
+        sets.data + kLegSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
+    ArrayView general{};
+    ArrayView legs{};
+    if (!resolve_array(generalDescriptor, kMaximumMemberCount, kPlugMemberStride, general)
+        || !resolve_array(legDescriptor, kMaximumMemberCount, kPlugMemberStride, legs)
+        || !native_membership(general, legs, routes, reference)) {
+        return false;
+    }
+    output = {source, general, legs};
+    return true;
+}
+
+/** Finds exactly one native pair among every registered investment-globals candidate. */
+[[nodiscard]] bool locate_sets(std::span<const std::uint16_t> routes,
+                               std::uint16_t reference,
+                               LocatedSets& output,
+                               Failure& failure,
+                               std::uint64_t& detail) noexcept {
+    output = {};
+    failure = Failure::source;
+    detail = 0;
+    if (!targets::game::content::is_resolved()
+        || targets::game::content::get().contentHandleTablesSlot == nullptr) {
+        failure = Failure::targets;
+        return false;
+    }
+    std::array<state::content::Definition, kBootstrapCandidateCapacity> candidates{};
+    std::size_t candidateCount = 0;
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, candidateCount)
+        && candidateCount == 0) {
+        return false;
+    }
+    std::size_t matches = 0;
+    for (std::size_t index = 0; index < candidateCount; ++index) {
+        LocatedSets candidateSets{};
+        if (!resolve_candidate(candidates[index], routes, reference, candidateSets)) {
+            continue;
+        }
+        if (matches != 0
+            && (candidateSets.general.descriptor != output.general.descriptor
+                || candidateSets.legs.descriptor != output.legs.descriptor)) {
+            failure = Failure::ambiguous;
+            detail = matches + 1U;
+            return false;
+        }
+        output = candidateSets;
+        ++matches;
+    }
+    detail = matches;
+    return matches != 0;
+}
+
+[[nodiscard]] std::uintptr_t align_up(std::uintptr_t value, std::uintptr_t alignment) noexcept {
+    const std::uintptr_t mask = alignment - 1U;
+    if (value > (std::numeric_limits<std::uintptr_t>::max)() - mask) {
+        return 0;
+    }
+    return (value + mask) & ~mask;
+}
+
+/** Reserves one process-lifetime arena while the low content address domain is still available. */
+[[nodiscard]] std::byte* reserve_low_arena() noexcept {
+    SYSTEM_INFO system{};
+    GetSystemInfo(&system);
+    const std::uintptr_t granularity = system.dwAllocationGranularity;
+    std::uintptr_t cursor =
+        align_up(reinterpret_cast<std::uintptr_t>(system.lpMinimumApplicationAddress), granularity);
+    while (cursor != 0 && cursor <= kMaximumLowAddress
+           && kLowArenaSize <= kMaximumLowAddress - cursor + 1U) {
+        MEMORY_BASIC_INFORMATION information{};
+        if (VirtualQuery(reinterpret_cast<const void*>(cursor), &information, sizeof information)
+            == 0) {
+            break;
+        }
+        const std::uintptr_t base = reinterpret_cast<std::uintptr_t>(information.BaseAddress);
+        const std::uintptr_t next =
+            information.RegionSize <= (std::numeric_limits<std::uintptr_t>::max)() - base
+                ? base + information.RegionSize
+                : 0;
+        if (information.State == MEM_FREE) {
+            const std::uintptr_t candidate = align_up((std::max)(cursor, base), granularity);
+            const std::uintptr_t offset = candidate >= base ? candidate - base : 0;
+            if (candidate != 0 && candidate <= kMaximumLowAddress
+                && kLowArenaSize <= kMaximumLowAddress - candidate + 1U && candidate >= base
+                && offset <= information.RegionSize
+                && kLowArenaSize <= information.RegionSize - offset) {
+                void* const allocated = VirtualAlloc(reinterpret_cast<void*>(candidate),
+                                                     kLowArenaSize,
+                                                     MEM_RESERVE | MEM_COMMIT,
+                                                     PAGE_READWRITE);
+                if (allocated != nullptr) {
+                    return static_cast<std::byte*>(allocated);
+                }
+            }
+        }
+        if (next == 0 || next <= cursor) {
+            break;
+        }
+        cursor = align_up(next, granularity);
+    }
+    return nullptr;
+}
+
+/** Carves one immutable array payload from the arena reserved at DLL process attach. */
+[[nodiscard]] Allocation allocate_array(std::size_t size) noexcept {
+    const std::size_t aligned = static_cast<std::size_t>(align_up(g_lowArenaUsed, kArenaAlignment));
+    if (g_lowArena == nullptr || aligned > kLowArenaSize || size > kLowArenaSize - aligned) {
+        return {};
+    }
+    g_lowArenaUsed = aligned + size;
+    return {g_lowArena + aligned, size};
+}
+
+void release(Allocation& allocation) noexcept {
+    // Storage belongs to the process-lifetime low arena, not to an individual array.
+    allocation = {};
+}
+
+[[nodiscard]] bool
+snapshot_row(const ArrayView& source, std::size_t position, relocation::Row& output) noexcept {
+    output = {};
+    const auto address = source.data + position * kPlugMemberStride;
+    if (position >= source.count || address % 8 != 0
+        || source.elementClass != relocation::kMemberClass || !read_bytes(address, output.bytes))
+        return false;
+    const auto count = relocation::get<std::uint64_t>(output.bytes.data() + 8);
+    if (count != 0) {
+        std::uintptr_t header = 0;
+        if (count != 1
+            || !add_relative(
+                address + 16, relocation::get<std::int64_t>(output.bytes.data() + 16), header)
+            || header < 4 || header % 8 != 0 || !read_bytes(header - 4, output.condition))
+            return false;
+    }
+    return relocation::valid(output);
+}
+
+[[nodiscard]] bool verify_owned(std::size_t set, const Allocation& allocation) noexcept {
+    return allocation
+           && relocation::verify(std::span(g_expected[set].data(), g_expectedCounts[set]),
+                                 std::span<const std::byte>(allocation.base, allocation.size));
+}
+
+/** Validates the exact plug blocks observed in this build before changing four category fields. */
+bool stage_categories(const content_investment::Source& source,
+                      std::span<const std::uint16_t> routes,
+                      std::uint16_t reference,
+                      std::array<CategoryPatch, 4>& output) noexcept {
+    std::uintptr_t globals = 0, root = 0, table = 0;
+    g_categoryFailure = "table";
+    std::uint32_t tag = 0;
+    if (!content_handles::resolve(source.handles, source.investmentGlobalsTag, globals)
+        || !read(globals + content_investment::layout::kGlobalsRootTagOffset, tag)
+        || !content_handles::resolve(source.handles, tag, root)
+        || !read(root + content_investment::layout::kItemTableTagOffset, tag)
+        || !content_handles::resolve(source.handles, tag, table))
+        return false;
+    const auto block = [&](std::uint16_t index,
+                           std::uint32_t hash,
+                           std::uintptr_t& address,
+                           std::array<std::byte, kPlugBlockSize>& bytes) noexcept {
+        item_layout::ItemIndexRow row{};
+        g_categoryFailure = "definition";
+        std::uintptr_t definition = 0;
+        if (!read(table + item_layout::kTableFirstRowOffset + index * sizeof row, row)
+            || row.definitionHash != hash
+            || !content_handles::resolve(source.handles, row.targetHandle, definition))
+            return false;
+        address = definition + kPlugBlockOffset + kPlugCategoryOffset;
+        g_categoryFailure = "block";
+        return read_bytes(definition + kPlugBlockOffset, bytes)
+               && relocation::get<std::uint32_t>(bytes.data()) == 0x808077E3U;
+    };
+    std::array<std::byte, kPlugBlockSize> referenceBlock{};
+    std::uintptr_t referenceAddress = 0;
+    if (!block(reference, kLegArmorReferenceHash, referenceAddress, referenceBlock)) return false;
+    g_categoryFailure = "reference_category";
+    if (relocation::get<std::uint32_t>(referenceBlock.data() + 4) != kLegCategory) return false;
+    for (std::size_t i = 0; i < routes.size(); ++i) {
+        std::array<std::byte, kPlugBlockSize> bytes{};
+        if (!block(routes[i], kArrivalsLegModHashes[i], output[i].address, bytes)) return false;
+        output[i].original = relocation::get<std::uint32_t>(bytes.data() + 4);
+        g_categoryFailure = "category_or_metadata";
+        if ((output[i].original != kGeneralCategory && output[i].original != kLegCategory)
+            || std::memcmp(bytes.data() + 8, referenceBlock.data() + 8, 56) != 0)
+            return false;
+    }
+    return true;
+}
+
+bool categories_current() noexcept {
+    if (g_categoryCount != g_categories.size()) return false;
+    for (const auto& category : g_categories) {
+        std::uint32_t value = 0;
+        if (!read(category.address, value) || value != kLegCategory) return false;
+    }
+    return true;
+}
+
+/** Restore only fields still owned by this patch; retain ownership if any restore fails. */
+bool restore_categories() noexcept {
+    bool restored = true;
+    for (std::size_t i = 0; i < g_categoryCount; ++i) {
+        const auto& category = g_categories[i];
+        std::uint32_t current = 0;
+        if (!read(category.address, current)
+            || (current != category.original
+                && (current != kLegCategory || !write(category.address, category.original)))) {
+            restored = false;
+        }
+    }
+    if (restored) g_categoryCount = 0;
+    return restored;
+}
+
+/** Bounded retry for item definitions that become available after set-table initialization. */
+void apply_pending_categories() noexcept {
+    if (g_categoryAttempts == 0) return;
+    const auto now = GetTickCount64();
+    if (now < g_categoryNext) return;
+    g_categoryNext = now + kCategoryRetryIntervalMs;
+    --g_categoryAttempts;
+    std::array<CategoryPatch, 4> categories{};
+    if (!stage_categories(g_categorySource, g_categoryRoutes, g_categoryReference, categories)) {
+        if (now >= g_categoryDeadline) g_categoryAttempts = 0;
+        if (g_categoryAttempts == kCategoryAttemptLimit - 1 || g_categoryAttempts == 0) {
+            std::array<char, 200> line{};
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=investment stage=arrivals_leg_categories result=%s reason=%s",
+                          g_categoryAttempts == 0 ? "failed" : "pending",
+                          g_categoryFailure);
+            core::log::write(core::log::Channel::client, core::log::Level::warn, line.data());
+        }
+        return;
+    }
+    g_categoryAttempts = 0;
+    g_categories = categories;
+    g_categoryCount = categories.size();
+    bool written = true;
+    for (const auto& category : categories) {
+        if (!write(category.address, kLegCategory)) {
+            written = false;
+            break;
+        }
+    }
+    if (written && categories_current()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         "ev=investment stage=arrivals_leg_categories result=applied verified=4");
+        return;
+    }
+    (void)restore_categories();
+    core::log::write(
+        core::log::Channel::client,
+        core::log::Level::warn,
+        "ev=investment stage=arrivals_leg_categories result=failed reason=write_or_verify");
+}
+
+[[nodiscard]] bool encode_descriptor(std::uintptr_t descriptor,
+                                     const Allocation& allocation,
+                                     std::uint64_t count,
+                                     ArrayDescriptor& output) noexcept {
+    const std::uintptr_t header = reinterpret_cast<std::uintptr_t>(allocation.base) + 8;
+    const std::uintptr_t relativeBase = descriptor + sizeof(std::uint64_t);
+    if (header >= relativeBase) {
+        const std::uintptr_t distance = header - relativeBase;
+        if (distance > static_cast<std::uintptr_t>((std::numeric_limits<std::int64_t>::max)())) {
+            return false;
+        }
+        output = {count, static_cast<std::int64_t>(distance)};
+        return true;
+    }
+    const std::uintptr_t distance = relativeBase - header;
+    if (distance > static_cast<std::uintptr_t>((std::numeric_limits<std::int64_t>::max)())) {
+        return false;
+    }
+    output = {count, -static_cast<std::int64_t>(distance)};
+    return true;
+}
+
+/** Builds the general array with the four Arrivals leg mods removed. */
+[[nodiscard]] bool build_general(const ArrayView& source,
+                                 std::span<const std::uint16_t> routes,
+                                 Allocation& allocation,
+                                 ArrayDescriptor& descriptor) noexcept {
+    const std::uint64_t newCount = source.count - routes.size();
+    if (newCount != kGeneralMemberCount - kArrivalsLegModHashes.size()) return false;
+    std::size_t written = 0;
+    for (std::size_t position = 0; position < source.count; ++position) {
+        std::uint32_t index = 0;
+        if (!member_index(source, position, index)) {
+            release(allocation);
+            return false;
+        }
+        if (std::find(routes.begin(), routes.end(), index) != routes.end()) {
+            continue;
+        }
+        if (written >= newCount || !snapshot_row(source, position, g_expected[0][written])) {
+            return false;
+        }
+        ++written;
+    }
+    if (written != newCount) {
+        release(allocation);
+        return false;
+    }
+    g_expectedCounts[0] = written;
+    allocation = allocate_array(relocation::capacity(written));
+    return allocation
+           && relocation::build(std::span(g_expected[0].data(), written),
+                                std::span(allocation.base, allocation.size))
+           && verify_owned(0, allocation)
+           && encode_descriptor(source.descriptor, allocation, newCount, descriptor);
+}
+
+/** Moves each target's OWN original row and condition data into the leg array. */
+[[nodiscard]] bool build_legs(const ArrayView& source,
+                              const ArrayView& general,
+                              std::span<const std::uint16_t> routes,
+                              std::uint16_t reference,
+                              Allocation& allocation,
+                              ArrayDescriptor& descriptor) noexcept {
+    const std::uint64_t newCount = source.count + routes.size();
+    if (newCount != kLegMemberCount + kArrivalsLegModHashes.size()) return false;
+    std::size_t referencePosition = source.count;
+    for (std::size_t position = 0; position < source.count; ++position) {
+        if (!snapshot_row(source, position, g_expected[1][position])) {
+            release(allocation);
+            return false;
+        }
+        std::uint32_t index = 0;
+        if (!member_index(source, position, index)) {
+            release(allocation);
+            return false;
+        }
+        if (index == reference) {
+            referencePosition = position;
+        }
+    }
+    if (referencePosition == source.count) {
+        release(allocation);
+        return false;
+    }
+    for (std::size_t route = 0; route < routes.size(); ++route) {
+        bool found = false;
+        for (std::size_t position = 0; position < general.count; ++position) {
+            std::uint32_t index = 0;
+            if (!member_index(general, position, index)) return false;
+            if (index != routes[route]) continue;
+            if (found || !snapshot_row(general, position, g_expected[1][source.count + route]))
+                return false;
+            found = true;
+        }
+        if (!found) return false;
+    }
+    g_expectedCounts[1] = static_cast<std::size_t>(newCount);
+    // Keep the native Empty Mod Socket first, followed by the four artifact mods.
+    std::rotate(g_expected[1].begin() + 1,
+                g_expected[1].begin() + source.count,
+                g_expected[1].begin() + newCount);
+    allocation = allocate_array(relocation::capacity(g_expectedCounts[1]));
+    return allocation
+           && relocation::build(std::span(g_expected[1].data(), g_expectedCounts[1]),
+                                std::span(allocation.base, allocation.size))
+           && verify_owned(1, allocation)
+           && encode_descriptor(source.descriptor, allocation, newCount, descriptor);
+}
+
+[[nodiscard]] bool patched_membership(const ArrayView& general,
+                                      const ArrayView& legs,
+                                      std::span<const std::uint16_t> routes,
+                                      std::uint16_t reference) noexcept {
+    if (general.count != kGeneralMemberCount - routes.size()
+        || legs.count != kLegMemberCount + routes.size() || count_member(legs, reference) != 1) {
+        return false;
+    }
+    for (const std::uint16_t route : routes) {
+        if (count_member(general, route) != 0 || count_member(legs, route) != 1) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Fast steady-state check used by the callback fallback after synchronous application. */
+[[nodiscard]] bool descriptors_current() noexcept {
+    if (g_appliedCount != g_applied.size()) {
+        return false;
+    }
+    ArrayDescriptor generalDescriptor{};
+    ArrayDescriptor legDescriptor{};
+    if (!read(g_applied[0].descriptor, generalDescriptor)
+        || !read(g_applied[1].descriptor, legDescriptor)
+        || generalDescriptor != g_applied[0].replacement
+        || legDescriptor != g_applied[1].replacement) {
+        return false;
+    }
+    return true;
+}
+
+/** Full post-write proof, deliberately paid only once rather than on every callback. */
+[[nodiscard]] bool patched_sets_current(std::span<const std::uint16_t> routes,
+                                        std::uint16_t reference) noexcept {
+    if (!descriptors_current()) {
+        return false;
+    }
+    ArrayView general{};
+    ArrayView legs{};
+    return resolve_array(g_applied[0].descriptor, kMaximumMemberCount, kPlugMemberStride, general)
+           && resolve_array(g_applied[1].descriptor, kMaximumMemberCount, kPlugMemberStride, legs)
+           && patched_membership(general, legs, routes, reference)
+           && verify_owned(0, g_allocations[0]) && verify_owned(1, g_allocations[1]);
+}
+
+/** Restores descriptors only when they still name this module's allocations. */
+bool restore_applied() noexcept {
+    g_categoryAttempts = 0;
+    bool restoredAll = restore_categories();
+    for (std::size_t index = g_appliedCount; index > 0; --index) {
+        const AppliedSet& applied = g_applied[index - 1];
+        ArrayDescriptor current{};
+        if (!read(applied.descriptor, current)) {
+            restoredAll = false;
+        } else if (current == applied.original) {
+            continue;
+        } else if (current != applied.replacement || !write(applied.descriptor, applied.original)) {
+            restoredAll = false;
+        }
+    }
+    if (restoredAll) {
+        for (Allocation& allocation : g_allocations) {
+            release(allocation);
+        }
+        g_applied = {};
+        g_appliedCount = 0;
+        g_categoryCount = 0;
+    }
+    return restoredAll;
+}
+
+} // namespace
+
+void reserve_socket_menu_routing_storage() noexcept {
+    if (g_lowArena == nullptr) {
+        g_lowArena = reserve_low_arena();
+    }
+}
+
+/** Arms the one synchronous correction after native content-table patching completes. */
+void arm_socket_menu_routing() noexcept {
+    g_armed.store(true, std::memory_order_release);
+}
+
+/** Moves exactly four members between two validated native reusable plug sets. */
+void apply_socket_menu_routing() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!g_armed.load(std::memory_order_acquire)) {
+        apply_pending_categories();
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    // One attempt per native patch-completion event. No callback-pump retry loop.
+    g_armed.store(false, std::memory_order_release);
+
+    std::array<std::uint16_t, kArrivalsLegModHashes.size()> routeIndices{};
+    std::uint16_t referenceIndex = UINT16_MAX;
+    state::build_data::items::Definition definition{};
+    bool mapped = state::build_data::find_item_definition_hash(kLegArmorReferenceHash, definition);
+    if (mapped) {
+        referenceIndex = definition.definitionIndex;
+    }
+    for (std::size_t route = 0; mapped && route < kArrivalsLegModHashes.size(); ++route) {
+        mapped =
+            state::build_data::find_item_definition_hash(kArrivalsLegModHashes[route], definition);
+        if (mapped) {
+            routeIndices[route] = definition.definitionIndex;
+        }
+    }
+    if (!mapped) {
+        report_failure(Failure::buildData);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (descriptors_current()) {
+        apply_pending_categories();
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (g_appliedCount != 0 || g_categoryCount != 0) {
+        if (!restore_applied()) {
+            report_failure(Failure::write);
+            ReleaseSRWLockExclusive(&g_lock);
+            return;
+        }
+    }
+    // Never reuse published storage: native readers may still retain a pointer after restoration.
+
+    LocatedSets located{};
+    Failure failure = Failure::none;
+    std::uint64_t detail = 0;
+    if (!locate_sets(routeIndices, referenceIndex, located, failure, detail)) {
+        report_failure(failure, detail);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    std::array<Allocation, 2> allocations{};
+    std::array<ArrayDescriptor, 2> replacements{};
+    if (!build_general(located.general, routeIndices, allocations[0], replacements[0])
+        || !build_legs(located.legs,
+                       located.general,
+                       routeIndices,
+                       referenceIndex,
+                       allocations[1],
+                       replacements[1])) {
+        release(allocations[0]);
+        release(allocations[1]);
+        report_failure(Failure::allocation);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    ArrayDescriptor originalGeneral{};
+    ArrayDescriptor originalLegs{};
+    const bool originalsRead = read(located.general.descriptor, originalGeneral)
+                               && read(located.legs.descriptor, originalLegs);
+    if (!originalsRead) {
+        report_failure(Failure::write);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    // Record ownership BEFORE either write; a protection-restoration failure may follow a copy.
+    g_allocations = allocations;
+    g_applied = {{{located.general.descriptor, originalGeneral, replacements[0]},
+                  {located.legs.descriptor, originalLegs, replacements[1]}}};
+    g_appliedCount = g_applied.size();
+    const bool generalWritten = write(located.general.descriptor, replacements[0]);
+    const bool legsWritten = generalWritten && write(located.legs.descriptor, replacements[1]);
+    if (!legsWritten) {
+        restore_applied();
+        report_failure(Failure::write);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    if (!patched_sets_current(routeIndices, referenceIndex)) {
+        restore_applied();
+        report_failure(Failure::verification);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    g_lastFailure = Failure::none;
+    g_categorySource = located.source;
+    g_categoryRoutes = routeIndices;
+    g_categoryReference = referenceIndex;
+    g_categoryAttempts = kCategoryAttemptLimit;
+    g_categoryNext = 0;
+    g_categoryDeadline = GetTickCount64() + kCategoryRetryWindowMs;
+    apply_pending_categories();
+    g_armed.store(false, std::memory_order_release);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=investment stage=arrivals_leg_sets result=applied source_count=%llu "
+        "leg_count=%llu source=0x%llX legs=0x%llX general_data=0x%llX leg_data=0x%llX "
+        "validation=deep_copy_v3 rows_verified=71 order=empty_then_artifact",
+        static_cast<unsigned long long>(kGeneralMemberCount - routeIndices.size()),
+        static_cast<unsigned long long>(kLegMemberCount + routeIndices.size()),
+        static_cast<unsigned long long>(located.general.descriptor),
+        static_cast<unsigned long long>(located.legs.descriptor),
+        static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(g_allocations[0].base)),
+        static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(g_allocations[1].base)));
+    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() - 1U)});
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+void restore_socket_menu_routing() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    restore_applied();
+    g_armed.store(false, std::memory_order_release);
+    g_lastFailure = Failure::none;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+} // namespace sunrise::client::hooks::network::investment

+ 87 - 0
Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h

@@ -0,0 +1,87 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::hooks::network::investment::lore {
+struct Instruction {
+    std::uint32_t opcode{}, operand{};
+    friend bool operator==(const Instruction&, const Instruction&) = default;
+};
+enum class Shape { constant, eva, confessions, chronicon };
+struct Target {
+    std::uint16_t row, field;
+    std::uint32_t hash;
+    Shape shape;
+    bool node;
+};
+inline constexpr auto kTargets = [] {
+    std::array<Target, 36> targets{};
+    std::size_t n = 0;
+    targets[n++] = {820, 64, 0x13F7E95CU, Shape::eva, true};
+    targets[n++] = {837, 64, 0x3FCE8988U, Shape::chronicon, true};
+    constexpr std::array<std::uint32_t, 15> wishes{0xFA360CA1U,
+                                                   0xFA360CA2U,
+                                                   0xFA360CA3U,
+                                                   0xFA360CA4U,
+                                                   0xFA360CA5U,
+                                                   0xFA360CA6U,
+                                                   0xFA360CA7U,
+                                                   0xFA360CA8U,
+                                                   0xFA360CA9U,
+                                                   0xFB360E13U,
+                                                   0xFB360E12U,
+                                                   0xFB360E11U,
+                                                   0xFB360E10U,
+                                                   0xFB360E17U,
+                                                   0xFB360E16U};
+    for (std::size_t i = 0; i < wishes.size(); ++i)
+        targets[n++] = {
+            static_cast<std::uint16_t>(825 + i), 120, wishes[i], Shape::constant, false};
+    targets[n++] = {1707, 136, 0xB337A52FU, Shape::confessions, false};
+    constexpr std::array<std::uint32_t, 9> chapters{0xB780F393U,
+                                                    0xB780F390U,
+                                                    0xB780F391U,
+                                                    0xB780F396U,
+                                                    0xB780F397U,
+                                                    0xB780F394U,
+                                                    0xB780F395U,
+                                                    0xB780F39AU,
+                                                    0xB780F39BU};
+    for (std::size_t i = 0; i < chapters.size(); ++i) {
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 120, chapters[i], Shape::confessions, false};
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 136, chapters[i], Shape::constant, false};
+    }
+    return targets;
+}();
+
+/** Validate the entire shipped expression before replacing just its first instruction. */
+[[nodiscard]] inline bool
+replacement(Shape shape, std::span<const Instruction> code, Instruction& output) noexcept {
+    if (shape == Shape::constant) {
+        if (code.size() != 1 || code[0] != Instruction{11, 1}) return false;
+        output = {11, 0}; // false
+        return true;
+    }
+    if (shape == Shape::eva || shape == Shape::confessions) {
+        const Instruction read =
+            shape == Shape::eva ? Instruction{10, 10343} : Instruction{1, 8702};
+        if (code.size() != 2 || code[0] != read || code[1] != Instruction{2, 0}) return false;
+        output = {11, 1}; // NOT true = false
+        return true;
+    }
+    if (code.size() != 59) return false;
+    for (std::size_t i = 0; i < 15; ++i) {
+        if (code[i * 3] != Instruction{10, static_cast<std::uint32_t>(10615 + i)}
+            || code[i * 3 + 1] != Instruction{11, 0}
+            || code[i * 3 + 2] != Instruction{8, UINT32_MAX})
+            return false;
+    }
+    for (std::size_t i = 45; i < 59; ++i)
+        if (code[i] != Instruction{4, UINT32_MAX}) return false;
+    output = {11, 1}; // First equality is 1 == 0, making the entire conjunction false.
+    return true;
+}
+} // namespace sunrise::client::hooks::network::investment::lore

+ 122 - 0
Sunrise/src/client/hooks/network/investment/socket_row_relocation.h

@@ -0,0 +1,122 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <span>
+
+namespace sunrise::client::hooks::network::investment::relocation {
+
+inline constexpr std::uint32_t kMarker = 0x80809FBDU;
+inline constexpr std::uint32_t kMemberClass = 0x80802E03U;
+inline constexpr std::uint32_t kConditionClass = 0x80807D31U;
+inline constexpr std::size_t kDataOffset = 24;
+inline constexpr std::size_t kMaximumMembers = 56;
+inline constexpr std::size_t kMemberSize = 32;
+inline constexpr std::size_t kConditionSize = 32;
+
+/** A captured member and its own opaque, single-record condition allocation. */
+struct Row {
+    std::array<std::byte, kMemberSize> bytes{};
+    std::array<std::byte, kConditionSize> condition{};
+};
+
+template <typename T> T get(const std::byte* p) noexcept {
+    T value{};
+    std::memcpy(&value, p, sizeof value);
+    return value;
+}
+
+template <typename T> void put(std::byte* p, T value) noexcept {
+    std::memcpy(p, &value, sizeof value);
+}
+
+inline bool valid(const Row& row) noexcept {
+    const auto count = get<std::uint64_t>(row.bytes.data() + 8);
+    if (count == 0) {
+        return get<std::int64_t>(row.bytes.data() + 16) == 0;
+    }
+    return count == 1 && get<std::uint32_t>(row.condition.data()) == kMarker
+           && get<std::uint64_t>(row.condition.data() + 4) == 1
+           && get<std::uint32_t>(row.condition.data() + 12) == kConditionClass
+           && get<std::uint32_t>(row.condition.data() + 16) == 0;
+}
+
+inline std::size_t capacity(std::size_t count) noexcept {
+    return kDataOffset + count * (kMemberSize + kConditionSize) + 8;
+}
+
+/** Builds aligned, self-contained arrays; the original pointer bits are never reused. */
+inline bool build(std::span<const Row> rows, std::span<std::byte> output) noexcept {
+    if (rows.empty() || rows.size() > kMaximumMembers || output.size() < capacity(rows.size())
+        || reinterpret_cast<std::uintptr_t>(output.data()) % 8 != 0) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        if (!valid(rows[i])) {
+            return false;
+        }
+        for (std::size_t j = 0; j < i; ++j) {
+            if (get<std::uint32_t>(rows[i].bytes.data())
+                == get<std::uint32_t>(rows[j].bytes.data())) {
+                return false;
+            }
+        }
+    }
+    std::memset(output.data(), 0, output.size());
+    put(output.data() + 4, kMarker);
+    put(output.data() + 8, static_cast<std::uint64_t>(rows.size()));
+    put(output.data() + 16, kMemberClass);
+    std::size_t cursor = kDataOffset + rows.size() * 32 + 4;
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        const std::size_t at = kDataOffset + i * 32;
+        std::memcpy(output.data() + at, rows[i].bytes.data(), 32);
+        if (get<std::uint64_t>(rows[i].bytes.data() + 8) != 0) {
+            std::memcpy(output.data() + cursor, rows[i].condition.data(), 32);
+            put(output.data() + at + 16,
+                static_cast<std::int64_t>(cursor + 4) - static_cast<std::int64_t>(at + 16));
+            cursor += 32;
+        }
+    }
+    return true;
+}
+
+/** Independently follows every relocated reference and compares all original payload bytes. */
+inline bool verify(std::span<const Row> rows, std::span<const std::byte> blob) noexcept {
+    if (rows.empty() || rows.size() > kMaximumMembers
+        || blob.size() < kDataOffset + rows.size() * 32
+        || reinterpret_cast<std::uintptr_t>(blob.data()) % 8 != 0
+        || get<std::uint32_t>(blob.data() + 4) != kMarker
+        || get<std::uint64_t>(blob.data() + 8) != rows.size()
+        || get<std::uint32_t>(blob.data() + 16) != kMemberClass
+        || get<std::uint32_t>(blob.data() + 20) != 0) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        const std::size_t at = kDataOffset + i * 32;
+        const auto* member = blob.data() + at;
+        if (!valid(rows[i]) || std::memcmp(member, rows[i].bytes.data(), 16) != 0
+            || std::memcmp(member + 24, rows[i].bytes.data() + 24, 8) != 0) {
+            return false;
+        }
+        const auto relative = get<std::int64_t>(member + 16);
+        if (get<std::uint64_t>(member + 8) == 0) {
+            if (relative != 0) return false;
+            continue;
+        }
+        // Owned condition storage is after the member array, so all relocated offsets are positive.
+        if (relative <= 0 || static_cast<std::uint64_t>(relative) > blob.size() - at - 16) {
+            return false;
+        }
+        const std::size_t header = at + 16 + static_cast<std::size_t>(relative);
+        if (header % 8 != 0 || header < kDataOffset + rows.size() * 32 + 4
+            || blob.size() - header < 28
+            || std::memcmp(blob.data() + header - 4, rows[i].condition.data(), 32) != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::client::hooks::network::investment::relocation

+ 68 - 2
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -1,5 +1,7 @@
 #include "retail_log_enqueue_observer.h"
 
+#include <intrin.h>
+
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -7,6 +9,8 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../bootflow/bootflow_hook_lifecycle.h"
+#include "../network/investment/internal.h"
 #include "../../targets/game.h"
 
 namespace sunrise::client::hooks::retail_log {
@@ -28,6 +32,9 @@ constexpr std::uint64_t kReassertIntervalMs = 2'000;
 constexpr std::uint32_t kCategoryCount = 26;
 /** 0 is the game's loosest category threshold. A higher value logs less. */
 constexpr std::uint32_t kMostVerbose = 0;
+/** Native boundary after plug tables finish patching and before their derived views resume. */
+constexpr std::string_view kContentTablePatchingComplete =
+    "content_table_patching: patch contents have been cleared";
 
 thread_local bool g_inObserver{};
 /** Tick at which the next re-assert is due. Zero makes the first call assert. */
@@ -62,11 +69,19 @@ volatile LONG64 g_nextAssertTick{};
  * @param text Borrowed native buffer.
  */
 void capture_line(std::int32_t siteId, const char* text) noexcept {
+    std::array<char, kNativeTextSize> sanitized{};
+    const std::size_t textLength = sanitize(text, sanitized);
+    const std::string_view message{sanitized.data(), textLength};
+    if (message.find(kContentTablePatchingComplete) != std::string_view::npos) {
+        network::investment::arm_socket_menu_routing();
+        // This must happen before the native logger returns to investment initialization. A later
+        // callback tick races the socket-menu caches that consume these descriptors.
+        network::investment::apply_socket_menu_routing();
+        network::investment::apply_lore_visibility();
+    }
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::info)) {
         return;
     }
-    std::array<char, kNativeTextSize> sanitized{};
-    const std::size_t textLength = sanitize(text, sanitized);
     std::array<char, kEventCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),
@@ -83,6 +98,53 @@ void capture_line(std::int32_t siteId, const char* text) noexcept {
     core::log::write(core::log::Channel::client, core::log::Level::info, {line.data(), length});
 }
 
+/** Text whose emitting call site is worth locating in the image. */
+constexpr std::string_view kTracedText = "failed to create";
+/** Call sites named per run, so a repeating line cannot flood the sink. */
+constexpr std::size_t kMaxCallSiteReports = 64;
+
+/** Reports already spent. */
+volatile LONG g_callSiteReports{};
+
+/**
+ * Names the image offset of the code that emitted one line.
+ * The packed executable cannot be disassembled on disk, so a dump of the mapped image is the only
+ * readable copy, and an offset from the load base is what addresses it. The retail text itself
+ * carries no address, and the site id is assigned by the game's own registration rather than by
+ * position, so nothing else here says which function produced a line. `_ReturnAddress` inside the
+ * funnel is the emitting call site, which is exactly the function to disassemble.
+ * @param returnAddress Return address captured in the funnel.
+ * @param text Already-formatted native line.
+ */
+void report_call_site(const void* returnAddress, const char* text) noexcept {
+    if (returnAddress == nullptr
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    const auto site = reinterpret_cast<std::uintptr_t>(returnAddress);
+    if (base == 0 || site < base) {
+        return;
+    }
+    if (InterlockedIncrement(&g_callSiteReports) > static_cast<LONG>(kMaxCallSiteReports)) {
+        return;
+    }
+    std::array<char, kEventCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=retail_site stage=caller rva=0x%llX va=0x%llX text=%s",
+                                      static_cast<unsigned long long>(site - base),
+                                      static_cast<unsigned long long>(site),
+                                      text);
+    if (written > 0) {
+        const auto length = static_cast<std::size_t>(written) < line.size()
+                                ? static_cast<std::size_t>(written)
+                                : line.size() - 1;
+        core::log::write(core::log::Channel::client, core::log::Level::debug,
+                         {line.data(), length});
+    }
+}
+
 /**
  * Mirrors the single funnel every retail log line passes through.
  * @param siteId Registered site id.
@@ -99,6 +161,10 @@ __declspec(noinline) void __fastcall enqueue_body(std::int32_t siteId, const cha
     if (outer) {
         if (siteId != kUnregisteredSite && text != nullptr) {
             capture_line(siteId, text);
+            // Cheap guard first: the search only runs on the handful of lines that match.
+            if (std::string_view(text).find(kTracedText) != std::string_view::npos) {
+                report_call_site(_ReturnAddress(), text);
+            }
         }
         assert_verbosity();
         g_inObserver = false;

+ 3 - 0
Sunrise/src/client/hooks/teleport/runtime.h

@@ -93,6 +93,9 @@ void apply_pending(void* component) noexcept;
  */
 [[nodiscard]] bool owns_local_player(void* component) noexcept;
 
+/** @return True when the game currently publishes a controlled local-player object handle. */
+[[nodiscard]] bool controlled_player_present() noexcept;
+
 /**
  * Reads the world position of the body a physics component drives.
  * @param component Physics component.

+ 10 - 0
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -468,6 +468,16 @@ bool owns_local_player(void* component) noexcept {
            && owns_player(static_cast<std::byte*>(component));
 }
 
+/** Reports whether the native controlled-object accessor has published a local player. */
+bool controlled_player_present() noexcept {
+    if (g_controlledHandle == nullptr) {
+        return false;
+    }
+    std::uint32_t controlled = kInvalidHandle;
+    g_controlledHandle(&controlled);
+    return controlled != kInvalidHandle;
+}
+
 /** Reads the world position of the body a physics component drives. */
 bool read_position(void* component, Vector& position) noexcept {
     if (component == nullptr) {

+ 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

+ 114 - 6
Sunrise/src/client/patterns/registry.cpp

@@ -1,5 +1,9 @@
 #include "registry.h"
 
+#include <Windows.h>
+
+#include <array>
+#include <cstdint>
 #include <cstring>
 
 namespace sunrise::client::patterns {
@@ -7,6 +11,91 @@ namespace {
 
 /** Returned by next_candidate when a range holds no further anchor byte. */
 constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
+/** One count per distinct byte value. */
+constexpr std::size_t kByteValueCount = 256;
+/** Most ranges one fingerprint describes. No PE image carries more sections than this. */
+constexpr std::size_t kFingerprintCapacity = 96;
+
+/** How often each byte value occurs across one set of scanned ranges. */
+struct ByteCounts {
+    std::array<std::uint64_t, kByteValueCount> values{};
+};
+
+/** Identity of the range set one histogram was built from. */
+struct Fingerprint {
+    std::array<const std::byte*, kFingerprintCapacity> data{};
+    std::array<std::size_t, kFingerprintCapacity> size{};
+    std::size_t count{};
+    /** False for a range set too large to describe, which must never match a stored print. */
+    bool valid{};
+};
+
+/**
+ * The byte histogram and the ranges it came from.
+ * Building it costs one traversal of the image. Without this cache every pattern would pay that
+ * traversal, which is the very cost the anchor choice exists to avoid.
+ */
+struct FrequencyCache {
+    SRWLOCK lock{SRWLOCK_INIT};
+    Fingerprint fingerprint{};
+    ByteCounts counts{};
+};
+
+FrequencyCache g_frequency;
+
+/** @return Fingerprint of one range set, invalid when it holds more ranges than one can describe. */
+[[nodiscard]] Fingerprint fingerprint_of(std::span<const ImageRange> image) noexcept {
+    Fingerprint print{};
+    if (image.size() > kFingerprintCapacity) {
+        return print;
+    }
+    for (std::size_t index = 0; index < image.size(); ++index) {
+        print.data[index] = image[index].bytes.data();
+        print.size[index] = image[index].bytes.size();
+    }
+    print.count = image.size();
+    print.valid = true;
+    return print;
+}
+
+/** @return True when both fingerprints name the same ranges in the same order. */
+[[nodiscard]] bool same_ranges(const Fingerprint& left, const Fingerprint& right) noexcept {
+    if (!left.valid || !right.valid || left.count != right.count) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.count; ++index) {
+        if (left.data[index] != right.data[index] || left.size[index] != right.size[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Counts every byte value across one range set. */
+void count_bytes(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    counts = {};
+    for (const ImageRange range : image) {
+        for (const std::byte value : range.bytes) {
+            ++counts.values[std::to_integer<unsigned char>(value)];
+        }
+    }
+}
+
+/**
+ * Reads the byte histogram for one range set, building it on the first request.
+ * @param image Ranges about to be scanned.
+ * @param counts Receives a copy, so no caller holds the cache lock while it scans.
+ */
+void byte_counts(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    const Fingerprint wanted = fingerprint_of(image);
+    AcquireSRWLockExclusive(&g_frequency.lock);
+    if (!same_ranges(g_frequency.fingerprint, wanted)) {
+        count_bytes(image, g_frequency.counts);
+        g_frequency.fingerprint = wanted;
+    }
+    counts = g_frequency.counts;
+    ReleaseSRWLockExclusive(&g_frequency.lock);
+}
 
 /**
  * The one exact byte a pattern's candidate search keys on.
@@ -23,19 +112,34 @@ struct Anchor {
 
 /**
  * Picks the anchor byte for one pattern.
+ * The candidate search keys on this byte, so the rarest exact byte is the one that lets memchr
+ * skip the most. Taking the first exact byte instead lands on a REX prefix for most function
+ * prologues, and those are among the most common bytes there are in compiled x64: the sweep then
+ * stops to verify millions of times per pattern.
  * @param pattern Pattern name, bytes, and exact-byte mask.
+ * @param counts How often each byte value occurs in the ranges about to be scanned.
  * @return A valid anchor when the pattern has a name, bytes, and at least one exact byte.
  */
-[[nodiscard]] Anchor anchor_of(const Pattern& pattern) noexcept {
+[[nodiscard]] Anchor anchor_of(const Pattern& pattern, const ByteCounts& counts) noexcept {
     if (pattern.name.empty() || pattern.bytes.empty()) {
         return {};
     }
+    Anchor best{};
+    std::uint64_t bestCount = 0;
     for (std::size_t index = 0; index < pattern.bytes.size(); ++index) {
-        if (pattern.bytes[index].exact) {
-            return Anchor{index, std::to_integer<unsigned char>(pattern.bytes[index].value), true};
+        if (!pattern.bytes[index].exact) {
+            continue;
+        }
+        const auto value = std::to_integer<unsigned char>(pattern.bytes[index].value);
+        const std::uint64_t occurrences = counts.values[value];
+        // The earliest byte wins a tie, so one image always picks the same anchor.
+        if (best.valid && occurrences >= bestCount) {
+            continue;
         }
+        best = Anchor{index, value, true};
+        bestCount = occurrences;
     }
-    return {};
+    return best;
 }
 
 /**
@@ -109,8 +213,10 @@ bool resolve_all(std::span<const ImageRange> image,
         return false;
     }
 
+    ByteCounts counts;
+    byte_counts(image, counts);
     for (std::size_t index = 0; index < patterns.size(); ++index) {
-        const Anchor anchor = anchor_of(patterns[index]);
+        const Anchor anchor = anchor_of(patterns[index], counts);
         matches[index] = anchor.valid ? Match{MatchStatus::missing, nullptr} : Match{};
         if (!anchor.valid) {
             continue;
@@ -146,7 +252,9 @@ bool resolve_all(std::span<const ImageRange> image,
 std::size_t collect_matches(std::span<const ImageRange> image,
                             const Pattern& pattern,
                             std::span<std::byte*> output) noexcept {
-    const Anchor anchor = anchor_of(pattern);
+    ByteCounts counts;
+    byte_counts(image, counts);
+    const Anchor anchor = anchor_of(pattern, counts);
     if (!anchor.valid || output.empty()) {
         return 0;
     }

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

@@ -7,11 +7,14 @@
 #include <string_view>
 
 #include "../../core/logging/log.h"
+#include "../../core/settings/settings.h"
 #include "../../core/ui/busy/busy.h"
 #include "../../core/ui/notice/ui_notice_overlay.h"
 #include "../content/activity/scriptable_catalog_worker.h"
 #include "../content/bootstrap/bootstrap_token_publish.h"
 #include "../content/investment/worker.h"
+#include "../diagnostics/entity_create_probe.h"
+#include "../diagnostics/image_dump.h"
 #include "../executable/image.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
 #include "../hooks/async_io/async_io_lifetime_guard.h"
@@ -36,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"
@@ -49,6 +53,7 @@ StageState g_mainStage{StageState::pending};
 StageState g_graphicsStage{StageState::pending};
 StageState g_platformStage{StageState::pending};
 HMODULE g_platformModule{};
+void* g_sunriseModule{};
 
 namespace {
 
@@ -134,6 +139,11 @@ void clear_game_targets() noexcept {
         clear_game_targets();
         return false;
     }
+    // The inspection above proves the packer has finished: these spans are the decrypted code the
+    // signatures match. That makes this the first point at which a dump is worth taking.
+    if (core::settings::get().client.dumpGameImage) {
+        (void)diagnostics::dump_game_image(g_sunriseModule);
+    }
     const std::span<patterns::ImageRange> imageRanges = ranges(gameImage);
     if (!targets::game::resolution::resolve(imageRanges)) {
         report_resolve_failure();
@@ -172,7 +182,14 @@ void clear_game_targets() noexcept {
                      packageKeys ? "ev=activate stage=package_keys result=ok"
                                  : "ev=activate stage=package_keys result=fail");
     // Diagnostic capture reports its own outcome and never demotes this stage.
+    // The probe hooks only the index allocator, whose two-argument shape was read out of its own
+    // body. The initialiser beside it is left alone: its fifth argument is passed on the stack,
+    // and a four-argument replacement black-screened the load on 2026-08-25.
+    (void)diagnostics::install_entity_create_probe(
+        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.

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

@@ -7,11 +7,13 @@
 #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"
 #include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
+#include "../hooks/membership_probe/membership_probe.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
@@ -38,6 +40,8 @@ bool initialize(void* module) noexcept {
         core::settings::get().activitySdkGeneration;
     content::activity::sdk_generation::initialize(module,
                                                   {generation.enabled, generation.luaDeclarations});
+    // Kept for activation, which resolves the artifact directory from Sunrise's own module.
+    runtime::g_sunriseModule = module;
     // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     player::initialize(module);
@@ -73,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,
@@ -80,6 +91,15 @@ bool shutdown() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return false;
     }
+    // Attached last, so it detaches first. The probe reads through a detour, so one left in
+    // place is a branch into code a later unload unmaps.
+    if (!hooks::membership_probe::uninstall()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=shutdown stage=membership_probe result=fail");
+        ReleaseSRWLockExclusive(&runtime::g_lock);
+        return false;
+    }
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::infinite_ammo::uninstall();

+ 2 - 0
Sunrise/src/client/runtime/internal.h

@@ -18,5 +18,7 @@ extern StageState g_mainStage;
 extern StageState g_graphicsStage;
 extern StageState g_platformStage;
 extern HMODULE g_platformModule;
+/** Sunrise's own module, kept so activation can resolve the artifact directory. */
+extern void* g_sunriseModule;
 
 } // namespace sunrise::client::runtime

+ 2 - 0
Sunrise/src/client/targets/game/content.h

@@ -11,6 +11,8 @@ namespace sunrise::client::targets::game::content {
 /** Unowned main-image entry points required only by runtime content extraction. */
 struct Targets {
     std::byte* queuezObjectStoreGetter{};
+    /** Native schema-object resolver retained for checked live-layout diagnostics. */
+    std::byte* queuezObjectResolver{};
     std::byte* contentHandleTablesSlot{};
     std::byte* getItemStatValue{};
     std::byte* lightValueToScalar{};

+ 1 - 0
Sunrise/src/client/targets/game/game_content_targets.cpp

@@ -62,6 +62,7 @@ bool derive(std::span<const patterns::ImageRange> image,
         || !relative::contains(image, resolved.queuezObjectStoreGetter)) {
         return false;
     }
+    resolved.queuezObjectResolver = objectResolver;
     std::memcpy(&resolved.queuezDescriptorFamilyBias,
                 objectResolver + kDescriptorFamilyBiasOffset,
                 sizeof resolved.queuezDescriptorFamilyBias);

+ 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

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä